GitHub - TheRootKIDD/Frigg: Frigg is a hybrid resident+streamed inference engine built for one hardware story: running Mimir from Danish Foundation Models on four consumer RTX 3060s. · GitHub
Skip to content
TheRootKIDDPublic

About

Frigg is a hybrid resident+streamed inference engine built for one hardware story: running Mimir from Danish Foundation Models on four consumer RTX 3060s.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Latest commit

 

History

61 Commits

Folders and files

Repository files navigation

Frigg

Inference engine for DFM Mimir v1 (danish-foundation-models/DFM-Mimir, HRM-Text architecture, Danish Foundation Models / SDU) on consumer Ampere GPUs — the 4× RTX 3060 12 GB box that also runs zaphira.

Mimir is 1.8B parameters that behave like an 8B-class inference problem: two 16-layer modules applied 8 times per token (3.9B parameter-reads), 128 virtual layers of KV cache, and a PrefixLM mask that no Ampere attention kernel ships.

Status: M0–M6 complete. frigg serve speaks the OpenAI API and works against the official Python SDK. int8 decode runs at 46.7 tok/s single stream (3.8× HF eager, measured at a fixed 65 °C — see Finding 12); GSM8K-200 scores 92.0% on bf16 and 91.0-91.5% on int8 against the paper's 89.9 — the range is the elementwise fusion, which is one question either way (Finding 7b). Four replicas serve 405.9 tok/s aggregate over 32 streams with zero errors; one replica does 141 tok/s over 8 streams, up from 22.8 when M4 first measured it — batched CUDA graphs and a batched W8A16 kernel, neither of which was worth much without the other (Finding 8). M0's job was to turn every assumption in the spec into a measured fact before writing a single kernel; this README is the record of what M0 and M1 found. Everything below was measured on this machine unless it says derived.


Contents


The machine

4× RTX 3060 12 GB (GA106, SM 8.6, 3 MB L2), Threadripper PRO 3945WX, 128 GB DDR4, Fedora, NVIDIA driver 595.80. Toolchain: Python 3.12 / uv, torch 2.11.0+cu128, triton 3.6.0, transformers 5.16.1.

uv run frigg bench vram, all four cards:

GPU D2D copy GB/s fused read GB/s GEMV GB/s
0 333.1 341.5 338.7
1 333.1 341.4 338.7
2 333.9 342.3 339.7
3 333.0 341.4 338.7

The cards are within 0.3% of each other. The GEMV number (a [1,1536] × [1536,262144] bf16 matrix-vector — Mimir's actual lm_head shape) is 94% of the 360 GB/s nominal figure, and it is the ceiling that matters: at batch 1 every decode GEMM degenerates to this shape. All ceilings below use the slowest card's 338.7 GB/s, not the nominal 360.

Derived decode ceilings

Bytes per decoded token = core weights × 8 runs + lm_head + KV read. Derived from the measured bandwidth, not measured end to end.

Build @512 ctx @2048 @4096
bf16 weights, bf16 KV 9.06 GB → 37.4 tok/s 10.27 GB → 33.0 11.88 GB → 28.5
int8 weights, int8 KV 4.53 GB → 74.7 5.15 GB → 65.8 5.96 GB → 56.8
int4 core + int8 vocab, int8 KV 2.57 GB → 131.8 3.18 GB → 106.4 4.00 GB → 84.6

Frigg's engineering target is ≥ 80% of these.


The model, as verified

Every number here was read from the downloaded artifacts in M0, not from the paper. Full detail and provenance in docs/MODEL.md; the VERIFY log in §10 is complete.

Revision read 2844f0178e695d7d9ce182cb660671fd34c76ce5, lastModified 2026-08-20
hidden_size / heads / head_dim 1,536 / 12 / 128 — MHA, not GQA (num_key_value_heads: 12)
Layers num_hidden_layers: 16 per module; 16 H + 16 L = 32 blocks
H_cycles / L_cycles 2 / 3 → 8 module runs per token (L L L H L L L H)
Virtual attention layers 128 = 16 × 2 × (3+1). The config class rewrites num_hidden_layers 16 → 128 on load
intermediate_size 4,096, SwiGLU
Norm RMSNorm, no learnable weight anywhere, eps 1e-6, pre-norm + per-module final norm
RoPE theta 10,000, rope_type: default, no scaling → 4,096 is a hard wall
embedding_scale 39.191835884530846 — bit-exactly sqrt(1536), and exactly 1/initializer_range
Vocab 262,144 (Gemma-4), untied lm_head. The two vocab matrices are 45% of all params
Checkpoint packed layout, 131 tensors, all BF16, 3,573,566,200 bytes (accounting exact to the byte)
z_L_init present as model.z_L_init [1536], stored bf16. No zH_init — z_H starts from the scaled embeddings
Zero-count check 0 norm tensors, 0 bias tensors
Parameters 1,786,775,040 = 981,467,136 core + 2 × 402,653,184 vocab + 1,536

Tokenizer and template

Token id Token id
<pad> 0 <|tool> / <tool|> 46 / 47
<eos> 1 <|tool_call> / <tool_call|> 48 / 49
<bos> 2 <|tool_response> / <tool_response|> 50 / 51
<unk> 3 <|"|> (string delimiter) 52
<|turn> 105 \n / \n\n 107 / 108
<turn|> (generation EOS) 106

vocab_size == len(tokenizer) == 262,144 (no added tokens). fix_mistral_regex: true. model_max_length is 1e30, so the 4,096 limit is entirely Frigg's to enforce.

The template emits <bos> itself — never add it again. Assistant renders as model:

<bos><|turn>system
Du er Mimir, en hjælpsom dansk assistent.<turn|>
<|turn>user
Hvad er 2+2?<turn|>
<|turn>model
4<turn|>
<|turn>user
Og gange 3?<turn|>
<|turn>model

Tools render inside the system turn, with JSON types upper-cased and strings delimited by the <|"|> token rather than ASCII quotes:

<|tool>declaration:get_weather{description:<|"|>Get the current weather in a city<|"|>,
parameters:{properties:{city:{description:<|"|>City name<|"|>,type:<|"|>STRING<|"|>}},
required:[<|"|>city<|"|>],type:<|"|>OBJECT<|"|>}}<tool|>

Finding 1: token_type_ids is the whole ballgame

This is the finding the rest of the project rests on.

HF's HrmTextModel.forward turns token_type_ids into a per-position block id and ORs a bidirectional overlay onto the causal mask:

block_sequence_ids = torch.where(token_type_ids == 1, 0, -1)
mask = causal OR blockwise_overlay(block_sequence_ids)
#   overlay unmasks (q, kv)  iff  q_group == kv_group and q_group >= 0

Four consequences, all covered by tests/test_prefixlm_mask.py (CPU, runs in CI):

  1. Only the literal value 1 marks a prefix token. 0, 2, -1 and omission are all plain causal — silently, with no error and no warning. The check is not "did I pass token_type_ids", it is "are they equal to 1".
  2. ones over the whole input → fully bidirectional. At prefill the input is the prompt, so this is exactly the PrefixLM prefill mask.
  3. ones on the first P positions only → the training-time mask: a bidirectional P×P block plus causal response rows. So the multi-token verification mask is expressible through this interface.
  4. It is honoured only on the first forward (past_key_values is None or not initialized), so generate() takes it once and ignores it during decode — which is the correct semantics, not a bug.

What it costs to get wrong

24 golden prompts × 3 variants, bf16, greedy, GPU 0:

Comparison Result
ones vs causal — greedy output differs 19 / 24 prompts (11 diverge at the first generated token)
ones vs causal — same argmax at the last prompt row 15 / 24
zeros vs omitted — same argmax 23 / 24 (they are the same mask)
zeros vs omitted — identical fp32 logit bytes 0 / 24 (see Finding 4)
generate()'s first token == uncached prefill argmax 72 / 72

Running Mimir without the prefix mask is not a subtle quality regression — it changes the answer on ~79% of prompts. This is the single most likely way to build a fast engine that is confidently wrong, and it is why tests/golden/hf_reference.json records all three variants rather than just the right one.

What it costs, in points

M6 put a price on it (docs/RESEARCH.md E4c). Same weights, same tokenizer, same sampling, same problems — only the prompt's attention mask differs:

prompt mask GSM8K (60 items) invalid answers
bidirectional, as trained 88.3% 0
causal, i.e. ignoring prefix_lm 50.0% 4

38.3 points — 43% of the model's accuracy — and it starts emitting answers with no parseable number in them at all. No error, no warning, fluent Danish either way.


Finding 2: vLLM does run on Ampere

The spec listed this as an open question (Q4) and assumed the answer was probably no, since the paper's evaluations ran on vLLM + FlashAttention 4 (Hopper/Blackwell).

It runs. vLLM 0.28.0, HrmTextForCausalLM, FLASH_ATTN backend, CUDA graphs captured, 33.0 tok/s single-stream greedy — 2.7× HF eager. Three things had to be right, each of which looks like "unsupported" if you stop at the first traceback:

  1. VLLM_USE_FLASHINFER_SAMPLER=0 is mandatory here. FlashInfer JIT-compiles its sampling kernels at startup using the system /usr/local/cuda nvcc (CUDA 12.6.3), which refuses this host's gcc 16.2.1. This is unrelated to the paper's note that the FlashInfer attention backend was unusable for Mimir. ninja must also be on PATH.
  2. Default memory settings OOM in the sampler, not the model. vLLM upcasts logits to fp32; at 262,144 vocab a 256-row warm-up batch is a 256 MiB allocation on an already 85%-full card. max_num_seqs=8, gpu_memory_utilization=0.75 fixes it. This is the "vocab matrices are 45% of params" problem biting a production engine.
  3. KV is the binding constraint. vLLM reports a 5,024-token GPU KV cache and "maximum concurrency for 4,096 tokens per request: 1.23x". That is the 128-virtual-layer tax measured on a real engine: ~9.5 GB of the card buys barely more than one full-context request. It is the strongest quantitative argument for Frigg's int8 KV work in M3.

Two independent confirmations of Finding 1 fell out of this:

  • vLLM's greedy output matches HF-with-token_type_ids=1 on 21/24 prompts and 94.3% of decode steps, but HF-without on only 5/24. Two separate implementations agree on the prefix mask. (The 3 divergences are late-position FA2-vs-SDPA drift.)
  • vLLM auto-disables chunked prefill and prefix caching, reason: "model has non-causal attention layers" — independently confirming that a PrefixLM prefix cannot be cached or chunked across turns.

One thing vLLM cannot do: its PrefillPrefixLMAttention gets bidirectionality by reusing the encoder-only wrapper (causal=False on every metadata build) with attn_type=DECODER. That makes the whole prefill bidirectional and cannot express a partial-prefix mask — so it cannot do multi-token verification. Frigg's token_type_ids-shaped mask can.

So why Frigg? Not "because nothing else runs" — that premise is now dead. Because vLLM's 33 tok/s is ~26% of the derived bf16 ceiling, it cannot do int4 or int8 KV for this model, and it can hold 1.23 full-context requests per card. Those are the gaps.


Finding 3: it is launch-bound and bandwidth-bound

torch.profiler over one cached decode step of the HF oracle counts 5,454 CUDA kernels per decoded token. The spec estimated ~1,700; the real figure is 3.2× that. Roughly 256 of the excess are CatArrayBatchedCopy calls from HF's DynamicCache growing K and V once per virtual layer per step — a class of kernel a static preallocated cache removes outright.

At the measured 5.0 µs per launch:

ms/token launch-bound ceiling
eager, 5,454 kernels 27.5 36.4 tok/s
one CUDA graph replay 5.0 198.5 tok/s

The eager launch ceiling (36.4) sits essentially on top of the bf16 bandwidth ceiling (37.4 @512 ctx). Eager bf16 decode is launch-bound and bandwidth-bound simultaneously, so neither number alone explains anything.

The scheduling consequence is sharp: quantization before CUDA graphs would measure nothing. An int8 build's 74.7 tok/s ceiling and an int4 build's 131.8 tok/s ceiling are both far above what eager PyTorch can issue. Any int8 number benchmarked without graphs would be a measurement of the launch path, not of the quantization. This independently confirms the roadmap's M2-before-M3 ordering.

For reference, HF eager actually achieves 12.20 tok/s — a third of its own launch ceiling — because those 5,454 kernels also carry Python-side dispatch. Summed GPU kernel time for one decode step is 38.3 ms against ~82 ms of wall time, so more than half of HF's decode time is CPU gap, not GPU work.


Finding 4: "bit-identical" is not a well-defined target

The spec's NF1 asks for torch.equal on logits against HF. M0 found that the oracle is not bit-identical to itself, twice over:

Same mask, different representation. Passing token_type_ids of all zeros and omitting it entirely produce the same semantic mask (plain causal), but the fp32 logit bytes differ on 24 of 24 prompts. Omission makes create_causal_mask return None so SDPA takes its is_causal=True fast path; zeros materialise a dense additive mask and take a different kernel. On 3 of 24 prompts the difference flips a near-tie argmax.

Same math, different GEMM shape. lm_head applied to one sliced row is not bit-identical to lm_head applied to a batch containing that row: cuBLAS picks a different kernel and bf16 logits differ by up to 0.031 absolute.

So NF1 is only meaningful against one pinned SDPA call shape and one pinned row count. Concretely, for M1/M2:

  • The exact path must reproduce the oracle's call, not just its math: same mask dtype, same mask shape, same attn_implementation.
  • frigg parity must compare a decode-row logit against a decode-row logit, never against a row sliced out of a prefill batch, or it will report a spurious mismatch.
  • The golden digests in tests/golden/hf_reference.json are valid only at the row count that produced them (1 row, from a full prefill forward).
  • M2's graph-vs-eager test must keep the lm_head batch shape identical on both sides.

A related trap: a standalone AutoConfig leaves _attn_implementation unset, and create_causal_mask then returns None for every input, silently. Pin it to sdpa when building masks outside a loaded model.


Finding 5: self-speculative decoding is dead on arrival

docs/RESEARCH.md E2 proposed cycle-split self-speculative decoding: the first H-cycle (L0 L1 L2 H0) is a complete sub-model, so lm_head on z_H after cycle 1 gives draft logits at half the weight traffic. The pre-test measures the acceptance ceiling before anything is built — a forward hook on the HF oracle, one teacher-forced pass per golden prompt under the real PrefixLM mask, 1,322 response positions.

Metric Measured Needed
Greedy agreement, cycle-1 draft vs final 55.1% ≥ 70% to be viable at all
Top-5 overlap 34.4% —
Final greedy token inside draft's top-5 73.0% —
E[accepted] per block at k = 4 1.12 ≥ 2.5 (the go/no-go rule)

NO-GO. 55.1% is below the 70% floor and well below the ~75% break-even the cost model gives for k=4. At 1.12 expected accepted tokens per block the scheme would be slower than plain decode. The cheaper (1,1) draft variant is strictly further from the final state, so it cannot rescue this. E2 is a published negative result and is out of the M6 plan; --spec-decode self will not be implemented.

The more interesting number: mean ‖z_H⁽²⁾ − z_H⁽¹⁾‖ / ‖z_H⁽²⁾‖ = 1.16 (1.11–1.27 by prompt). The second H-cycle moves the high-level state by more than the state's own magnitude. The two cycles are not successive refinements of one answer — they compute substantially different states. That answers experiment E4e for free, and makes a strong prediction for E1 (recurrence scaling): reducing H_cycles should degrade sharply rather than gracefully, and the schedule is not a smooth compute dial downward. E1 is now the more valuable M6 experiment and should be run first.


Finding 6: the tokenizer depends on how you name the model

Found in M1 while chasing a decode mismatch that turned out not to be a decode bug at all. tokenizer_config.json sets fix_mistral_regex: true, which asks transformers to swap the on-disk pre-tokenizer for a corrected regex Split. Whether it does so depends on how you name the model:

AutoTokenizer.from_pretrained("danish-foundation-models/DFM-Mimir")
    # pre_tokenizer = Split(" ", MergedWithPrevious)                     UNFIXED
AutoTokenizer.from_pretrained("/path/to/that/exact/snapshot")
    # pre_tokenizer = Sequence([Split(<mistral regex>, Isolated),
    #                           Split(" ", MergedWithPrevious)])         fixed

Same files, same TokenizersBackend, both reporting init_kwargs["fix_mistral_regex"] == True, no warning in either direction. Passing fix_mistral_regex=True explicitly does not change the hub-id result — the flag is only read inside a branch that path has already skipped. The on-disk tokenizer.json holds the unfixed pre-tokenizer, so the fix is a load-time patch, not a file.

The corrected form is the intended one, on the DFM team's own authority: their fork's notes say "The export declares fix_mistral_regex=true. vLLM applies Transformers' corrected pre-tokenizer", and their converter persists the flag deliberately after finding that "loading with and without the flag produced different token IDs".

It is not cosmetic — 14 of the 24 golden prompts tokenize differently, +33 tokens overall:

"Den danske sommer er kort, men"
  fixed   (correct) : ... '▁kort' ',' '▁' 'men'    9 tokens
  hub id  (wrong)   : ... '▁kort' ',' '▁men'       8 tokens

An unfixed tokenizer feeds the model out-of-distribution ids on most Danish text, and nothing anywhere says so. This is the same shape of failure as Finding 1: a default that is silently wrong, in a component everybody assumes is settled.

Frigg loads through frigg/tokenizer.py, which applies the patch from any path and then verifies the ids against known-good probes rather than trusting the loader. The M0 golden files were regenerated, because they had been produced through a hub-id load. The M0 parity conclusions are unaffected — both sides of every comparison used the same ids, so token_type_ids, the mask semantics, the eager tok/s and the vLLM agreement all still stand — but the recorded prompt ids and continuations were for the wrong tokenization.


Finding 7: two real speedups that did not pay for themselves

M2 produced three optimizations. One was kept, two were measured and shelved — and the reason is the same each time: a kernel-level win is not an end-to-end win, and quality has to be measured on the same run as speed.

optimization kernel-level end to end greedy agreement kept?
CUDA-graph capture — 2.08x 100.00% yes
Fast prefill (drop the all-true mask) 2.0x on attention 1.24x 93.15% no
Elementwise fusion (RoPE/SwiGLU/gate) ~5x fewer launches 1.06x 97.39% opt-in

Fast prefill. In an ordinary prefill the whole call is prefix, so the PrefixLM mask is entirely True — passing no mask is mathematically identical and lets SDPA use its flash kernel instead of reading a 16.8 MB boolean mask. That is a clean 2x per virtual layer at P = 4096. But attention is only part of prefill (the linears dominate), so 2x becomes 1.24x; and the 0.3% per-layer numerical difference compounds over 128 virtual layers into a 1.9% logit error that costs 7% of decode steps. Half a second of prefill is not worth that.

Elementwise fusion. The decode profile showed 3,643 elementwise kernels costing 17% of the step, RoPE worst at ~1,536 launches per token. Three triton kernels recover about a fifth of it — +6.4% throughput — for 2.6% of greedy steps, because they accumulate in fp32 where the torch chain stays in bf16. Better trade, still a trade, so it is a flag rather than a default. Worth revisiting in M3, where quantization error will dwarf it.

Neither is a failure of the optimization; both are a failure of the assumption that a faster kernel is a faster engine. Measuring the two together is what caught it.


7b: the same fusion, re-measured per build (2026-08-31)

Finding 7 shelved the elementwise fusion on a bf16 measurement. Re-run once the batched kernel, batched graphs and bucket selection had changed what fraction of a step is elementwise — and, this time, on every build rather than one:

build fused B1 tok/s B8 tok/s free-running agreement
bf16 off 27.21 158.5 100.00% (24/24)
bf16 on 28.85 165.0 99.28% (23/24)
int8 off 42.55 150.8 91.14% (20/24)
int8 on 48.11 169.9 97.64% (22/24)
int4 off 30.99 96.0 74.37% (12/24)
int4 on 32.51 99.2 74.22% (12/24)

Finding 7 was right, for the build it measured. bf16 reproduces its trade exactly (twice, to the decimal): ~6% faster, and the oracle stops being bit-exact. On a quantized build the trade is different — int8 gains 13% and 6.5 points of agreement — because the fused kernels carry an fp32 accumulator where the unfused chain rounds through bf16. That rounding is noise the bf16 path does not want and the quantized path benefits from.

So fusion is now on by default for quantized builds and off for bf16, which is what is_quantized decides. The exact path stays exact.

And the part that did not replicate

The token-agreement gain did not show up in task accuracy. GSM8K-200 on int8, run back to back:

accuracy wall
unfused 91.5% (183/200) 18.5 min
fused 91.0% (182/200) 13.5 min

One question, on a 200-item set whose standard error is about 2 points — indistinguishable. Free-running token agreement against the bf16 oracle moved 6.5 points and GSM8K moved by less than noise in the other direction. Agreement with an oracle's tokens is a much weaker proxy for quality than it looks, which is worth remembering given how often this project reaches for it. The fusion is kept for the 13% and the 27% shorter eval, not for a quality claim it cannot support.


Finding 8: quantization's win inverts under load

The load test contradicted an assumption, and the build matrix then found the real shape of it — which is not the one I first wrote down.

Decode, aggregate tok/s at 512 context (frigg bench matrix, 2026-08-30):

build batch 1 batch 4 batch 8
bf16 26.6 47.5 95.1
int8 41.0 32.9 65.3

At batch 1 int8 wins by 1.5×. At batch 4 that reverses: bf16 is faster than int8, and it stays ahead at batch 8. So for a loaded server today, bf16 weights are the better choice, and the int8 checkpoint is for single-stream work.

The cause is specific, and it is not "batching is hard". bf16 goes through cuBLAS, which reads each weight matrix once for the whole batch — exactly what batching is for. The W8A16 path had no such kernel, and neither of its two routes amortized: looping the gemv reads the weight once per row, so aggregate stays flat; dequant + cuBLAS reads it once but writes 3.57 GB of bf16 to do so.

Chasing this also found a plain defect: GEMV_MAX_ROWS was defined and never used, so every batched step dequantized all 128 matrices plus the 805 MB lm_head — about 9 GB of needless traffic, 25 times a second.

8b: the batched kernel was written, and it did not fix this (2026-08-30)

Superseded. This section previously read: "a proper batched W8A16 kernel is the single highest-value piece of work left", and that a first tl.dot attempt had been thrown away at 3× slower. The second half is still true. The prediction was wrong, and the way it was wrong is the interesting part.

frigg/kernels/w8a16_batched.py now exists: one tl.dot program per (row-tile, N-tile, K-split), BLOCK_N=128 with split-K reducing through a partials buffer rather than atomics, so a captured graph replays bit-identically. The first attempt failed because it inherited the gemv's tile_for (BLOCK_N 16–32) — far too small for the tensor cores on 1,536-wide outputs. Sizing the tile for tl.dot instead of for the gemv is the whole fix.

Its cost is nearly constant in M, which is exactly what was missing (128 matrices + lm_head, GPU 0, isolated):

rows batched loop the gemv dequant + cuBLAS
1 26.5 ms 19.5 ms 69.2 ms
2 25.8 37.3 68.4
4 25.9 72.0 68.5
8 26.7 141.9 68.7
16 27.5 281.8 69.0

It is also numerically free: batched greedy output is identical to single-stream at batch 2, 4 and 8 (39/39, 122/122, 236/236 tokens), identical with the kernel disabled, and 8 identical prompts in 8 slots produce 8 identical rows.

And in a real batch-4 decode step it does what it promises. torch.profiler, GPU time:

with kernel without
total GPU per step 43.3 ms 86.2 ms
the 128 matmuls + lm_head 27.1 ms (62%) 69.9 ms (81%)
decode attention (_stage1) 7.25 ms 7.21 ms

The decode step now does half the GPU work. Throughput moved 5%. frigg bench matrix puts int8 batch 4 at 31.2 → 32.9 tok/s, and the server at 8 slots at 42.3 → 44.0 tok/s.

The reason is in engine.py:292: GraphedDecoder raises NotImplementedError for batch != 1. Only single-stream decode is CUDA-graph captured. Every batch > 1 step — the entire reason slots exist — runs eager, at ~1,700 launches per token. A batch-4 step takes 121 ms of wall clock to do 43.3 ms of GPU work: 78 ms of pure launch gap. Halving GPU work inside a step that is 64% launch overhead buys almost nothing. Finding 3 said this model is launch-bound before it is bandwidth-bound; that is still the binding constraint at batch > 1, and it is the reason the batch-1 graphed path (38.9 tok/s) beats the batch-4 eager one per stream.

So the corrected verdict: the batched kernel was worth writing — it is 2.7× on its own terms, it removes ~9 GB/step of dequant traffic, and it is a prerequisite — but it was not the highest-value work. Batched CUDA-graph capture is, and until it lands, int8 under load stays behind bf16 and the kernel's 2× sits unclaimed.

This is the fifth time the same root cause has bitten, in a new direction: small matrices on this model defeat optimizations that assume bandwidth is the constraint (Findings 3, 7, 8). The new wrinkle is that it also defeats measurement — the isolated microbenchmark predicted 146 tok/s aggregate at batch 4 and delivered 32.9. Amdahl's law was the missing term, and only a profile of the real step, not the kernel, showed it.

8c: batched CUDA graphs, and why neither change worked alone (2026-08-30)

GraphedDecoder now captures any batch, so §8b's diagnosis could be tested directly. The cache's cursors were already per-slot device tensors, so the body barely changed; what batching adds is that a replay steps every slot and the host can no longer hold one back, so the cursors are clamped inside the captured region. Without that, a short slot runs on while a long one hits the wall, write_pos walks past max_seq, and write_decode's index_copy_ writes outside the slab — corrupting another slot's rows silently rather than raising.

The full 2×2 at int8, 512 context, batch 4 (frigg bench matrix):

eager CUDA graph
dequant + cuBLAS 31.2 tok/s 48.7 (1.56×)
batched W8A16 32.9 (1.05×) 85.6 (2.74×)

Neither change alone was worth much; together they are worth 2.74×, which is more than their product (1.05 × 1.56 = 1.64). They are not independent — each one's benefit is capped by the other's bottleneck. Removing launch overhead does nothing while the GPU is busy dequantizing 3.57 GB per step; halving GPU work does nothing while the step is 64% launch gap. The isolated microbenchmark that said the kernel was 2.7× was right about the kernel and useless as a prediction of throughput, and the profile that explained it only became actionable once the other half was fixed too.

Decode, aggregate tok/s at 512 context, everything on:

build batch 1 batch 4 batch 8
bf16 26.2 92.8 164.2
int8 39.1 85.6 146.7
int4 26.4 48.4 89.8

bf16 still wins at batch > 1, so the headline of this finding survives: int8 is a single-stream win (1.5×) and a batched loss. The reason has changed, though. It is no longer that the W8A16 path fails to amortize — it does now — but that at batch 4–8 the step is compute-bound, where cuBLAS on the tensor cores beats a triton int8 kernel that has to dequantize on the fly. Bandwidth is what int8 buys, and bandwidth stops being the constraint as the batch grows.

The server, one replica, 8 slots, int8, 180 s:

streams decode requests errors aggregate TTFT p50 / p95
8 eager 100 0 44.0 tok/s 0.31 s / 1.19 s
8 graphed 255 0 119.5 tok/s 0.20 s / 0.79 s
16 graphed 262 0 115.2 tok/s 5.77 s / 7.93 s

2.7× aggregate, and 2.5× the requests served. Graphed completions are byte-identical to --no-graphs ones, and tests/test_graph_vs_eager.py checks replay-equals-eager and replay-equals-single-stream at batch 2, 4 and 8. The 16-stream row shows the new ceiling is ~117 tok/s at 8 slots: past the slot count, more clients still only buy latency.

Taken together the server went from 22.8 tok/s (Finding 8 as first written) to 119.5, a 5.2× improvement, from three changes of which the one predicted to matter most mattered least on its own.

What the server numbers actually depended on

The 4-slot saturation reported below was blamed on the kernel. It was not the kernel. 8 streams, 180 s, one replica, int8:

slots max tokens batched kernel requests errors aggregate TTFT p50 / p95
4 96 no (original) 64 0 22.8 tok/s 12.3 s / 15.9 s
4 96 yes 68 0 23.0 tok/s 11.8 s / 15.5 s
8 128 no 97 0 42.3 tok/s 0.31 s / 1.17 s
8 128 yes 100 0 44.7 tok/s 0.30 s / 1.18 s

Three separate 8-slot runs with the kernel gave 44.0, 44.3 and 44.7 tok/s, so its ~4% edge over the 42.3 control is real but only a few times the run-to-run spread — read it as "a few percent", not a precise figure. The slot change is not close: with 4 slots serving 8 clients, half the wall clock was queueing, not decoding — TTFT p50 of 12.3 s says so plainly. Doubling the slots nearly doubled aggregate throughput and cut TTFT p50 by 40×, and it would have done so without the kernel. --kv-slots 4 was simply the wrong default for 8 clients on a card with room for 8.


Finding 9: the recurrence schedule is not a dial

HRM's premise is that recurrence decouples parameters from compute, which invites the obvious question: can you turn the schedule up at inference for more quality, or down for speed? Nobody had measured it for Mimir. The answer is no, in both directions, and the failure is not graceful.

Held-out NLL, teacher-forced, against the trained (2, 3):

H L runs NLL ΔNLL tok/s
1 1 2 7.102 +6.867 48.7
1 3 4 6.244 +6.009 26.1
2 1 4 0.451 +0.217 26.1
2 2 6 0.295 +0.060 17.8
2 3 8 0.235 — 13.5
2 4 10 0.253 +0.018 10.9
2 6 14 0.254 +0.019 7.8
3 3 12 1.767 +1.532 9.0
4 3 16 5.149 +4.914 6.7

H_cycles is an architectural constant, not a knob. One H-cycle destroys the model (6.2–7.1 nats, and no amount of L compensates). Three is worse than halving L. Four is nearly as bad as one. Only 2 works, and not by a little.

L_cycles is a shallow dial whose optimum is exactly the trained value. Spending 75% more compute (L=6: 14 runs against 8) makes it slightly worse. There is no test-time scaling to be had.

This confirms a prediction M0 made before any of the engine existed. The E2 pre-test measured ‖z_H⁽²⁾ − z_H⁽¹⁾‖ / ‖z_H⁽²⁾‖ = 1.16 — the second H-cycle moves the high-level state by more than the state's own magnitude — and concluded the cycles are not successive refinements but a fixed two-stage computation. A two-stage computation breaks if you run one stage and breaks if you run three. It does.

The practical reading: H_cycles and L_cycles belong to the checkpoint, not to runtime tuning. An engine that defaults them differently produces a model that is fluent and wrong — the same failure mode as token_type_ids and fix_mistral_regex. That is now three separate silent-default traps in one 1.8B model.


Finding 10: the server was silently ignoring temperature

frigg serve accepted temperature, top_p, seed, n, stop, frequency_penalty and logprobs, and acted on none of them. The decode step was a bare argmax, so a client asking for temperature: 1.0 got greedy text, with a 200 and no warning. sampling.py had existed since M1 with a complete Sampler; frigg run used it; the server simply never wired it in, and no test asked whether it had.

That is the worst failure shape in this project so far. Every other bug here produced a wrong number or a crash. This one produced plausible output, which is why it survived a whole milestone with test_server.py green — the tests checked the response schema, and a greedy completion has exactly the right schema.

What it needed

Slots in one batch belong to different requests, so every parameter is per-slot: BatchedSampler keeps temperature[B], top_k[B], top_p[B], noise_scale[B] and a [B, vocab] noise buffer as static device tensors, which is the signature docs/ARCHITECTURE.md §5 specified a milestone earlier. Greedy is not a special case: a greedy slot gets temperature = 1, noise_scale = 0, which reduces argmax(logits/T + s·g) to argmax(logits) exactly, so the body stays branch-free while a batch mixes greedy and sampled slots — which it does, constantly.

Sampling is captured as a second CUDA graph rather than folded into the first, and the replica picks per step. Exact top-p costs a full vocabulary sort and the noise fill another draw, and the eval and benchmark paths are greedy; they should not pay for a feature they do not use. Greedy throughput after the change is 132.8 tok/s aggregate over a 120 s run, against 119.5 over the 180 s run before it — run length and prompt mix move this by ~10%, so that is "no regression", not a speedup.

The measurement that changed the design

The obvious way to make top-p cheap is to prefilter to a top-K shortlist and sort that. Before doing it I measured the thing it assumes — that Mimir's tail is negligible:

T p(top-1) tokens for 95% mass tokens for 99% mass outside top-64
→0 1.000 1 1 0.0000
0.5 0.967 1 2 0.0000
0.7 0.926 2 3 0.0002
1.0 0.805 1,897 44,554 0.028
1.3 0.501 133,417 214,950 0.316
2.0 0.035 237,254 255,622 0.935

A 262,144-token vocabulary has a very long tail, and temperature exposes it violently: between T=0.7 and T=1.0 the nucleus goes from 2 tokens to 1,897. At T=1.3 a top-1024 prefilter would discard almost the entire 95% nucleus and silently sample from a distribution that is not the one requested.

So the shortcut was wrong, and it would have been wrong invisibly. A full sort of [8, 262144] costs 1.16 ms against a ~55 ms decode step — 2%. Frigg sorts. Exact top-p, no shortlist, no accuracy question to answer.

Guidance this produces

On this model, temperature above ~1.0 needs top_p. Bare temperature: 1.3 puts 32% of the mass outside the top 64 tokens and the output degrades into other scripts within a sentence — that is the model and its vocabulary, not the sampler. The sampler is verified against the tempered softmax directly: empirical frequencies match softmax(logits/T) within 0.03 over 4,000 draws at T = 0.7, 1.0 and 2.0.

Unimplemented parameters (frequency_penalty, presence_penalty, logprobs, logit_bias) now return 400 with a reason instead of being dropped. Their defaults still pass, because stock clients send them constantly.

n is supported (non-streaming, up to 8) as one request per choice, with seeds derived from the base seed so the choices differ while staying reproducible; n > 1 with stream is refused rather than interleaved onto one SSE stream. user is accepted and ignored on purpose: SPEC FR14 wanted it as a session key, but a PrefixLM prompt re-prefills every turn, so there is no session state for a router to be sticky to — see the dated correction in docs/SPEC.md. Ignoring user changes nothing observable, which is what separates it from the temperature case above.

repetition_penalty, batched (2026-08-31)

The last of SPEC FR5. A variable-length token history cannot be gathered inside a captured graph, so the penalty rides on a per-slot [B, vocab] boolean mask of what that slot has already produced — 2 MB at 8 slots, and it turns the penalty into two elementwise ops. docs/MODEL.md §8: the mask covers the response only, so it starts empty after prefill and is never seeded with prompt tokens; it is cleared whenever a slot is assigned, since slots are reused across requests.

Both graphs update the mask, not just the sampling one, so a slot that acquires a penalty part-way through still sees the whole response behind it. A penalty forces the sampling path even at temperature 0 — the penalty is applied inside select — so such a request pays for a vocabulary sort it does not otherwise need. Plain greedy still skips all of it.

The half of the CTRL rule that is easy to invert: negative logits are multiplied, not divided. Divide a negative logit and the "penalty" makes a repeat more likely. There is a test built on values where the two rules disagree.

Measured through the API on a prompt that invites repetition ("gentag ordet 'hej' mange gange"), unique-word ratio:

repetition_penalty output unique ratio
1.0 Hej, hej, hej, hej, hej, hej, … (27 words) 0.07
1.5 Hej, hej! Hej igen. 1.00
2.0 Hej, hej! Hejsa. 1.00

stop strings, and the invariant they need (2026-08-31)

stop is now implemented rather than refused, and it has one requirement that is easy to miss: a partial match cannot be streamed. With stop: ["END"] and a model part-way through emitting E, N, D, the naive loop sends E and then N before it knows they are the stop string — and a token already streamed cannot be recalled. So the emittable text ends before any suffix that is a proper prefix of some stop string, and "abcNE" holds back the E too, because "abcNE" + "ND" contains END.

Wiring it up produced a second bug worth recording, because it is the same shape as Finding 10 itself. The non-streaming path cut the text before the tool-call parser and the streaming path cut it after; the parser strips whitespace (tools.py:196), so the same request returned "hi " streamed and "hi" whole. Both now parse then cut, and a test asserts the two agree character for character across four cases. Nothing about that divergence was visible in either path alone — only in comparing them.

Verified against the real model, where stop strings straddle token boundaries: with the model producing "København\nAarhus\nOdense", stop: ["Aarhus"] yields "København\n", finish reason stop, the stop string absent, the result a prefix of the untruncated text, and streamed output identical to non-streamed.


Finding 11: the four-GPU flag had never worked

--devices 0,1,2,3 is in the quickstart, in CLAUDE.md, and is the reason this machine has four cards. It failed on startup for every replica after the first, and had done since the server was written, because nothing had ever run it — every load test in M4 and since used --devices 0. The error names neither the cause nor the device:

ValueError: Pointer argument (at 0) cannot be accessed from Triton (cpu tensor?)

Two independent causes, and the misleading "cpu tensor?" is a red herring in both:

  1. Triton launches against the current CUDA device, not the device its argument pointers live on. Once a kernel has been compiled on cuda:0, launching it with cuda:1 pointers while cuda:0 is still current fails. A replica runs on its own thread, and a fresh thread points at cuda:0 whatever its model's device is. The fix belongs in Mimir, not the call sites: a model now sets its own device on entry, so it is correct whatever the ambient device is.
  2. Graph capture needs an explicitly device-bound stream. Without one, capturing on a non-current device records an empty graph — torch warns, nothing errors, and the replay silently does nothing. bench launch hit exactly this in M0 and fixed it the same way; the lesson did not transfer because the two pieces of code never met.

Once it ran, the four-replica number is worth having and is below the prediction docs/BENCHMARKS.md had been carrying:

Alternating arms, every GPU cooled to 60 °C before each — the method Finding 12 says is the minimum for a valid comparison here:

replicas streams aggregate requests errors
1 8 140.8 tok/s 220 0
4 32 405.9 tok/s 689 0
1 8 141.5 221 0
4 32 405.8 688 0

2.9× on four GPUs, not the "close to linear" the docs predicted — 72% of it. The arms reproduce to within 0.5% once each starts from the same temperature, which is what makes the ratio trustworthy rather than the spread being hidden.

Corrected 2026-08-31. This first read 3.2–3.3×, from 393.3 tok/s over a 123.6 control — measured consecutively, the four-replica arm first and the single-replica control second on a GPU 0 that had just been driven hard. Finding 12, written hours later, is precisely the argument that such a comparison is biased rather than noisy, and the bias runs against the second arm: the control was understated by 14% (123.6 against 141 from a cold start), which inflated the ratio. Publishing a number and then publishing the reason it was wrong is not a good look, so: bench load now takes --cooldown-c and records gpu_c_start/gpu_c_end, and the figures above are alternated arms from a matched 60 °C.

The replicas share no GPU state, so the ~28% shortfall from linear is host-side: four replica threads each doing per-step Python, plus the HTTP layer and the load generator on one CPU. That is consistent with the numbers, not measured. The replicas share no GPU state, so the shortfall is almost certainly host-side: four replica threads doing per-step Python, plus the HTTP layer and the load generator, on one CPU. The router itself is fine — completions came out 196 / 192 / 207 / 204.

The general point is the one M0 was built around and this still got wrong: a documented flag that no test and no benchmark exercises is an untested flag, however prominent it is in the docs. Four GPUs are the premise of the whole project and the path that uses them all had never been run once.


Finding 12: the benchmark numbers are a function of GPU temperature

This project's rule is "honest benchmarks: every number from a run on this machine with the reproducing command next to it". That turns out not to be sufficient, because running the same command twice does not give the same number, and the difference is not noise.

Same cell (int8, 512 context, batch 8), five separate invocations back to back, then a cooldown:

run GPU 0 aggregate spread within the run
1 warm 186.5 tok/s ±0.2%
2 185.0 ±0.3%
3 174.5 ±4.0%
4 159.9 ±2.3%
5 85 °C 152.7 ±1.5%
after cooling to 57 °C 191.7 ±0.1%

26% between the coldest and hottest reading of identical code, monotonic, fully recovered by cooling. A GA106 in a four-card chassis heat-soaks in minutes.

Two things follow, and this repo had already been caught by both:

  • Repeating the measurement inside one process measures almost nothing. It gives ±0.1–4% and looks reassuring. The uncertainty that matters is between invocations and is an order of magnitude larger. (Worse: bench matrix had been returning a single sample while labelling it "median of 3" — now fixed, though the thermal state dominates either way.)
  • A consecutive before/after is biased, not just noisy — the first arm gets a cooler card. Alternating the arms, which Finding 7b and the bucket-selection measurement do, is the minimum for a valid comparison here. Two conclusions in this project were nearly drawn from consecutive single runs.

The published comparison table was biased by this, not merely noisy. A sweep heats the card as it goes — 70 °C on the first cell, 93 °C from the third — and builds are swept in order, so whichever ran last was measured ~20 °C hotter. Same code, one uncontrolled sweep against one with --cooldown-c 65:

cell uncontrolled all at 65 °C
bf16 B8 @512 (first) 166.7 167.0 unchanged
int8 B8 @512 157.2 190.7 +21%
int4 B1 @512 (last) 26.0 32.7 +26%

int4 was penalised by a quarter for the crime of being swept last, which made it look level with bf16 at batch 1 when it is in fact clearly faster (32.7 vs 26.6). The standing advice not to use int4 survives — it is slower than int8 and far less accurate — but it survives on the reasons that were not distorted.

bench matrix now records the GPU temperature with every cell and takes --cooldown-c to equalise it; the table above is produced at a fixed 65 °C. A figure without a temperature beside it cannot be compared with another figure. The generated table above carries the within-run spread, which is the small number — treat cross-commit comparisons of a single figure as worth very little on this hardware.


Quickstart

You need a CUDA GPU with at least 12 GB, Python 3.12 and uv. Nothing is gated: the model repo is public and no token is required.

git clone https://github.com/TheRootKIDD/frigg && cd frigg
uv sync

# 1. Measure the machine first — every ceiling in these docs comes from these two.
uv run frigg bench vram              # ~340 GB/s per RTX 3060
uv run frigg bench launch            # ~5 us per kernel launch

# 2. Build the int8 checkpoint (7 s, 3.57 GB -> 1.79 GB) and verify it.
uv run frigg quantize danish-foundation-models/DFM-Mimir \
    ~/.cache/frigg/mimir-int8 --method int8 --check

# 3. Generate.
uv run frigg run ~/.cache/frigg/mimir-int8 \
    -p "Hvad er hovedstaden i Danmark?" --max-new 64

# 4. Serve, one replica per GPU.
uv run frigg serve ~/.cache/frigg/mimir-int8 \
    --devices 0,1,2,3 --kv-slots 4 --kv-dtype int8 --port 8000

Then point any OpenAI client at it:

from openai import OpenAI
c = OpenAI(base_url="http://localhost:8000/v1", api_key="unused")
print(c.chat.completions.create(model="dfm-mimir",
      messages=[{"role": "user", "content": "Hvad er 12 gange 8?"}]).choices[0].message.content)

frigg serve prints a memory budget per replica at startup and refuses to start a configuration that does not leave 512 MB of headroom, so a bad --kv-slots fails in seconds rather than OOM-ing under load. Binding anything other than loopback requires --api-key. See deploy/ for a systemd unit and firewall guidance.

Verifying it yourself

uv run pytest -q                  # the CPU-only subset also runs in CI
uv run pytest -q -m parity        # against HF transformers; needs a GPU
uv run frigg parity               # 24 golden prompts, bit-exactness report
uv run frigg eval gsm8k-200 --ckpt ~/.cache/frigg/mimir-int8

Limitations

Read these before deploying anything.

  • 4,096 tokens, hard. The RoPE tables are built for 4,096 positions and the config has no scaling. Requests past it are rejected with the counts, never truncated.
  • Every turn re-prefills the whole conversation. PrefixLM prefix K/V depend on all prompt tokens, so there is no cross-turn cache to warm and long chats cost linearly more per turn. That is the model, not the engine — and it is measured, not assumed: appending a turn causally instead is 2.3× faster and costs 33.3 GSM8K points (docs/RESEARCH.md E3).
  • --kv-dtype int8 is a capacity flag, not a speed flag. It is ~13% slower at 512 context, ~13% faster at 4096, and it is the only way to run batch 4 at full context on a 12 GB card at all (70.4 tok/s aggregate, against "does not fit" for bf16 KV). Quality cost is +0.0001 nats. BENCHMARKS §5.10.
  • int8 is a single-stream win and a batched loss. It is 1.5x at batch 1 and behind bf16 from batch 4 up, because batched decode is compute-bound and int8 buys bandwidth. Pick weights by expected concurrency. Finding 8.
  • --kv-slots no longer costs you when idle. Graphs are captured per bucket and the replica replays the smallest that covers the active slots, so an over-provisioned replica is ~2x faster for a lone client than it was (BENCHMARKS §5.8). More clients than slots still only buys latency: the ceiling is ~117 tok/s at 8 slots.
  • Unimplemented API parameters return 400, not silence: frequency_penalty, presence_penalty, logprobs, logit_bias. Their defaults pass. Implemented: temperature, top_p, top_k, seed, repetition_penalty, stop (4 strings), and n (non-streaming, ≤ 8).
  • Temperature above ~1.0 needs top_p on this model — a 262k vocabulary puts 32% of the mass outside the top 64 tokens at T=1.3 (Finding 10).
  • Prefill blocks decode on its replica, ~3 s for a 4K prompt.
  • Danish and English only, and the model has no safety alignment. Treat output as raw model text.
  • LAN only. An API key and nothing else — no TLS, no rate limiting, no tenancy.
  • int4 is implemented but not recommended on this model: slower than int8 and 11x less accurate. Use --method int8.

How it works

frigg/
  config.py      ModelSpec  — every shape, validated; refuses what it cannot run
  checkpoint.py  Weights    — packed or split safetensors, bf16, shape-checked
  tokenizer.py   the corrected pre-tokenizer, applied and verified (Finding 6)
  layers.py      RMSNorm · RoPE · gated MHA · SwiGLU · pre-norm block
  prefixlm.py    the three masks: prefill · decode · verify
  kvcache.py     one static slab, 128 virtual layers, rewind and fork
  model.py       the recurrence, lm_head, prefill/decode/generate
  sampling.py    greedy · temperature · top-k/p · repetition penalty · stops

One forward. Embeddings are looked up and scaled by sqrt(1536); that is z_H. z_L starts from the learned z_L_init buffer. Then the fixed schedule runs — for each of 2 H-cycles, 3 L-module passes over z_L + z_H, then one H-module pass over z_H + z_L. Eight 16-layer module runs, 3.9B parameter-reads, one token. lm_head is applied to z_H from the final H run.

Masks. Prefill is bidirectional over the whole prompt. Decode passes no mask — one query against the whole cache, which is what the reference does and what keeps the bits identical. A run of response tokens gets a bidirectional prefix block plus a causal block, which is the mask the model was trained with.

Cache. One preallocated slab of [128, heads, 4096, 128] for K and V — 3.22 GB at full context. Every module run writes its own virtual layer at the same offset; the length advances once per forward. rewind_to_prefix() makes "regenerate" free, and fork() copies just the prompt for n > 1. Static addresses are the point: M2 captures the whole decode step in a CUDA graph, which the launch ceiling says is worth ~5x.

Parity. The exact path is checked against HF HrmTextForCausalLM on 24 golden prompts, at three levels: torch.equal on the full [1, T, 262144] prefill logits, the sha256 digests recorded in tests/golden/, and the greedy continuations token for token. Prefill is bit-identical. Cached decode is bit-identical for ~128 steps and then drifts by at most one ulp, which matters only where the reference itself has an exact tie — the tolerance, its cause and its bound are in docs/MODEL.md §11.

uv run frigg run -p "Hvad er hovedstaden i Danmark?" --max-new 64
uv run frigg run --chat conversation.jsonl --json
uv run frigg parity --layers

Where the spec was wrong

Every correction is dated in docs/MODEL.md §11 with the old claim marked rather than deleted.

# Spec said Actually
1 Repo is gated; mimir-license-v1.0-research-model-license Not gated (gated: false), plain Apache-2.0 in metadata, card and LICENSE blob alike
2 Native hrm_text landed in transformers 5.12.x 5.9.0 (bisected: 5.8.1 lacks it, 5.9.0 has it)
3 ~1,700 kernels per decoded token 5,454 (3.2×)
4 H_sliding_window/L_sliding_window are null The keys do not exist. Validation must accept missing, not require present-and-null
5 (unmentioned) config.json declares no dtype — from_pretrained without an explicit dtype= loads this bf16 checkpoint as fp32, 7.1 GB and different numerics
6 NF1: "bit-identical logits against HF" Not well defined — see Finding 4
7 vLLM on SM 8.6: unknown, probably not Runs, 33.0 tok/s
8 Cache as cache.L[k] / cache.H[i] arrays HF uses one flat interleaved array of 128 slots: slot(h,l,layer) = (h*(L_cycles+1)+l)*16 + layer, so L0 L1 L2 H0 L3 L4 L5 H1 occupy 0-15, 16-31, 32-47, 48-63, 64-79, 80-95, 96-111, 112-127
9 "load with AutoTokenizer.from_pretrained and verify a Danish sentence" Not sufficient — the result depends on hub id vs local path, see Finding 6
10 §4: RMSNorm is F.rms_norm(x, (1536,), eps) It is NanoChatRMSNorm — an explicit fp32 upcast around x.pow(2).mean(-1), a different kernel and not bit-identical
11 §4: RoPE is "fp32 math, cast to bf16" The table is fp32; it is cast to bf16 before use, so the rotation runs in bf16
12 §8: the reference passes an additive float mask It passes a boolean mask — and at decode, no mask at all

Smaller things worth knowing for M1/M4: apply_chat_template(tokenize=True) returns a BatchEncoding in transformers 5.16, not a list of ids; and loading the oracle with device_map= pulls in accelerate unnecessarily — .to(device) is enough.


Measured performance

Everything in this table is produced by frigg bench matrix and written here by frigg bench render-readme — no number below is typed by hand.

build weights decode B1 @512 decode B1 @4096 decode B4 @512 decode B4 @4096 decode B8 @512 decode B8 @4096 prefill 1024 prefill 4096
bf16 3.57 GB 26.6 ±0% 21.6 ±1% 94.3 ±0% n/f 167.0 ±0% n/f 1897 1428
int8 W / int8 vocab 1.79 GB 46.7 ±0% 33.4 ±0% 111.0 ±0% n/f 190.7 ±0% n/f 1723 1403
int4 W / int8 vocab 1.33 GB 32.7 ±0% 25.5 ±1% 55.5 ±0% n/f 103.3 ±0% n/f 1769 1409

Decode figures are aggregate tok/s (all slots together), greedy, with the decode step CUDA-graph captured at every batch size; ± is half the observed min-max spread over the repeats, not a confidence interval — treat differences smaller than it as noise. Prefill is tok/s for a prompt of that length. n/f means the configuration does not fit on a 12 GB card — batch slots of bf16 KV at 4096 tokens is 3.22 GB each. Method: greedy, 8 warm-up + 64 timed tokens, median of 3 runs. The per-cell spread is WITHIN one process and is small; the real uncertainty is thermal drift ACROSS runs (BENCHMARKS §5.12), so gpu_c is recorded too.

Baselines

Full methodology in docs/BENCHMARKS.md §5. Greedy, 32 warm-up tokens then 256 timed, median of 3.

System Version SM 8.6? Greedy agreement vs HF Decode tok/s @512 Prefill tok/s @512
HF transformers eager, bf16 5.16.1 / torch 2.11.0+cu128 yes reference 12.33 1,661
vLLM HrmTextForCausalLM 0.28.0 / torch 2.13.0+cu130 yes¹ 21/24 prompts, 94.3% of steps 33.01 —
Frigg bf16 exact (M1) this repo yes 24/24 bit-identical logits, 23/24 identical continuations² 12.9 ~1,650
Frigg bf16 + graphs (M2) this repo yes 24/24 identical continuations 26.8 1809
Frigg bf16 + graphs — — M2 — —
Frigg int8 / int8 KV — — M3 — —
Frigg int4 core / int8 vocab — — M3 — —

¹ with VLLM_USE_FLASHINFER_SAMPLER=0 and reduced batch settings; see Finding 2. ² the one exception is inside the documented decode tolerance (docs/MODEL.md §11): a one-ulp drift that flips a position where the reference's top two logits are equal.

HF at 2048 ctx: 11.66 tok/s decode, 1,239 tok/s prefill. HF numbers were re-measured after Finding 6; they were 12.20 / 1,626 on the mis-tokenized prompts. vLLM aggregate over a 24-prompt batch: 177.7 tok/s.

Frigg's eager decode is a shade above HF's and about a third of the 37.4 tok/s bf16 bandwidth ceiling — which is what Finding 3 predicts for a launch-bound path. Closing that gap is M2's job, not a tuning exercise.


Reproducing everything

uv sync --extra dev
uv run pytest -q                  # the CPU-only subset also runs in CI
uv run pytest -q -m parity        # needs GPU 0 + the downloaded checkpoint

uv run frigg bench vram           # -> benchmarks/<host>-<date>/vram.json
uv run frigg bench launch         # -> benchmarks/<host>-<date>/launch.json

# the generated decode table. --cooldown-c is not optional for a fair comparison:
# without it the build swept last is measured ~20 C hotter (Finding 12).
uv run frigg bench matrix --cooldown-c 65 \
  --builds danish-foundation-models/DFM-Mimir,~/.cache/frigg/mimir-int8,~/.cache/frigg/mimir-int4
uv run frigg bench render-readme

# int8 KV is a capacity flag; sweep it to see the rows bf16 KV cannot run at all
uv run frigg bench matrix --kv-dtypes bf16,int8 --contexts 4096 --batches 1,2,4

# four replicas, one per GPU
uv run frigg serve ~/.cache/frigg/mimir-int8 --devices 0,1,2,3 --kv-slots 8
uv run frigg bench load --url http://127.0.0.1:8000 --streams 32 --duration-s 180

# regenerate the golden reference files (~7 min on GPU 0)
uv run python -m frigg.golden --timing

# the E2 pre-test
uv run python -m frigg.research.e2_pretest --out benchmarks/<host>-<date>/e2_pretest.json

# the vLLM probe (isolated venv — it needs its own torch build)
cat benchmarks/vllm-probe/README.md

The model repo is public, so no token is needed. Reference training code (read-only, never copied into Frigg) is cloned next to the repo:

git clone --depth 1 https://github.com/schneiderkamplab/HRM-Text ../HRM-Text

Golden files

tests/golden/ holds prompts.jsonl (24 Danish/English prompts: QA, math, code, grammar, summarisation, multi-turn, system prompts, tool use, raw completions) plus config.json, tokenizer.json, safetensors_layout.json, chat_template.json and hf_reference.json.

hf_reference.json stores, per prompt and per token_type_ids variant, a sha256 over the exact fp32 bytes of the last-prompt-row logits, the top-64 (id, logit) pairs, summary statistics, and the greedy continuation (256 tokens for the ones variant, 64 for the two wrong-oracle variants). The digest rather than the raw rows because 24 prompts × 3 variants × 262,144 fp32 would be 75 MB of binary in git, and a digest is exactly the torch.equal pass/fail that NF1 asks for.


Repository layout

docs/          MODEL.md ARCHITECTURE.md ROADMAP.md BENCHMARKS.md RESEARCH.md SPEC.md
src/frigg/
  artifacts.py     config / safetensors header / tokenizer fact extraction
  reference.py     the HF oracle, loaded the way parity requires
  golden.py        regenerates tests/golden/hf_reference.json
  bench/           vram, launch, env capture
  research/        e2_pretest.py
  cli.py           frigg quantize | run | serve | bench | parity | eval
tests/         test_cli.py test_artifacts.py test_prefixlm_mask.py test_bench.py golden/
benchmarks/    per-machine snapshots + the vLLM probe

Milestones M1–M6 fill in config.py, checkpoint.py, layers.py, prefixlm.py, kvcache.py, model.py, engine.py, sampling.py, chat.py, quantize/, kernels/ and server/ — see docs/ROADMAP.md.


Licences

The engine is Apache-2.0 (see LICENSE).

The weights are also Apache-2.0, verified in M0 by reading the LICENSE blob of danish-foundation-models/DFM-Mimir at revision 2844f017: it is the canonical Apache-2.0 text with the appendix filled in "Copyright © 2026 Danish Foundation Models" — no added clause, no research-only restriction — and the repo is not gated, so no token or conditions-acceptance is needed. The project spec had assumed a mimir-license-v1.0-research-model-license; that was wrong.

Practical consequence: quantized derivative checkpoints produced by frigg quantize may be redistributed, provided they carry the Apache-2.0 licence, a NOTICE stating that the weights were modified, and attribution to Danish Foundation Models.

Mimir is Danish and English only and has no safety alignment ("not specifically aligned for safety" per the model card). Treat its output as raw model text.

References

About

Frigg is a hybrid resident+streamed inference engine built for one hardware story: running Mimir from Danish Foundation Models on four consumer RTX 3060s.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages