Simulando 20 milhões de partículas com JavaScript
(dgerrells.com)- Partindo de uma simulação de partículas em JavaScript usando apenas CPU, o objetivo de 1 milhão de partículas a 60 fps no celular foi alcançado, e o exemplo final foi escalado até 20 milhões de partículas
- O ponto central do desempenho foi evitar arrays de objetos e manter os dados das partículas em memória contígua com TypedArray e SharedArrayBuffer, dividindo o trabalho por núcleo de CPU com Web Workers
- O gargalo real apareceu mais no desenho na tela e no acesso à memória do que no cálculo da posição das partículas, e escritas aleatórias no buffer de pixels causavam cache misses da CPU
- O gargalo foi reduzido gradualmente passando por buffers de pixels por Worker, sincronização via postMessage, double buffering, uma grade de número de partículas por pixel e renderização de textura na GPU
- A arquitetura final usa a CPU para simular e a GPU para renderizar uma textura de grade do tamanho da resolução da tela, processando 20 milhões de partículas a cerca de 20 fps em um Mac M1
Objetivo e abordagem básica
- O desafio era simular 1 milhão de partículas a 60 fps em um celular usando apenas JavaScript puro e CPU, sem WebAssembly
- Considerando que um simples array de objetos JavaScript teria limitações em desempenho single-core móvel e layout de memória, foi escolhida uma abordagem de tratar os dados como arrays contíguos
- Em cálculos em grande escala, arrays de memória contígua e compacta, capazes de colocar o máximo de dados possível no cache da CPU, são importantes
- Arrays e objetos comuns de JavaScript dificilmente garantem um layout tão denso quanto arrays ou
structsde linguagens de baixo nível TypedArraypermite criar arrays de bytes contíguos de tamanho fixo, possibilitando um gerenciamento de memória relativamente baixo nível em JavaScript
Primeira implementação: SharedArrayBuffer e Web Worker
- Os dados das partículas são armazenados colocando uma visão
Float32Arraysobre umSharedArrayBuffer- Uma partícula é representada por quatro valores de ponto flutuante de 32 bits:
x,y,dx,dy - O buffer plano é indexado com
stride = 4ebyte_stride = stride * 4
- Uma partícula é representada por quatro valores de ponto flutuante de 32 bits:
- A simulação começa com uma atualização simples de posição em cada frame, executando
x += dx,y += dy - O multithreading é composto por Web Workers e
SharedArrayBuffer- A thread principal divide e entrega blocos de partículas para cada Worker
- Cada Worker lê e escreve apenas o bloco atribuído a ele, evitando que várias threads escrevam na mesma posição de memória
- Para usar
SharedArrayBufferno navegador, os seguintes headers são necessáriosCross-Origin-Embedder-Policy: require-corpCross-Origin-Opener-Policy: same-origin
- A sincronização inicial usa um SharedArrayBuffer para sinalização separado
- A thread principal escreve um sinal de execução para o Worker
- O Worker verifica o sinal com
setInterval(..., 1)e registra o estado de pronto quando termina o trabalho
- A renderização é tratada como um rasterizador baseado em CPU usando
ImageData- Cada partícula é desenhada como um pixel da tela
- Partículas fora da tela são ignoradas para evitar acesso fora dos limites do array
- O resultado é desenhado no Canvas com
putImageData
- No profiling inicial, o trabalho dos Workers ficava na casa de poucos ms, e a maior parte do tempo era gasta na renderização na thread principal
- A memória era alocada uma vez no início, e nenhum objeto novo era criado no loop de simulação, gerando praticamente nenhum garbage
Segunda implementação: entrada e interação parecida com gravidade
- Os dados de entrada são adicionados ao
SharedArrayBufferexistente para o estado da simulação- Armazenam delta time, x/y do mouse, estado de toque e largura/altura da tela
- O estado de toque é um valor próximo de booleano, mas usa 4 bytes
- Ao tocar na tela, foi adicionado um efeito em que as partículas são atraídas para o ponto de toque
- A forma
1 / r²de uma fórmula real de gravidade foi usada como referência, mas modificada visando uma interação divertida em vez de uma simulação física precisa - Foi adicionada uma atenuação equivalente a atrito para impedir que as partículas acelerem indefinidamente e voem para fora da tela
- A forma
- A cor da renderização foi modificada para mudar conforme a posição da partícula
- Os valores
x / widthey / heightsão usados para ajustar os canais RGB - Como armazenar a cor por partícula aumentaria o tamanho das partículas, foi usada uma cor baseada na posição para reduzir o tamanho dos dados
- Os valores
- Foi possível experimentar até 2 milhões de partículas, e o número de partículas passou a poder ser alterado pelo parâmetro de query
countda URL - Com 10 milhões de partículas, o framerate caiu, e o profiling mostrou que a parte lenta era a escrita de pixels em ImageData, mais do que os Workers
- Ao escrever apenas os canais azul e alfa em vez de todo o RGB, em alguns ambientes o tempo de renderização caiu de cerca de 80–90 ms para 16–18 ms
- Essa diferença variava conforme a CPU e foi maior em um desktop Ryzen do que em um MacBook Air M1
Terceira implementação: escrita do buffer de pixels nos Workers
- Para usar mais núcleos de CPU, os Workers passaram a assumir não apenas a simulação, mas também o desenho dos pixels
- Para evitar que vários Workers escrevessem simultaneamente no mesmo buffer de pixels, foi criado um buffer de pixels por Worker
- É usado um
SharedArrayBufferde tamanhoCPU_CORES * window.innerWidth * window.innerHeight * 3 - Cada Worker possui um buffer RGB da tela inteira
- É usado um
- A thread principal soma todos os buffers de pixels de cada Worker e depois grava em
ImageData - Nesta versão, a tela passou a piscar
- Enquanto um Worker limpava seu buffer de pixels com
fill(0), a thread principal estava lendo o mesmo buffer - Antes, a renderização também podia ler dados antigos, mas a diferença entre frames não era perceptível; agora a operação de limpeza do Worker aparecia como piscadas em partes da tela
- Enquanto um Worker limpava seu buffer de pixels com
- As possíveis soluções eram sincronização baseada em
Atomics, esperar a conclusão dos Workers compostMessageou double buffering
Quarta implementação: sincronização baseada em postMessage
- Para eliminar as piscadas, o array de sinalização foi removido e os Workers passaram a avisar a conclusão via
postMessage - A thread principal gerencia o valor de
activeWorkers- Cada Worker envia uma mensagem ao terminar, e
activeWorkersé decrementado - Quando todos os Workers terminam, a renderização é feita e o próximo
requestAnimationFrameé solicitado
- Cada Worker envia uma mensagem ao terminar, e
- Com essa abordagem, as piscadas foram resolvidas
- O tempo por frame não melhorou muito e podia até piorar
- Isso acontece porque a thread principal não faz nada enquanto espera os Workers terminarem
- Com 2 milhões de partículas, havia situações em que cerca de 7 ms poderiam ser economizados se os Workers não esperassem a renderização da thread principal
- Para reduzir esse gargalo, foi aplicado o double buffering, comum em drivers gráficos
Quinta implementação: double buffering e gargalo de cache
- Double buffering consiste em criar dois buffers de pixels e alternar o buffer ativo a cada frame
- Enquanto a thread principal desenha um buffer, os Workers preparam o próximo frame no outro buffer
- Depois da aplicação, os Workers deixaram de esperar a renderização da thread principal, e alguns frames que antes passavam de 50 ms ficaram mais próximos de 16 ms
- O código que continuava lento eram as três linhas que somavam valores aos canais RGB do buffer de pixels
- A causa era que, embora os dados estivessem armazenados de forma contígua, a ordem de acesso não era contígua
- O
pixelIndexcalculado a partir dex,yda partícula não é alinhado com a ordem do array de partículas - A cada iteração, o loop salta para uma posição arbitrária do buffer de pixels, criando um padrão próximo de acesso aleatório
- O
- Esse padrão de acesso causa cache misses quando os dados de partículas e pixels não cabem todos no cache
- Um exemplo de cálculo para iPhone é o seguinte
- Fórmula:
screen width * screen height * bytes per pixel + bytes per particle * total particles / number of workers 2532 * 1170 * 3 + 16 * 2,000,000 / 5- O resultado passa de 15 MB, grande demais para caber no cache L1
- Fórmula:
- Nesta versão, em um chip M1, a simulação de 2 milhões de partículas leva cerca de 4 ms, o acesso aos dados de pixels cerca de 3,5 ms, e a escrita no buffer de pixels cerca de 7 ms
- Em celulares, foi possível manter 1 milhão de partículas a 60 fps, mas em desktops com mais de 20 threads a thread principal gastava 30 ms a cada frame somando mais de 20 buffers de pixels
Sexta implementação: força para voltar à posição inicial
- O novo efeito faz com que as partículas sejam puxadas de volta com mais força quanto mais se afastam da posição inicial
- Para isso, foram adicionados à partícula dois valores de posição inicial,
sx,sy- Cada partícula passa a ter seis floats:
x,y,dx,dy,sx,sy - O
particleStrideaumenta para 6
- Cada partícula passa a ter seis floats:
- Ao código do Worker foi adicionada uma função de cálculo de força, e um objeto de cache é usado para evitar geração de garbage
- O resultado criou um efeito visual parecido com gelatina ou fluido, com partículas formando redemoinhos como papel ou tecido amassado
- Nesse processo surgiu a ideia de não armazenar o buffer de pixels por Worker, mas trocar para uma grade que conta apenas o número de partículas por pixel
Sétima implementação: grade de número de partículas por pixel
- Armazenar apenas o número de partículas em cada posição de pixel, em vez dos valores RGB, reduz a memória para 1/3
- Como a cor é calculada a partir da posição
x,y, não de dados por partícula, a renderização pode ser feita apenas com a contagem por pixel - A implementação foi alterada para que todos os Workers compartilhem uma única grade de contagem de partículas
- O trabalho da thread principal de somar buffers por Worker desaparece
- O problema de o custo de soma na renderização aumentar conforme cresce o número de Workers é resolvido
- A nova fórmula de cache é a seguinte
screen width * screen height + bytes per particle * total particles / number of workers- No iPhone:
2532 * 1170 + 16 * 2,000,000 / 5 - Cerca de 9,3 MB, aproximadamente 30% menor que antes, mas ainda grande para o cache L1
- A renderização percorre todos os pixels, lê o número de partículas naquele pixel, calcula os valores RGB e depois inicializa a grade com
fill(0) - Após a simulação, o Worker calcula o
pCountIndexdas partículas que estão dentro da tela e executaactiveGrid[pCountIndex]++ - Essa mudança ajudou especialmente quando havia muitos Workers e permitiu usar todos os 24 threads em um desktop
- Ainda assim, em alguns casos o MacBook Air M1 foi mais rápido que um desktop Ryzen 9 3900X
- O Ryzen 9 3900X tem 64 MB de cache L3, mas o L1 é de 64 KB
- O M1 de primeira geração tem 320 KB de cache L1, dos quais 128 KB são para dados e 192 KB para instruções
- Um cache L1 de dados maior parece ter ajudado a reduzir a latência de memória
Limites da otimização somente com CPU
- Como resultado de várias otimizações, foi obtido cerca de 2x de melhoria de velocidade em relação à primeira versão multithread
- O objetivo de processar 1 milhão de partículas a 60 fps em um celular, com CPU e apenas JavaScript, foi alcançado
- O resultado geral não é especialmente impressionante, e a expectativa é que uma linguagem compilada fosse 10x mais rápida
- Haveria espaço para ficar mais rápido se fosse possível usar instruções SIMD no tight loop
- Na etapa seguinte, a simulação permanece na CPU e apenas a renderização é movida para um pipeline tradicional de GPU para comparação
20 milhões de partículas e renderização na GPU
- A primeira comparação com renderização na GPU usou GPU instancing do threejs para desenhar um plane/quad por partícula
- Essa abordagem teve desempenho menor do que o esperado
- O instancing do threejs precisa atualizar uma matriz para cada posição de partícula, e esse trabalho é feito em uma única thread da CPU
- Os dados de transformação das partículas precisam ser enviados para a GPU a cada frame, e essa transferência é lenta
- Ao simular 4 milhões de partículas em um chip M1, 80% do tempo do frame era gasto enviando dados para a GPU
- No desktop, a transferência de dados representava 30%, mas ainda era o maior gargalo
- A alternativa é usar a grade de número de partículas criada antes como textura e renderizar um quad de tela cheia
- A quantidade de dados enviada para a GPU fica fixa na resolução da tela, não no número de partículas
- Em simulações grandes, a quantidade transferida diminui, mas em simulações pequenas pode ser necessário enviar mais dados; é um trade-off
- Com uma implementação rápida usando threejs e um pixel shader customizado, a renderização na thread principal passou a ficar na casa de poucos ms, independentemente do tamanho da simulação
- Nessa arquitetura, os Workers rodam o mais rápido possível, e a simulação fica diretamente atrelada ao número de núcleos de CPU tanto em escalas grandes quanto pequenas
- Cada Worker usa cerca de 50% do tempo em cálculos numéricos e cerca de 50% atualizando a contagem da grade de partículas
- O desempenho da versão final é o seguinte
- Em um Mac M1 na bateria, executa 20 milhões de partículas a cerca de 20 fps
- Um desktop processa cerca de 30 milhões de partículas com fps semelhante
- No teste de um amigo com CPU de 32 núcleos, chegou a cerca de 40 milhões de partículas
- Demos relacionadas:
Resumo
- CPU e GPU conseguem processar cálculos numéricos com muita rapidez
- As partes lentas são a movimentação de dados e, em especial, o acesso aleatório à memória
- Para escrever código rápido, é preciso entender como o hardware armazena dados em cache e os movimenta
SharedArrayBuffere Web Worker foram úteis para experimentos que aproveitam vários núcleos em JavaScript- Quando o WebGPU for adotado de forma mais ampla, há planos de testar compute shaders
1 comentários
Comentários do Hacker News
Seria bom incorporar a simulação diretamente no blog
Tive que rolar bastante para cima e para baixo até encontrar o link da simulação real
Daria para escolher valores que rodem razoavelmente até em celulares antigos, ou ajustar de acordo com a taxa de quadros; ou então apenas colocar alguns links no topo do artigo
https://ciechanow.ski/ é um exemplo de primeiríssimo nível mundial de como pode ser incrível colocar simulações dentro de um artigo
Antigamente, todos os sites costumavam incorporar elementos interativos legais assim
E acho que dá para rodar simulações de partículas na GPU mesmo sem WebGPU
Ex.: https://news.ycombinator.com/item?id=19963640
Eu gostaria que esse fosse o padrão para compartilhar informação. Se algo vale a pena ser compartilhado, também vale a pena torná-lo fácil de entender
Pensei em incorporar cada versão, mas fiquei preocupado com a possibilidade de muitos workers rodarem ao mesmo tempo
Pretendo atualizar o texto para incorporar a versão final no fim
Aquele blog é excelente; cada exemplo tem um nível de acabamento enorme
Edit: tentei adicionar uma versão incorporada, mas os headers necessários não se encaixavam bem com outros embeds, e as versões antigas ainda estão presas ao codesandbox
Testei diretamente a demo mobile [0] e é bem impressionante
[0] https://dgerrells.com/sabby
É surpreendente que dê para fazer isso em JavaScript puro; como o atraso aumenta conforme há mais dedos, parece que mais dados estão indo e voltando entre threads
Por pura curiosidade, fico pensando se, em vez de representar cada partícula com quatro números de ponto flutuante de 32 bits — x, y, dx, dy —, seria possível codificar esses dados em um único número JS
O intervalo de inteiros seguros do JS é
2^53 - 1 = 9,007,199,254,740,991, e o intervalo doFloat32Arrayusado no texto vai de-3.4e38a3.4e38Por exemplo, se a posição na tela for em um canvas 1000x1000, ela pode ser representada por números de 0 a 1.000.000; mesmo adicionando 10 níveis de subpixel, seriam 100.000.000, ainda bem dentro do intervalo do JS
As velocidades
dxedytambém são calculadas como decimais aleatórios entre -10 e +10 com(Math.random()*2-1)*10, mas, se limitar a uma casa decimal for suficiente, daria para convertê-las para uma faixa inteira de -100 a +100, e bastariam 10.000 valores possíveisCombinando os dois,
10,000 * 100,000,000 = 1,000,000,000,000, ou seja, um trilhão de números, daria para representar uma partícula dentro doMAX_SAFE_INTEGERdo JSEntão parece que os dados de uma única partícula poderiam caber em um único
MAX_SAFE_INTEGERou em um único elemento deFloat32Array, o que eliminaria a necessidade de stride e talvez tornasse a consistência dos dados mais garantidaMas o custo de codificar/decodificar pode ser maior que a economia de memória, então talvez não valha nada em termos de desempenho
Há implementações úteis no ecossistema
@thi.ng, e também uma implementação com WASM SIMD[0]: https://github.com/thi-ng/umbrella/tree/develop/packages/vec...
[1]: https://github.com/thi-ng/umbrella/tree/develop/packages/vec...
[2]: https://github.com/thi-ng/umbrella/tree/develop/packages/bit...
[3]: https://github.com/thi-ng/umbrella/tree/develop/packages/sim...
Também existe o Structura, que implementa algumas estruturas de dados de alto desempenho
[4]: https://github.com/zandaqo/structurae/blob/master/README.md#...
Usar
Float16Arrayreduziria imediatamente pela metade a exigência de memóriaOutra abordagem é separar os arrays por precisão
Por exemplo, x e y poderiam usar
Float16Array, e dx/dy poderiam usarInt8Array, mas em ambos os casos podem surgir artefatos de movimento; especialmente comInt8Array, isso pode ficar perceptível por causa do clamp e do aliasing em dx/dyMuito legal, mas fiquei curioso sobre esta parte
Javascript does support an Atomics API but it uses promises which are gross. Eww sick.Exceto por
waitAsync[1], a Atomics API parece não usar promisesJá usei Atomics antes, mas não precisei lidar com código assíncrono/promises
Fiquei curioso se ela usa promises internamente ou se há alguma parte que eu deixei passar
[1] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...
Também existe uma simulação que lida com quase 20 milhões de partículas
Dá para executar e programar: https://tinlizzie.org/~ohshima/shadama2/
https://tinlizzie.org/~ohshima/shadama2/live2017/
https://www.youtube.com/watch?v=tqBO_5GMfWw
O vídeo é legal, mas os links do CodeSandbox em
try it out herenão funcionam no desktop Chrome no MacOSAparecem erros de CORS como
Uncaught ReferenceError: SharedArrayBuffer is not definedeERR_BLOCKED_BY_RESPONSE.NotSameOriginAfterDefaultedToSameOriginByCoepSe você usa um navegador focado em privacidade, isso também pode ser bloqueado
Daqui em diante vou tentar incluir exemplos embedados
A versão final pode ser testada aqui: https://dgerrells.com/sabby
Excelente
Passei uma hora entendendo e mexendo no código, e deixei aqui uma implementação em tempo real: https://particules.kapochamo.com/index.html
É muito bonito, e é difícil acreditar que tantas partículas estejam sendo simuladas em JS
Impressiona que você tenha se aprofundado tanto no código
Vale conferir se
chrome://tracingtraz mais insightsDescobri por aqui: https://youtu.be/easvMCCBFkQ?t=114
Quero muito mandar isso para a equipe de UI que faz apps React lentos
JS é realmente rápido, especialmente quando é bem escrito, é muito rápido
JS rápido parece um dialeto ruim de C, e não se deve fazer alocação de objetos com coleta de lixo, mas a linguagem carece de boas alternativas sem alocação
Em apps JS complexos, quase ninguém vai tratar todas as alocações de memória como ponteiros dentro de um
ArrayBuffergigante; nesse ponto é mais fácil partir para WebAssemblyO lamentável é que não dá para depender só de JS, porque é preciso misturar muita manipulação do DOM, e nas UIs que fazemos a parte lenta costuma ser justamente essa manipulação do DOM