$cat ~/posts/bend-ml-v3

bilíngue · en / pt

← /blog

bend-ml depois do lançamento: copiar era o custo, e o bug era do compilador

Linguagens e compiladores#Bend#compiladores#performance

O post anterior contou como eu construí o bend-ml, uma biblioteca de machine learning em Bend onde erro de shape não compila. Ele terminou com uma frase sobre o que viria depois:

$ quote ~/posts/bend-ml

O próximo passo óbvio é dividir o array de pesos entre as tarefas sem copiar: como o Array é uma árvore por baixo, dá para separar os galhos e entregar um para cada tarefa.

um pytorch onde erro de shape não compila2026-10-04pt

Eu dei esse passo. No caminho, o paralelismo sumiu sem nenhum erro, e o motivo estava no compilador do Bend, não no meu código. Abri uma issue, fui atrás da causa, e a correção foi aceita no repositório oficial. Este post conta esses três dias, do jeito que o outro contou os dois primeiros: explicando cada peça antes de usar, e com todos os números, inclusive os que não melhoraram.

  1. 2026-10-04v2.11 diao estado do post anterior: array plano, CI, 24 leis.
  2. 2026-10-05lançamento e v31 diaas perguntas do lançamento, as bandas, e o paralelismo que sumia.
  3. 2026-10-06v3.1 e três issues1 diacarregar os pesos, o custo com uma thread, e três relatos para o time do bend.
  4. 2026-10-07a correçãoa causa no compilador, a PR, e o merge.
cada barra é o intervalo até o marco seguinte.

as perguntas do lançamento

No dia 5 eu publiquei o projeto no X, respondendo à lista de desejos do Taelin. Vieram três perguntas que viraram o plano da v3.

A primeira: "o dfdx já não faz isso?". O dfdx é uma biblioteca de ML em Rust que também confere shapes antes de rodar, usando um recurso do Rust chamado const generics: números fixos dentro do tipo. A pergunta era justa, e a resposta está num detalhe: números fixos. Quando o tamanho só é conhecido com o programa rodando (o tamanho do batch lido de um arquivo, por exemplo), ele vira uma variável comum no Rust, e a partir daí o dfdx confere o shape do jeito antigo, com uma verificação em tempo de execução que derruba o programa se falhar.

No Bend, um tamanho que só existe em tempo de execução continua sendo uma variável dentro do tipo. Este reshape reorganiza uma matriz de n linhas e 6 colunas em n·2 linhas e 3 colunas, com n vindo da linha de comando:

def regroup(+n: Nat, x: T.Mat<n, 6n>) -> T.Mat<Nat.mul(n, 2n), 3n>:
  T.Mat.reshape(n, 6n, Nat.mul(n, 2n), 3n, NL.mul_assoc(n, 2n, 3n), x)

Lembra que o reshape exige uma prova de que o total de números não muda? Aqui o total é n·6 de um lado e (n·2)·3 do outro, e a prova é mul_assoc, a associatividade da multiplicação, um dos lemas do primeiro pacote. Ela vale para todo n, então o checador aceita a função uma vez, para qualquer tamanho que chegar. Pedir n×5 em vez disso não compila: não existe prova de que n·6 é igual a n·5.

Isso pega um bug que nenhum checador de números fixos consegue pegar. No GPT-2, a projeção de saída da atenção tem pesos de 768×768. Os pesos ficam guardados uma linha por saída, então o produto certo é X·Wᵀ. Se alguém escreve X·W, com 768 dos dois lados os shapes batem do mesmo jeito, e o programa roda e dá números errados. Escrita para qualquer tamanho de entrada e de saída, a mesma camada não compila:

def proj(+n: Nat, +din: Nat, +dout: Nat, x: TA.Mat<n, din>, w: TA.Mat<dout, din>) -> TA.MMul<n, din, dout>:
  TA.Mat.matmul(n, din, dout, 0n, x, w)
Error:
- expected : TA.Mat<din, dout>
- observed : TA.Mat<dout, din>

Para o checador, din e dout são nomes diferentes, então W tem dout linhas e não din, mesmo que o programa só use a camada com 768 e 768.

A segunda pergunta foi sobre tempo de compilação: com tipos tão detalhados, o que acontece numa rede funda? Medi. Checar 8 camadas com tamanhos todos diferentes leva 0,22 segundo; 128 camadas, 0,27. O build é dominado pelo clang, o compilador de C: de 1,1 a 4,7 segundos. O GPT-2 inteiro checa em 0,38 segundo.

A terceira não foi uma pergunta. O autor do Bend-Conv, uma biblioteca de tensores e convolução em Bend, comentou que o custo real do paralelismo estava em copiar dados. Era exatamente o problema do experimento 3 do post anterior, e foi o que ocupou o resto da v3.

copiar era o custo

Relembrando o experimento 3: para gerar texto, o GPT-2 faz produtos de matriz por vetor, uma linha só de entrada. Paralelizar isso dividindo a matriz em blocos deixou tudo 10 vezes mais lento, porque cada tarefa recebia uma cópia inteira dos pesos, e copiar custava mais que fazer a conta. A frase do fim do post era a saída: em vez de copiar, entregar a cada tarefa só o seu pedaço.

O Bend-Conv já guardava matrizes em faixas de linhas. A ideia que eu peguei dele foi essa, e o que eu acrescentei foi o tipo. No pacote, a matriz em faixas se chama Bands:

type Bands<-r: Nat, -c: Nat> is Type:
  BLeaf{m: Mat<r, c>}
  BNode{x: Bands<half(r), c>, y: Bands<Nat.sub(r, half(r)), c>}

Uma Bands de r linhas é uma de duas coisas. Ou é uma folha (BLeaf) com uma matriz comum de r linhas, ou é um nó (BNode) que divide as linhas em duas metades, cada uma sendo outra Bands: a de cima com half(r) linhas, a de baixo com o resto. Cada folha é uma banda, com o seu próprio array.

dividir a árvore não copia nada: cada tarefa recebe o seu galho. o que se copia é só o vetor de entrada, que é pequeno.

O detalhe que faz isso funcionar sem truque nenhum é que o Array do Bend é, por baixo, uma árvore comum da biblioteca padrão. Abrir um nó com match entrega as duas metades na hora, sem copiar, e sem precisar de @unsafe. Por causa dos tipos afins, quem abre o nó passa a ser dono das metades, e pode entregar uma para cada tarefa. O vetor de entrada x ainda é copiado para cada tarefa, mas ele tem 768 números, contra os 38 milhões da maior matriz do GPT-2.

E o tipo garante a parte que dá errado fácil: as duas metades somam exatamente r linhas, nenhuma se perde e nenhuma conta duas vezes. Isso é uma lei:

law half_cover:
  for +n: Nat
  {n == Nat.add(half(n), Nat.sub(n, half(n))) : Nat}

Uma matriz montada com o número de linhas errado não compila. E cada função que percorre a árvore carrega uma prova apagada, {rr == r}, amarrando o número de linhas usado em tempo de execução (rr) ao do tipo (r). A prova não custa nada rodando, e foi ela que me deu trabalho, como você vai ver daqui a pouco.

Experimento 11. O mesmo produto de matriz por vetor, nos shapes do GPT-2, com 16 threads (nas bandas, o melhor entre 16 e 32 bandas):

matrizsequencialblocos com cópiabandas, sem cópia
2304 × 7680,80 ms10,4 ms0,32 ms
3072 × 7681,07 ms23,5 ms0,40 ms
50257 × 76825,1 ms337 ms4,5 ms

A coluna do meio é a ideia do experimento 3, com blocos que copiam os pesos: mais lenta que fazer tudo numa thread só. Sem cópia, a maior matriz, a dos logits (a que dá a nota de cada um dos 50257 tokens), ficou 5,6 vezes mais rápida que a sequencial. E com o mesmo resultado bit a bit: cada banda roda a mesma conta do produto sequencial, então os 11 textos de teste do GPT-2 dão os mesmos tokens e os mesmos logits da v2.1, dígito por dígito.

Mas essa tabela é do fim do dia. A primeira versão das bandas não era mais rápida.

o paralelismo que sumia

A primeira versão compilava, dava o resultado certo e levava o mesmo tempo com 1 e com 16 threads. Nenhum erro, nenhum aviso.

Para entender por quê, precisa de um pedaço de como o Bend paraleliza. No Bend, você escreve duas chamadas lado a lado num mesmo let, e ele roda as duas ao mesmo tempo:

p q = bmv(..., metade_de_cima, ...) bmv(..., metade_de_baixo, ...)

Isso se chama um let paralelo. O compilador transforma o programa em C, e no C, para rodar as duas chamadas em paralelo, ele cria uma função de join: a parte que espera as duas metades terminarem e junta os resultados. Se o join não existe, as duas chamadas viram uma sequência comum, uma depois da outra.

o resultado é o mesmo nos dois casos. só o tempo muda, por isso nenhum teste reclama.

O que me levou à causa foi ler o C que o compilador gera (bend X.bend -o x.c). Na versão lenta, a função de join não estava lá. E a diferença entre a versão lenta e uma que funcionava era só a ordem dos parâmetros:

bmv(+c, -r, b, xp, +rr, -e)    sem join: 1 núcleo
bmv(+c, -r, b, +rr, -e, xp)    com join: 16 núcleos

O -e é a prova apagada {rr == r}. Ela some do programa compilado, então em tese a posição dela não deveria importar. Mas quando ela era o último parâmetro, o let paralelo virava duas chamadas em sequência. Mudei a prova de lugar, com um parâmetro de verdade depois dela, e o join voltou.

Diminuí o problema até o menor programa que mostrava o mesmo efeito: uma árvore de chamadas que soma folhas, com um parâmetro apagado -z que não faz nada. Com -z no fim, 1,01 segundo com 1 thread e 1,01 com 16. Com -z no meio, 0,17 com 16. Na matriz de logits do GPT-2, a mesma troca de posição levou o produto de 26,7 ms para 3,3 ms.

Teve uma segunda armadilha, menor. A função half, que calcula o tamanho da metade, era recursiva do jeito mais simples (half(n) = 1 + half(n − 2)) e era chamada em cada nó da árvore. Uma recursão assim, que ainda tem conta para fazer depois de voltar, atrapalha o paralelismo em volta dela. Reescrita como um laço que carrega o resultado num acumulador, a mesma bateria de 40 produtos caiu de 0,69 para 0,36 segundo.

As duas armadilhas viraram regra no projeto: quando código paralelo não escala, compare o tempo com 1 e com 16 threads e leia o C gerado. E para a primeira não voltar, o CI compila o produto em bandas para C e falha se a função de join não estiver lá. Conferi que a checagem pega o erro: com a prova de volta no fim, ela falha.

No GPT-2, gerando 8 tokens, a v3 ficou mais rápida que a v2.1 com 16 threads. Os números exatos variaram com a carga da máquina, e eu volto neles no fim. O que também apareceu: com uma thread, a v3 era uns 12% mais lenta que a v2.1. Isso foi o começo da v3.1.

v3.1: uma thread, carregar os pesos, e um resultado negativo

Com uma thread só, as bandas não ajudam: as tarefas rodam uma depois da outra, e o que sobra é o custo de montar a árvore e juntar os resultados. Encontrei duas causas. A API com tipos convertia o resultado para Mat e o demo convertia de volta para lista; um Bands.matvec_l, que recebe e devolve listas, tirou essa ida e volta. E com 2 threads ou menos o demo agora usa uma banda só, que roda exatamente a conta da v2.1. Medindo 36 passadas do modelo:

36 passadas do GPT-21 thread16 threads
v2.14,7 s4,7 s
v3.05,2 s3,8 a 4,2 s
v3.14,8 s3,6 a 3,8 s

O ganho no modelo inteiro é menor que o do produto sozinho porque a atenção, que ainda roda sobre listas, ocupa uma fatia cada vez maior conforme o texto cresce.

Experimento 12. O outro alvo foi carregar os pesos. Um perfil mostrou uns 50 nanossegundos por número, gastos em transformar cada bloco de 1 MB numa lista de bytes, depois numa lista de F32, e só então escrever no array. Decodificando direto para o array, 4 bytes viram um F32 e já vão para o lugar, o carregamento caiu uns 25%.

A ideia seguinte era carregar as 12 camadas ao mesmo tempo. O Bend tem o IO.fork, que dispara várias tarefas de entrada e saída, e o guia da linguagem diz que cada uma roda o seu código puro "em paralelo, em todos os núcleos". Carregando cada camada num IO.fork, ficou mais lento: de 5,1–5,6 para 6,6–7,1 segundos, igual com 1 e com 16 threads. Num teste mínimo, quatro forks de um laço longo levaram 1,86 segundo com 1 thread e 1,73 com 16, com o tempo de CPU igual ao tempo de relógio: um núcleo só. Os mesmos laços num let paralelo escalam. Desfiz a mudança, anotei o resultado negativo e escrevi um relato.

Ficou também uma checagem na fronteira: cada arquivo de pesos precisa ter exatamente r·c·4 bytes, senão o programa para com uma mensagem que diz qual arquivo, quantos bytes esperava e qual script rodar de novo, em vez de carregar meia matriz em silêncio.

a prova que não fechou

A v3.1 tinha uma lei que eu queria muito: "o produto em bandas devolve exatamente r números". Provei metade. band_len diz que uma banda de rows linhas devolve rows números, e join_len diz que juntar duas metades soma os tamanhos. Com as duas, a árvore inteira parecia uma indução fácil: cada nó junta as duas metades, e cada metade, por indução, tem o tamanho certo.

Só que a indução precisa falar das duas chamadas recursivas duas vezes: uma para usar o resultado, outra para dizer o tamanho dele. Cada chamada carrega o array da sua banda, e array é afim, só pode ser usado uma vez. O checador recusou (consumed more than once), inclusive dentro da reescrita da prova. Cópias apagadas também não resolveram, por regras da linguagem sobre onde um valor apagado pode ser usado.

Essa lei ficou fora do README e está nas notas como dívida. O tamanho da saída é coberto por testes: 20 shapes, todas as profundidades da árvore, conferindo cada número. A regra do projeto é que uma lei que ainda não foi provada não aparece como provada.

Um truque que funcionou nas leis menores, e que eu não teria achado lendo o guia: passar a expressão que menciona os arrays só num argumento apagado, que não conta como uso, e os arrays em si uma vez só.

três relatos para o time do bend

No fim do dia 6 eu tinha três coisas que não eram do meu código, eram da linguagem. Escrevi cada uma como relato, com um programa mínimo que reproduz, os números, e o que eu tinha descartado:

  • #1374: um parâmetro apagado no fim da lista transforma o let paralelo em chamadas sequenciais.
  • #1375: o IO.fork roda o trabalho puro num núcleo só, e o guia diz que roda em todos.
  • #1376: um jeito de várias tarefas lerem o mesmo array sem copiar e sem @unsafe. Hoje a única forma, Array.fork, é @unsafe, e um pacote com isso não passa como provado.

Antes de postar, conferi cada um no código mais recente do Bend, não só na versão que o projeto usa. Os três continuavam lá.

indo atrás da causa

Contornar no meu código resolvia o meu caso. A #1374 continuava lá para qualquer outra pessoa que escrevesse uma prova no fim da lista, e pior: sem erro nenhum, só lento. Então, no dia 7, eu clonei o compilador.

O compilador do Bend 2 é escrito em TypeScript, e a parte que gera o código fica num arquivo só, o comp.ts, com um limite de tamanho que o próprio repositório confere. Eu não conhecia o código. Coloquei logs temporários no passo que reorganiza as chamadas antes de gerar o C, rodei o programa mínimo nas três versões (prova no fim, prova no meio, sem prova) e comparei o que cada uma imprimia.

A causa, em português: antes de gerar o C, o compilador quebra uma chamada com vários argumentos em pedaços, aplicando um argumento de cada vez. Quando ele encontra um pedaço que já tem todos os argumentos que existem em tempo de execução, trata esse pedaço como uma chamada completa e separa ela num passo próprio, sequencial. Com a prova no meio, o pedaço antes dela ainda está incompleto, e nada é separado. Com a prova no fim, o pedaço antes dela já tem todos os argumentos de verdade. O compilador separava a chamada como um passo comum, e o let paralelo ficava com duas variáveis prontas em vez de duas chamadas: nada para paralelizar, nenhum join.

A correção: só um argumento que existe em tempo de execução pode fazer o compilador separar a chamada. Um argumento apagado fica onde está, como já ficava quando estava no meio. No comp.ts, foram cinco linhas:

- if (m.t.$ === "Var" && !m.args.includes(f.x)) {
+ const on = m.args.includes(f.x);
+ if (m.t.$ === "Var" && !on) {
    return spine(f.f);
  }
- const g = cut(spine(f.f), ty_ann(f.f));
- const x = m.args.includes(f.x) ? go(f.x, false, null) : f.x;
+ const g = on ? cut(spine(f.f), ty_ann(f.f)) : spine(f.f);
+ const x = on ? go(f.x, false, null) : f.x;

O on diz se o argumento f.x está entre os que existem em tempo de execução. O cut é o que separava a chamada; agora ele só acontece quando on é verdadeiro. Com a correção, o programa mínimo foi de 1,01 para 0,17 segundo com 16 threads, e o bmv do bend-ml ganhou o seu join mesmo com a prova no fim.

A parte que levou mais tempo não foi a correção, foi provar que ela não quebrava nada. Mexer no passo que todo programa atravessa pode mudar qualquer coisa. Então eu gerei o C e o JavaScript de todos os 1.659 programas do repositório do Bend (testes, benchmarks e demos) com o compilador antes e depois, e comparei byte a byte. O JavaScript ficou todo idêntico. No C, dois arquivos mudaram, e os dois têm explicação: num deles, oito chamadas que o bug separava viraram chamadas de cauda (a última coisa que a função faz, sem nada guardado para depois), pelo mesmo motivo do bug; no outro, só o nome de uma variável interna mudou, e o binário saiu idêntico. Rodei cada teste nos três jeitos que o Bend executa (interpretador, C e JavaScript), antes e depois: mesma saída. Os 17 benchmarks geraram binários idênticos. E disse na PR o que eu não tinha como rodar, como os testes que precisam do cluster de máquinas do time.

Escrevi também um teste no formato do repositório. Ele tem uma limitação que eu deixei escrita nele: a saída de um programa não mostra se ele rodou em paralelo, então o teste confere o resultado em todos os caminhos, mas não pegaria a correção sendo desfeita. A prova de que o join voltou é o C gerado e os tempos, que estão na descrição da PR.

o merge

A PR foi aprovada e entrou no repositório oficial no mesmo dia, sem pedido de mudança, do jeito que foi enviada. A #1374 fechou junto. Foi a minha primeira contribuição para o Bend.

Ela ainda não está numa versão lançada: a 2.0.36 saiu horas antes do merge. Até a próxima, o bend-ml continua fixado na 2.0.35, com a prova fora do fim no bmv. Quando a versão sair, o contorno pode ir embora, mas a checagem do join no CI fica. A #1375 e a #1376 continuam abertas, com o time do Bend.

os números, sem enfeite

Tudo no mesmo notebook do post anterior (Core Ultra 7 155H, 22 threads, 32 GB). Desta vez a máquina não estava ociosa: um emulador de Android e um daemon do Gradle rodaram a sessão inteira. Por isso as versões foram medidas alternadas, nas mesmas condições, e os números aparecem como faixas.

GPT-2 smallv2.1v3.0v3.1
36 passadas, 16 threads4,7 s3,8 a 4,2 s3,6 a 3,8 s
36 passadas, 1 thread4,7 s5,2 s4,8 s
carregar os pesos6 a 7 s5,1 a 6,1 s3,9 a 5,1 s
pico de memória1462 MB895 MB903 MB
produto matriz · vetor, 16 threadsv2.1v3
2304 × 7680,80 ms0,32 ms
50257 × 76825,1 ms4,5 ms

O que não melhorou ou ficou sem explicação:

  • com uma banda só, na matriz de logits, o produto com listas é uns 25% mais lento que o da v2.1 (29,6 contra 23,5 ms). É o resto do custo de uma thread. Causa provável: o resultado de 50 mil números montado como lista, contra escrever num array. Não investiguei.
  • o tamanho virtual máximo do processo foi de 10,6 GB para 41 GB. É espaço reservado, não memória usada (o pico real caiu), mas a causa eu não investiguei.
  • o gráfico do README ainda mostra os números da v2, até eu conseguir medir com a máquina ociosa.

o que ficou

27 leis em 5 pacotes, todas passando no --verdict, sem @unsafe e sem ?TODO. O bend-ml-tensor-array foi para a versão 0.1.5.0 no BendHub, com as bandas. O CI roda 43 checagens, GPT-2 e MNIST incluídos, agora também no branch de desenvolvimento, e a versão estável só chega no principal por pull request.

E o que eu levo destes três dias:

  • a pergunta "o dfdx já não faz isso?" me fez achar o melhor exemplo do projeto: o bug que só aparece quando a camada é escrita para qualquer tamanho.
  • quem apontou o problema da cópia foi alguém que já tinha enfrentado ele em outra biblioteca. Publicar cedo trouxe a ideia certa.
  • quando código paralelo não escala, o tempo com 1 e com 16 threads e o C gerado dizem mais que reler o próprio código.
  • um resultado negativo medido (o IO.fork) vale um relato, e uma lei não provada vale uma linha nas notas, não uma linha no README.
  • quando o problema está na ferramenta, dá para ir até ele. O compilador do Bend era um arquivo que eu nunca tinha aberto, e a correção coube em poucas linhas. O que deu mais trabalho foi a parte chata: mostrar que nada mais mudou.

O código e todas as notas, com o comando para reproduzir cada número, estão no repositório.

acompanhe os próximos textos

Adicione este feed RSS ao seu aplicativo leitor de feeds para acompanhar novos textos. Sem cadastro ou e-mail.

abrir feed RSS ↗