$cat ~/posts/llama-bend

bilíngue · en / pt

← /blog

llama.bend: modelos de linguagem com pesos e cache tipados

IA e machine learning#Bend#LLMs#tipos

O primeiro post sobre o bend-ml começou com uma pergunta pequena: e se uma multiplicação de matrizes com dimensões erradas não pudesse compilar? O seguinte foi atrás do custo de fazer o GPT-2 rodar de verdade, dos pesos guardados em bandas e do paralelismo que desaparecia no C gerado. O llama.bend continua essa história com outra arquitetura e uma exigência a mais: carregar a estrutura do modelo e o estado do cache nos tipos.

Construí um pacote que executa SmolLM2-135M, SmolLM2-360M e SmolLM2-1.7B na CPU. Os três usam as mesmas camadas em Bend, com outra configuração. Nos testes publicados, os tokens gerados e o texto decodificado correspondem ao transformers. Há também 21 leis sobre a estrutura do programa, checadas pelo Bend e novamente com --verdict. A execução continua mais lenta que as referências; carregar o modelo é especialmente caro.

Vou explicar como o texto vira uma sequência de contas, por que algumas dessas contas permitem rejeitar pesos trocados, o que significa um cache causal e onde uma prova deixa de ajudar. Depois vêm o erro numérico que apareceu no modelo maior, a troca do cache e os números, com o contexto de cada comparação. O código e os resultados usados aqui estão nos repositórios públicos llama.bend e bend-ml, em revisões fixas.

  1. 2026-10-09tokenizer e uma camada1 diapreparar os pesos, adaptar a tokenização e comparar as peças da camada.
  2. 2026-10-10o modelo inteiro2 diasgeração, API tipada, leis e os três tamanhos de SmolLM2.
  3. 2026-10-12cache em arrays1 diacabeças em paralelo e a capacidade do cache exigida no tipo.
  4. 2026-10-13Bend 2.0.37atualização do compilador e comparação pública de desempenho.
marcos concluídos registrados nas notas públicas; as barras medem o intervalo até a entrada seguinte.

primeiro, o que esse nome quer dizer

Llama, aqui, é a arquitetura: a organização das operações que transformam os tokens de entrada nos escores do próximo token. Os pesos que usei são os checkpoints públicos da família SmolLM2, identificados como HuggingFaceTB/SmolLM2-*. Um checkpoint é o conjunto de números que um treinamento já encontrou. Escrever as operações em outra linguagem permite usar esses números sem refazer o treinamento.

Isso importa para ler o resultado. Não treinei um modelo Llama neste projeto, e o nome não diz que baixei os pesos de um modelo da Meta. Implementei a inferência, que é calcular a resposta com os pesos já prontos. O programa recebe o texto, lê os pesos preparados e produz tokens. Não há atualização desses pesos durante a geração que este post descreve.

Também não é qualquer modelo que tenha “Llama” no nome. A preparação confere uma combinação específica: SwiGLU, ausência de biases, RoPE comum e pesos de saída compartilhados com o embedding. Cada uma dessas condições corresponde a uma peça do programa. Um modelo com outra variante de posição, por exemplo, não fica suportado só porque suas matrizes cabem na memória. Compatibilidade de arquitetura precisa vir antes da comparação de resultados.

O bend-ml entra como base dessas contas. O llama.bend importa os pacotes de tensores e de tensores em arrays, e adapta a tokenização a partir do trabalho com GPT-2. O pacote novo reúne a configuração, os pesos de uma camada, a sequência de camadas, o cache e as leis que relacionam essas partes. Assim dá para discutir o modelo inteiro sem reimplementar o produto de matriz por vetor a cada camada.

No código público, as revisões dos três checkpoints ficam fixadas em reference/models.py. Essa distinção entre código, configuração e pesos ajuda a entender o resto do post: o código diz como calcular; a configuração diz os tamanhos e algumas escolhas da arquitetura; os pesos são os números aprendidos. Para reproduzir um resultado, as três coisas precisam corresponder.

do texto ao próximo token

Um modelo desse tipo não recebe uma frase como uma string. Primeiro, o tokenizer divide o texto em unidades de um vocabulário e devolve seus identificadores, números inteiros. Uma unidade pode ser parte de uma palavra, um espaço junto de um pedaço de palavra ou outro fragmento. Não dá para contar palavras e concluir quantos tokens uma frase terá: isso depende das regras e do vocabulário daquele modelo.

O identificador serve de endereço para o embedding, uma tabela de vetores. Um vetor é só uma sequência de números; no 135M, são 576 números por token. Buscar a linha correspondente ao identificador dá a representação inicial daquela posição. Depois ela atravessa as camadas do decoder, cada uma usando seus pesos e o histórico permitido para transformar essa representação. No fim há outra normalização e uma projeção para o vocabulário.

Essa última projeção produz um número por token possível: os logits. São escores, ainda não probabilidades. A demo usa a escolha mais simples, chamada greedy: pega o identificador do maior escore. Esse token passa a fazer parte do texto e entra no próximo passo. Repetir isso gera a sequência. O prompt precisa ser processado antes, para que os caches de todas as camadas tenham as posições que já apareceram.

fluxo conceitual do gerador público. o prompt é processado antes de continuar a sequência, e os pesos permanecem fixos.

O tokenizer do SmolLM2 exigiu uma adaptação própria. Reutilizar o do GPT-2 sem mudanças não dava os mesmos IDs. O pré-processamento separa dígitos individualmente, inclusive os dígitos Unicode reconhecidos pela referência; a numeração dos merges é outra. Um merge é a regra que junta dois fragmentos para formar uma unidade maior no BPE. Acertar essas regras vem antes de investigar qualquer diferença no modelo: entradas distintas produzem contas distintas.

Há um detalhe menos bonito que também precisa aparecer: alguns bytes não têm token nesse vocabulário e são descartados pelo pré-processamento para corresponder à referência. Por isso, uma lei sobre desfazer merges do BPE não implica que qualquer sequência de bytes atravesse esse tokenizer completo e volte intacta. O componente matemático e a convenção concreta de um modelo são partes diferentes do caminho. A adaptação e os testes estão no tokenizer em Bend e nas notas públicas de 9 de outubro.

o que uma camada calcula

Imagine acompanhar o vetor de uma posição. Uma camada precisa consultar as posições anteriores e depois transformar o resultado. No llama.bend, isso aparece em dois blocos: atenção e uma rede de projeções chamada MLP. Antes de cada bloco há uma RMSNorm; depois há uma soma com o vetor que entrou naquele bloco. É essa soma, a conexão residual, que conserva um caminho direto para a representação anterior enquanto a camada acrescenta uma transformação.

normalizar antes de consultar

A RMSNorm ajusta a escala dos números. Ela eleva as componentes ao quadrado, calcula a média e usa a raiz dessa média para obter um fator de escala. Um pequeno eps entra antes da raiz, e cada componente recebe também um ganho aprendido. Não é subtrair a média do vetor: o que essa operação usa é a média dos quadrados. A implementação mantém a ordem de multiplicações da referência, porque em ponto flutuante a ordem pode mudar os últimos bits.

A atenção começa projetando o vetor normalizado em três outros: query, key e value, ou Q, K e V. Dá para ler a query como o que a posição procura, a key como o que permite comparar cada posição com essa procura, e o value como a informação que será combinada. Essa é uma forma de acompanhar a conta, não uma tradução literal do significado de cada número aprendido.

A query é comparada com as keys disponíveis por produtos escalares. A conta recebe um ajuste de escala e passa por softmax, que transforma os escores em pesos positivos cuja soma é um, na descrição matemática da operação. Esses pesos combinam os values. Assim a saída depende das posições consultadas com intensidades diferentes. O modelo faz isso em várias cabeças, partes menores do vetor que mantêm consultas separadas; depois junta seus resultados e aplica uma projeção de saída.

a transformação depois da atenção

Depois da atenção e da primeira soma residual, vem outra RMSNorm e o MLP. Neste caso ele usa SwiGLU. Duas projeções, gate e up, recebem o mesmo vetor. A primeira passa pela função silu; o resultado é multiplicado componente a componente pela segunda. Uma terceira projeção, down, devolve o tamanho usado pelo restante do modelo. O vetor resultante entra na segunda soma residual.

Uma maneira útil de ler gate é como uma transformação que modula a outra, em vez de apenas encadear duas matrizes. Os nomes não significam uma decisão binária de abrir ou fechar: o valor é numérico. No 135M, o vetor de 576 componentes é projetado para 1536 no interior do MLP e volta a 576. Essa volta é necessária para somar o resultado ao caminho residual, posição a posição.

A função pública block permite seguir esse percurso inteiro. Ela devolve o vetor, o cache atualizado, os pesos e um registro dos intermediários. Isso ajuda a comparar uma camada por dentro: se o resultado final divergir, dá para olhar a primeira normalização, Q/K/V, a atenção e o MLP separadamente. Uma frase gerada não conta em qual dessas contas uma diferença começou.

nove consultas, três conjuntos de chaves

No desenho mais direto de atenção com várias cabeças, cada cabeça de queries tem uma cabeça de keys e uma de values correspondentes. Mas elas não precisam existir em igual quantidade. O 135M tem nove cabeças de queries e só três de keys e values. Cada conjunto de keys e values atende a três consultas diferentes. Isso é grouped-query attention, ou GQA: agrupar consultas para compartilhar K e V.

Compartilhar não torna as queries iguais. Cada uma continua produzindo seus próprios escores e sua própria combinação dos values. O que muda é o conjunto consultado. Para um mesmo tamanho de cabeça, guardar menos conjuntos K/V também reduz a quantidade de números no cache em relação a guardar nove deles. Essa relação não é uma promessa de redução proporcional no tempo do programa inteiro: os outros produtos continuam existindo.

índices começam em zero. a lei gqa_head confere o mapeamento entre consultas e conjuntos K/V.

O risco está no endereço: fazer uma query ler a cabeça K/V vizinha continua sendo uma conta com números e pode não provocar um erro de execução. No pacote, as queries são organizadas em grupos, e cada grupo caminha junto de seu conjunto K/V. A lei gqa_head demonstra que essa organização escolhe a mesma cabeça que a divisão usada pelo mapeamento da referência.

Em termos simples: se há g queries por grupo, a query de índice j·g + i, com i menor que g, pertence ao grupo j. A lei prova que dividir esse índice por g dá j. O detalhe útil é o “para qualquer”: ela não enumera apenas as nove queries do 135M. O argumento vale para os índices que satisfazem essas condições; as configurações concretas entram como casos dessa relação.

No 360M são quinze queries para cinco conjuntos K/V, de novo três por grupo. No 1.7B são 32 para 32: g = 1. Esse caso é atenção multi-head comum, representada pela mesma configuração geral. As leis de quantidade de grupos e de arrays completam a parte estrutural, conferindo que o agrupamento e a criação do cache têm as contagens esperadas. O README do pacote explica as leis e o código contém os enunciados e as provas.

posição como rotação

Até aqui expliquei como comparar queries e keys, mas falta dizer em qual posição cada uma está. No llama.bend essa informação entra pelo RoPE, os rotary position embeddings. A operação gira pares de componentes das queries e keys por um ângulo que depende da posição e da frequência daquele par. Os values não passam por essa rotação. Os pesos aprendidos continuam os mesmos; a rotação muda conforme a posição processada.

Pense em duas coordenadas como a ponta de uma seta num plano. Girar essa seta muda suas coordenadas sem precisar acrescentar uma componente nova ao vetor. Pares diferentes usam frequências diferentes. Quando queries e keys rotacionadas se encontram no produto escalar, a posição já participa da comparação. O desenho abaixo só ilustra a ideia de rotação: os ângulos e as setas não são uma medição das ativações de um prompt.

esquema, sem valores medidos. nesta implementação, cada componente da primeira metade da cabeça é pareada com a correspondente da segunda metade.

A forma de formar esses pares precisa corresponder ao checkpoint. Aqui a cabeça é dividida em duas metades: a componente de índice i da primeira se combina com a de índice i da segunda. Não são simplesmente pares vizinhos percorrendo o vetor inteiro. A configuração guarda half, metade do tamanho da cabeça, e calcula o tamanho total como 2·half. Isso torna o tamanho par por construção, em vez de depender de um comentário dizendo para escolher um número par.

A lei head_split diz algo preciso e mais restrito que “RoPE está correto”: cortar uma lista nessas partes e juntá-las devolve a lista original, na mesma ordem. Ela evita que a reorganização perca ou mova componentes. Não prova a precisão do seno, do cosseno ou das multiplicações em F32. Esses resultados entram nas comparações numéricas da camada. Uma prova de que as peças voltam ao lugar não é uma prova de que todos os números calculados com elas correspondem à referência.

Os três modelos usam cabeças de 64 componentes. O 1.7B muda também a base do RoPE, theta: 130000, contra 100000 nos dois menores. Esses valores entram no cálculo das frequências, não no tamanho das matrizes. É um exemplo de diferença de configuração que continuaria existindo mesmo se fosse possível reaproveitar algum shape. A implementação de RoPE e as configurações deixam essas escolhas explícitas.

a configuração chega aos tipos dos pesos

Uma configuração comum seria só um conjunto de números que o programa lê antes de alocar memória. Aqui ela participa também dos tipos. Cfg guarda a quantidade de cabeças K/V, a quantidade de queries por grupo, metade do tamanho da cabeça, a largura do MLP, o número de camadas, o vocabulário e os parâmetros da normalização e da posição. O tipo dos pesos de uma camada é LW<c>: ele depende daquela configuração c.

type Cfg is Data:
  Cfg{kv: Nat, g: Nat, half: Nat, f: Nat, layers: Nat, vocab: Nat, eps: F32, theta: F32}

Desse registro saem três contas: cabeças de queries = kv·g; largura do vetor = kv·g·2·half; largura de K ou V = kv·2·half. No 135M, isso dá nove cabeças, 576 componentes no vetor e 192 em K e V. Cada matriz tem uma linha por componente de saída e uma coluna por componente de entrada, a convenção usada para carregar os pesos. Não basta guardar a quantidade total de números: as duas dimensões têm papéis diferentes.

configuração135M360M1.7B
cabeças Q / K/V9 / 315 / 532 / 32
largura do vetor5769602048
largura de K ou V1923202048
largura interna do MLP153625608192
camadas303224

O teste mais fácil de entender é tentar trocar Q e K do 135M. A matriz de Q é Bands<576n, 576n>; a de K é Bands<192n, 576n>. Bands é a representação de matrizes em bandas do bend-ml, com linhas e colunas no tipo. O trecho abaixo, extraído do teste público, reconstrói a camada com as duas matrizes invertidas. É um programa que deve ser rejeitado, não um exemplo de uso correto.

def swap(lw: M.LW<M.smollm2_135m()>) -> M.LW<M.smollm2_135m()>:
  match lw:
    case M.LW{ln1, q, k, v, o, ln2, gate, up, down}:
      M.LW{ln1, k, q, v, o, ln2, gate, up, down}

O campo Q exige 576 linhas e recebe uma matriz com 192. O erro aparece antes de calcular qualquer token. Não depende de escolher um prompt que revele o problema ou de inspecionar o texto depois. Mas a garantia termina nas dimensões expressas pelo tipo: trocar duas matrizes com o mesmo shape não vira automaticamente um erro de tipos. Nem o tipo consegue descobrir sozinho que um arquivo com o tamanho certo contém pesos de outra origem.

As leis de layout complementam isso contando os valores esperados: 134515008, 361821120 e 1711376384 para os três checkpoints. O loader confere o tamanho de cada arquivo contra o shape esperado e para se não corresponder. São verificações de organização e quantidade, junto das revisões fixadas e dos testes; não uma prova de autenticidade semântica de qualquer sequência de bytes. Os tipos dos pesos e as leis de layout mostram exatamente onde essas fronteiras ficam.

um cache que sabe quanto já foi escrito

Para gerar a próxima posição, a atenção volta a consultar keys e values das posições anteriores. Recalcular tudo desde o início repetiria trabalho. O KV cache guarda esses resultados por camada. Ele não guarda “a resposta” pronta para reutilizar: guarda representações de cada posição, e cada nova query ainda precisa consultar o histórico. Por isso, ter cache não transforma a atenção inteira numa operação de custo constante.

Há duas perguntas separadas: quais posições existem e quanto espaço existe para elas? O tipo KV<cap, t> carrega ambas. cap é a capacidade reservada em posições; t é quantas já foram escritas. Antes de processar a posição de índice t, existem as posições de zero até t − 1. O bloco escreve a key e o value da posição atual e devolve um cache de tipo KV<cap, t + 1>. Só então a atenção consulta de zero até t, incluindo a posição atual.

exemplo de estado, não uma execução medida. espaço reservado não é histórico válido; as leis causal e read_len contam apenas as posições escritas.

Essa é a parte causal: a posição atual só consulta seu próprio passo e o passado, sem ler uma posição futura. causal e read_len relacionam a leitura a exatamente t + 1 keys e escores. A capacidade física pode ser maior. Inicializar espaço sobrando não faz dele texto anterior, e confundir esses dois tamanhos seria um jeito de ler posições que não fazem parte da sequência.

Para escrever, o programa precisa ainda de uma prova de que t é menor que cap. kv_room liga esse limite à quantidade de slots dos arrays, incluindo o tamanho de uma cabeça. A prova de espaço entra obrigatoriamente em KV.push, block e forward. Assim o contrato não fica só numa lei que alguém pode esquecer de aplicar: as funções que avançam o modelo pedem esse argumento.

O teste de capacidade tenta uma terceira escrita num KV<2n, 2n>. Já existem duas posições e só há capacidade para duas. A chamada precisaria provar 2 < 2; o checador rejeita a tentativa. Isso importa especialmente porque os índices dos arrays do Bend podem dar a volta: estar fora da capacidade poderia sobrescrever um slot anterior. A API exige o limite antes de chegar a essa escrita.

As provas são apagadas na compilação. Elas servem para aceitar ou rejeitar o programa, sem uma árvore de justificativas para percorrer a cada token. Isso não elimina alocação, leitura de memória ou produtos de matrizes; elimina o custo em execução da evidência formal. A descrição completa está nas leis do cache e nas notas da mudança de API.

as provas não fizeram a soma ficar certa

Rodar os outros dois tamanhos era uma maneira concreta de testar se a configuração realmente bastava. O 1.7B trouxe um problema que não era de shape: o teste da camada 15 falhou em intermediários da atenção e do MLP. A saída final da camada ainda estava perto da referência, mas algumas partes internas passavam do limite de comparação. Olhar só o último vetor teria escondido a diferença.

A causa apareceu na primeira RMSNorm. Ela precisava somar 2048 quadrados, com componentes de escalas muito diferentes. F32 guarda números com precisão finita. Se uma soma acumulada já é grande, acrescentar uma parcela pequena pode perder parte dela no arredondamento. Repetir essa operação milhares de vezes não é numericamente igual a somar números reais com precisão infinita, mesmo que todas as dimensões estejam corretas.

A versão inicial acrescentava cada quadrado a um único acumulador. A correção foi usar uma soma em árvore: somar pares vizinhos, depois pares dos resultados, até chegar ao total. pair_sums e tree_sum fazem isso. Não muda quais componentes entram na normalização, mas muda a ordem dos arredondamentos. É justamente o tipo de mudança em que um programa estruturalmente igual pode deixar de produzir os mesmos bits.

em aritmética real as expressões coincidem. em F32 a ordem altera arredondamentos; o diagrama não estabelece um limite universal de erro.

O resultado público dá uma comparação específica para essa primeira normalização da camada 15 do 1.7B: em relação à execução em float64 usada nas notas, o erro reportado do Bend caiu de 4.6e-6 com a soma sequencial para 1.5e-7 com a soma em pares. São números daquele caso, não uma garantia para qualquer vetor, qualquer ordem ou qualquer hardware. O ponto é que a mudança foi confrontada com uma referência numérica, além de continuar passando no checador.

Isso também impede tratar PyTorch como uma definição de número real. Ele calcula em ponto flutuante também. Comparar com transformers responde se a minha implementação acompanha aquela referência, sob versões e escolhas fixas; uma comparação em maior precisão ajuda a investigar uma divergência particular. Não transforma cada diferença em bug nem cada concordância em prova de exatidão. O relato e os resultados dessa correção estão nas notas sobre RMSNorm.

mudar o cache mudou o custo

Guardar K e V evita recalcular suas projeções, mas a maneira de guardá-los também custa. A primeira representação mantinha o histórico em listas. A atenção precisava reconstruir linhas do cache, reorganizá-las por cabeça e montar resultados intermediários enquanto percorria as posições. Quanto mais histórico, mais trabalho de listas havia em cada novo passo. Reutilizar os números não bastava para reutilizar sua organização.

A troca foi para arrays planos, com um array de keys e um de values por cabeça K/V em cada camada. A nova posição é escrita no espaço reservado para ela. Os produtos da atenção leem as posições válidas diretamente nesses arrays, com os strides apropriados. Um stride é o salto entre elementos na memória: ele permite olhar os mesmos números como linhas ou colunas sem materializar uma transposição em listas a cada token.

Depois veio a divisão entre cabeças K/V para executar grupos em paralelo. Como cada grupo consulta seu conjunto de arrays, dá para entregar conjuntos diferentes a tarefas diferentes e juntar os resultados na ordem dos grupos. Isso se soma aos produtos de pesos em bandas herdados do bend-ml. São lugares diferentes para dividir trabalho, com custos diferentes; mais tarefas não significam automaticamente menos tempo.

O resultado histórico de 12 de outubro ajuda a separar os efeitos. Com Bend 2.0.36 e tensor-array 0.1.7.0, no 1.7B gerando 128 tokens com 16 threads, a versão com listas levou 1692 ms por token. Arrays com as cabeças em sequência deram 427 ms; arrays com as cabeças em paralelo, 385 ms. O ganho grande daquela comparação veio de mudar a representação do cache. Paralelizar as cabeças acrescentou outro ganho naquele caso.

Esses números pertencem àquela comparação entre três versões, identificadas no benchmark público. Eles não devem ser misturados com a tabela final do dia 13 para inventar uma regressão ou um ganho do compilador. Também não são uma regra para uma thread: abrir e juntar tarefas tem custo, mesmo quando não há núcleos disponíveis para aproveitá-las. A versão medida no dia 13 mantém cabeças em sequência com até duas threads e as paraleliza acima disso.

O cache em arrays ainda lê as posições anteriores. Em uma geração de 512 tokens do 1.7B com 16 threads, os tempos publicados do primeiro e do último quarto foram 427 e 452 ms por token. Há crescimento medido; ele ficou pequeno em relação ao problema anterior de listas, não desapareceu por uma propriedade matemática do cache. “Ficou mais plano neste intervalo” é uma descrição mais fiel que “o contexto deixou de custar”.

o que eu conferi, e o que cada teste diz

Uma geração que parece plausível é uma demonstração fraca de uma implementação. Mesmo escolher o mesmo token só mostra que o maior escore continuou no mesmo lugar. Dois vetores de logits podem ser diferentes e ter o mesmo máximo. Por isso o repositório separa a comparação dos números, a escolha de token e o texto decodificado, além dos testes da camada e do tokenizer.

O teste de forward usa 11 prompts, com 88 posições ao todo, e compara os logits de cada posição com transformers 5.19 em F32 e atenção eager. “Eager” identifica a implementação de atenção escolhida na referência; trocar esse caminho pode mudar a ordem das operações. A medida reportada divide a maior diferença absoluta entre os logits pelo maior valor absoluto do vetor de referência. Ela não é o erro relativo de cada componente individual.

resultado publicado135M360M1.7B
pior erro dos logits nessa medida2.1e-54.8e-58.3e-5
argmax nas 88 posiçõesigualigualigual
32 tokens greedy em 12 promptsiguaisiguaisiguais
texto decodificado nesses promptsigualigualigual

As linhas de geração são outro conjunto: 12 prompts, com 32 tokens gerados por caso. O teste confere os IDs do prompt, os IDs gerados e o texto, além dos logits dos tokens escolhidos dentro da tolerância. Não são 12 prompts usados para calcular a primeira linha da tabela. Os dois scripts públicos deixam essa separação explícita: forward examina o vetor de logits; geração examina a sequência produzida.

A tokenização também é conferida separadamente. O resultado publicado registra os mesmos IDs em 68 textos fixos e 600 aleatórios; a decodificação foi comparada em 1500 sequências aleatórias de IDs. São execuções ampliadas documentadas, e não a afirmação de que toda chamada padrão do script roda exatamente essas quantidades. Comparar a decodificação isoladamente importa porque sequências arbitrárias de IDs podem formar bytes que não são UTF-8 válido.

As 21 leis ficam numa categoria diferente. Elas tratam de divisão de cabeças, associação entre Q e K/V, quantidade de grupos e arrays, leituras causais, limite de escrita e layout, apoiadas por lemas aritméticos. O pacote não usa @unsafe, ?TODO ou axiomas para substituir essas provas. Mas os vetores entre camadas ainda são listas sem o comprimento no tipo, e as operações F32 continuam verificadas por testes. Dizer “toda a inferência foi formalmente provada” ultrapassaria o que está implementado.

Também existe uma exceção pública à frase “todos os sistemas dão os mesmos tokens”. Na comparação longa do 360M, o texto do llama.cpp divergiu depois de aproximadamente 340 tokens. O benchmark registra que a sequência do llama.bend foi conferida com transformers. Isso não invalida as comparações curtas da tabela, mas delimita o alcance delas. Mesmos tokens nas execuções testadas não são uma garantia universal para qualquer duração, implementação ou prompt.

os números ainda mostram a distância

A comparação final publicada foi medida em 13 de outubro de 2026, num Intel Core Ultra 7 155H com 32 GB de RAM, na CPU e em F32. O código medido é o commit 3ea435a, com tensor-array 0.1.7.0 e Bend 2.0.37. A revisão usada para documentar este post é posterior e contém esse relatório; não estou apresentando a tabela como uma nova medição feita hoje.

As referências são transformers 5.19 com PyTorch 2.14.1, llama.cpp no commit 23b0202 usando GGUF F32 e candle no c68b249, também F32. A tabela abaixo mostra milissegundos por token gerado, separando uma thread e 16 threads. O 135M e o 360M geram 32 tokens; o 1.7B, 128. O custo de carregar os pesos e processar o prompt fica fora dessa métrica. Os resultados completos estão em BENCHMARK.md.

modelo e geraçãosistema1 thread16 threads
135M · 32 tokensllama.bend12176.7
135M · 32 tokenstransformers26.321.1
135M · 32 tokensllama.cpp23.714.0
135M · 32 tokenscandle26.628.8
360M · 32 tokensllama.bend283133
360M · 32 tokenstransformers57.140.0
360M · 32 tokensllama.cpp63.128.3
360M · 32 tokenscandle71.674.3
1.7B · 128 tokensllama.bend1203492
1.7B · 128 tokenstransformers231132
1.7B · 128 tokensllama.cpp324101
1.7B · 128 tokenscandle292295

O 1.7B exige uma ressalva presente no próprio relatório: os 492 ms carregam a variação do tempo de carregamento na métrica comparativa publicada. O relógio interno do gerador marcou 426 ms por token com 16 threads. São medidas diferentes, e substituir uma pela outra na tabela esconderia essa diferença. O tempo interno ajuda a ler a dispersão; não autoriza escolher só o número mais favorável para comparar sistemas.

Bend 2.0.37 inclui a correção do compilador comentada no post anterior, mas este projeto não ganhou velocidade por causa dela na comparação publicada. Os tempos internos do 1.7B com 16 threads foram 423 ms em 2.0.36 e 426 em 2.0.37. A atualização importa como versão do ambiente; não vou atribuir a ela os ganhos que vieram de mudar o cache. Ter uma correção relevante no compilador e ganhar tempo neste programa são afirmações que precisam de evidências separadas.

esperar o primeiro token também conta

Para quem abre a demo, esperar os pesos entrarem na memória faz parte da experiência. O relatório mede também uma execução que carrega o modelo e gera um token. A tabela seguinte traz esse tempo total e o pico de memória residente com 16 threads. Ela não é uma medição isolada só do loader, nem o custo de cada token depois que o programa já está pronto.

sistema135M360M1.7B
llama.bend4.4 s · 781 MB11.5 s · 2332 MB66.4 s · 6935 MB
transformers2.7 s · 1168 MB2.7 s · 2469 MB4.1 s · 10189 MB
llama.cpp0.2 s · 743 MB0.2 s · 1752 MB0.5 s · 8116 MB
candle0.2 s · 791 MB0.4 s · 2107 MB2.5 s · 9844 MB

Carregar é a parte mais lenta do llama.bend. O caminho de leitura produz listas com uma célula por byte, e construir e percorrer essas listas tem custo. O pico de memória menor que algumas referências nessa tabela não compensa automaticamente mais de um minuto até o primeiro token do 1.7B. Também não é uma garantia geral de menor memória: o mesmo relatório registra pico de 8.4 GB para o 1.7B com uma thread.

Esses resultados deixam uma distância clara para llama.cpp e transformers. O cache em arrays resolveu um custo grande, mas não fez os produtos dos pesos nem a leitura dos arquivos desaparecerem. As medições descrevem essa máquina, essas versões e essas gerações. Não são projeções de velocidade em outro processador, com quantização ou com uma GPU. Este projeto executa na CPU e os números apresentados aqui usam F32.

rodar o que está publicado

O ponto de entrada está no repositório público e na página do projeto. Os comandos documentados começam preparando o ambiente, baixando e convertendo o checkpoint e conferindo os resultados. Para o 135M, o README lista Linux x86_64 ou WSL, clang, Python 3.14 e curl. O primeiro preparo também precisa de espaço para o ambiente Python e os arquivos do modelo; não é só o tamanho do executável Bend.

git clone https://github.com/nuxyel/llama.bend.git
cd llama.bend
make setup
make prep
make check
bend demo/gen.bend -o gen
./gen data/smollm2-135m "The capital of France is" 32

make setup prepara as versões de ferramentas documentadas; make prep traz o modelo e escreve os arquivos que o programa Bend lê. make check confere as leis, os programas que devem ser rejeitados e as comparações com as referências. Não quer dizer que a inferência em Bend dependa de chamar Python a cada token: Python participa da preparação e da verificação. A geração do exemplo roda no executável compilado.

Para preparar e conferir outro tamanho, o comando público é make prep check MODEL=smollm2-360m, ou MODEL=smollm2-1.7b. O programa recebe o diretório correspondente na linha de comando. A mesma API também está publicada no BendHub como bend-ml-llama@0.1.0.0; a documentação do pacote descreve forward, block, a criação dos caches e as provas de espaço necessárias para avançar uma posição.

import bend-ml-llama@0.1.0.0/main.bend as M

Há um detalhe na API que vem dos tipos afins: os produtos devolvem os pesos que receberam. Os arrays têm um dono, então calcular com uma matriz e continuar usando-a exige passá-la adiante explicitamente. block devolve LW<c> e forward devolve Model<c>. Isso explica por que uma chamada retorna mais que um vetor de logits. A estrutura dos resultados faz parte de como os pesos permanecem disponíveis para o próximo token.

O que está publicado ainda é uma implementação de inferência para modelos base, com escolha greedy, sem template de chat. Não equivale a abrir um assistente pronto para conversar. Também não inclui uma prova de todo valor intermediário nem suporte automático a qualquer variante da arquitetura. Essas limitações estão junto dos resultados porque mudam o que faz sentido esperar ao rodar a demo.

Para mim, a parte interessante deste estágio é poder apontar para erros específicos que já não passam pela API: Q e K incompatíveis no lugar errado, uma query ligada ao grupo errado no mapeamento provado, uma escrita sem espaço no cache. Ao mesmo tempo, a soma da RMSNorm precisou de investigação numérica e a geração continua perdendo em tempo para as referências. O código e os resultados permitem olhar as duas partes: o que ficou garantido na estrutura e o que ainda precisa ser medido e testado.

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 ↗