$cat ~/posts/llama-bend

bilingual · en / pt

← /blog

llama.bend: language models with typed weights and cache

AI and machine learning#Bend#LLMs#types

The first bend-ml post started with a small question: what if a matrix multiplication with incompatible dimensions could not compile? The next one followed the cost of making GPT-2 actually run, weights stored in bands, and parallelism disappearing from the generated C. llama.bend continues that story with another architecture and an extra requirement: carrying the model's structure and the cache's state in its types.

I built a package that runs SmolLM2-135M, SmolLM2-360M and SmolLM2-1.7B on the CPU. All three use the same Bend layers with a different configuration. In the published tests, generated tokens and decoded text match transformers. There are also 21 laws about the program's structure, checked by Bend and again with --verdict. Execution remains slower than the references; loading the model is especially expensive.

I will explain how text becomes a sequence of computations, why some of those computations let the checker reject swapped weights, what a causal cache means, and where a proof stops helping. Then come the numerical error that appeared in the larger model, the cache change, and the numbers, with the context of each comparison. The code and results used here are in the public llama.bend and bend-ml repositories, at fixed revisions.

  1. 2026-10-09tokenizer and one layer1 dayprepare the weights, adapt tokenization and compare the layer's pieces.
  2. 2026-10-10the whole model2 daysgeneration, a typed API, laws and all three SmolLM2 sizes.
  3. 2026-10-12array cache1 dayparallel heads and cache capacity required in the type.
  4. 2026-10-13Bend 2.0.37compiler update and the public performance comparison.
completed milestones recorded in the public notes; bars measure the interval to the next entry.

first, what the name means

Llama here means the architecture: the arrangement of operations that turn input tokens into scores for the next token. The weights I used are the public SmolLM2 family checkpoints, identified as HuggingFaceTB/SmolLM2-*. A checkpoint is the collection of numbers a training run has already found. Writing the operations in another language lets me use those numbers without repeating the training.

That matters when reading the result. I did not train a Llama model in this project, and the name does not mean I downloaded a Meta model's weights. I implemented inference, which means computing an answer with weights that are already available. The program receives text, reads the prepared weights and produces tokens. Those weights are not updated during the generation described in this post.

It is also not every model with “Llama” in its name. Preparation checks a particular combination: SwiGLU, no biases, plain RoPE and output weights shared with the embedding. Each condition corresponds to a piece of the program. A model with another positional variant, for example, is not supported merely because its matrices fit in memory. Architecture compatibility has to come before comparing results.

bend-ml provides the foundation for those computations. llama.bend imports the tensor and array tensor packages, and adapts tokenization from the GPT-2 work. The new package brings together the configuration, one layer's weights, the sequence of layers, the cache and the laws relating those pieces. That makes it possible to discuss the whole model without reimplementing matrix–vector multiplication in every layer.

In the public code, the three checkpoint revisions are pinned in reference/models.py. Separating code, configuration and weights helps with the rest of this post: code says how to compute; configuration supplies the sizes and some architecture choices; weights are the learned numbers. To reproduce a result, all three need to agree.

from text to the next token

This kind of model does not receive a sentence as a string. First, the tokenizer divides text into units from a vocabulary and returns their identifiers, which are integers. A unit can be part of a word, a space attached to a word fragment, or another piece. Counting words does not tell you how many tokens a sentence will have: that depends on the model's rules and vocabulary.

An identifier acts as an address into the embedding, a table of vectors. A vector is just a sequence of numbers; in the 135M, there are 576 numbers per token. Looking up the row for an identifier gives that position's initial representation. It then passes through the decoder layers, each using its weights and the allowed history to transform that representation. At the end there is another normalization and a projection into the vocabulary.

That final projection produces one number for every possible token: the logits. They are scores, not yet probabilities. The demo uses the simplest choice, called greedy decoding: take the identifier with the largest score. That token becomes part of the text and goes into the next step. Repeating this generates the sequence. The prompt has to be processed first so that every layer's cache contains the positions that have already appeared.

conceptual flow of the public generator. the prompt is processed before continuing the sequence, and the weights stay fixed.

The SmolLM2 tokenizer needed its own adaptation. Reusing the GPT-2 tokenizer unchanged did not produce the same IDs. Preprocessing splits digits individually, including the Unicode digits recognized by the reference; merge numbering is different too. A merge is the rule that joins two fragments into a larger unit in BPE. Getting those rules right comes before investigating model differences: different inputs produce different computations.

There is a less tidy detail that also belongs here: some bytes have no token in this vocabulary and are dropped during preprocessing to match the reference. A law about undoing BPE merges therefore does not imply that every byte sequence passes through this complete tokenizer and comes back intact. The mathematical component and a particular model's convention are different parts of the path. The adaptation and tests are in the Bend tokenizer and the public October 9 notes.

what one layer computes

Imagine following the vector at one position. A layer needs to consult earlier positions and then transform the result. In llama.bend, this appears in two blocks: attention and a projection network called the MLP. Each block is preceded by RMSNorm and followed by an addition to the vector that entered it. That addition, the residual connection, preserves a direct path for the previous representation while the layer adds a transformation.

normalize before looking things up

The RMSNorm operation adjusts the scale of the numbers. It squares the components, computes their mean and uses the square root of that mean to obtain a scale factor. A small eps is added before the square root, and each component also receives a learned gain. This does not subtract the vector's mean: the operation uses the mean of its squares. The implementation preserves the reference's multiplication order, because floating-point ordering can change the last bits.

Attention starts by projecting the normalized vector into three others: query, key and value, or Q, K and V. You can read the query as what the position is looking for, the key as what lets each position be compared with that query, and the value as the information to combine. This is a way to follow the computation, not a literal translation of the meaning of each learned number.

The query is compared with the available keys through dot products. The computation is scaled and passed through softmax, which turns scores into positive weights adding up to one in the mathematical description of the operation. Those weights combine the values. The output therefore depends on the consulted positions with different strengths. The model does this in several heads, smaller parts of the vector that keep separate queries, then joins their results and applies an output projection.

the transformation after attention

After attention and the first residual addition come another RMSNorm and the MLP. Here it uses SwiGLU. Two projections, gate and up, receive the same vector. The first passes through the silu function; its result is multiplied component by component with the second. A third projection, down, restores the size used by the rest of the model. The resulting vector enters the second residual addition.

A useful way to read gate is as a transformation that modulates the other one, rather than simply chaining two matrices. The names do not mean a binary decision to open or close: the value is numerical. In the 135M, the 576-component vector is projected to 1536 inside the MLP and returns to 576. Returning to that size is necessary to add the result to the residual path, component by component.

The public function block lets you follow that entire path. It returns the vector, the updated cache, the weights and a record of intermediate values. This helps compare a layer internally: if the final result differs, the first normalization, Q/K/V, attention and MLP can be inspected separately. A generated sentence does not tell you which computation first introduced a difference.

nine queries, three sets of keys

In the most direct version of multi-head attention, each query head has a matching key head and value head. But their counts do not have to be equal. The 135M has nine query heads and only three key/value heads. Each set of keys and values serves three different queries. This is grouped-query attention, or GQA: grouping queries to share K and V.

Sharing does not make the queries identical. Each still produces its own scores and its own combination of values. What changes is the set it consults. For a fixed head size, storing fewer K/V sets also reduces the number of cache values compared with storing nine of them. That relationship is not a promise of a proportional reduction in whole-program time: the other products still exist.

indices start at zero. the gqa_head law checks the mapping between queries and K/V sets.

The risk is in the address: making a query read the neighboring K/V head is still a numerical computation and may not cause a runtime error. In the package, queries are organized into groups, and each group is traversed alongside its K/V set. The gqa_head law establishes that this organization chooses the same head as the division used by the reference mapping.

In plain terms: with g queries per group, query index j·g + i, with i smaller than g, belongs to group j. The law proves that dividing that index by g gives j. The useful detail is “for any”: it does not merely enumerate the nine queries in the 135M. The argument holds for indices satisfying those conditions; concrete configurations are instances of that relationship.

In the 360M there are fifteen queries for five K/V sets, again three per group. In the 1.7B there are 32 for 32: g = 1. That case is ordinary multi-head attention, represented by the same general configuration. The group and array count laws complete the structural part, checking that grouping and cache creation have the expected counts. The package README explains the laws, and the code contains their statements and proofs.

position as rotation

So far I have explained how to compare queries and keys, but not how their positions enter the computation. In llama.bend that information comes through RoPE, rotary position embeddings. The operation rotates pairs of query and key components by an angle depending on the position and that pair's frequency. Values do not go through this rotation. The learned weights stay the same; the rotation changes with the position being processed.

Think of two coordinates as the tip of an arrow on a plane. Rotating that arrow changes its coordinates without adding a new component to the vector. Different pairs use different frequencies. When rotated queries and keys meet in a dot product, position already participates in the comparison. The drawing below only illustrates rotation: its angles and arrows are not measured prompt activations.

schematic, with no measured values. in this implementation, each component in the first half of a head is paired with its counterpart in the second half.

The pairing convention has to match the checkpoint. Here a head is split into two halves: component i of the first is paired with component i of the second. These are not simply neighboring pairs along the whole vector. The configuration stores half, half the head size, and computes the total size as 2·half. That makes the size even by construction, instead of relying on a comment asking for an even number.

The head_split law says something precise and narrower than “RoPE is correct”: splitting a list into those parts and joining them returns the original list in the same order. It prevents the rearrangement from losing or moving components. It does not prove the accuracy of sine, cosine or F32 multiplications. Those results are covered by numerical layer comparisons. A proof that the pieces return to their places is not a proof that every number computed with them matches the reference.

All three models use 64-component heads. The 1.7B also changes RoPE's base, theta: 130000, compared with 100000 in the two smaller models. Those values enter the frequency computation, not the matrix sizes. This is a configuration difference that would remain even if some shape could be reused. The RoPE implementation and the configurations make those choices explicit.

the configuration reaches the weight types

An ordinary configuration might just be a set of numbers read before allocating memory. Here it also participates in types. Cfg stores the K/V head count, queries per group, half the head size, MLP width, layer count, vocabulary and the normalization and position parameters. One layer's weight type is LW<c>: it depends on that configuration c.

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

Three calculations follow from that record: query heads = kv·g; vector width = kv·g·2·half; K or V width = kv·2·half. In the 135M, that gives nine heads, a 576-component vector and 192 components in K and V. Each matrix has one row per output component and one column per input component, the convention used to load the weights. Storing only the total number of values is insufficient: the two dimensions play different roles.

configuration135M360M1.7B
Q / K/V heads9 / 315 / 532 / 32
vector width5769602048
K or V width1923202048
MLP inner width153625608192
layers303224

The easiest test to understand is swapping Q and K in the 135M. Q's matrix is Bands<576n, 576n>; K's is Bands<192n, 576n>. Bands is bend-ml's banded matrix representation, with rows and columns in the type. The excerpt below, from the public test, rebuilds the layer with those matrices reversed. It is a program meant to be rejected, not an example of correct usage.

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}

The Q field requires 576 rows and receives a matrix with 192. The error appears before computing any token. It does not depend on choosing a prompt that reveals the problem or inspecting the text afterward. But the guarantee stops at the dimensions expressed by the type: swapping two matrices with the same shape does not automatically become a type error. Nor can the type discover on its own that a correctly sized file contains weights from another source.

The layout laws complement this by counting expected values: 134515008, 361821120 and 1711376384 for the three checkpoints. The loader checks each file's size against the expected shape and stops on a mismatch. These are checks of organization and quantity, alongside pinned revisions and tests; they are not a proof of the semantic authenticity of an arbitrary byte sequence. The weight types and layout laws show exactly where those boundaries lie.

a cache that knows how much has been written

To generate the next position, attention consults keys and values from earlier positions again. Recomputing everything from the beginning would repeat work. The KV cache stores those results per layer. It does not store a ready-made “answer” to reuse: it stores representations of each position, and each new query still has to consult the history. Having a cache therefore does not turn all of attention into a constant-cost operation.

There are two separate questions: which positions exist, and how much space is available for them? The type KV<cap, t> carries both. cap is the reserved capacity in positions; t is how many have been written. Before processing position index t, positions zero through t − 1 exist. The block writes the current position's key and value and returns a cache of type KV<cap, t + 1>. Attention then consults zero through t, including the current position.

state example, not a measured execution. reserved space is not valid history; causal and read_len count only written positions.

This is the causal part: the current position consults its own step and the past, without reading a future position. causal and read_len relate the read to exactly t + 1 keys and scores. Physical capacity can be larger. Initializing spare space does not make it previous text, and confusing those two sizes would allow reading positions outside the sequence.

Writing additionally requires a proof that t is smaller than cap. kv_room connects that bound to the number of array slots, including the head size. The room proof is a required argument of KV.push, block and forward. The contract therefore does not remain only in a law someone might forget to apply: the functions advancing the model request that argument.

The capacity test attempts a third write into KV<2n, 2n>. Two positions already exist, and capacity is only two. The call would need to prove 2 < 2; the checker rejects the attempt. This matters especially because Bend array indices can wrap around: exceeding capacity could overwrite an earlier slot. The API requires the bound before reaching that write.

The proofs are erased during compilation. They determine whether the program is accepted or rejected, without a justification tree to traverse at every token. That does not eliminate allocation, memory reads or matrix products; it eliminates the runtime cost of the formal evidence. The full description is in the cache laws and the API change notes.

the proofs did not make the sum accurate

Running the other two sizes was a concrete way to test whether changing the configuration really was sufficient. The 1.7B exposed a problem unrelated to shapes: the layer 15 test failed on attention and MLP intermediates. The layer's final output was still close to the reference, but some internal values exceeded the comparison threshold. Looking only at the last vector would have hidden the difference.

The cause appeared in the first RMSNorm. It needed to sum 2048 squares, with components at very different scales. F32 stores numbers with finite precision. If an accumulated sum is already large, adding a small term can lose part of that term to rounding. Repeating the operation thousands of times is not numerically identical to adding real numbers with infinite precision, even when every dimension is correct.

The initial version added each square to a single accumulator. The correction was a tree sum: add neighboring pairs, then pairs of their results, until reaching the total. pair_sums and tree_sum do that. It does not change which components enter normalization, but it changes the order of rounding. This is exactly the sort of change where a structurally equivalent program can stop producing the same bits.

in real arithmetic the expressions agree. in F32 the order changes rounding; the diagram does not establish a universal error bound.

The public result gives a specific comparison for that first normalization in the 1.7B's layer 15: against the float64 execution used in the notes, Bend's reported error fell from 4.6e-6 with sequential summation to 1.5e-7 with pairwise summation. Those are numbers from that case, not a guarantee for every vector, ordering or machine. The point is that the change was compared with a numerical reference as well as continuing to pass the checker.

This also prevents treating PyTorch as a definition of real arithmetic. It computes in floating point too. Comparing with transformers asks whether my implementation tracks that reference under fixed versions and choices; a higher-precision comparison helps investigate a particular discrepancy. It does not turn every difference into a bug or every agreement into a proof of exactness. The account and results of this correction are in the RMSNorm notes.

changing the cache changed the cost

Storing K and V avoids recomputing their projections, but storing them has a cost of its own. The first representation kept history in lists. Attention had to reconstruct cache rows, reorganize them by head and build intermediate results while traversing positions. More history meant more list work in every new step. Reusing the numbers was not enough to reuse their organization.

The change was to flat arrays, with one key array and one value array per K/V head in each layer. A new position is written into its reserved space. Attention products read valid positions directly from those arrays with the appropriate strides. A stride is the step between elements in memory: it lets the same numbers be viewed as rows or columns without materializing a list transpose at every token.

Then came splitting K/V heads to execute groups in parallel. Since each group consults its own array set, different sets can be handed to different tasks and their results joined in group order. This adds to the banded weight products inherited from bend-ml. These are different places to divide work, with different costs; more tasks do not automatically mean less time.

The historical October 12 result helps separate the effects. With Bend 2.0.36 and tensor-array 0.1.7.0, on the 1.7B generating 128 tokens at 16 threads, the list version took 1692 ms per token. Arrays with sequential heads gave 427 ms; arrays with parallel heads gave 385 ms. The large gain in that comparison came from changing the cache representation. Parallelizing heads added another gain in that case.

Those numbers belong to that comparison among three versions, identified in the public benchmark. They should not be mixed with the final October 13 table to invent a compiler regression or gain. Nor are they a rule for one thread: creating and joining tasks costs something even when no additional cores are available to use them. The version measured on October 13 keeps heads sequential with up to two threads and parallelizes them above that.

The array cache still reads previous positions. In a 512-token generation on the 1.7B at 16 threads, the published first- and last-quarter times were 427 and 452 ms per token. There is measured growth; it became small relative to the earlier list problem, rather than disappearing through a mathematical property of caching. “Flatter over this interval” is more accurate than “context stopped costing anything.”

what I checked, and what each test says

A plausible-looking generation is weak evidence for an implementation. Even choosing the same token only shows that the largest score stayed in the same place. Two logit vectors can differ and still have the same maximum. The repository therefore separates numerical comparison, token selection and decoded text, alongside layer and tokenizer tests.

The forward test uses 11 prompts with 88 positions in total and compares each position's logits with transformers 5.19 in F32 and eager attention. “Eager” identifies the attention implementation selected in the reference; changing that path can change operation ordering. The reported measure divides the largest absolute logit difference by the largest absolute value in the reference vector. It is not each component's individual relative error.

published result135M360M1.7B
worst logit error under this measure2.1e-54.8e-58.3e-5
argmax at all 88 positionssamesamesame
32 greedy tokens on 12 promptsidenticalidenticalidentical
decoded text on those promptsidenticalidenticalidentical

The generation rows are a separate set: 12 prompts, with 32 tokens generated per case. The test checks prompt IDs, generated IDs and text, plus the chosen tokens' logits within tolerance. Those are not 12 prompts used to calculate the first table row. The two public scripts make the distinction explicit: forward examines the logit vector; generation examines the produced sequence.

Tokenization is checked separately too. The published result records matching IDs on 68 fixed texts and 600 random ones; decoding was compared on 1500 random ID sequences. These are documented expanded runs, not a claim that every default script invocation runs exactly those counts. Checking decoding independently matters because arbitrary ID sequences can form bytes that are not valid UTF-8.

The 21 laws belong in a different category. They cover head splitting, Q-to-K/V mapping, group and array counts, causal reads, write bounds and layout, supported by arithmetic lemmas. The package does not use @unsafe, ?TODO or axioms in place of those proofs. But vectors between layers remain lists without their lengths in their types, and F32 operations are still validated through tests. Saying “the whole inference process was formally proved” would exceed what is implemented.

There is also a public exception to “all systems produce the same tokens.” In the long 360M comparison, llama.cpp's text diverged after approximately 340 tokens. The benchmark records that llama.bend's sequence was checked with transformers. This does not invalidate the table's short comparisons, but it limits their scope. Matching tokens in tested runs is not a universal guarantee for every length, implementation or prompt.

the numbers still show the gap

The final published comparison was measured on October 13, 2026, on an Intel Core Ultra 7 155H with 32 GB RAM, on the CPU in F32. The measured code is commit 3ea435a, with tensor-array 0.1.7.0 and Bend 2.0.37. The revision used to document this post is later and contains that report; I am not presenting the table as a new measurement made today.

The references are transformers 5.19 with PyTorch 2.14.1, llama.cpp at commit 23b0202 using F32 GGUF, and candle at c68b249, also F32. The table below shows milliseconds per generated token, separating one thread and 16 threads. The 135M and 360M generate 32 tokens; the 1.7B generates 128. Weight loading and prompt processing are outside this metric. Complete results are in BENCHMARK.md.

model and generationsystem1 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

The 1.7B needs a caveat already present in the report: the 492 ms carries loading-time variation in the published comparative metric. The generator's internal clock recorded 426 ms per token at 16 threads. These are different measurements, and replacing one with the other in the table would hide that difference. Internal timing helps interpret the spread; it does not justify choosing only the most favorable number for a system comparison.

Bend 2.0.37 includes the compiler correction discussed in the previous post, but this project did not gain speed from it in the published comparison. The 1.7B's internal times at 16 threads were 423 ms on 2.0.36 and 426 on 2.0.37. The update matters as an environment version; I will not credit it with gains that came from changing the cache. Having a relevant compiler fix and saving time in this program are claims requiring separate evidence.

waiting for the first token counts too

For someone opening the demo, waiting for weights to reach memory is part of the experience. The report also measures a run that loads the model and generates one token. The following table gives that total time and peak resident memory at 16 threads. It is neither an isolated loader measurement nor the cost of each token after the program is ready.

system135M360M1.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

Loading is llama.bend's slowest part. The read path produces lists with one cell per byte, and building and traversing those lists costs time. A lower peak than some references in this table does not automatically compensate for more than a minute until the 1.7B's first token. Nor is it a general guarantee of lower memory: the same report records an 8.4 GB peak for the 1.7B at one thread.

These results leave a clear gap to llama.cpp and transformers. The array cache removed a large cost, but did not make weight products or file reads disappear. The measurements describe this machine, these versions and these generations. They are not speed predictions for another processor, quantization or a GPU. This project runs on the CPU, and the numbers presented here use F32.

running what is published

The entry points are the public repository and the project page. The documented commands start by preparing the environment, downloading and converting the checkpoint, and checking the results. For the 135M, the README lists Linux x86_64 or WSL, clang, Python 3.14 and curl. Initial preparation also needs room for the Python environment and model files; it is not just the size of the Bend executable.

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 prepares the documented tool versions; make prep fetches the model and writes files the Bend program reads. make check checks laws, programs that must be rejected and reference comparisons. That does not mean Bend inference calls Python for every token: Python participates in preparation and verification. Generation in the example runs in the compiled executable.

To prepare and check another size, the public command is make prep check MODEL=smollm2-360m, or MODEL=smollm2-1.7b. The program receives the corresponding directory on the command line. The same API is published on BendHub as bend-ml-llama@0.1.0.0; its documentation describes forward, block, cache creation and the room proofs needed to advance one position.

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

There is an API detail coming from affine types: products return the weights they received. Arrays have one owner, so computing with a matrix and continuing to use it requires explicitly passing it onward. block returns LW<c> and forward returns Model<c>. This explains why a call returns more than a logit vector. The result structure is part of how weights remain available for the next token.

What is published remains an inference implementation for base models, using greedy selection, without a chat template. It does not amount to opening a ready-to-chat assistant. It also does not include a proof of every intermediate value or automatic support for every architecture variant. Those limits belong alongside the results because they change what it makes sense to expect from the demo.

For me, the interesting part of this stage is being able to point to specific mistakes the API now excludes: incompatible Q and K weights in the wrong places, a query assigned to the wrong group under the proved mapping, a cache write without room. At the same time, the RMSNorm sum needed numerical investigation, and generation still loses to the references on time. The code and results let you inspect both parts: what the structure guarantees and what still needs measurement and tests.

follow the next posts

Add this RSS feed to your feed reader to follow new posts. No account or email required.

open RSS feed ↗