Neste artigo, examinaremos alguns detalhes fundamentais de baixo nível para entender por que as GPUs são boas em tarefas gráficas, redes neurais e aprendizado profundo e as CPUs são boas em um amplo número de tarefas de computação de uso geral complexas e sequenciais. Houve vários tópicos que tive que pesquisar e obter uma compreensão um pouco mais granular para este post, alguns dos quais mencionarei apenas de passagem. Isso é feito deliberadamente para focar apenas nos fundamentos absolutos do processamento de CPU e GPU.
Os computadores anteriores eram dispositivos dedicados. Circuitos de hardware e portas lógicas foram programados para fazer um conjunto específico de coisas. Se algo novo tivesse que ser feito, os circuitos precisavam ser religados. “Algo novo” poderia ser tão simples quanto fazer cálculos matemáticos para duas equações diferentes. Durante a Segunda Guerra Mundial, Alan Turing estava trabalhando em uma máquina programável para vencer a máquina Enigma e mais tarde publicou o artigo "Máquina de Turing". Na mesma época, John von Neumann e outros pesquisadores também estavam trabalhando em uma ideia que propunha fundamentalmente:
Sabemos que tudo em nosso computador é binário. String, imagem, vídeo, áudio, sistema operacional, programa aplicativo, etc., são todos representados como 1s e 0s. As especificações da arquitetura da CPU (RISC, CISC, etc.) possuem conjuntos de instruções (x86, x86-64, ARM, etc.), que os fabricantes de CPU devem cumprir e estão disponíveis para o sistema operacional fazer interface com o hardware.
Os programas de sistema operacional e aplicativos, incluindo dados, são traduzidos em conjuntos de instruções e dados binários para processamento na CPU. No nível do chip, o processamento é feito em transistores e portas lógicas. Se você executar um programa para somar dois números, a adição (o "processamento") será feita em uma porta lógica do processador.
Na CPU, de acordo com a arquitetura Von Neumann, quando adicionamos dois números, uma única instrução de adição é executada em dois números no circuito. Por uma fração desse milissegundo, apenas a instrução add foi executada no núcleo (de execução) da unidade de processamento! Esse detalhe sempre me fascinou.
Os componentes no diagrama acima são evidentes. Para mais detalhes e explicações detalhadas consulte este excelente artigo . Nas CPUs modernas, um único núcleo físico pode conter mais de uma ALU inteira, ALU de ponto flutuante, etc. Novamente, essas unidades são portas lógicas físicas.
Precisamos entender o ‘Hardware Thread’ no núcleo da CPU para uma melhor apreciação da GPU. Um thread de hardware é uma unidade de computação que pode ser feita em unidades de execução de um núcleo de CPU, a cada ciclo de clock da CPU . Representa a menor unidade de trabalho que pode ser executada em um núcleo.
O diagrama acima ilustra o ciclo de instrução da CPU/ciclo de máquina. É uma série de etapas que a CPU executa para executar uma única instrução (ex.: c=a+b).
Fetch: O contador do programa (registro especial no núcleo da CPU) monitora quais instruções devem ser buscadas. A instrução é buscada e armazenada no registrador de instruções. Para operações simples, os dados correspondentes também são buscados.
Decodificar: a instrução é decodificada para ver operadores e operandos.
Executar: Com base na operação especificada, a unidade de processamento apropriada é escolhida e executada.
Acesso à memória: Se uma instrução for complexa ou forem necessários dados adicionais (vários fatores podem causar isso), o acesso à memória é feito antes da execução. (Ignorado no diagrama acima para simplificar). Para uma instrução complexa, os dados iniciais estarão disponíveis no registro de dados da unidade de computação, mas para a execução completa da instrução, é necessário o acesso aos dados do cache L1 e L2. Isso significa que pode haver um pequeno tempo de espera antes que a unidade de computação seja executada e o thread de hardware ainda retenha a unidade de computação durante o tempo de espera.
Write Back: Se a execução produzir saída (por exemplo: c = a + b), a saída será gravada de volta no registro/cache/memória. (Ignorado no diagrama acima ou em qualquer lugar posterior na postagem para simplificar)
No diagrama acima, apenas em t2 o cálculo está sendo feito. No resto do tempo, o núcleo fica ocioso (não estamos realizando nenhum trabalho).
CPUs modernas possuem componentes de HW que essencialmente permitem que etapas (buscar-decodificar-executar) ocorram simultaneamente por ciclo de clock.
Um único thread de hardware agora pode fazer cálculos em cada ciclo de clock. Isso é chamado de pipelining de instruções.
Buscar, decodificar, acessar a memória e gravar de volta são feitos por outros componentes em uma CPU. Por falta de uma palavra melhor, eles são chamados de “threads de pipeline”. O thread do pipeline se torna um thread de hardware quando está no estágio de execução de um ciclo de instrução.
Como você pode ver, obtemos resultados computacionais a cada ciclo de t2. Anteriormente, recebíamos saída de computação uma vez a cada 3 ciclos. O pipeline melhora o rendimento da computação. Esta é uma das técnicas para gerenciar gargalos de processamento na Arquitetura Von Neumann. Existem também outras otimizações, como execução fora de ordem, previsão de ramificação, execução especulativa, etc.,
Este é o último conceito que quero discutir em CPU antes de concluirmos e passarmos para GPUs. À medida que a velocidade do clock aumentou, os processadores também ficaram mais rápidos e eficientes. Com o aumento da complexidade do aplicativo (conjunto de instruções), os núcleos de computação da CPU foram subutilizados e passaram mais tempo aguardando o acesso à memória.
Então, vemos um gargalo de memória. A unidade de computação está gastando tempo acessando a memória e não realizando nenhum trabalho útil. A memória é várias ordens mais lenta que a CPU e a lacuna não vai diminuir tão cedo. A ideia era aumentar a largura de banda da memória em algumas unidades de um único núcleo de CPU e manter os dados prontos para utilizar as unidades de computação quando aguardarem acesso à memória.
O Hyper-threading foi disponibilizado em 2002 pela Intel nos processadores Xeon e Pentium 4. Antes do hyper-threading, havia apenas um thread de hardware por núcleo. Com o hyper-threading, haverá 2 threads de hardware por núcleo. O que isso significa? Circuito de processamento duplicado para alguns registros, contador de programa, unidade de busca, unidade de decodificação, etc.
O diagrama acima mostra apenas novos elementos de circuito em um núcleo de CPU com hyperthreading. É assim que um único núcleo físico é visível como 2 núcleos para o sistema operacional. Se você tivesse um processador de 4 núcleos, com hyper-threading habilitado, ele seria visto pelo SO como 8 núcleos . O tamanho do cache L1 - L3 aumentará para acomodar registros adicionais. Observe que as unidades de execução são compartilhadas.
Suponha que temos processos P1 e P2 fazendo a=b+c, d=e+f, eles podem ser executados simultaneamente em um único ciclo de clock por causa dos threads de HW 1 e 2. Com um único thread de HW, como vimos anteriormente, isso não seria possível. Aqui estamos aumentando a largura de banda da memória dentro de um núcleo adicionando Thread de Hardware para que a unidade de processamento possa ser utilizada de forma eficiente. Isso melhora a simultaneidade de computação.
Alguns cenários interessantes:
Confira este artigo e experimente também o notebook Colab . Ele mostra como a multiplicação de matrizes é uma tarefa paralelizável e como núcleos de computação paralelos podem acelerar o cálculo.
À medida que o poder da computação aumentava, também aumentava a demanda por processamento gráfico. Tarefas como renderização de UI e jogos exigem operações paralelas, gerando a necessidade de inúmeras ALUs e FPUs no nível do circuito. As CPUs, projetadas para tarefas sequenciais, não conseguiam lidar com essas cargas de trabalho paralelas de maneira eficaz. Assim, as GPUs foram desenvolvidas para atender à demanda de processamento paralelo em tarefas gráficas, abrindo posteriormente caminho para sua adoção na aceleração de algoritmos de aprendizagem profunda.
Eu recomendo:
Núcleos, threads de hardware, velocidade de clock, largura de banda de memória e memória on-chip de CPUs e GPUs diferem significativamente. Exemplo:
Este número é usado para comparação com a GPU, pois obter o desempenho máximo da computação de uso geral é muito subjetivo. Este número é um limite máximo teórico, o que significa que os circuitos FP64 estão sendo usados ao máximo.
As terminologias que vimos na CPU nem sempre são traduzidas diretamente nas GPUs. Aqui veremos os componentes e núcleo da GPU NVIDIA A100. Uma coisa que me surpreendeu enquanto pesquisava para este artigo foi que os fornecedores de CPU não publicam quantas ALUs, FPUs, etc., estão disponíveis nas unidades de execução de um núcleo. A NVIDIA é muito transparente quanto ao número de núcleos e a estrutura CUDA oferece total flexibilidade e acesso no nível do circuito.
No diagrama acima na GPU, podemos ver que não há cache L3, cache L2 menor, menor, mas muito mais unidade de controle e cache L1 e um grande número de unidades de processamento.
Aqui estão os componentes da GPU nos diagramas acima e seus equivalentes de CPU para nosso entendimento inicial. Não fiz programação CUDA, então compará-la com equivalentes de CPU ajuda no entendimento inicial. Os programadores CUDA entendem isso muito bem.
Tarefas gráficas e de aprendizado profundo exigem execução do tipo SIM (D/T) [instrução única multidados/thread]. isto é, ler e trabalhar com grandes quantidades de dados para uma única instrução.
Discutimos o pipeline de instruções e o hyper-threading em CPU e GPUs também têm capacidade. A forma como é implementado e funciona é um pouco diferente, mas os princípios são os mesmos.
Ao contrário das CPUs, as GPUs (via CUDA) fornecem acesso direto aos Pipeline Threads (buscando dados da memória e utilizando a largura de banda da memória). Os agendadores de GPU funcionam primeiro tentando preencher as unidades de computação (incluindo cache L1 compartilhado e registros para armazenar operandos de computação) e, em seguida, "threads de pipeline" que buscam dados em registros e HBM. Novamente, quero enfatizar que os programadores de aplicativos de CPU não pensam sobre isso e as especificações sobre "threads de pipeline" e o número de unidades de computação por núcleo não são publicadas. A Nvidia não apenas os publica, mas também fornece controle completo aos programadores.
Entrarei em mais detalhes sobre isso em um post dedicado sobre o modelo de programação CUDA e "lote" na técnica de otimização de serviço de modelo, onde podemos ver como isso é benéfico.
O diagrama acima descreve a execução de threads de hardware no núcleo da CPU e GPU. Consulte a seção "acesso à memória" que discutimos anteriormente em Pipelining de CPU. Este diagrama mostra isso. O complexo gerenciamento de memória das CPUs torna esse tempo de espera pequeno o suficiente (alguns ciclos de clock) para buscar dados do cache L1 para os registradores. Quando os dados precisam ser buscados no L3 ou na memória principal, o outro thread para o qual os dados já estão registrados (vimos isso na seção de hyper-threading) obtém o controle das unidades de execução.
Nas GPUs, devido ao excesso de assinaturas (alto número de threads e registros de pipeline) e ao conjunto de instruções simples, uma grande quantidade de dados já está disponível em registros com execução pendente. Esses threads de pipeline aguardando execução tornam-se threads de hardware e executam a mesma frequência a cada ciclo de clock, pois os threads de pipeline em GPUs são leves.
O que está acima do objetivo?
Esta é a principal razão pela qual a latência da multiplicação de matrizes menores é mais ou menos a mesma em CPU e GPU. Experimente .
As tarefas precisam ser paralelas o suficiente, os dados precisam ser grandes o suficiente para saturar os FLOPs de computação e a largura de banda da memória. Se uma única tarefa não for grande o suficiente, várias dessas tarefas precisarão ser compactadas para saturar a memória e a computação para utilizar totalmente o hardware.
Intensidade de computação = FLOPs / largura de banda . ou seja, a proporção entre a quantidade de trabalho que pode ser realizada pelas unidades de computação por segundo e a quantidade de dados que pode ser fornecida pela memória por segundo.
No diagrama acima, vemos que a intensidade da computação aumenta à medida que avançamos para maior latência e menor largura de banda de memória. Queremos que esse número seja o menor possível para que a computação seja totalmente utilizada. Para isso, precisamos manter o máximo de dados em L1/Registradores para que a computação possa acontecer rapidamente. Se buscarmos dados únicos do HBM, haverá apenas algumas operações em que realizamos 100 operações em dados únicos para que valha a pena. Se não fizermos 100 operações, as unidades de computação ficarão ociosas. É aqui que entra em jogo o grande número de threads e registros nas GPUs. Manter o máximo de dados em L1/registros para manter a intensidade de computação baixa e manter os núcleos paralelos ocupados.
Há uma diferença na intensidade de computação de 4X entre os núcleos CUDA e Tensor porque os núcleos CUDA podem executar apenas um FP64 MMA 1x1, enquanto os núcleos Tensor podem executar instruções 4x4 FP64 MMA por ciclo de clock.
Alto número de unidades de computação (núcleos CUDA e Tensor), alto número de threads e registros (sobre assinatura), conjunto de instruções reduzido, sem cache L3, HBM (SRAM), padrão de acesso à memória simples e de alto rendimento (em comparação com CPUs - comutação de contexto , cache multicamadas, paginação de memória, TLB, etc.) são os princípios que tornam as GPUs muito melhores do que as CPUs na computação paralela (renderização gráfica, aprendizado profundo, etc.)
As GPUs foram criadas inicialmente para lidar com tarefas de processamento gráfico. Os pesquisadores de IA começaram a aproveitar o CUDA e seu acesso direto ao poderoso processamento paralelo por meio de núcleos CUDA. A GPU NVIDIA possui mecanismos de processamento de textura, Ray Tracing, Raster, Polymorph, etc. (digamos conjuntos de instruções específicas para gráficos). Com o aumento da adoção da IA, estão sendo adicionados núcleos Tensor que são bons no cálculo de matrizes 4x4 (instrução MMA), dedicados ao aprendizado profundo.
Desde 2017, a NVIDIA vem aumentando o número de núcleos Tensor em cada arquitetura. Mas essas GPUs também são boas em processamento gráfico. Embora o conjunto de instruções e a complexidade sejam muito menores nas GPUs, elas não são totalmente dedicadas ao aprendizado profundo (especialmente a Transformer Architecture).
FlashAttention 2 , uma otimização da camada de software (simpatia mecânica para o padrão de acesso à memória da camada de atenção) para arquitetura de transformador fornece velocidade 2X nas tarefas.
Com nosso conhecimento aprofundado de CPU e GPU baseado em primeiros princípios, podemos entender a necessidade de aceleradores de transformador: um chip dedicado (circuito apenas para operações de transformador), com um número ainda maior de unidades de computação para paralelismo, conjunto de instruções reduzido, sem Caches L1/L2, DRAM (registros) massivos substituindo HBM, unidades de memória otimizadas para padrão de acesso à memória da arquitetura do transformador. Afinal, os LLMs são novos companheiros para os humanos (depois da web e dos dispositivos móveis) e precisam de chips dedicados para eficiência e desempenho.