Minha GPU Era Mais Lenta Que a CPU Que Ela Substituiu

Certa vez transferi uma computação pesada da CPU para a GPU, esperando que ela voasse. Em vez disso, ela se arrastou — dramaticamente mais lenta que o código comum que deveria substituir. Eu havia comprado velocidade e, de algum modo, instalei uma lentidão.

O hardware não era o gargalo. Uma GPU moderna é uma das coisas mais rápidas que você pode colocar em um computador. O problema era que eu lhe havia dado um trabalho moldado de uma forma na qual ela é quase singularmente ruim, e então me surpreendi quando ela não conseguiu executá-lo.

O upgrade que foi um downgrade

A expectativa era simples e, eu pensava, segura: GPUs são rápidas, meu trabalho era lento, então mover o trabalho para a GPU deveria torná-lo rápido. Essa é a versão popular de como a aceleração funciona, e ela está errada de uma maneira específica e instrutiva.

Quando medi a versão “acelerada”, ela não estava um pouco mais lenta. Estava mais lenta por um múltiplo grande — o tipo de resultado que lhe diz que o problema não é uma otimização faltante, mas uma incompatibilidade fundamental. Algo na forma do que eu estava fazendo estava ativamente lutando contra o hardware.

O que “colocar na GPU” realmente significava

Eis o que eu realmente havia feito. Eu tinha um loop existente: pegar uma unidade de trabalho, computá-la, passar para a próxima, milhares de vezes seguidas. Para “usar a GPU”, eu havia simplesmente feito cada unidade individual rodar na GPU — e deixado o loop exatamente onde estava.

Então, milhares de vezes seguidas, meu programa se voltava para a GPU, entregava-lhe uma pequena tarefa, esperava pela resposta e se voltava de novo para a próxima. O loop ainda estava no comando. A GPU era apenas um empreiteiro muito rápido para quem eu telefonava, individualmente, a cada prego que eu queria martelar.

O custo oculto: custa algo só para perguntar

Toda vez que você entrega um pedaço de trabalho a uma GPU, há um custo fixo para configurá-lo e despachá-lo — pequeno por si só, invisível se você o paga uma vez. Eu o estava pagando milhares e milhares de vezes. Quando olhei de perto, a maior parte do tempo de execução não era computação alguma. Era sobrecarga: a GPU parada ociosa, esperando que lhe dissessem o que fazer, enquanto meu programa passava o tempo fazendo uma pergunta minúscula atrás da outra.

Eu havia construído um processo que era quase inteiramente o ato de perguntar, com o trabalho de verdade como erro de arredondamento. A versão em CPU tinha sido lenta, mas pelo menos se mantinha ocupada. A versão em GPU era lenta porque passava a maior parte de sua vida esperando.

Uma GPU é um ônibus, não um táxi

O modelo mental que finalmente resolveu isso: uma GPU não é uma forma mais rápida de fazer uma coisa. É uma forma de fazer um número enorme da mesma coisa de uma vez. É um ônibus de cinquenta lugares, não um carro esportivo.

Chamá-la uma vez por item é como rodar aquele ônibus de um lado a outro da cidade com um único passageiro por viagem. O ônibus é genuinamente rápido. Sua vazão é deplorável — e é deplorável precisamente porque você está usando um veículo feito para multidões para mover pessoas uma de cada vez. A culpa não é do ônibus. É do despacho.

A correção: pare de iterar, comece a agrupar em lotes

O reparo não foi um kernel mais rápido ou uma placa melhor. Foi reestruturar o loop. Em vez de “para cada item, envie-o para a GPU”, remodelei o trabalho para que a GPU pudesse pegar muitos itens de uma vez e processá-los juntos — transformando milhares de despachos minúsculos em um punhado de despachos grandes. A sobrecarga que eu vinha pagando repetidas vezes colapsou em algo que passei a pagar poucas vezes, em vez de milhares.

Esta é uma forma comum do que as pessoas querem dizer com “vetorizar” ou agrupar em lotes: expressar muitas operações semelhantes juntas, em vez de como uma longa sequência de pequenos passos. Muitas vezes é a diferença entre uma GPU que envergonha sua CPU e uma GPU que é envergonhada por ela, e o abismo entre as duas pode ser enorme.

Por que esse é todo o propósito do hardware

Levei um tempo para internalizar por que a estrutura importava tanto, em vez de tratá-la como um detalhe de ajuste. A vantagem de uma GPU aqui não vinha de qualquer operação individual ser rápida. Vinha de fazer um vasto número de operações semelhantes ao mesmo tempo. Esse paralelismo é toda a proposta de valor. Se seu código não consegue expressar seu trabalho como “faça tudo isto ao mesmo tempo”, não há nada para o paralelismo agarrar — e uma GPU rodando trabalho serial, um de cada vez, é apenas uma CPU cara, desajeitada e mal moldada.

Então “coloque na GPU” nunca foi a instrução real. A instrução real era “remodele o trabalho para que ele possa ser feito tudo de uma vez”. O hardware sempre seria apenas tão rápido quanto a forma do problema permitisse.

A ferramenta não consegue salvar a forma errada

A lição se generaliza para muito além das GPUs. Recorrer a um hardware mais rápido pouco adianta se a estrutura da sua computação está lutando contra esse hardware. Para um trabalho como este, o desempenho residia mais na forma da computação — como ela era organizada, agrupada em lotes e expressa — do que na velocidade nominal da coisa que a executava. Você pode colocar um algoritmo lento em uma máquina rápida e obter uma decepção rápida.

Mantenho aquela lentidão em mente como um lembrete de que “eu o tornei mais rápido” e “eu o movi para um hardware mais rápido” não são a mesma frase. A primeira é sobre entender o trabalho. A segunda é sobre torcer para que o silício o entenda por você. Ele não vai.

— Sem sinais, sem retornos, isto não é aconselhamento de investimento.