File size: 19,489 Bytes
28c70af
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
# `bankML/forward.rs` — the forward pass, bit-exact against llama.cpp b11192's graphs

## Summary

`forward.rs` turns token ids into logits. It reproduces, operation by operation and in the same float order, the
graph llama.cpp b11192 builds for two architectures: Qwen3 (`llm_build_qwen3`: per-head RMS norms on Q and K, RoPE in
NEOX mode, YaRN when the header asks) and Llama (`llm_build_llama`: no Q/K norms, RoPE in NORM mode on adjacent
pairs, GQA). The weights may be Q1_0 (1-bit), Q2_0_g64 (ternary) or F16, each through its own bit-exact kernel
(`q1_0.rs`, `q2_0.rs`, `f16.rs`). Tied embeddings are supported: with no `output.weight`, the logits read
`token_embd.weight`, as llama.cpp's `TENSOR_DUPLICATED` does.

The oracle is the shipped ggml computing the same graph, not the C source. The comments record why: GCC contracts
several expressions into FMAs in `libggml-cpu-haswell.so`, and the bits follow the binary. Those expressions are
written here as the `mul_add`s the disassembly shows.

Callers: the native engine (`native.rs`, which owns a `Weights` and the K/V caches of its one slot; the host prompt
cache, `prompt_cache.rs`, holds copies of those caches),
and through it `bankml serve --native` (`/v1/chat/completions`, `/api/chat`, `/api/generate`) and the C API;
`bankml generate` (`main.rs`) directly; `native.rs` also calls `plan` to check a model before it is loaded.

## Technical usage

### Deciding what a file is: `plan`

```rust
pub fn plan(h: &Header) -> Result<Plan, String>
pub struct Plan { pub arch: Arch, pub wtype: u32, pub output: &'static str }
pub enum Arch { Qwen3, Llama }
```

`plan` reads the GGUF header only, before any weight. It returns the architecture, the one weight type every matrix
must share (`TYPE_Q1_0` = 41, `TYPE_Q2_0` = 42, `TYPE_F16` = 1), and the matrix the logits come from. Anything
outside the reproduced graph is an `Err` that names the tensor or key and why (see Limitations).

### Weights and caches

```rust
pub fn Weights::open(path: &Path) -> Result<Self, String>
pub fn caches(&self) -> Vec<KvCache>                       // f16
pub fn caches_of(&self, kind: KvType) -> Vec<KvCache>      // 0.3.9: KvType::F16 or KvType::Q8_0
pub fn prefill(&self, caches: &mut [KvCache], tokens: &[u32], each: impl FnMut(usize, usize, &[f32])) -> Result<Vec<f32>, String>
pub fn prefill_with(&self, caches: &mut [KvCache], tokens: &[u32], outputs: Outputs, each: impl FnMut(usize, usize, &[f32])) -> Result<Vec<Vec<f32>>, String>
pub fn decode(&self, caches: &mut [KvCache], token: u32) -> Result<Vec<f32>, String>
pub fn logits(&self, result_norm: &[f32]) -> Result<Vec<f32>, String>
pub fn logits_rows(&self, result_norms: &[Vec<f32>]) -> Result<Vec<Vec<f32>>, String>
```

- `Weights::open` guards the file (`gguf::guard_file`, mainline engine), runs `plan`, memory-maps the file, reads
  every F32 tensor (the norms) once, and builds a name → index map of the tensors. With Q1_0 weights it also tries
  the GPU worker (`gpu::worker::Worker::open`); a card joins only after it passes the on-card oracle.
- `caches()` gives one empty `KvCache` per layer. A `KvCache` holds K and V as f16 (`set_rows` rounding), one row of
  `n_head_kv · head_dim` per position. `truncate(n)` keeps the first `n` positions, as llama.cpp's `seq_rm` does for
  its prompt cache.
- 0.3.9: `caches_of(KvType::Q8_0)` keeps them as q8_0 blocks instead (`kq`/`vq`), as llama.cpp's `--cache-type-k/v
  q8_0`. llama.cpp b11192 rotates around a quantized cache (`attn_rot_k`/`attn_rot_v`): K and Q by a Hadamard
  transform over 128 values (the head), V over 64, the attention output back by the same 64-wide transform; ggml
  computes it as a fast Walsh–Hadamard transform (`fwht`: scale by `1/sqrtf(n)`, then `u + v`, `u − v` butterflies).
  K and V are rotated and quantized with ggml's AVX2 `quantize_row_q8_0` as they are stored. Attention over a q8_0
  cache always takes ggml's reference kernel, as ggml does whenever K is not f16 (`attend_head_q8`: Q rotated and
  quantized, scores by the AVX2 `vec_dot_q8_0_q8_0`, V dequantized into an f32 accumulator). 53 % of the f16
  cache's bytes (34 bytes per 32 values against 64). The engine reads the type from `BANKML_CACHE_TYPE`
  (`KvType::parse`: `f16` or `q8_0`, anything else refused) and refuses a q8_0 cache for a head size that is not a
  multiple of 32.

  ```rust
  pub enum KvType { F16, Q8_0 }                              // parse("f16" | "q8_0"), name()
  pub fn KvCache::of(kind: KvType, width: usize) -> KvCache  // len, is_empty, bytes, row_bytes, truncate, push
  pub fn fwht(x: &mut [f32], n: usize)                       // ggml's fast Walsh–Hadamard transform, in blocks of n
  pub fn rot_k_size(head_dim: usize) -> usize                // the K/Q rotation's block; ROT_V = 64
  pub fn vec_dot_q8_0_q8_0(n: usize, x: &[u8], y: &[u8]) -> f32
  pub fn attend_head_q8(q: &[f32], k: &[u8], v: &[u8], n_kv: usize, rb: usize, scale: f32, out: &mut [f32])
  ```
- `prefill` computes a prompt in micro-batches of up to `N_UBATCH` (512) tokens, each through every layer together.
  It returns the last token's `result_norm` (`output_norm` of the last layer's output). `prefill_with(…,
  Outputs::All, …)` returns every token's, which is what the model oracle's graph computes.
- `decode` runs one generated token at the next position. `logits` multiplies `result_norm` by the output matrix.
- `step` and `step_with` run one token with a given attention kernel; `step` always uses the reference kernel.
  `decode` and `prefill` choose the kernel llama.cpp would choose.

A minimal greedy loop, as `bankml generate` writes it:

```rust
let w = Weights::open(model)?;
let mut caches = w.caches();
let mut rn = w.prefill(&mut caches, &prompt, |_, _, _| {})?;
loop {
    let l = w.logits(&rn)?;
    let next = /* argmax, or Sampler::sample(&l) */;
    if ends.contains(&next) { break; }
    rn = w.decode(&mut caches, next)?;
}
```

### The three attention kernels

llama.cpp runs one of three CPU flash-attention kernels, and each gives different bits. `kernel_for` picks the one
llama.cpp would run:

```rust
pub fn kernel_for(rows: usize, cells: usize, llama_threads: usize) -> Result<Kernel, String>
pub enum Kernel { Reference, Tiled, Split { padded: usize, nth: usize } }
pub fn padded_kv(cells: usize) -> usize   // multiples of 256, at least 256
```

| kernel | when | function | what it reproduces |
|---|---|---|---|
| `Reference` | micro-batches of 2–63 rows; one-token decodes under 512 padded cells | `attend_head`, `attend_head_partial` | `flash_attn_ext_f16_one_chunk`: Q rounded to f16, online softmax, V accumulator rounded to f16 at every step |
| `Tiled` | micro-batches of 64 rows or more | `attend_head_tiled`, `attend_heads_tiled` | `flash_attn_ext_tiled`: f32 Q, 64-cell KV tiles, a vectorized softmax summed in double, f32 accumulator |
| `Split` | a one-token decode whose padded KV length reaches 512 | `attend_head_split` | the split-KV path: one chunk per llama.cpp thread, partials reduced in chunk order |

The split-KV bits depend on llama.cpp's thread count. `Weights::llama_threads` holds it.

### Other public pieces

- `rms_norm_mul(x, w, eps, out)`: `rms_norm` then `mul`, the sum of squares in double, no FMA.
- `Rope::new(n_dims, freq_base, yarn_factor, n_ctx_orig)`, `Rope::cache`, `Rope::apply`: RoPE with the parameters
  llama-context.cpp derives (YaRN: `freq_scale = 1/factor`, `ext_factor = 1`, beta_fast 32, beta_slow 1).
- `v_expf(x)`: ggml's AVX2 `ggml_v_expf`, one lane. `swiglu(gate, up, out)`: `silu(g) · u` with that `expf`, not libm's.
- `dot_f16`, `dot_f16_ref`: `ggml_vec_dot_f16` as the AVX2 build reduces it.
- `Weights::embed`, `f32_vec`, `norm`, `matrix`, `quantize`, `quantize_rows`, `mv`, `mm`, `mv_many`, `qkv`,
  `attention`, `attention_with`, `attend`, `ffn`: the layer pieces the oracle tests call one by one.

### Environment

| variable | default | effect |
|---|---|---|
| `BANKML_LLAMA_THREADS` | 3 | the `-t` of the llama.cpp being matched (Savante runs 3); decides the split-KV chunks |
| `BANKML_THREADS` | all cores | size of the thread pool (`par::Pool::from_env`) |
| `BANKML_CACHE_TYPE` | `f16` | the K/V cache type, `f16` or `q8_0` (read by `native.rs`, 0.3.9) |
| `BANKML_GPU`, `BANKML_GPU_SHARE`, `BANKML_GPU_LIMIT` | —, measured, 0.8 | the GPU worker for 1-bit matrices and its limiter ([gpu.md](gpu.md), docs/usage.md §13) |

## How it is verified

All oracle tests are `#[ignore]`d (they need the models and recorded files) and run in `testing/release_gate.sh`.

| test | oracle | last recorded result |
|---|---|---|
| `rms_norm_of_a_constant_row` | unit test | — |
| `oracle_forward_embed_norm` | shipped ggml via `testing/forward_oracle.py` | 300 of 300 rows, `inp_embd` and `attn_norm-0` |
| `oracle_forward_qkv_rope` | same | 140 of 140 rows, positions 0–27 and 7–63,214 |
| `oracle_forward_attention` | same | 112 of 112 rows through `l_out` |
| `oracle_forward_swiglu_sweep` | `ggml_swiglu_split` | 24,600 of 24,600 values |
| `oracle_forward_attention_tiled` | same | 150 of 150 rows (the reference kernel would match 1) |
| `oracle_forward_attention_split` | same | 14 of 14 decode rows, 257–1,000 cells, 3 and 4 threads |
| `oracle_forward_model`, `_ternary` | whole graph, `testing/model_oracle.py` | 1,064 of 1,064 rows each |
| `oracle_forward_model_bonsai_1_7b` | same (tied embeddings) | 840 of 840 |
| `oracle_forward_model_llama_f16` | same (SmolLM2-135M-Instruct, mindx-gen39) | 800 of 800 each |
| `oracle_greedy_llama_server` (`_ternary`, `_long`, `_deep`) | llama-server b11192, `testing/greedy_oracle.py` | 6 of 6 (164 tokens); ternary 6 of 6 (140); long 6 of 6; deep 600 tokens |
| `oracle_sample_llama_server`, `oracle_llama_server_bonsai_1_7b`, `oracle_llama_server_llama_f16` | `testing/{greedy,sample}_oracle.py` | 40 of 40 seeded continuations (1,175 tokens) on Bonsai-8B; 40 of 40 on each O4 model |
| `oracle_ggml_b11192_q8_0_kv_kernels` (0.3.9) | the shipped haswell library through `dlopen` (`BANKML_GGML_LIB`) | 4,000 rows quantized byte-exact, 4,000 dot products bit-exact, saturating `maddubs` cases included |
| `kv_oracle_live` (0.3.9, `testing/kv_oracle.py`, Bonsai-1.7B in the gate) | llama-server b11192 with `--cache-type-k q8_0 --cache-type-v q8_0` | 6 of 6 answers (greedy, seeded, a 2,244-token prompt, a 320-token answer, two turns; 567 tokens) |

Rows are compared by the sha256 of their f32 bytes, not within a tolerance. Figures are from docs/oracles.md §1d and
§5d and the 0.3.6 gate record (`testing/results/0.3.6.txt`); the two 0.3.9 rows from CHANGELOG.md (Unreleased 0.3.9).

## Advantages and efficiency

- **One answer, not a close one.** Because the float order, the FMA contractions and the kernel choice all follow
  the shipped llama.cpp, a prompt gives the same logits and the same tokens as llama-server. Speed work can be
  checked: a change counts only if every oracle above still passes.
- **Refusal up front.** `plan` decides from the header alone whether the file is in scope, so an unsupported model is
  refused with a reason before any weight is read.
- **Memory map, read once.** The weights stay in bankml's own memory map. The F32 norm weights are copied once at
  open, and tensors are found through a hash map (0.3.4; before, every lookup scanned the list).
- **Shared activations.** One quantized activation feeds Q, K and V, and one feeds gate and up (`Act`, `quantize`).
  For F16, `mv_many` runs several matrices in one pass of the pool.
- **Batched prefill.** A micro-batch goes through each layer as matrix–matrix products (`q1_0::mat_mul_act_par`,
  `q2_0::mat_mul_par`, `f16::mat_mul_par`). Each element keeps the bits of the per-pair dot. On the last layer only
  the rows llama.cpp outputs (`inp_out_ids`) go through the feed-forward block.
- **Attention on the pool.** Each (row, head) job is computed whole by one worker, so the bits do not depend on the
  thread count. Very small attention jobs run inline instead of waking the pool. The tiled kernel widens each 64-cell
  KV tile once per block of 16 rows, compiled with AVX2, FMA and F16C where the CPU has them.
- **Measured** (docs/PERFORMANCE.md, 0.3.4, laptop, 3 threads): SmolLM2-135M-Instruct F16 decodes at 38.0–38.9
  tok/s against llama-server's 40.4–42.7; the 858-token prompt went from 18.5 s in the first correct build to
  7.8–9.6 s. The same attention work cut the 8B gate runs (for example `oracle_greedy_llama_server_deep` from 756 s
  to 320 s). One loaded-machine pair on Bonsai-8B Q1_0 read 2.30–2.59 tok/s against llama-server's 2.33–2.48; parity
  is not claimed until a pinned, idle measurement.
- **Rust practice.** No crates (`Cargo.toml` has no dependencies). Errors are `Result<_, String>` with the reason.
  `unsafe` is limited to `#[target_feature]` entry points behind a CPU check and the disjoint-slice writes of the
  attention jobs, each with a `SAFETY` comment. The toolchain is pinned (`rust-toolchain.toml`, 1.99.0), and the gate
  runs `cargo clippy -D warnings`.
- **Next** (docs/TODO.md 0.4.0): 1-bit decode at least at llama-server's speed (cut per-token allocations, compute
  logits only where sampled); after the `q8_0` cache (0.3.9), a 4-bit `q4_0` K/V with the same rotation. 0.5.0:
  batched GPU submissions and the ternary GPU kernel.

## Design notes

### How the forward pass was built (P3)

The forward pass was built one verified operation at a time. Each step reproduces the float order of the ggml
operation llama.cpp b11192's graph uses (read from the tag's `ggml/src/ggml-cpu/ops.cpp`), and each was checked against
the shipped ggml computing the same graph (`testing/forward_oracle.py` → `oracle_forward_*`) before the next began.

- **Step three (0.2.3):** the token embedding (`get_rows` on a Q1_0 table → `inp_embd`) and the first RMS norm with
  its weight (`rms_norm` then `mul` → `attn_norm-0`).
- **Step four (0.2.4):** layer 0's attention inputs — the Q, K and V projections (`mul_mat` of a Q1_0 weight with the
  q8_0-quantized normed row, bankml's bit-exact kernel), the per-head RMS norms of Q and K (`attn_q_norm`,
  `attn_k_norm`), and RoPE (`rope_ext`, NEOX pairs, YaRN) with the parameters llama.cpp's context derives.
- **Step five (0.2.5):** attention — K and V kept in f16 as llama.cpp's cache keeps them, the causal flash attention
  of ggml's CPU reference path (`flash_attn_ext`, fewer than 64 query rows and fewer than 512 KV cells), then `wo` and
  the residual (`kqv_out`, `attn_out`, `ffn_inp`).
- **Step six (0.2.6):** the feed-forward block — `ffn_norm`, the gate and up projections, SwiGLU with ggml's own
  vectorized `expf` (not libm's), `ffn_down` and the residual: `l_out`, the whole of layer 0.
- **Step seven (0.2.7):** the whole model — every layer in turn with its own K/V cache, `output_norm` and the logits
  (`Weights::step`, `Weights::logits`). Each matmul runs on a thread pool; `mat_vec_par` has the same bits at any
  thread count.
- **Step eight (0.2.8):** the ternary model. The same forward pass over either weight type — Q1_0 (1-bit, 128-weight
  blocks) or Q2_0_g64 (ternary, 64-weight blocks) — through each type's bit-exact kernel (TECHNICAL.md §III.4,
  §III.6).
- **Step nine (0.2.9):** long prompts. llama.cpp computes a prompt in micro-batches of up to 512 tokens, and a
  micro-batch of 64 rows or more takes ggml's *tiled* flash attention (f32 Q, a SIMD GEMM over 64-cell KV tiles, a
  vectorized softmax summed in double, an f32 accumulator) instead of the reference path. `Weights::prefill` follows
  the same micro-batching and kernel choice.
- **Step ten (0.2.10):** long contexts. A single-token decode whose padded KV length (multiples of 256) reaches 512
  takes ggml's split-KV kernel: the padded cells cut into one chunk per llama.cpp thread, a partial reference pass per
  chunk, then a reduction — so the bits depend on llama.cpp's thread count (`Weights::llama_threads`).
- **0.2.12, batched prefill:** a micro-batch goes through each layer together. Every matmul is one matrix–matrix
  product over the micro-batch's rows (`q1_0::mat_mul_act_par`, `q2_0::mat_mul_par`: each element has the bits of the
  per-pair dot, so the result is the token-by-token result), the micro-batch's K and V enter the cache before its
  attention (as llama.cpp writes them), and each row attends over the cells up to its own position.
- **0.3.4 (O4):** tied embeddings (no `output.weight`: the logits read `token_embd`, as llama.cpp's
  `TENSOR_DUPLICATED` does), F16 weights (`f16.rs`: ggml's two F16 paths, chosen by the product's shape exactly as
  `ggml_compute_forward_mul_mat` chooses them), and the Llama graph (`llm_build_llama`: no Q/K norms, RoPE in NORM mode
  on adjacent pairs, GQA). The multi-row tiled attention (`attend_heads_tiled`) and the tensor-name hash map date from
  this release. What the forward pass runs is decided from the header alone (`plan`); everything else is refused with
  the reason.
- **0.3.9:** the q8_0 K/V cache (`KvType::Q8_0`, `caches_of`) with llama.cpp's Hadamard rotation, and its oracle
  `oracle_ggml_b11192_q8_0_kv_kernels`.

### Why only the output rows leave the last layer

On the last layer of a prompt only the rows llama.cpp outputs go through the feed-forward block (its `inp_out_ids`:
none, the last, or all). For the quantized types this changes no bits, since each element of a matrix–matrix product
has the per-pair dot's bits. For an F16 model it matters: ggml chooses between `ggml_vec_dot_f16` (one column) and
llamafile's tinyBLAS (two or more) by the product's shape, so a different number of rows would give different bits.
For the same reason the model oracle replays an F16 model as one micro-batch with every row output, which is what
the oracle's graph computes.

## Limitations

- Architectures: Qwen3 and Llama only. Each new architecture needs its own oracle set.
- Weight types: Q1_0, Q2_0_g64 and F16 only. Q8_0, BF16 and Q4_K are refused (O3). Mixed matrix types are refused;
  norms must be F32.
- Refused tensors: biases, fused QKV, rope factors, experts, and Q/K norms on Llama. On Llama, any RoPE scaling
  other than `none`, an attention scale, or experts are refused. Llama 3.x is therefore refused (docs/TODO.md).
- Partial RoPE, a value head of another width, and a head width that is not a multiple of 32 are refused.
- The K/V cache is f16 or q8_0 (both K and V the same type; llama.cpp also allows them to differ, and q4/q5 types).
  A q8_0 cache has no tiled or split-KV kernel, as in ggml, so its long prefills are slower than f16's. The
  split-KV bits are correct only when `BANKML_LLAMA_THREADS` equals the matched llama.cpp's `-t`.
- The GPU worker takes part only in 1-bit matrix–vector products; the ternary GPU kernel is next.

## See also

- [../oracles.md](../oracles.md) §1d, §5d — the forward, model, greedy and sampling oracles
- [../PERFORMANCE.md](../PERFORMANCE.md) — F16 and Llama graph speed, decode budgets
- [../TECHNICAL.md](../TECHNICAL.md) §III.7 — from a template to a token
- [../TODO.md](../TODO.md) — 0.4.0 to 0.6.0
- [../OLLAMA.md](../OLLAMA.md) — O3, O4 and what is still refused
- [../usage.md](../usage.md) §13 — `bankml generate` and the environment
- Sibling pages: [native.md](native.md), [sampler.md](sampler.md), [tokenizer.md](tokenizer.md),
  [chat.md](chat.md), [q1_0.md](q1_0.md), [q2_0.md](q2_0.md), [f16.md](f16.md), [par.md](par.md), [gguf.md](gguf.md)