1 pontos por GN⁺ 2024-05-16 | 1 comentários | Compartilhar no WhatsApp
  • O PR #7154 do llama.cpp foi mesclado ao master em 17 de maio de 2024, reescrevendo os cálculos de SiLU e SoftMax para CPU no GGML com uma implementação baseada no expf() vetorizado do llamafile
  • O GGML anterior usava uma tabela de consulta short[65536] por velocidade, mas a nova implementação mira cálculos mais precisos, mantendo o pior erro de arredondamento em 2 ULP em aarch64 e SSE2+
  • No teste de desempenho de CPU SOFT_MAX, SSE2+FMA ficou 1,5x mais rápido, AVX2+FMA 1,9x e AVX512 2,1x; resultados cerca de 1,5x mais rápidos que o master também foram confirmados em AMD Ryzen 9 5950X e M2 Ultra
  • As mudanças incluem a adição de ggml_v_expf() e ggml_v_silu(), a extração de código duplicado para ggml_vec_soft_max_f32(), a remoção de funções relacionadas a GGML_SILU_FP16 e ajustes no caminho condicional de SiLU para SSE2 ou ARM NEON
  • Após o merge, resultados não determinísticos foram reproduzidos ao executar o servidor com >1 slots; depois, a causa foi restringida a -ffinite-math-only, levando a uma restrição de build que exige -fno-finite-math-only

Objetivo das mudanças do PR e status do merge

  • O PR #7154, intitulado ggml : rewrite silu and softmax for cpu, reescreve os cálculos de SiLU e SoftMax no caminho de CPU do GGML no llama.cpp
  • A mudança começou como um upstream da função expf() vetorizada do llamafile
  • O PR foi mesclado ao ggml-org:master em 17 de maio de 2024, e o commit de merge é indicado como 934266c
  • O autor afirmou que o novo método consegue calcular SoftMax e SiLU com mais precisão do que a tabela de consulta short[65536] que o GGML existente usava por velocidade

Precisão e escopo de suporte

  • O novo caminho baseado em expf() dá suporte a aarch64 e SSE2+, com pior erro de arredondamento apresentado como 2 ULP
  • Na explicação inicial, implementações AVX2 e AVX512 também tinham sido escritas, mas não foram incluídas porque o benefício não parecia grande o suficiente para justificar a complexidade de código em comparação com SSE2+FMA
  • Posteriormente, com base nos resultados de benchmark, o código AVX2 e AVX512 também foi incluído
  • Em uma saída de teste separada, foi apresentado 4294967296 numbers tested successfully, incluindo comparações de resultados entre exp e a implementação do llamafile para vários valores de entrada

Escopo das mudanças de código

  • As principais mudanças resumidas pelo revisor foram as seguintes
    • Remoção de um #define comentado
    • Extração de 5 linhas duplicadas para ggml_vec_soft_max_f32()
    • Remoção de várias funções relacionadas a GGML_SILU_FP16
    • Adição de ggml_v_expf()
    • Adição de ggml_v_silu()
    • Ajuste das diretivas de pré-processador para que ggml_vec_silu_f32() use funções diferentes conforme as flags SSE2 ou __ARM_NEON
  • O número de arquivos alterados aparece como 1 nos metadados do GitHub
  • O PR recebeu os rótulos refactoring e Review Complexity : High; este último inclui a explicação de que pode ser necessário conhecimento profundo de LLM ou GPU

Benchmarks e resultados de desempenho

  • ggerganov confirmou que SOFT_MAX ficou cerca de 1,5x mais rápido que o master em AMD Ryzen 9 5950X e M2 Ultra
  • O comando de teste usado foi o seguinte
make -j tests && ./tests/test-backend-ops -o SOFT_MAX -b CPU perf
  • Depois, o autor afirmou que, no mesmo comando, os ganhos de desempenho aumentaram da seguinte forma
    • SSE2+FMA: 1,5x
    • AVX2+FMA: 1,9x
    • AVX512: 2,1x
  • Em um script de desenvolvimento separado, os seguintes números foram apresentados
    • run_expf(): 2.98601 ns
    • run_llamafile_expf_sse2(): 1.35154 ns
    • run_llamafile_expf_avx2(): 1.16659 ns
    • run_llamafile_expf_avx512(): 1.18844 ns
  • O benchmark llama.cpp server do GitHub Actions registrou 543 iterations em Standard_NC4as_T4_v3 com a configuração phi-2 q4_0
    • Usuários simultâneos: 8
    • Duração: 10 minutos
    • Média das requisições HTTP: 8626.19ms
    • p95: 21696.44ms
    • Média de processamento de prompt: 94.59 tk/s
    • Média de geração de tokens: 33.43 tk/s

Discussão sobre otimização AVX512

  • chriselrod sugeriu o uso de vscalefps no AVX512
    • vscalefps calcula zmm0 = zmm1 * 2^{zmm2}
    • Ele disse que isso pode remover checks e blends ao tratar overflow e underflow adequadamente
  • Um exemplo de implementação em Julia e um loop em assembly foram compartilhados, e, se o teste estivesse correto, o erro máximo em x=47.483456f teria sido menor que 1 ULP
  • Foi explicado que a abordagem com vscalefps não usa lookup table e que, na implementação Float64/double, é usada uma lookup table de 16 elementos via vpermi2pd
  • Depois, um link para a implementação em C++ também foi compartilhado
    • ExpAVX512
    • O código-fonte está em include/ExpAVX512.hpp
    • O README inclui benchmarks, mas foi dito que não houve benchmark comparativo com outras implementações

Problema de não determinismo após o merge

  • Após o merge, foi relatado um caso reproduzível em que o servidor produzia resultados não determinísticos ao usar >1 slots
  • O procedimento mínimo de reprodução era o seguinte
make clean && make server
./server -m models/opt/llama_2-7b-q4_0.gguf --parallel 2 --threads 1
  • A requisição executada em outro shell era a seguinte
curl --request POST --url http://localhost:8080/completion --header "Content-Type: application/json" --data '{"prompt": "", "n_predict":10, "n_probs": 2, "temperature": -1}' | python3 -m json.tool
  • As probabilidades de token do último token alternavam entre dois valores a cada chamada curl; com 4 slots, alternavam entre quatro valores possíveis

-ffinite-math-only e restrições de build

  • Commits relacionados posteriores mencionam que a causa do problema foi restringida a -ffinite-math-only
  • Foi registrado que a suspeita era de que o SiLU retornasse NaN ou outros valores lixo em vez de fazer flush de valores pequenos para 0
  • A correção verifica se -fno-finite-math-only está definido e força uma checagem de que o modo de compilação não seja finite math
  • A mensagem de erro informa que algumas rotinas do GGML exigem aritmética matemática não finita e orienta a passar -fno-finite-math-only ao compilador
  • Depois, usuários compartilharam experiências de que -Ofast ou -ffast-math podem incluir -ffinite-math-only e quebrar o build
    • Há um relato de que era possível usar -Ofast até o GCC 13.2, mas, a partir do GCC 14, os resultados viraram lixo
    • Em alguns testes, além de -fno-finite-math-only, -fmath-errno também teria sido necessário
    • Commits subsequentes em vários repositórios referenciaram a correção de erros de compilação do ggml ao remover -ffast-math ou especificar -fno-finite-math-only

1 comentários

 
GN⁺ 2024-05-16
Opiniões no Hacker News
  • Há uns 20 anos, quando eu programava para o processador de sinais de radar da Hughes, precisava calcular e^x no intervalo 0 < x < 1
    Aquele processador tinha multiplicação, então criei 4 tabelas de e^x, com os 256 valores possíveis para cada um dos 4 blocos de 8 bits de uma palavra de 32 bits, e multiplicava esses valores para obter o resultado final
    Era cerca de 5 vezes mais rápido que a melhor rotina de e^x anterior e, embora hoje seja obsoleto, era uma máquina interessante que por um tempo processava sinais de radar mais rapidamente do que processadores nominalmente muito mais rápidos

    • Se ficou difícil de acompanhar, a ideia parece ser mais ou menos e^x = e^(a+b+c+d), em que a/b/c/d são os bytes de x, transformando isso em e^a * e^b * e^c * e^d e então criando uma lookup table de e^a, e^b para cada um
      A rigor, como a é algo como high byte << 24, a tabela de e^a acaba sendo um mapeamento a => e^(a<<24), e os outros bytes são tratados de forma parecida
  • Fico curioso para saber quanto essas melhorias em silu e softmax afetam a velocidade geral de inferência de LLMs
    Corrijam-me se eu estiver errado, mas como a maior parte do tempo é gasta em multiplicação de matrizes, acho que o efeito dessa mudança seria pequeno

    • É verdade que a maior parte das operações de ponto flutuante é gasta em multiplicação de matrizes, mas o softmax usa uma quantidade desproporcional de largura de banda de memória, então normalmente leva bem mais tempo do que se esperaria olhando apenas para a quantidade de operações
  • Um pouco fora do assunto, mas ao dar uma olhada pensei: “isso parece uma otimização bem insana. É um código complexo e que muita gente já viu”, e então olhei o colaborador e pensei: “claro, é o jart. Soluções insanamente boas são sempre do jart”

    • O principal motivo de parecer assustador é que a sintaxe de intrinsics em C/C++ é assim mesmo
      Como muita coisa nessa área, esse sofrimento é em boa parte autoinfligido
      Sei que existem bibliotecas C++ que permitem uma sintaxe de SIMD e hardware intrinsics no estilo C#, mas elas têm a desvantagem de dificultar a consulta direta dos mnemônicos na documentação do conjunto de instruções
      Não estou tentando diminuir a importância do trabalho feito aqui; só quero dizer que talvez pudesse ser mais acessível a um público mais amplo. Dito isso, ainda não vou propor o que todos aqui achariam absurdo: reescrever o backend de inferência em C#
    • adapted from arm limited optimized routine, então no fim das contas está sobre os ombros de gigantes
    • Acho que esse tipo de coisa não é o que se ensina em aulas de análise assintótica
      Lembro de um professor que dizia, de forma famosa, que “aquela constante que todo mundo ignora pode, na engenharia, comer sua cabeça inteira”
  • Dizem que substitui uma lookup table short[65536], mas isso não parece uma escolha meio lenta desde o começo?
    É basicamente ter uma lookup table do tamanho inteiro do cache L1; será que funciona surpreendentemente bem porque, probabilisticamente, dá mais ou menos certo?

    • O motivo de lookup tables funcionarem surpreendentemente bem é que a própria carga de trabalho é extremamente inamistosa ao cache
      Não faz muita diferença detonar o cache L1, e os dados expulsos para colocar a LUT provavelmente não seriam reutilizados de qualquer forma
      Cargas de machine learning em geral são loads em streaming, lendo linearmente o conjunto de dados inteiro a cada iteração
    • Este texto sobre por que provavelmente não se deve usar lookup tables https://specbranch.com/posts/lookup-tables/ aborda em que situações elas são apropriadas em geral
      Pela minha experiência limitada, dá para fazer bastante computação em tempo real antes de ela ficar mais lenta que um lookup
  • No llama.cpp, isso é para CPU

    • Originalmente foi desenvolvido para o llamafile e entrou nas duas versões mais recentes: https://github.com/Mozilla-Ocho/llamafile/releases/tag/0.8.2
      Agora estão fazendo upstream disso para o projeto llama.cpp
      Há outras melhorias de desempenho que no momento só estão disponíveis no llamafile, por exemplo o trabalho do Kawrakow que deixou os K quants muito mais rápidos
  • Talvez seja meio fora do assunto, mas alguém sabe como algo como ggml se compara a runtimes como tensorflow lite e onnxruntime?

    • Eu mantenho bibliotecas Flutter de ONNX e llama.cpp em todas as 6 True Platforms, então conheço isso razoavelmente bem
      Resumindo: para LLMs, llama.cpp é a escolha certa; com a dependência central dele, GGML, também dá para rodar whisper
      Para o resto, use ONNX
      TF é como a Apple do mundo de machine learning: se você está totalmente preso ao ecossistema de ML do Google, é excelente, mas fora disso está praticamente morto. Uma proporção absurda dos modelos do HF, algo como 94%, é PyTorch
      Uma comparação direta de desempenho de inferência que valeria fazer seria ONNX Whisper contra GGML, mas alguém rodou minha biblioteca llama.cpp com Whisper e não relatou diferença significativa de desempenho
    • Depende de exatamente qual hardware você está falando
  • No momento, para inferência sem batching em dispositivos CUDA, gguf/llama.cpp é a solução com melhor desempenho ou exllamav2+flashattention ainda está na frente?

    • Em 2x 4090, a diferença é desprezível
      Há diferenças mais importantes, como cache KV de 4 bits
  • LUTs também podem ser vetorizadas
    https://www.intel.com/content/www/us/en/docs/intrinsics-guid...
    Também escrevi no passado sobre o que é possível fazer com LUTs https://darkcephas.blogspot.com/2018/10/validating-utf8-stri...

    • Verdade, mas mesmo implementando exp diretamente, dependendo da precisão desejada, bastam cerca de 10 a 20 FMAs
      É difícil gather ou permutation competir com computação pura
  • Em uma linha parecida, também há um tanh mais rápido https://github.com/microsoft/onnxruntime/pull/20612

    • Excelente trabalho
      Mas qual é o objetivo? Tornar aquela aproximação de GeLU mais rápida?
      Se voltarem para erff(), provavelmente ficaria muito mais rápido
  • Isso também ajuda nos casos de uso de offloading parcial para GPU do gguf?
    O lado da CPU também fica mais rápido?