Toda pessoa desenvolvedora deve conhecer computação com GPU
(codeconfessions.substack.com)- A GPU é uma arquitetura que prioriza alto throughput massivamente paralelo em vez de baixa latência de instrução única, por isso é forte em tarefas que executam em grande volume o mesmo tipo de operação, como deep learning, gráficos e computação numérica
- Enquanto a CPU reduz a latência de execução sequencial com pipelining, execução fora de ordem, execução especulativa e cache multinível, a GPU esconde a latência com muitas ALUs e threads para aumentar o throughput
- Em precisão de 32 bits, a Nvidia Ampere A100 entrega 19.5 TFLOPS, enquanto um processador Intel de 24 núcleos de 2021 chega a 0.66 TFLOPS, e a diferença de throughput em computação numérica continua aumentando
- Em um kernel CUDA, o código host na CPU prepara a execução, e o código device na GPU roda na estrutura de grid, block e thread; as threads são agrupadas em unidades de 32 chamadas warp e processadas no modelo SIMT
- O desempenho real depende fortemente de como os registradores, a memória compartilhada, os slots de bloco e os slots de thread do SM são divididos; quando a occupancy é baixa, fica mais difícil esconder a latência e talvez nem se atinja o throughput máximo
Diferença de objetivos de projeto entre CPU e GPU
- A CPU foi projetada principalmente para processar rapidamente a execução sequencial de instruções
- Para reduzir a latência de execução de instruções, usa recursos como instruction pipelining, out-of-order execution, speculative execution e multilevel cache
- Uma operação única, como somar dois números, ou um fluxo curto de operações, pode ser executado pela CPU com latência menor do que pela GPU
- A GPU foi projetada com foco em paralelismo massivo e alto throughput
- Tarefas como videogames, gráficos, computação numérica e deep learning, que precisam executar rapidamente muitas operações de álgebra linear e cálculo numérico, combinam bem com essa arquitetura
- Em milhões ou bilhões de operações do mesmo tipo, a GPU pode processar muito mais rápido que a CPU graças ao paralelismo em grande escala
- O desempenho em computação numérica é medido em FLOPS, ou número de operações de ponto flutuante por segundo
- A Nvidia Ampere A100 oferece throughput de 19.5 TFLOPS em precisão de 32 bits
- Em 2021, um processador Intel de 24 núcleos ficava na faixa de 0.66 TFLOPS em precisão de 32 bits
- A diferença de throughput entre GPU e CPU aumenta a cada ano
Como a GPU esconde a latência
- Mesmo quando a latência de instruções individuais é alta, a GPU obtém tolerância à latência usando muitas threads e muitos recursos de computação
- Enquanto uma thread espera o resultado de uma instrução, a GPU executa outras threads que não estão esperando
- Graças a esse escalonamento, as unidades de computação continuam trabalhando o máximo possível e conseguem manter alto throughput
Arquitetura de computação da GPU
- A GPU é composta por um arranjo de vários streaming multiprocessors (SM)
- Cada SM inclui vários streaming processor, core e thread
- A Nvidia H100 tem 132 SMs, e cada SM possui 64 cores, totalizando 8,448 cores
- Cada SM possui uma quantidade limitada de memória on-chip compartilhada por todos os cores
- Essa memória é chamada de shared memory ou scratchpad
- Os recursos da unidade de controle do SM também são compartilhados entre os cores
- Cada SM possui um thread scheduler baseado em hardware para executar threads
- Dependendo da carga de trabalho, também pode incluir unidades funcionais especiais ou unidades de aceleração, como tensor core e ray tracing unit
Hierarquia de memória da GPU
-
Registradores
- Cada SM possui um grande número de registradores
- A Nvidia A100 e a H100 têm 65,536 registradores por SM
- Os registradores são compartilhados entre os cores e alocados dinamicamente conforme a demanda das threads
- Durante a execução, os registradores atribuídos a uma thread específica são exclusivos dela, e outras threads não podem lê-los nem gravá-los
-
Constant cache
- Armazena em cache os dados constantes usados pelo código executado no SM
- Para que a GPU possa guardar um objeto no constant cache, a pessoa programadora precisa declará-lo explicitamente como constante no código
-
Shared memory
- É uma SRAM on-chip pequena, rápida, programável e de baixa latência presente em cada SM
- É compartilhada pelos thread blocks em execução no mesmo SM
- Quando várias threads usam o mesmo pedaço de dado, uma thread pode ler da global memory e compartilhar com as demais, reduzindo cargas redundantes
- Também é usada como mecanismo de sincronização entre threads dentro de um thread block
-
L1 cache e L2 cache
- Cada SM possui um L1 cache que armazena em cache dados frequentemente acessados a partir do L2 cache
- O L2 cache é compartilhado por todos os SMs e reduz a latência ao armazenar em cache dados da global memory acessados com frequência
- Como L1 e L2 cache funcionam de forma transparente para o SM, do ponto de vista dele parece que os dados vêm da global memory
-
Global memory
- A GPU possui uma global memory off-chip, formada por DRAM de grande capacidade e alta largura de banda
- A Nvidia H100 tem 80GB de HBM e largura de banda de 3000GB/s
- Como a global memory fica longe dos SMs, sua latência é alta, mas a hierarquia de memória on-chip e as muitas unidades de computação ajudam a esconder essa latência
Kernels CUDA e estrutura de threads
- CUDA é uma interface de programação para escrever programas para GPUs Nvidia
- O cálculo executado na GPU é expresso como um kernel em formato semelhante a uma função C/C++
- Um exemplo é um kernel de soma de vetores que recebe dois vetores como entrada, soma elemento a elemento e grava o resultado em um terceiro vetor
- Ao executar um kernel, várias threads são iniciadas, e esse conjunto completo é chamado de grid
- Um grid é composto de um ou mais thread blocks
- Cada thread block é composto de uma ou mais threads
- O número de blocos e o número de threads variam conforme o tamanho dos dados e o paralelismo desejado
- Em uma soma de vetores de dimensão 256, pode-se criar um bloco com 256 threads para que cada thread processe um elemento do vetor
- Em problemas maiores, o número de threads disponíveis na GPU pode não ser suficiente, então cada thread pode processar vários pontos de dados
- Uma implementação CUDA se divide em duas partes
- O host code roda na CPU e é responsável por carregar dados, alocar memória na GPU e lançar o kernel com o grid de threads configurado
- O device code roda na GPU e define a função kernel propriamente dita
Etapas de execução de um kernel na GPU
-
Copiar dados do host para o device
- Antes de executar o kernel, é preciso copiar os dados necessários da memória da CPU para a global memory da GPU
- Em hardware GPU mais recente, também é possível ler diretamente da memória do host usando unified virtual memory
-
Escalonar thread blocks nos SMs
- Quando os dados necessários já estão prontos na memória da GPU, os thread blocks são atribuídos aos SMs
- Todas as threads de um bloco são processadas simultaneamente no mesmo SM
- Antes da execução, a GPU precisa garantir os recursos do SM necessários para essas threads
- Na prática, vários thread blocks podem ser atribuídos ao mesmo SM ao mesmo tempo
- Como o número de SMs é limitado e kernels grandes podem ter muitos blocos, nem todos os blocos são executados imediatamente
- A GPU mantém uma lista de blocos em espera e, quando algum bloco termina, atribui um bloco pendente para execução
-
SIMT e warp
- As threads atribuídas a um SM são agrupadas novamente em unidades de 32, chamadas warp
- Nas GPUs Nvidia da geração atual, o tamanho do warp é 32, mas isso pode mudar em hardware futuro
- O SM busca e emite a mesma instrução para todas as threads dentro de um warp
- As threads executam a mesma instrução ao mesmo tempo, mas processam partes diferentes dos dados
- Esse modelo é chamado de single instruction multiple threads (SIMT) e é semelhante às instruções SIMD da CPU
- GPUs modernas desde Volta também têm independent thread scheduling, que permite concorrência total entre threads independentemente do warp
-
Escalonamento de warp e tolerância à latência
- Mesmo que todos os processing blocks dentro de um SM possam lidar com warps, em um dado instante apenas parte dos warps realmente executa instruções
- Isso acontece porque o número de unidades de execução do SM é limitado
- Se um warp estiver esperando o resultado de uma instrução demorada, o SM coloca esse warp em espera e executa outro warp que não precisa esperar
- Como cada thread de cada warp possui seu próprio conjunto de registradores, não há overhead extra para alternar entre warps
- Já a troca de contexto de processos na CPU é cara, porque exige salvar registradores na memória principal e restaurar o estado de outro processo
-
Copiar os dados de resultado do device para o host
- Quando todas as threads do kernel terminam a execução, os resultados são copiados de volta para a host memory
Particionamento de recursos e occupancy
- O uso dos recursos da GPU é medido por uma métrica chamada occupancy
- Occupancy é a razão entre o número de warps atribuídos a um SM e o número máximo de warps que esse SM consegue suportar
- Para obter o throughput máximo, 100% de occupancy é desejável, mas nem sempre é possível por causa de várias restrições
- O SM possui recursos de execução fixos, como registradores, shared memory, thread block slot e thread slot
- Esses recursos são divididos dinamicamente conforme as exigências das threads e os limites da GPU
- Exemplo da Nvidia H100
- Cada SM pode processar 32 blocks, 64 warps, ou seja, 2048 threads
- Há suporte para no máximo 1024 threads por block
- Se o tamanho do block for 1024 threads, os 2048 thread slots serão divididos em 2 blocks
- O particionamento dinâmico pode usar os recursos de computação com mais eficiência do que o particionamento fixo
- No particionamento fixo, cada thread block recebe uma quantidade fixa de recursos de execução
- Em alguns casos, uma thread pode receber mais recursos do que precisa, o que gera desperdício e reduz o throughput
- Exemplos de redução de occupancy
- Se o tamanho do block for 32 threads e forem necessárias 2048 threads no total, serão criados 64 blocks
- Porém, como cada SM só consegue processar 32 blocks por vez, na prática apenas 1024 threads serão executadas e a occupancy será de 50%
- Se houver 65,536 registradores por SM, para executar 2048 threads ao mesmo tempo cada thread pode usar no máximo 32 registradores
- Se o kernel exigir 64 registradores por thread, então apenas 1024 threads poderão executar por SM e a occupancy voltará a 50%
- Uma occupancy baixa dificulta esconder suficientemente a latência e também pode reduzir o throughput computacional necessário para atingir o pico de desempenho do hardware
- Para escrever kernels GPU eficientes, é preciso distribuir os recursos com cuidado para manter alta occupancy e ao mesmo tempo reduzir a latência
- Usar muitos registradores pode acelerar o próprio código, mas pode reduzir a occupancy, então o equilíbrio de otimização é importante
Materiais para explorar mais
- Programming Massively Parallel Processors: a 4ª edição é a referência mais recente, mas edições anteriores também podem ser usadas
- Programming Massively Parallel Processors: aulas online do Prof. Hwu
- Nvidia’s CUDA C++ Programming Guide
- How GPU Computing Works
- GPU Programming: When, Why and How?
1 comentários
Opiniões no Hacker News
Alguém enviou um e-mail de reclamação sobre este texto: https://twitter.com/abhi9u/status/1715753871564476597
Isso é uma violação das regras do HN. Na verdade, é o único item importante o bastante para aparecer tanto nas diretrizes do site quanto no FAQ, e os usuários do HN são muito sensíveis a esse problema
P: Posso pedir recomendações para o meu texto?
R: Não. Os usuários devem votar quando eles próprios acharem algo intelectualmente interessante, não porque alguém tem conteúdo para promover. Se você violar essa regra, seu post, conta ou site pode receber penalidades ou ser bloqueado, então não faça isso
https://news.ycombinator.com/newsfaq.html
Não peça recomendações, comentários nem submissões. Os usuários devem votar e comentar não com fins de promoção, mas quando acharem pessoalmente interessante algo que encontraram por conta própria
https://news.ycombinator.com/newsguidelines.html
Agora que sei, não farei de novo
Fiquei surpreso por a parte sobre “copiar dados do host para o dispositivo” não mencionar cópias assíncronas. Para aproveitar a GPU ao máximo, ela não deve ficar ociosa enquanto os dados são copiados entre o host e a GPU
Muitos frameworks oferecem mecanismos para agendar cópias assíncronas que podem ser executadas junto com o envio assíncrono de tarefas. O texto em si está mais para uma introdução a GPUs, mas, na programação real de GPU, há todo tipo de truque e técnica além disso para espremer até o fim uma GPU cara. Como acontece com a maior parte da otimização hoje em dia, há muitos penhascos ocultos e não linearidades, então ferramentas de profiling ajudam muito
Porém, usar uma GPU com muitas unidades FP64 pode acelerar bastante. Normalmente isso não é uma GPU gamer, mas, se você simplesmente tiver uma 4060, o desempenho FP64 dela fica em torno de 300 GFLOPS, então há grande chance de ser maior que o de uma CPU. CPUs modernas também são fortes nessa área, conseguindo emitir várias operações FP64 por ciclo de clock em cada núcleo
A primeira frase, “a maioria dos programadores entende profundamente CPUs”, é tão obviamente falsa que, embora o texto possa ser excelente, fica difícil levar o restante a sério
Na faculdade, fiz uma disciplina de filosofia por diversão, e nela desenvolvi a capacidade de não descartar uma frase de imediato, mas reescrevê-la mentalmente numa forma melhor. Agora meu cérebro traduz automaticamente generalizações excessivas ou falsidades óbvias para proposições razoavelmente próximas da verdade. À medida que o argumento se desenvolve, consigo reconstruir as ideias e avaliar o texto inteiro como algo logicamente consistente
Graças a isso, mesmo ao ler um texto ruim, fico com novas premissas e afirmações verdadeiras ou falsas sobre um tema que me interessa, e assim meu mundo mental se expande
Aprendi os fatos básicos de arquitetura de CPU na universidade, tenho uma noção bem básica do panorama geral e ocasionalmente recebo atualizações do meu conhecimento limitado, mas eu não chamaria isso de “entendimento profundo”. Algo como “entendimento básico de como CPUs funcionam, são projetadas e usadas” parece mais correto
Se alguém for proficiente em assembly, talvez dê para dizer que “entende profundamente” como usar uma CPU em baixo nível, mas ainda soa um pouco exagerado. Também é diferente de ser especialista em projeto de CPU/GPU
Então concordo. Ainda assim, o texto é interessante, especialmente os diagramas
A parte que diz que “os registradores alocados a uma thread em execução são exclusivos daquela thread, então outras threads não podem lê-los nem escrevê-los” tem exceções
Os wave intrinsics do HLSL e recursos semelhantes do CUDA conseguem ler registradores de outras threads dentro do wavefront atual. Também valeria incluir, no parágrafo sobre arquitetura de memória, que os caches não garantem consistência entre threads dentro do mesmo dispatch/grid, mas que há um bloco funcional especial, global no chip inteiro, que implementa operações atômicas na memória global
Programação SIMD é realmente bruta
Quer executar cálculos em todos os pixels da tela? Sem problema
Quer colocar uma condição de ramificação? Ai
Por que ainda chamamos isso de GPU? PPU (unidade de processamento paralelo) soa como um nome melhor
A relação entre drone e quadricóptero é parecida
Texto excelente. E, no que diz respeito ao que as GPUs fazem, elas são mais avançadas e têm melhor desempenho do que qualquer outra coisa que eu consiga imaginar.
Mas eu colocaria SIMD na categoria de coisas não essenciais depois que se aprende outros paradigmas mais flexíveis. Eu prefiro MIMD e clusters/transputers, que parecem ter desaparecido por volta dos anos 2000. O estado atual exige que o desenvolvedor mova dados manualmente, escreva shaders sob limites arbitrários quanto ao número de posições de memória que podem ser acessadas simultaneamente, duplique trabalho usando linguagens separadas para GPU/CPU, saiba qual hardware existe para recursos como ray tracing e fique preso a frameworks com opiniões fortes como OpenGL/Metal/Vulkan. Para mim, GPUs são um desvio que jamais poderá me levar aonde quero ir; os últimos 25 anos foram como viver na linha do tempo errada.
Falando de modo solto, dentro das limitações do fim da Lei de Moore, uma CPU de uso geral escalável deveria ser multicore com memória local, compartilhar dados por meio de memória endereçada por conteúdo com copy-on-write ou algum outro esquema de cache, e oferecer um único espaço de endereçamento unificado para que o usuário possa explorar livremente todos os modos de computação em um ambiente de computação desktop. Ela usaria assembly padrão, mas normalmente seria programada em linguagens funcionais como Erlang/Go, Octave/MATLAB e, idealmente, Julia. Renderização 3D e bibliotecas de IA seriam camadas acima disso, não elementos fundamentais.
Curiosamente, as GPUs chegaram aproximadamente à configuração multicore de que estou falando, mas os drivers separam o usuário do acesso bare-metal necessário para MIMD de uso geral. Eu achava que a única forma de derrubar a vantagem das GPUs seria com FPGAs, mas talvez haja a oportunidade de escrever drivers que façam o hardware de GPU parecer MIMD com memória unificada. Não sei quão bem os núcleos de GPU lidam com operações inteiras, mas talvez isso possa ser aproximado usando a parte inteira de 32 bits de floats de 64 bits. Por causa desse tipo de compromisso, uma máquina MIMD poderia ser 10 a 100 vezes mais lenta que uma GPU, mas ainda 10 a 100 vezes mais rápida que uma CPU. E, ao mesmo tempo, seria escalável sem depender demais dos grandes caches e barramentos rápidos que estagnaram as CPUs desde por volta de 2007, quando o mercado móvel assumiu a liderança e preço e eficiência energética passaram a ter prioridade sobre desempenho. Máquinas MIMD poderiam ser agrupadas para formar redes de computação distribuída tipo SETI@home sem mudanças no código. Para ter uma ideia do quanto isso empoderaria usuários comuns, seria algo como comparar BitTorrent versus FTP da computação, não dos dados.
Não entendo bem como a arquitetura Apple Silicon difere da NVIDIA.
Quando vejo a frase “a GPU Nvidia H100 tem 132 SMs, 64 cores por SM, totalizando 8448 cores”, 8448 cores certamente impressionam. Mas o Apple M2 Ultra tem apenas 76 cores?
Como a GPU NVIDIA H100 pode ter mais de 110 vezes mais cores? Ela claramente não tem 110 vezes o desempenho do M2 Ultra; então o que está acontecendo aqui?
Veja este diagrama no blog da NVIDIA: https://developer-blogs.nvidia.com/wp-content/uploads/2021/g...
(https://developer.nvidia.com/blog/nvidia-ampere-architecture...)
Claro que dá para sentir que há justificativa para chamá-las de “threads”, já que cada lane oferece suporte a um contador de programa separado, mas no fim o que importa é a velocidade e o throughput das ALUs.
Agora entendi por que machine learning usa ponto flutuante para precisão. Não foi uma escolha; é porque o código gráfico já usava assim
É mais uma peça do quebra-cabeça de “por que machine learning é tão ineficiente”
Fico curioso para saber quanto pesa, em ambientes reais, o overhead de cópia de memória. Se se comportar como costuma acontecer, deve ser bem severo. Tanto que se transfere o processamento TCP para hardware para evitar isso. Aqui há muito mais dados, embora sejam processados em blocos maiores
Ou seja, copiar um minibatch de imagens em ponto flutuante ainda é rápido o suficiente. O que é lento são as iterações de gradiente/SGD, que exigem muito cálculo. Mesmo usando precisão mista
Em redes rasas, pode haver vantagem em copiar apenas os dados comprimidos originais para a memória da GPU e fazer a descompressão etc. na GPU. Mas o fato de GPUs modernas ainda não terem adotado PCIe 5 mostra que o desempenho bruto de computação é mais importante
Por fim, o impacto dos tensor cores também foi grande e, dependendo da rede, eles podem ser tão rápidos que a taxa de utilização acaba ficando muito baixa
A matemática do treinamento também assume que os números são contínuos
Ainda assim, eu me perguntava por que LLMs em CPU fazem quantização. Pelo que entendo, é um processo de reduzir a precisão dos pesos para usar menos memória
Não está claro se a falta de precisão faz diferença. Se faz, por que usar ponto flutuante desde o início? Se a precisão não é importante, a precisão extra só consome mais recursos sem motivo real, e provavelmente consome ordens de magnitude mais recursos do que o necessário
Essa área não foi iniciada por pessoas que entendiam de desempenho. Elas usaram ferramentas para construir algo, mas sem o “por quê”. Fizeram assim porque a ferramenta fazia assim
O motivo de isso ser importante é este: mesmo em uma CPU comum, uma forma de acessar dados pode ser ordens de magnitude mais rápida que outra, mas é preciso saber disso. Você não gostaria de reduzir os custos de LLM em ordens de magnitude?
Também vale ver esta apresentação e estes slides de alguns anos atrás sobre as partes complicadas de CPUs e GPUs
Alexander Titov — Know your hardware: CPU memory hierarchy https://youtu.be/QOJ2hsop6hM
https://github.com/alexander-titov/public/blob/master/confer...
Know Your Hardware - CPU Memory Hierarchy -- Alexander Titov -- C%2B%2B Moscow Meetup March 2019.pdf
https://github.com/alexander-titov/public/blob/master/confer...
GPGPU - what it is and why you should care -- Alexander Titov -- CoreHard 2019.pdf