13. The LLM stack (fucina_llm)¶
fucina_llm is a second Zig module layered on top of the fucina facade (its
only module dependency; see §2 for build wiring). It contains everything a
transformer inference/fine-tuning runner needs that is not a tensor op:
GGUF-to-weight binding, KV caching, tokenizers, sampling, SFT data plumbing,
multi-turn chat, and lossless draft-free speculative decoding. Import it as:
const fucina = @import("fucina");
const llm = @import("fucina_llm");
13.1 Module layout (src/llm.zig)¶
Model families live in subdirectories and are exposed as namespaces; generic, family-agnostic helpers stay flat:
| Namespace | Contents | Files |
|---|---|---|
llm.qwen3 |
model, train — Qwen3 dense + LoRA fine-tuning |
llm/qwen3/ |
llm.kimi3 |
model — Kimi-K3 (Kimi-Linear lineage: KDA + gated-MLA-NoPE hybrid, latent MoE, attention residuals, SiTU) |
llm/kimi3/ |
llm.qwen35 |
model, chat — Qwen3.5 Gated-DeltaNet hybrid |
llm/qwen35/ |
llm.gemma |
gemma4, gemma4_train, moe, moe_route, moe_route_tensor |
llm/gemma/ |
llm.diffusion_gemma |
model — block text-diffusion on the gemma4 backbone |
llm/diffusion_gemma/ |
llm.parakeet |
loader, frontend, subsampling, encoder, weights, decoder, tokenizer, streaming, transcription — NeMo FastConformer/RNN-T ASR |
llm/parakeet/ |
llm.speculative |
core, sam_index, recycling, cascade, constrained |
llm/speculative/ |
llm.deepseek2 |
model — DeepSeek-V2 MLA + fine-grained MoE with shared experts |
llm/deepseek2/ |
llm.glm4moe |
model — GLM-4.5 MoE with native MTP (nextn) self-speculation |
llm/glm4moe/ |
llm.deepseek4 |
model — DeepSeek V4 Flash (hyper-connections, compressed-KV MQA, streamed experts, MTP) |
llm/deepseek4/ |
llm.inkling |
model, mmproj, chat — Inkling (hybrid SWA/global rel-bias attention, shortconv sites, sink-shared MoE; hMLP vision + dMel audio towers) |
llm/inkling/ |
| Flat helper | Purpose | Section |
|---|---|---|
llm.weights |
GGUF tensor → typed linear weight binding | §13.2 |
llm.ptqtp_gguf |
PTQTP plane persistence — <name>.ptqtp0/1/2 writer + pair-detecting loader |
§13.2 |
llm.gguf_meta |
metadata readers + parallel layer loader | §13.3 |
llm.kv_cache |
per-layer K/V store for autoregressive decode | §13.4 |
llm.kv_persist |
crash-safe append-only KV-cache sidecar: conversations reopen warm | §13.4 |
llm.tokenizer |
byte-level BPE (GPT-2/Qwen) | §13.5 |
llm.spm_tokenizer |
SentencePiece Unigram (Gemma/llama-vocab) | §13.5 |
llm.unicode_categories |
generated \p{L}/\p{N}/\p{M}/\s tables (byte-BPE pretokenizer; shared with out-of-module tokenizers) |
§13.5 |
llm.sampler |
greedy/temperature/top-k/top-p/min-p/penalties + logit-processor seam | §13.6 |
llm.logit_processor |
pluggable logit-transform interface (grammar masks, bias lists) | §13.6 |
llm.llguidance |
grammar/JSON-schema constrained decoding (vendored engine, -Dllguidance) |
§13.6 |
llm.data |
SFT pairs, encodePair, deterministic Loader | §13.7 |
llm.chat |
templates + generic Conversation(Model, Tok) |
§13.8 |
llm.cartridge |
trained KV-prefix corpus compression (Cartridges, arXiv 2506.06266) | §13.10 |
llm.cartridge_fleet |
per-document cartridge fleets: manifest, RAM/disk budget manager, cosine chunk index (Cartridges at Scale, arXiv 2606.04557) | §13.10 |
llm.engram |
conditional n-gram memory: hashed-lookup embedding tables grafted onto a frozen model (Engram, arXiv 2601.07372) | §13.11 |
The family namespaces are covered in §14 (kimi3 in §14.7, deepseek2/glm4moe/deepseek4/inkling by their module doc comments); this section documents the shared stack they are built from.
13.2 Weight loading (src/llm/weights.zig)¶
weights.zig turns raw GGUF tensor payloads (§12) into typed, immediately
usable linear weights. Its error set is
Error = error{ InvalidWeightShape, UnsupportedWeightType, GradUnsupported }.
13.2.1 LinearWeight¶
pub const LinearWeight = union(enum) {
f32: WeightF32, // fucina.Tensor(.{ .out, .in })
f16: WeightF16, // fucina.Tensor(.{ .dtype = .f16, .tags = .{ .out, .in } })
bf16: WeightBf16, // fucina.Tensor(.{ .dtype = .bf16, .tags = .{ .out, .in } })
q8_0: WeightQ8_0, q4_k: WeightQ4_K, q5_k: WeightQ5_K, q6_k: WeightQ6_K,
// plus one QuantWeight(dtype) arm per remaining GGUF block format:
// q1_0, q2_0, q4_0, q4_1, q5_0, q5_1, q2_k, q3_k, iq1_s, iq1_m, iq2_xxs, iq2_xs,
// iq2_s, iq3_xxs, iq3_s, iq4_nl, iq4_xs, tq1_0, tq2_0, mxfp4, nvfp4
ptqtp: WeightPtqtp, // 1-3 packed TQ2_0 trit-planes (PTQTP, section 10.9)
};
Every arm is a [.out, .in]-tagged tensor kept resident in its source
precision — nothing is widened to f32 at load time:
| Arm | Resident form | Forward path |
|---|---|---|
f32 |
f32 tensor (f64 sources are narrowed) | plain dot |
f16 |
f16 tensor, 2 B/weight | f16-operands GEMM (§9); GPU-resident on -Dgpu=metal |
bf16 |
raw u16 bit patterns, 2 B/weight | mixed f32×bf16 TransB kernel, exact in-register widening |
q8_0, q4_k, q5_k, q6_k |
raw GGUF blocks plus a pre-packed matmul RHS | dotPacked on the CPU quantized hot path (§10); q4_k/q6_k/q8_0 also try Metal, and CUDA additionally supports q5_k |
| all other quant arms | raw GGUF blocks (QuantWeight(dtype)) |
tagged dot through the generic quantized matmul |
ptqtp |
up to three .tq2_0 plane tensors (§10.9) — built in place by toPtqtp, or rebuilt bitwise from persisted <name>.ptqtp0/1/2 plane tensors (llm.ptqtp_gguf pair-detection; PTQTP.md) |
fused multi-plane entry: ONE Q8_K activation quantization + ONE worker-team dispatch running every plane on the x4 column-interleaved packs and summing in fixed plane order (bitwise equal to the per-plane facade dots, which remain the gradient-path fallback); scale-tied K=2 planes additionally fold into one 4-bit pack served by a single dot pass, and on Metal builds prefill-sized inputs dispatch against resident plane copies — one ternary dequant-in-kernel dispatch per plane, ONE folded dispatch when tied (the dense quant offload's accepted-numerics stance, not bitwise) |
pub fn QuantWeight(comptime dtype: DType) type returns
fucina.Tensor(.{ .dtype = dtype, .tags = .{ .out, .in } }). The four hot
K-quant/Q8 formats get dedicated wrapper structs — WeightQ4_K, WeightQ5_K,
WeightQ6_K, WeightQ8_0 — each holding value (the raw block tensor) and
packed_rhs: fucina.PackedRhs(dtype) built once at init, with
init/deinit/cloneView/concat, plus initWithRhsLifetime and a
rhs_lifetime: fucina.RhsLifetime field that tells GPU dispatch whether the
block bytes are process-stable.
Binding a GGUF tensor:
pub fn load(ctx: *ExecContext, info: *const gguf.TensorInfo,
expected_rows: usize, expected_cols: usize) !LinearWeight
pub fn loadForFusion(...same args...) !LinearWeight
pub fn loadWithOptions(...same args..., options: LoadOptions) !LinearWeight
pub const LoadOptions = struct { gpu_resident: bool = true };
- The tensor's
logicalMatrixShape()must equal(expected_rows, expected_cols)=(out, in), elseError.InvalidWeightShape; a GGML type without an arm isError.UnsupportedWeightType. loadcallsgguf.prefetchon the payload first (readahead for cold-mmapped bytes) and copies/repacks it, so the result does not borrow thegguf.File— the file may be freed after loading (MoE borrow mode below is the exception).LoadOptions.gpu_resident(defaulttrue): on GPU builds, provider-supported payloads are copied into device-owned storage (f16/q4_k/q6_k/q8_0 on Metal; q4_k/q5_k/q6_k/q8_0 on CUDA) throughinternal.gpu.allocResidentBytes, so GPU matmuls read them with zero per-call transfer. The storage buffer OWNS the device bytes through a release hook: when the last tensor reference (includingcloneViews sharing the buffer) drops, the hook frees the device allocation and evicts the GPU shim's cached wrap. The bytes stay CPU-readable (and, for dense f16/f32, CPU-writable in place — in-place trainers mutate resident weights and GPU dispatch reads the live values). If the device budget is exhausted the load silently falls back to heap storage with.transientRHS lifetime.loadForFusionisloadWithOptions(..., .{ .gpu_resident = false }): a weight loaded only to be consumed byfuseLinearskips the per-part device copy, because the fused result re-acquires residency itself — per-part copies would be alloc+memcpy+free waste. If fusion later declines,fuseLinearrestores ordinary per-part residency for provider-supported formats before returning them as independent linears.
Pre-fusion:
pub fn fuseLinear(ctx: *ExecContext, parts: []const *LinearWeight) !?LinearWeight
Concatenates 2–4 same-format weights along .out into one stacked matrix
(one GEMM instead of N on the forward path). Supported formats:
f32/f16/bf16/q4_k/q5_k/q6_k/q8_0, plus ptqtp parts with a uniform plane
count (planes concatenate plane-wise — byte-identical to decorating the
fused matrix; mixed plane counts return null like mixed formats). On
success the parts are consumed
(deinitialized) and the fused weight is returned; when the parts' formats
differ, or the format has no fused fast path, it returns null with all
parts still valid. On capable GPU builds their values may move to resident
storage (the semantic tensor/tag/packed-RHS values are unchanged); fewer than
2 or more than 4 parts is
Error.InvalidWeightShape. Fused dense f32/f16 and quant
q4_k/q6_k/q8_0 results re-acquire GPU residency on Metal builds; CUDA also
re-acquires it for q5_k.
Forward/apply entry points on LinearWeight:
pub fn linearSeq(self, ctx, input: anytype, comptime in_tag: Tag, comptime out_tag: Tag)
!fucina.Tensor(.{ .seq, out_tag })
pub fn linearSeqNormed(self, ctx, x: anytype, norm_weight: anytype, eps: f32,
comptime in_tag: Tag, comptime out_tag: Tag) !fucina.Tensor(.{ .seq, out_tag })
pub fn supportsNormedFusion(self, m: usize) bool
pub fn getRowsAs(self, ctx, token_ids: []const usize, comptime out_tag: Tag)
!fucina.Tensor(.{ .seq, out_tag })
pub fn toResidentF16(self: *LinearWeight, ctx: *ExecContext) !void
pub fn toPtqtp(self: *LinearWeight, ctx: *ExecContext, options: fucina.ptqtp.Options)
!fucina.ptqtp.MatrixStats // requires ptqtpEligible; drops the source storage
pub fn ptqtpEligible(self: *const LinearWeight) bool // non-ptqtp arm, inDim % 256 == 0
pub fn outDim(self) usize / pub fn inDim(self) usize
pub fn cloneView(self, ctx) !LinearWeight // shares storage, fresh tags/packed RHS
pub fn deinit(self: *LinearWeight) void
linearSeqcomputesinput · Wᵀwith the format's fastest route: packed quantized kernels for q4_k/q5_k/q6_k/q8_0 (with a GPU attempt first for q4_k/q6_k/q8_0 on Metal and those plus q5_k on CUDA — declined when the input requires gradients or the exec gate says the shape is too small, falling back to the CPU packed path; at decode shapes (seq < 4, no gradients) q4_k, q5_k, and q6_k instead contract against the resident GGUF-native compact blocks — bitwise-equal outputs, ~1.92x/1.57x/1.30x fewer weight bytes streamed than the byte-expanded packed layout; default on, with the per-formatFUCINA_Q4K_DECODE_COMPACT/FUCINA_NO_Q4K_DECODE_COMPACT(and the Q5K/Q6K pairs) forcing the route on/off andsetQ5kDecodeCompact/setQ6kDecodeCompactas the programmatic overrides), and a taggeddotfor everything else. The per-format helperslinearSeqQ8_0,linearSeqQ4_K,linearSeqQ5_K,linearSeqQ6_Kare alsopubfor callers that hold the wrapper struct directly.linearSeqNormedislinearSeqoverrmsNormMul(x, norm_weight, eps): on the packed CPU q4_k/q5_k/q6_k/q8_0 routes at prefill shapes (seq >= 4, no gradients; q4_k only on non-MMLA targets) the normalized tensor is never materialized — the fused kernel normalizes into task-private scratch and quantizes in place, matching the unfused pair to f32 roundoff. Every other arm — GPU builds, decode shapes, andFUCINA_NO_NORM_QUANT_FUSED=1(FUCINA_NORM_QUANT_FUSED=1forces the fused route;setNormQuantFusedis the programmatic override) — normalizes and delegates.supportsNormedFusion(m)reports whether the fused route applies for an m-row input; callers fanning one normalized input into several projections should require it for every projection — the fallback re-normalizes per call.getRowsAsgathers rows by index (the embedding-lookup shape) and returns f32; f16/bf16 rows are widened, quantized rows dequantized. Dedicated arms exist for f32/f16/bf16/q4_k/q5_k/q6_k/q8_0, and aninline elsearm routes every remaining block-quantized format through the generic quantized row gather — allLinearWeightforms work.toPtqtpdequantizes the weight row-chunk-wise throughgetRowsAs— so every loadable source dtype quantizes through one code path — solves the trit-planes (§10.9), and replaces the arm in place, dropping the source storage. On theptqtparm,getRowsAsreturns the dequantized plane sum (sotoResidentF16doubles as un-decorate).decoratePtqtpInto+PtqtpReportaggregate per-tensor solver stats over model walks;llm.qwen3.model.Model.decoratePtqtp(ctx, options)walks attention q/k/v (split or fused), o_proj, and dense FFN projections, withDecoratePtqtpOptionscovering per-projection plane overrides (down_planes/o_planes) and data-free edge-layer skip (skip_first_layers/skip_last_layers); embeddings, lm_head, and norms are not walked (decoratemodel.outputdirectly for a ternary head).Model.savePtqtpGguf(ctx, io, src_file, out_path)persists the decorated model:llm.ptqtp_ggufwrites one standalone TQ2_0 tensor per plane —<name>.ptqtp0/1/2replaces<name>, fused weights row-slicing back to their source tensor names — plus afucina.ptqtp.versionmetadata key and, when every decorated entry was tie-fitted (ptqtp.Options.tie_scales), afucina.ptqtp.tie_scaleskey the loaders read to rebuild the tied, fold-capable serving form (§10.9), everything else byte-verbatim; the qwen3 loaders pair-detect planes and rebuild the arm bitwise (re-fusing viafuseLinear's ptqtp arm — other families do not read decorated files yet), so decoration runs once and the saved file serves through the ordinary qwen3 runners (PTQTP.md). MoE expert stacks follow the same convention —<name>.ptqtpKsiblings with the base stack's 3D shape, plane-major on disk:ptqtp_gguf.maybeLoadMoeRhs/maybeStreamedMoeProjSpecpair-detect them into the residentMoeRhs.ptqtparm (weights.loadMoeRhsPtqtp) or a multi-plane expert-storeProjSpec(weights.streamedProjSpecPtqtp+registerStreamedMoeLayer), both wired into the qwen3 MoE loaders;ptqtp_gguf.quantizeMoeStackis the producer (per-expert-slice solve into plane-major stacks — the export-gguf--ptqtpMoE path).toResidentF16replaces the weight in place with a dequantized resident-f16 copy (2 B/weight — the f16 GEMM/GPU-offload operand format), dequantizing in 4096-row chunks through the same row gather so the transient peak stays a few MB. No-op when already f16.
fn snippetLinearWeight(ctx: *fucina.ExecContext, file: *const fucina.gguf.File, row: []const f32) !void {
const info = try file.get("blk.0.attn_q.weight");
var w = try llm.weights.LinearWeight.load(ctx, info, 1024, 1024); // expected [out, in]
defer w.deinit();
var x = try fucina.Tensor(.{ .seq, .embed }).fromSlice(ctx, .{ 1, w.inDim() }, row);
defer x.deinit();
var y = try w.linearSeq(ctx, &x, .embed, .attn_q); // format-dispatched matmul
defer y.deinit();
var rows = try w.getRowsAs(ctx, &.{ 0, 2 }, .embed); // dequantized f32 row gather
defer rows.deinit();
} // requires model assets to run
fn snippetFuseLinear(ctx: *fucina.ExecContext, file: *const fucina.gguf.File) !void {
var gate = try llm.weights.LinearWeight.loadForFusion(ctx, try file.get("blk.0.ffn_gate.weight"), 3072, 1024);
errdefer gate.deinit();
var up = try llm.weights.LinearWeight.loadForFusion(ctx, try file.get("blk.0.ffn_up.weight"), 3072, 1024);
errdefer up.deinit();
if (try llm.weights.fuseLinear(ctx, &.{ &gate, &up })) |fused| {
var owned = fused; // one [6144, 1024] weight; gate/up were consumed
defer owned.deinit();
} else {
gate.deinit(); // mixed formats: parts untouched, use them individually
up.deinit();
}
} // requires model assets to run
13.2.2 Vectors, MoE, and borrowed linears¶
pub fn loadVector(ctx: *ExecContext, info: *const gguf.TensorInfo,
expected_len: usize, comptime tag: Tag) !fucina.Tensor(.{tag})
pub fn layerName(buf: []u8, layer_i: usize, suffix: []const u8) ![]const u8
loadVector reads a 1-D tensor (f32/f16/bf16/f64 sources) into an f32 vector;
wrong rank/length is Error.InvalidWeightShape. layerName formats
"blk.{d}.{s}" into a caller buffer — the GGUF per-layer naming convention.
pub const LookupWeight = union(enum) { resident: LinearWeight, mapped: MappedTable };
pub fn load(ctx, file: *const gguf.File, info: *const gguf.TensorInfo,
expected_rows: usize, expected_cols: usize) !LookupWeight
pub fn getRowsAs(self, ctx, token_ids: []const usize, comptime out_tag: Tag)
!fucina.Tensor(.{ .seq, out_tag })
pub fn borrowsMapping(self) bool
LookupWeight is for tables consumed exclusively through getRowsAs — never
a matmul operand (gemma4's per-layer-embedding table). On CPU builds, when the
file is a single-file mmap and the dtype has a gguf.RowTable row decoder,
load returns the mapped arm: rows decode on demand straight out of the
mapping — no resident copy of the table, no matmul-RHS packing — bitwise-equal
to the resident gather. The caller that gets borrowsMapping() == true must
keep the mapping alive for the weight's lifetime via gguf.File.takeMapping
(gemma4's Model.weight_mapping does). Heap-read files, split GGUFs, GPU
builds, and undecodable dtypes fall back to the copying resident arm.
pub fn loadMoeRhs(ctx: *ExecContext, info: *const gguf.TensorInfo,
expected_in_dim: usize, expected_out_dim: usize, expected_n_expert: usize,
borrow: bool) !fucina.MoeRhs
pub fn moeSwiGluFfnSeq(ctx, input: *const Tensor(.{ .seq, .embed }),
gate: *const fucina.MoeRhs, up: ..., down: ...,
selected: []const usize, routing_weights: []const f32, top_k: usize,
out_pe: usize, io: ?std.Io, profile: ?*fucina.MoeBatchProfile)
!fucina.Tensor(.{ .seq, .embed })
loadMoeRhs binds one stacked-expert 3-D tensor
(blk.N.ffn_{gate,up,down}_exps.weight, GGUF shape [in, out, n_expert]) as
a single packed matmul RHS; the fused MoE kernel slices each expert as a
zero-copy row block. Supported expert formats: the K-quants
(q2_k/q3_k/q4_k/q5_k/q6_k) plus q8_0 (llama.cpp's fallback when an expert dim is
not a 256 multiple), iq2_xxs, iq2_s, iq3_xxs, iq4_xs, and tq2_0 —
other formats are Error.UnsupportedWeightType. With borrow = true the
blocks are borrowed straight from the (mmapped) GGUF, skipping the multi-GB
copy; the caller must then keep the mapping alive for the model's lifetime
(gguf.File.takeMapping, §12). moeSwiGluFfnSeq is the tensor-valued
Qwen-style SwiGLU MoE FFN over those RHS values; it refuses gradient-tracked
inputs (Error.GradUnsupported) and internally splits decode (seq == 1)
from batched prefill. moeGatedFfnSeq is the same entry with the gated
activation chosen by the caller (act: fucina.GatedOp; deepseek4 routes
through the clamped SwiGLU). loadMoeRhsStreamed(store, file, layer_i,
gate_info, up_info, down_info, expected_in_dim, expected_out_dim,
expected_n_expert) is the streamed counterpart of three loadMoeRhs calls:
it registers one layer's gate/up/down stacked expert tensors with the
fucina.ExpertStore (which preads individual experts on demand) and
returns a StreamedMoeFfnRhs{ gate, up, down } of .streamed RHS values —
only the geometry is validated, nothing of the expert stacks is read.
The store itself comes from createExpertStore(allocator, options:
MoeStreamOptions, n_layers), which expands split-GGUF part paths, opens
the fucina.ExpertStore with the stream policy —
cache_bytes/cache_slots_per_layer, the pinned learning tier
(auto_pin/pin_bytes), readahead, io_workers parallel demand-miss
reads, uncached streamed reads (macOS F_NOCACHE; keeps expert
streaming from churning the page cache backing the mmapped dense
weights), and pilot router-lookahead prefetch — and adds each
mirror_paths entry as a weighted read mirror (mirror_weights;
parseMirrorWeights parses the runners' shared --moe-mirror-weights=
comma list against the --moe-mirror count). cacheRouteSel(gate,
choice, sel) applies the store's resident-preferring top-k selection
when the layer streams from a store opened with cache_route
(quality-affecting, opt-in: route_sacred true top ranks are always
taken, route_window bounds the resident-preferring fill) and returns
false when the caller keeps its plain top-k.
reportAndSaveMoeStream(store, learn, writer) is the runners' exit-time
report — stream, pilot, prefetch, cache-route, and mirror stats — and
persists the usage histogram that seeds the next load's pinned tier.
MoeStreamCli is the runners' shared argv seam for the seven common
--moe-* flags (--moe-stream, --moe-cache-mb=, --moe-mirror=,
--moe-mirror-weights=, --moe-uncached, --moe-io-threads=,
--moe-trace=PATH):
tryParse(arg) consumes exactly those (false = not a shared flag, the
caller keeps its family-specific flags and unknown-flag error) and
options(gguf_path) assembles the MoeStreamOptions (null when nothing
armed streaming; the result borrows the CLI struct's mirror buffers).
Family-specific levers — --moe-pilot, the cache-route trio, the
pinned-tier knobs — stay in the runners, which arm streaming via armed
and set their fields on the returned options.
--moe-trace=PATH records the routed (layer, expert) sequence in request
order and writes it at store teardown (ExpertStore.saveTrace); zig
build replay-experts -- PATH [slots-per-layer...] [--pins-from=SIDECAR]
[--heat-decay=N] replays it through LRU, heat (decayed-LFU), segmented-
LRU, Belady-optimal, and pinned+LRU policies across a capacity sweep —
answering offline whether more cache would help and whether the policy
or the capacity is the bottleneck (LRU flat where Belady climbs =
policy). --pins-from draws the pinned set from a persisted
<gguf>.experts histogram instead of the whole-trace oracle, measuring
how pins learned in previous sessions generalize to a new prompt. The
persisted usage histogram alone cannot answer any of this: every policy
needs temporal order. The cache tier itself evicts by heat by default
(Options.heat_eviction; FUCINA_MOE_LRU=1 reverts to pure LRU for
A/B): victim = lowest decayed routed-pair count among slots not touched
by the current acquire, recency breaking ties — measured at +3 hit-rate
points and −4% streamed bytes over LRU on real DeepSeek-V2-Lite traces,
replay and live agreeing. Relatedly, auto-pin declines
(pins_declined_flat, threshold Options.auto_pin_min_advantage) when
the histogram is ~flat — a quantile-balanced router's pinned tier
retains no more traffic than random slots — handing the whole budget to
the LRU instead; the guard is bypassed whenever the budget holds every
used expert.
Zero-copy linears over caller-owned immutable bytes (used by runners that keep weights mmapped):
pub fn linearSeqBorrowedF16(ctx, input: anytype, bytes: []const u8, shape: [2]usize,
comptime in_tag: Tag, comptime out_tag: Tag) !fucina.Tensor(.{ .seq, out_tag })
pub fn linearSeqBorrowedQuantized(comptime dtype: DType, ctx, input: anytype,
bytes: []const u8, shape: [2]usize, options: BorrowedQuantLinearOptions,
comptime in_tag: Tag, comptime out_tag: Tag) !fucina.Tensor(.{ .seq, out_tag })
pub const BorrowedQuantLinearOptions = struct {
allow_gpu: bool = true,
rhs_lifetime: RhsLifetime = .transient,
};
The quantized variant is comptime-restricted to q8_0/q4_k/q5_k/q6_k, rejects
gradient-tracked inputs (Error.GradUnsupported), and validates
input.dim(in_tag) == shape[1] (Error.InvalidWeightShape). Neither takes
ownership of bytes.
Metal-residency utilities shared by loaders and eager dispatch batching:
ResidentByteRegistry(init/deinit/bytes): a session/model-owned map from host byte pointers to one-time device copies.bytes(src)returns the device-resident alias (still CPU-readable) on GPU builds, orsrcverbatim on non-GPU builds and on any allocation failure;deinitfrees all device copies. Not thread-safe.QuantByteStackPart,QuantByteStackOptions{ prefer_device = true, require_device = false },QuantByteStack(deinit(allocator)/bytesPerRow/totalOutRows) andmakeQuantByteStack(comptime dtype, allocator, parts, options) !?QuantByteStack: copy same-shaped quantized weights into one contiguous stack with the same residency policy as the loaders. Returnsnullfor emptypartsor whenrequire_deviceis set and no device storage is available; mismatched part shapes areError.InvalidWeightShape. Device-capable dtypes: q4_k/q6_k/q8_0 on both providers, plus q5_k on CUDA.
13.3 GGUF metadata glue (src/llm/gguf_meta.zig)¶
Flat helpers shared by every model family's loader. Error set:
Error = error{ InvalidConfig, MissingMetadata }.
pub const ZeroPolicy = enum { reject_zero, accept_zero };
pub fn metaInt(file: *const gguf.File, arch: []const u8, suffix: []const u8, zero: ZeroPolicy) Error!usize
pub fn metaIntOpt(file, arch, suffix, zero: ZeroPolicy) ?usize
pub fn metaFloat(file, arch, suffix) Error!f32
pub fn metaFloatOpt(file, arch, suffix) ?f32
pub fn readU32OrBoolArray(allocator, file, key: []const u8, n_layer: usize, comptime T: type) ![]T
The meta* quartet reads the key "<arch>.<suffix>". Missing keys,
negative integers, and — under .reject_zero — present-but-zero integers
are invalid: the Opt variants read them as null, the strict variants
return Error.InvalidConfig. ZeroPolicy exists because families disagree
about zero on purpose: qwen3 treats a zero-valued key like a missing key
everywhere, while gemma reads legitimately-zero keys such as
attention.shared_kv_layers. A key that overflows the internal 128-byte
format buffer reads as absent. readU32OrBoolArray reads a per-layer
metadata array (bool/int item types), broadcasting a scalar value across
all layers like llama.cpp's get_key_or_arr — the convention for
per-layer keys such as <arch>.attention.head_count_kv (gemma4, inkling,
diffusion-gemma).
test "gguf_meta: zero-valued keys split by policy" {
const alloc = std.testing.allocator;
var w = fucina.gguf.Writer.init(alloc);
defer w.deinit();
try w.addMetaInt("arch.block_count", u32, 24);
try w.addMetaInt("arch.shared_kv_layers", u32, 0);
var buf: [4096]u8 = undefined;
var sink = std.Io.Writer.fixed(&buf);
try w.finish(&sink);
var file = try fucina.gguf.File.parseOwned(alloc, try alloc.dupe(u8, sink.buffered()));
defer file.deinit();
const meta = llm.gguf_meta;
try std.testing.expectEqual(@as(usize, 24), try meta.metaInt(&file, "arch", "block_count", .reject_zero));
try std.testing.expectError(error.InvalidConfig, meta.metaInt(&file, "arch", "shared_kv_layers", .reject_zero));
try std.testing.expectEqual(@as(usize, 0), try meta.metaInt(&file, "arch", "shared_kv_layers", .accept_zero));
try std.testing.expectEqual(@as(?usize, null), meta.metaIntOpt(&file, "arch", "missing", .accept_zero));
}
compiled & run in CI ✓
pub fn parallelLoadLayers(comptime Layer: type, comptime Loader: type,
ctx: *ExecContext, loader: Loader, layers: []Layer) !void
Loads all model layers in parallel across the exec work pool (§9) when one is
available, serially otherwise — layer loads are independent and the
ExecContext allocator and buffer pool are thread-safe, so the multi-GB
copy+pack becomes an N-core job (the dominant chunk of model load time).
Loader is a small per-family adapter value providing
fn load(self, layer_i: usize) !Layer and
fn deinitLayer(self, layer: *Layer) void. On failure, only the layers that
DID load are deinitialized, and the first error in layer order is
returned (deterministic even under parallel execution).
13.4 KV cache (src/llm/kv_cache.zig)¶
pub const KvTensor = fucina.Tensor(.{ .dtype = .f16, .tags = .{ .seq, .kv_head, .d } });
pub const KvInput = fucina.Tensor(.{ .seq, .kv_head, .d }); // f32 rows handed to append
pub const KvDtype = enum { f16, q8_0 };
pub const Error = error{ KvCacheOverflow, KvCacheShapeMismatch, KvCacheHeadDimNotBlockAligned };
KvCache is the per-layer post-RoPE key/value store for autoregressive
decode, shared by every family and by the speculative decoder. Layout: one
contiguous [capacity, kv_heads, head_dim] tensor per layer for K and one for
V — exactly the [.seq, .kv_head, .d] layout the attention kernels consume,
so the active prefix [0..len] is a zero-copy narrow. K is stored after
RoPE (V has no RoPE), so past positions are never re-rotated.
- f16 default: 2 B/element — half the f32 footprint and per-step bandwidth; the attention kernel widens to f32 in-register. Matches llama.cpp's default cache type.
- Opt-in q8_0 (
initWithDtype/initPerLayerWithDtypewith.q8_0; llama.cpp's--cache-type-k/v q8_0): each (position, kv_head) row is stored ashead_dim/32BlockQ8_0— 34 bytes per 32 elements, roughly halving f16 again at a small quantization loss. Requireshead_dim % 32 == 0(checked at init:Error.KvCacheHeadDimNotBlockAligned); q8_0 layers are raw block slices, consumed viakBlocks/vBlocksand the attention kernels' q8_0-block KV arm. Decode serves the blocks through the INTEGER score path: the query row is quantized once per head to q8_0 and scores are q8xq8sdotdots straight on the cached K blocks, with the V dequant fused into the weighted accumulate — the sweep reads only quantized bytes, no f32 scratch row. Measured (Qwen3-4B, M1 Max, 64-token decode): +22%/+50%/+54% over the previous dequant-scratch path at 2k/8k/16k context, and at or above the f16 cache from ~8k up (15.5 vs 14.3 tok/s at 8k). The query-tiled prefill kernel keeps the dequant path (per-tile row reuse amortizes it) and stays bit-exact vs the f32 kernel on a dequantized cache.
pub fn init(ctx: *ExecContext, num_layers: usize, kv_heads: usize, head_dim: usize, capacity: usize) !KvCache
pub fn initWithDtype(...same..., dtype: KvDtype) !KvCache
pub fn initPerLayer(ctx, kv_heads_per_layer: []const usize, head_dims: []const usize, capacity: usize) !KvCache
pub fn initPerLayerWithDtype(...same..., dtype: KvDtype) !KvCache
pub fn deinit(self: *KvCache) void
The per-layer variants size each layer's slot independently — Gemma 4
interleaves local sliding-window layers (kv_heads 8, head_dim 256) with global
layers (kv_heads 2, head_dim 512). The cache itself has no window logic: every
position is appended and retained, and windowed models apply their sliding
window at read time through the windowed attention kernels (which also keeps
truncate rewind trivially correct). Allocations use ctx.allocator; the
caller owns the cache and must deinit it.
Decode-loop API:
pub fn appendLayer(self: *KvCache, ctx: *ExecContext, layer_i: usize,
k_rows: *const KvInput, v_rows: *const KvInput) !void
pub fn advance(self: *KvCache, m: usize) void
pub fn reset(self: *KvCache) void // len = 0, buffers retained
pub fn truncate(self: *KvCache, keep_len: usize) void
pub fn copyRows(self: *KvCache, src: *const KvCache, start: usize, end: usize) !void
pub fn kSlice(self, layer_i: usize, len: usize) ![]const f16 // f16 mode
pub fn vSlice(self, layer_i: usize, len: usize) ![]const f16
pub fn kBlocks(self, layer_i: usize, len: usize) []const fucina.BlockQ8_0 // q8_0 mode
pub fn vBlocks(self, layer_i: usize, len: usize) []const fucina.BlockQ8_0
pub fn byteSize(self) usize
appendLayerconverts the new tokens' f32 K/V rows to the cache dtype and writes them at offsetlen, in one pass with no temporaries. Shape mismatches against the layer's geometry areError.KvCacheShapeMismatch; exceedingcapacityisError.KvCacheOverflow. It does not advancelen— every layer appends at the same base offset; calladvance(m)once per step after all layers have been written.truncate(keep_len)rewinds to the firstkeep_lenpositions (a value at or abovelenis a no-op). Decrementinglensuffices for both storage modes: buffers are pre-allocated atcapacity, every position occupies whole per-(position, kv_head) rows, and every reader andappendLayeraddress rows strictly fromlen— the next append overwrites the abandoned rows. This is the speculative decoder's rewind primitive (§13.9): rejected draft positions are dropped with one integer store.copyRows(src, start, end)copies rows[start, end)of a same-geometry cache into this one at the SAME positions and advanceslentoend— the cross-slot prefix-share primitive (lmserve's slot pool): a new conversation adopts another slot's common prompt prefix by memcpy instead of re-prefilling it. Positions are preserved, so the copied rows are exactly the rows a prefill of the same tokens would have produced; both storage dtypes copy. Requiresself.len == start(rows append in order) andend <= src.len; a dtype or per-layer geometry mismatch isError.KvCacheShapeMismatch.
test "kv cache: append, advance, truncate rewind" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
// 1 layer, 2 kv heads, head_dim 4, capacity 8 positions (f16 storage).
var cache = try llm.kv_cache.KvCache.init(&ctx, 1, 2, 4, 8);
defer cache.deinit();
var k = try llm.kv_cache.KvInput.fromSlice(&ctx, .{ 3, 2, 4 }, &([_]f32{0.5} ** 24));
defer k.deinit();
var v = try llm.kv_cache.KvInput.fromSlice(&ctx, .{ 3, 2, 4 }, &([_]f32{0.25} ** 24));
defer v.deinit();
try cache.appendLayer(&ctx, 0, &k, &v); // writes at offset len, does not advance
cache.advance(3); // once per step, after all layers
try std.testing.expectEqual(@as(usize, 3), cache.len);
try std.testing.expectEqual(@as(usize, 3 * 2 * 4), (try cache.kSlice(0, cache.len)).len);
cache.truncate(1); // speculative rewind: drop rejected positions
try std.testing.expectEqual(@as(usize, 1), cache.len);
}
compiled & run in CI ✓
llm.kv_persist (src/llm/kv_persist.zig) persists the cache to a
crash-safe append-only sidecar file so a conversation reopens warm across
process restarts, with zero re-prefill. The sidecar is a fixed header —
magic FUXKV001, a record count, and a per-layer geometry guard (any
mismatch with the opening cache ignores the file wholesale) — followed by
one record per position: the token id plus every layer's K/V row bytes
(both cache dtypes round-trip). Conversations served behind a preloaded
KV prefix (a cartridge, §13.10) write FUXKV002 instead: one extra header
field, prefix_rows, and records for EVERY position — the leading
prefix_rows records carry a token sentinel — so a restore is
self-describing and keeps the exact prefix it was saved with, even across
a cartridge swap; prefix-free conversations keep writing byte-identical V1
files. reset(io, allocator, path, kv, prefix_rows) arms a fresh sidecar
for the cache's geometry. appendRange(io, allocator, path, kv, tokens,
prefix_rows) writes the positions the file does not hold yet — record
data first, the header's record count last, so a torn append is invisible;
prefix_rows + tokens.len != kv.len is Error.KvPersistTokenMismatch,
and a stored prefix shape that disagrees is treated as foreign (reset).
load(io, allocator, path, kv) resumes into an empty cache: it applies up
to the stored count (stopping early at a torn tail — the prefix stays
usable; a tear INSIDE a token-less prefix is not resumable), sets
kv.len, and returns the caller-owned Loaded{ tokens, prefix_rows }, or
null when nothing usable exists (absent file, foreign geometry, or a
history beyond capacity). chat.Conversation.enablePersistence (§13.8.2)
is the turnkey consumer and resumes kv_prefix_rows from the file.
13.5 Tokenizers¶
13.5.1 Byte-level BPE (src/llm/tokenizer.zig)¶
llm.tokenizer.Tokenizer is a native byte-level BPE tokenizer (GPT-2/Qwen
family) built entirely from a model's GGUF metadata
(tokenizer.ggml.{tokens,merges,pre,token_type,bos_token_id,eos_token_id,add_bos_token,add_eos_token})
— no external tokenizer dependency, no per-model hardcoding. Error set:
error{ NoTokenizerVocab, UnsupportedTokenizerFormat, TokenizerTooLarge } || Allocator.Error.
pub const SpecialTokens = struct {
bos: ?u32 = null, eos: ?u32 = null,
prepend_bos: bool = false, append_eos: bool = false,
};
pub fn initFromGguf(allocator: Allocator, file: *const gguf.File, overrides: SpecialTokens) !Tokenizer
pub fn initFromParts(allocator, vocab_strings: []const []const u8,
merge_strings: []const []const u8, special: SpecialTokens) !Tokenizer
pub fn deinit(self: *Tokenizer) void
initFromGgufrequires a string-array vocab and non-empty merges, and refuses SentencePiece-scored models (tokenizer.ggml.scorespresent →Error.UnsupportedTokenizerFormat— usespm_tokenizerinstead). Special tokens default from metadata; non-nulloverridesfields replace them, andprepend_bos/append_eosin the overrides can only force the policy on (afalseleaves the metadata value in effect).- The tokenizer copies all vocab/merge bytes into owned blobs, so it stays
valid after the source
gguf.Fileis freed. Duplicate token bytes resolve to the lowest id. - Pretokenizer parity: the chunker is a faithful port of llama.cpp's
hand-rolled qwen2 pretokenizer loop, backed by generated Unicode category
tables — on valid UTF-8 input it chunks and encodes token-ID-exact
against llama.cpp for qwen2-pre models (malformed UTF-8 is the one
documented deviation). The GGUF's
tokenizer.ggml.preselects the chunker via thepre: Pre = .qwen2field (Pre = enum { qwen2, qwen35, joyai_llm, glm4, inkling }):"qwen35"(Qwen3.5/3.6/Bonsai — the qwen2 rules with\p{M}combining marks folded into the word class and excluded from punctuation runs),"joyai-llm"(the DeepSeek-V4 family's byte-oriented splitter),"glm4"/"chatglm-bpe"(qwen2 rules with three-digit number runs), and"inkling"(the o200k variant with\p{M}in both word classes so combining marks attach to base letters, reproducing llama.cpp's collapsed-category regex semantics) — each backed by the generatedisMarktable and token-ID-exact againstllama-tokenize. If the GGUF declares a pretokenizer other than an implemented chunker, encoding still proceeds with the qwen2 rules, but the id is recorded in thepre_mismatch: ?[]u8field and a warning is logged once — token-ID parity is then not guaranteed.
Encode/decode surface:
pub fn encode(self, allocator, text: []const u8) ![]u32 // BOS/EOS policy applied
pub fn encodeRaw(self, allocator, text: []const u8) ![]u32 // no BOS/EOS (templates own structure)
pub fn encodePlainAppend(self, allocator, text, out: *std.ArrayList(u32)) !void // no marker resolution
pub fn decode(self, allocator, ids: []const u32) ![]u8
pub fn decodeAppend(self, allocator, id: u32, out: *std.ArrayList(u8)) !void
pub fn tokenId(self, token: []const u8) ?u32
pub fn vocabSize(self) usize
pub fn eosId(self) ?u32 / pub fn bosId(self) ?u32 / pub fn isEos(self, id: u32) bool
encode/encodeRaw single-id-match special tokens atomically before
pretokenization: with tokenizer.ggml.token_type metadata, every
CONTROL/USER_DEFINED token, longest marker first (llama.cpp's
tokenizer_st_partition); without it, <|...|>-shaped markers resolved
against the vocabulary (a <| that does not open a known marker is left to
normal pretokenization); encodePlainAppend skips
marker resolution entirely for callers with their own control-token sets.
Returned slices are owned by the caller.
test "byte-level BPE: merges, special markers, round-trip" {
const alloc = std.testing.allocator;
const vocab = [_][]const u8{ "<|im_end|>", "h", "i", "hi" };
const merges = [_][]const u8{"h i"};
var tok = try llm.tokenizer.Tokenizer.initFromParts(alloc, &vocab, &merges, .{});
defer tok.deinit();
const ids = try tok.encode(alloc, "hi<|im_end|>");
defer alloc.free(ids);
try std.testing.expectEqualSlices(u32, &.{ 3, 0 }, ids); // "hi" merged, marker resolved
const text = try tok.decode(alloc, ids);
defer alloc.free(text);
try std.testing.expectEqualStrings("hi<|im_end|>", text);
}
compiled & run in CI ✓
StreamDecoder handles token-by-token generation where one token can end in
the middle of a multi-byte UTF-8 character:
pub const StreamDecoder = struct {
pub fn init(tokenizer: *const Tokenizer) StreamDecoder
pub fn deinit(self: *StreamDecoder, allocator: Allocator) void
pub fn reset(self: *StreamDecoder) void
pub fn push(self, allocator, id: u32, writer: *std.Io.Writer) !void
pub fn flush(self, writer: *std.Io.Writer) !void
};
push emits only the complete-UTF-8 prefix and holds the incomplete tail
until a later token finishes it; flush emits any remainder when generation
ends. The sink is any *std.Io.Writer (stdout, an SSE response, an in-memory
buffer).
13.5.2 SentencePiece (src/llm/spm_tokenizer.zig)¶
llm.spm_tokenizer.Tokenizer is the Gemma-family counterpart: a faithful port
of llama.cpp's llm_tokenizer_spm Unigram model, driven by per-token
scores rather than merge ranks. Encoding seeds a max-heap of adjacent
symbol pairs keyed by the score of the token they would form, repeatedly
merges the highest-scoring pair, resegments, and byte-falls-back to <0xXX>
tokens for anything the vocabulary cannot cover. Special/control tokens
(<start_of_turn>, <bos>, …) are partitioned out of the raw text first
(longest marker wins), so they map to single ids. Error set:
error{ NoTokenizerVocab, UnsupportedTokenizerFormat, TokenizerArrayTooShort, TokenizerTooLarge } || Allocator.Error.
pub const Attr = enum(i32) { undef, normal, unknown, control, user_defined, unused, byte, _ };
pub const Options = struct {
bos: ?u32 = null, eos: ?u32 = null, unk: ?u32 = null,
add_bos: ?bool = null, add_eos: ?bool = null, add_space_prefix: ?bool = null,
};
pub fn initFromGguf(allocator, file: *const gguf.File, overrides: Options) !Tokenizer
pub fn initFromSlices(allocator, vocab_strings: []const []const u8,
scores: []const f32, attrs: ?[]const Attr, opts: Options) !Tokenizer
initFromGguf requires tokenizer.ggml.scores (its absence means a byte-BPE
vocab → Error.UnsupportedTokenizerFormat); tokenizer.ggml.token_type is
optional (absent = every token NORMAL). Defaults follow llama.cpp's SPM
defaults when metadata is silent: bos=1, eos=2, unk=0, add_bos=true,
add_eos=false, add_space_prefix=true. Attr mirrors llama.cpp's
LLAMA_TOKEN_TYPE_* numbering and controls encode partitioning and decode
rendering (NORMAL unescapes ▁, BYTE emits the raw byte, CONTROL/UNKNOWN are
suppressed). The public shape matches the byte-BPE tokenizer — encode,
encodeRaw, decode, decodeAppend, tokenId, vocabSize, eosId,
bosId, isEos, deinit, and an identical StreamDecoder — so a runner
picks one module per architecture and the rest of the stack (chat, data) is
generic over either.
test "SPM: score-driven merges and byte fallback" {
const alloc = std.testing.allocator;
const vocab = [_][]const u8{ "<unk>", "a", "b", "ab", "abc", "c", "<0x78>" };
const scores = [_]f32{ 0, -1, -1, -3, -2.5, -1, -5 };
var tok = try llm.spm_tokenizer.Tokenizer.initFromSlices(alloc, &vocab, &scores, null, .{
.add_bos = false,
.add_space_prefix = false,
});
defer tok.deinit();
const ids = try tok.encode(alloc, "abcx"); // "abc" outscores "ab"; 'x' byte-falls-back
defer alloc.free(ids);
try std.testing.expectEqualSlices(u32, &.{ 4, 6 }, ids);
}
compiled & run in CI ✓
13.5.3 Unicode tables (src/llm/unicode_categories.zig)¶
Generated (do not edit) \p{L}/\p{N}/\p{M}/\s classification tables
(isLetter/isNumber/isMark/isWhitespace) matching llama.cpp's tokenizer
data for token-ID-exact pretokenizer parity; regenerate with
python3 tools/gen_unicode_categories.py > src/llm/unicode_categories.zig
(the generator writes to stdout). Re-exported from llm.zig as
llm.unicode_categories so out-of-module consumers (nanochat's
example-local tokenizer) share the tables.
13.6 Sampling (src/llm/sampler.zig)¶
pub const Config = struct {
temperature: f32 = 0, // <= 0 selects greedy (argmax)
top_k: usize = 0, // 0 = exact full-vocab nucleus (256-candidate first window)
top_p: f32 = 1.0, // nucleus: smallest prefix with cum. prob >= top_p
min_p: f32 = 0, // keep tokens with p >= min_p * p(best); 0 disables
repeat_penalty: f32 = 1.0, // llama.cpp penalty_repeat; 1.0 disables
freq_penalty: f32 = 0, // llama.cpp penalty_freq (per-count subtraction)
presence_penalty: f32 = 0, // llama.cpp penalty_present (flat subtraction)
repeat_last_n: usize = 64, // penalty window over the most recent tokens
seed: u64 = 0,
pub fn isGreedy(self: Config) bool; // temperature <= 0
};
pub const Sampler = struct {
processor: ?LogitProcessor = null, // optional pre-sampling logit transform
pub fn init(config: Config) Sampler;
pub fn next(self: *Sampler, ctx: *ExecContext,
logits: *fucina.Tensor(.{ .seq, .vocab }), // shape [1, vocab]
history: []const usize) !usize;
};
next implements the llama.cpp-compatible pipeline in order: logit
processor → penalties → greedy shortcut → top-k truncation → temperature
softmax → top-p → min-p → categorical draw. Semantics worth pinning:
- Penalties mutate
logitsin place, applied once per unique token in the lastrepeat_last_ntokens ofhistory(withcount= occurrences in the window, matchingllama_sampler_penalties). - With
temperature <= 0(the default) the call is a deterministic argmax and the RNG is never touched — benchmarking and greedy decode share the path. - Sampling uses a
std.Random.DefaultPrngseeded once fromconfig.seedatinit: the draw sequence is a pure function of the seed, which the chat and speculative layers rely on (one draw per committed token, §13.8/§13.9). Withtop_k = 0the nucleus is exact: probabilities carry the full-vocab softmax denominator, and the 256-candidate work window grows (×4 per pass, up to the vocab) until it covers the requestedtop_pmass — no tail is silently clipped.top_p = 1.0keeps the window at 256, the work cap. An explicittop_kis clamped to 256 and keeps the llama.cpp order: top-k filter, softmax renormalized over the kept set, nucleus within it. - A
Sampleris single-stream mutable state (RNG + config): not thread-safe, one per decode stream. - With a
processorset, itsprocesshook mutates the logits row before everything else and itscommithook observes the selected token on every path (greedy included, exactly once pernext); a processor that masks out every candidate iserror.AllTokensMasked.
test "sampler: greedy default, seed-deterministic sampling" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
var logits = try fucina.Tensor(.{ .seq, .vocab }).fromSlice(&ctx, .{ 1, 5 }, &.{ 0.1, 0.2, 0.9, 0.3, 0.0 });
defer logits.deinit();
var greedy = llm.sampler.Sampler.init(.{}); // temperature 0 => argmax
try std.testing.expectEqual(@as(usize, 2), try greedy.next(&ctx, &logits, &.{}));
var a = llm.sampler.Sampler.init(.{ .temperature = 0.8, .top_k = 3, .seed = 42 });
var b = llm.sampler.Sampler.init(.{ .temperature = 0.8, .top_k = 3, .seed = 42 });
for (0..8) |_| { // same seed -> same draw sequence
try std.testing.expectEqual(try a.next(&ctx, &logits, &.{}), try b.next(&ctx, &logits, &.{}));
}
}
compiled & run in CI ✓
Logit processors (src/llm/logit_processor.zig)¶
LogitProcessor is the injectable pre-sampling transform — the seam
grammar-constrained decoding plugs into, and the hook for any custom logit
policy (bias lists, banned-token rules, watermarking). It follows the
DraftSource vtable pattern (§13.9):
pub const LogitProcessor = struct {
ptr: *anyopaque,
vtable: *const VTable,
pub const VTable = struct {
process: *const fn (ptr, logits: []f32, history: []const usize) anyerror!void,
commit: *const fn (ptr, token: usize) anyerror!void,
reset: ?*const fn (ptr) anyerror!void = null,
// structural hooks (optional; pure deterministic lookahead):
forcedTokens: ?*const fn (ptr, buf: []usize) usize = null,
validPrefixLen: ?*const fn (ptr, tokens: []const usize) usize = null,
};
pub fn process(...) / commit(...) / reset(...)
pub fn hasStructure(self) bool // both structural hooks present
pub fn forcedTokens(self, buf: []usize) usize / validPrefixLen(self, tokens) usize
};
process mutates one [vocab] logits row in place before the sampler's own
pipeline (a mask writes -inf over forbidden tokens); commit observes the
selected token, exactly once per Sampler.next; the optional reset re-arms
state for a fresh constrained region (chat.Conversation calls it at every
turn start). Because the seam lives inside the Sampler, every decode path
that samples through one — chat.send/sendBatch, the speculative
decoder's plain and verify steps, hand-rolled runner loops — picks the
processor up with no loop changes.
The two structural hooks let a processor expose what its state machine
knows beyond a mask: forcedTokens writes the unique legal continuation
(grammar-mandated JSON punctuation, a forced literal) and validPrefixLen
reports how many leading tokens of a candidate sequence the state accepts.
Both must be deterministic pure lookaheads. When present
(hasStructure()), the speculative layer turns them into drafts —
§13.9's ConstrainedSource.
The seam is speculative-safe by construction: the verify loop samples
each row only after that row's prefix is committed, and every sampled row
token is itself committed (accepted draft, correction, or bonus — §13.9), so
commit keeps processor state exactly in step with history and no rollback
hook is needed. A draft token the mask forbids simply loses the
sampled == draft comparison and is rejected; the constrained speculative
stream is token-for-token identical to the constrained plain stream (proven
greedy + sampled in chat_tests.zig). One processor per decode stream, like
the sampler that hosts it.
test "logit processor: mask before sampling, observe the selection" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
const OddMask = struct {
commits: usize = 0,
fn process(ptr: *anyopaque, logits: []f32, history: []const usize) anyerror!void {
_ = ptr;
_ = history;
for (logits, 0..) |*l, tok| {
if (tok % 2 == 1) l.* = -std.math.inf(f32);
}
}
fn commit(ptr: *anyopaque, token: usize) anyerror!void {
const self: *@This() = @ptrCast(@alignCast(ptr));
_ = token;
self.commits += 1;
}
};
var mask = OddMask{};
var logits = try fucina.Tensor(.{ .seq, .vocab }).fromSlice(&ctx, .{ 1, 4 }, &.{ 0.1, 0.9, 0.5, 0.8 });
defer logits.deinit();
var s = llm.sampler.Sampler.init(.{}); // greedy
s.processor = .{ .ptr = &mask, .vtable = &.{ .process = OddMask.process, .commit = OddMask.commit } };
// Unmasked argmax would be token 1; the mask forces the best even id.
try std.testing.expectEqual(@as(usize, 2), try s.next(&ctx, &logits, &.{}));
try std.testing.expectEqual(@as(usize, 1), mask.commits);
}
compiled & run in CI ✓
Constrained decoding: llguidance (src/llm/llguidance.zig, -Dllguidance)¶
llm.llguidance.Constraint compiles a grammar with the vendored
llguidance engine
(vendor/llguidance, MIT — version/update procedure in its
README) and adapts it to the
LogitProcessor seam: JSON-schema/regex/Lark-constrained generation for any
runner built on the shared sampler, ~50 µs of pure CPU mask work per token.
Requires -Dllguidance=true (§2.2; cargo builds the Rust staticlib); without
it the module still compiles and Constraint.init returns
error.LlguidanceNotEnabled.
CONSTRAINED-DECODING.md is the full design record
(seam adjudication, tokenizer-bridge details, the no-rollback speculation
argument, measured results).
pub const enabled: bool; // build-flag mirror
pub fn version() []const u8; // "llguidance@X.Y.Z derivre@..."
pub const Grammar = union(enum) {
json_schema: []const u8, // stringified JSON schema
regex: []const u8, // Rust-syntax regex the reply must match
lark: []const u8, // llguidance's Lark-variant grammar
llguidance: []const u8, // composite JSON list form
};
pub const Options = struct {
eos_token: ?u32 = null, // forced when the grammar completes; default tokenizer eosId()
extra_eos: []const u32 = &.{},
n_vocab: ?usize = null, // model vocab when padded larger than the tokenizer's
log_level: u32 = 1, // 0 silent, 1 warnings, 2 info
};
pub const Constraint = struct {
pub fn init(allocator, tokenizer: anytype, grammar: Grammar, options: Options) Error!Constraint
pub fn deinit(self: *Constraint) void
pub fn clone(self: *const Constraint) Error!Constraint // independent per-stream twin
pub fn processor(self: *Constraint) LogitProcessor // install on a Sampler / chat.Options
pub fn isStopped(self: *const Constraint) bool // grammar terminated
pub fn isAccepting(self: *Constraint) bool // tokens so far form a complete sentence
pub fn reset(self: *Constraint) Error!void // re-arm for a fresh reply
pub fn ffTokens(self: *Constraint, buf: []u32) Error!usize // grammar-forced continuation
};
tokenizeris*const llm.tokenizer.Tokenizer(byte-BPE) or*const llm.spm_tokenizer.Tokenizer(SPM) — both borrowed. The bridge hands llguidance every token's RAW bytes: BPE tokens byte-decoded, SPM pieces unescaped (▁→ space) and<0xXX>byte tokens as their byte. Control tokens (BPE: the<|...|>marker shape; SPM:control/unknownattrs) carry toktrie's0xFFspecial marker, so a grammar whose text could spell<|im_end|>can never steer the model into emitting the actual control token. Padding ids past the tokenizer vocab (setn_vocab = config.vocab_size) get empty bytes and are never allowed.- Stop forcing: when the grammar completes, the mask allows only
eos_token— pass the chat template's stop-marker id so a finished grammar ends the turn through the existing stop handling; a matcher failure mid-decode also degrades to the forced stop (details logged atlog_level >= 1). An invalid grammar failsinitloudly instead. - One
Constraintper decode stream; do not move it afterprocessor()is taken.chat.Conversationre-arms it per turn via theresethook. Multi-stream decode (sendBatch,--streams) gives each stream aclone()— a deep-cloned matcher over the refcounted tokenizer with the tokenize bridge borrowed from the original (which must outlive the clones); no vocab rebuild or grammar recompilation. - The processor exposes the §13.6 structural hooks (
forcedTokens/validPrefixLen, backed by llguidance's fast-forward and token-validation lookaheads), so speculation routes it through the grammar-aware draft source automatically (§13.9): grammar-forced spans draft themselves and are accepted with certainty. Measured on Qwen3-0.6B-Q8_0 with the JSON-schema example below: 0% draft acceptance (cost gate mutes speculation) without the hooks, 83% acceptance at 1.24 tok/step with them — output byte-identical either way. - The runner flags (qwen3 + gemma4, §14.2/§14.4):
--json-schema JSON|@FILE,--lark GRAMMAR|@FILE,--regex PATTERN— combine with--no-thinkon reasoning models (the grammar governs the whole reply, thinking channel included). Composes with--spec(output identical to the plain run) and with qwen3's--streams(per-stream clones; batch == sequential token-for-token).
test "llguidance: JSON-schema constrained greedy decode" {
if (!llm.llguidance.enabled) return error.SkipZigTest; // -Dllguidance=true builds only
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
const vocab = [_][]const u8{ "{", "}", "\"", "a", ":", "1", "<|end|>" };
var tok = try llm.tokenizer.Tokenizer.initFromParts(alloc, &vocab, &.{}, .{ .eos = 6 });
defer tok.deinit();
var constraint = try llm.llguidance.Constraint.init(alloc, &tok, .{
.json_schema =
\\{"type":"object","properties":{"a":{"type":"integer"}},"required":["a"],"additionalProperties":false}
}, .{});
defer constraint.deinit();
var s = llm.sampler.Sampler.init(.{}); // greedy
s.processor = constraint.processor();
// The model "wants" '}' everywhere; the mask walks it through a valid
// object instead — '}' only becomes samplable once {"a":1 is complete —
// then the finished grammar forces the stop token.
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
var steps: usize = 0;
while (!constraint.isStopped() and steps < 16) : (steps += 1) {
var logits = try fucina.Tensor(.{ .seq, .vocab }).fromSlice(&ctx, .{ 1, 7 }, &.{ 0, 1, 0, 0, 0, 0.5, 0 });
defer logits.deinit();
const next = try s.next(&ctx, &logits, &.{});
if (next == 6) break;
try tok.decodeAppend(alloc, @intCast(next), &out);
}
try std.testing.expectEqualStrings("{\"a\":1}", out.items);
}
compiled & run in CI ✓
13.7 SFT data (src/llm/data.zig)¶
Minimal supervised-fine-tuning helpers, generic across model families. Error
set: error{ MalformedJsonl, SampleTooLong, EmptyDataset, InvalidLoaderState }.
pub const Pair = struct { instruction: []const u8, response: []const u8 };
pub const SftText = struct {
pairs: []const Pair,
blob: ?[]u8 = null,
pub const JsonlOptions = struct {
instruction_key: []const u8 = "instruction",
response_key: []const u8 = "response",
};
pub fn fromPairs(pairs: []const Pair) SftText; // zero-copy borrow
pub fn fromJsonl(allocator, io: std.Io, path: []const u8, opts: JsonlOptions) !SftText;
pub fn deinit(self: *SftText, allocator: Allocator) void;
};
fromPairs borrows caller-owned pairs (deinit frees nothing). fromJsonl
loads one JSON object per line, reading strings under the configured keys into
one owned blob (so the result outlives the file); blank lines are skipped, and
any malformed line fails with Error.MalformedJsonl after logging the path
and line number.
pub const Sample = struct {
inputs: []usize, // full sequence minus its final token
labels: []usize, // next-token shift; prompt positions masked
pub fn deinit(self: *Sample, allocator: Allocator) void;
};
pub const EncodeOptions = struct {
seq_max: usize = 256,
ignore_index: usize = std.math.maxInt(usize), // the trainer's mask sentinel
mask_prompt: bool = true,
system: ?[]const u8 = null,
think_off: bool = true,
};
pub fn encodePrompt(allocator, tokenizer: anytype, template: chat.Template,
instruction: []const u8, opts: EncodeOptions) ![]usize
pub fn encodePair(allocator, tokenizer: anytype, template: chat.Template,
pair: Pair, opts: EncodeOptions) !Sample
encodePair renders one user turn through the chat template, tokenizes, and
builds the shifted training pair: inputs = prompt ++ response tokens minus
the last; labels[i-1] = token i, with all prompt positions replaced by
opts.ignore_index unless mask_prompt is off. The response (plus the
template's stop marker) is encoded separately from the prompt —
concatenating the text first would move BPE chunk boundaries across the join
and change token ids. Samples are truncated to seq_max input positions; a
window that leaves no supervised token is Error.SampleTooLong. The
tokenizer parameter is duck-typed over encodeRaw — byte-BPE and SPM both
satisfy it — and ignore_index is injected so this module never imports a
trainer (§11).
test "encodePair: render + tokenize + shift + prompt mask" {
const alloc = std.testing.allocator;
const vocab = [_][]const u8{
"<|im_start|>", "<|im_end|>", "u", "s", "e", "r", "a", "n", "t", "i",
"h", "k", "y", "o", "m", "<", ">", "/", "\xC4\x8A", // Ċ = byte-level '\n'
};
var tok = try llm.tokenizer.Tokenizer.initFromParts(alloc, &vocab, &.{}, .{});
defer tok.deinit();
const chatml = llm.chat.Template{ .format = .chatml };
var sample = try llm.data.encodePair(alloc, &tok, chatml, .{
.instruction = "hi",
.response = "yo",
}, .{ .seq_max = 64, .ignore_index = 9999 });
defer sample.deinit(alloc);
try std.testing.expectEqual(sample.inputs.len, sample.labels.len);
try std.testing.expectEqual(@as(usize, 9999), sample.labels[0]); // prompt masked
try std.testing.expectEqual(@as(usize, 1), sample.labels[sample.labels.len - 1]); // stop marker supervised
}
compiled & run in CI ✓
pub const Loader = struct {
pub const Order = enum { sequential, shuffled };
pub const State = struct { seed: u64, epoch: u64, index: u64 };
pub fn init(allocator, n: usize, order: Order, seed: u64) !Loader; // n == 0 => EmptyDataset
pub fn deinit(self: *Loader, allocator: Allocator) void;
pub fn next(self: *Loader) usize;
pub fn state(self: *const Loader) State;
pub fn restore(self: *Loader, s: State) !void; // out-of-range index => InvalidLoaderState
};
Loader is a deterministic sample-order iterator. .sequential is plain
round-robin; .shuffled draws each epoch as a fresh permutation that is a
pure function of (seed, epoch) — a checkpoint contract: the permutation
is identity order followed by a Fisher–Yates pass driven by a splitmix64
stream seeded with rng.at(seed, epoch) (j = splitmix64 % (i+1) for i
from n-1 down to 1). The formula is golden-pinned in data_tests.zig and
may never change once checkpoints exist against it; restore regenerates the
exact stream position from a saved State (u64 fields, so it round-trips
through trainer_state.json unchanged — §11).
test "Loader: (seed, epoch) -> permutation is a checkpoint contract" {
const alloc = std.testing.allocator;
var loader = try llm.data.Loader.init(alloc, 8, .shuffled, 42);
defer loader.deinit(alloc);
// Golden-pinned: this exact order may never change once checkpoints exist.
try std.testing.expectEqualSlices(usize, &.{ 3, 6, 0, 7, 1, 2, 5, 4 }, loader.perm);
for (0..3) |_| _ = loader.next();
const s = loader.state();
var expect: [8]usize = undefined;
for (&expect) |*e| e.* = loader.next(); // crosses the epoch boundary
var replay = try llm.data.Loader.init(alloc, 8, .shuffled, 0);
defer replay.deinit(alloc);
try replay.restore(s); // seed/epoch/index come from the saved State
for (expect) |want| try std.testing.expectEqual(want, replay.next());
}
compiled & run in CI ✓
13.8 Chat (src/llm/chat.zig)¶
13.8.1 Templates¶
pub const Format = enum { chatml, llama3, gemma, gemma4 };
pub const Template = struct {
format: Format,
pub fn detect(chat_template: ?[]const u8) ?Template;
pub fn stopMarker(self: Template) []const u8;
pub fn renderTurn(self, allocator, buf: *std.ArrayList(u8),
system: ?[]const u8, user: []const u8, first: bool, think_off: bool) !void;
};
detect sniffs the format from a GGUF tokenizer.chat_template string
(<|im_start|> → chatml, <|start_header_id|> → llama3, <|turn> → gemma4,
<start_of_turn> → gemma; anything else → null). stopMarker is the token
text that ends an assistant turn (<|im_end|>, <|eot_id|>,
<end_of_turn>, <turn|>). renderTurn appends the text to feed for one
user turn: first emits the conversation-start (bos/system) scaffolding,
otherwise it first closes the previous assistant turn; think_off suppresses
the reasoning channel on ChatML (empty <think> block) and Gemma 4
(primed-empty thought channel). Gemma 1–3 has no system role — the system
prompt is folded into the first user turn.
test "chat template: detect from GGUF metadata, render a turn" {
const alloc = std.testing.allocator;
const t = llm.chat.Template.detect("... {{ '<|im_start|>' }} ...").?;
try std.testing.expectEqual(llm.chat.Format.chatml, t.format);
try std.testing.expectEqualStrings("<|im_end|>", t.stopMarker());
var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(alloc);
try t.renderTurn(alloc, &buf, "Be terse.", "Hi", true, false);
try std.testing.expectEqualStrings(
"<|im_start|>system\nBe terse.<|im_end|>\n<|im_start|>user\nHi<|im_end|>\n<|im_start|>assistant\n",
buf.items,
);
}
compiled & run in CI ✓
renderMessages is renderTurn's stateless twin: it renders a FULL message
history for a fresh conversation, ending with the assistant-turn opener — the
shape a messages-array API server receives on every request (the lmserve
example, examples/lmserve/).
pub const Message = struct {
role: Role, // enum { system, user, assistant }
content: []const u8, // borrowed
};
pub fn renderMessages(self, allocator, buf: *std.ArrayList(u8),
messages: []const Message, think_off: bool) !void;
ChatML and Llama 3 render every message as its own role block, any order.
The Gemma formats have a single conversation-start system slot: leading
system messages merge into it (Gemma 1–3: folded into the first user turn),
and a later one is error.SystemMidConversation. An empty list is
error.EmptyMessages; a trailing assistant message is
error.TrailingAssistantMessage (rendering would open a SECOND assistant
turn after it rather than continue it). Historical assistant contents have
their reasoning block stripped (ChatML <think>…</think>, Gemma 4
<|channel>thought…<channel|>) — the reference templates drop prior-turn
reasoning, and stateless clients replay content without it. First-turn output
is byte-identical to renderTurn's, so a stateless render prefills the same
KV prefix as an incrementally driven conversation.
test "chat template: render a full message history (stateless server shape)" {
const alloc = std.testing.allocator;
const t = llm.chat.Template{ .format = .chatml };
var buf: std.ArrayList(u8) = .empty;
defer buf.deinit(alloc);
try t.renderMessages(alloc, &buf, &.{
.{ .role = .system, .content = "Be terse." },
.{ .role = .user, .content = "Hi" },
.{ .role = .assistant, .content = "<think>\nhm\n</think>\n\nHello!" },
.{ .role = .user, .content = "Bye" },
}, true);
try std.testing.expectEqualStrings(
"<|im_start|>system\nBe terse.<|im_end|>\n" ++
"<|im_start|>user\nHi<|im_end|>\n" ++
"<|im_start|>assistant\nHello!<|im_end|>\n" ++ // <think> stripped
"<|im_start|>user\nBye<|im_end|>\n" ++
"<|im_start|>assistant\n<think>\n\n</think>\n\n",
buf.items,
);
}
compiled & run in CI ✓
13.8.2 Conversation(Model, Tok)¶
pub fn Conversation(comptime Model: type, comptime Tok: type) type
Comptime-generic multi-turn chat over a model family and a tokenizer module. The duck-typed contract:
Modelexposesconfig.vocab_size,initKvCache(ctx, capacity) !KvCache(over the shared §13.4 cache), and the decode entries with the qwen3/gemma4 signatures:forwardStep(ctx, kv, token_ids, pos0) !Tensor(.{ .seq, .vocab })(last-token logits) andforwardStepAllLogits(same signature, all-row logits). The latter is a hard compile-time requirement even with speculation permanently off —sendunconditionally references the speculative path, so aModelwithout it fails to instantiate; it is only executed when speculation is enabled.sendBatchadditionally requiresforwardStepBatch(ctx, caches: []const *KvCache, token_ids: []const usize); the requirement is comptime-gated, so families without it still instantiate the type and geterror.BatchDecodeUnsupportedat runtime.Tokis the tokenizer module (llm.tokenizerorllm.spm_tokenizer): it must provide aTokenizertype withtokenId/eosId/encodeRaw/decodeAppendand aStreamDecoder.
pub const Options = struct {
system: ?[]const u8 = null,
capacity: usize = 4096, // total KV size; the whole conversation must fit
max_response_tokens: usize = 1024, // per-reply cap
think_off: bool = false,
sampler: sampler.Config = .{},
extra_stop_ids: []const u32 = &.{}, // borrowed
stop_sequences: []const []const u8 = &.{}, // borrowed; composes with speculation
logit_processor: ?sampler.LogitProcessor = null, // borrowed; §13.6
speculation: bool = false,
spec_options: speculative.Options = .{},
io: ?std.Io = null, // clock for the decoder's live cost gate
};
pub const WarmState = struct { cache: KvCache, tokens: []const usize, prefix_rows: usize = 0 };
pub fn init(ctx: *ExecContext, model: *const Model, tokenizer: *const Tok.Tokenizer,
template: Template, options: Options) !Self
pub fn initWarm(ctx: *ExecContext, model: *const Model, tokenizer: *const Tok.Tokenizer,
template: Template, options: Options, warm: WarmState) !Self
pub fn deinit(self: *Self) void
pub fn takeCache(self: *Self) KvCache
pub fn notePrefixRows(self: *Self, rows: usize) !void
pub fn send(self: *Self, user: []const u8, writer: *std.Io.Writer) !usize
pub fn sendRendered(self: *Self, rendered: []const u8, writer: *std.Io.Writer) !usize
pub fn sendRenderedReuse(self: *Self, rendered: []const u8, writer: *std.Io.Writer) !usize
pub fn sendTokensReuse(self: *Self, ids: []const u32, writer: *std.Io.Writer) !usize
pub fn sendBatch(convos: []const *Self, users: []const []const u8,
writers: []const *std.Io.Writer, produced: []usize) !void
pub fn sendBatchTokensReuse(convos: []const *Self, ids_list: []const []const u32,
writers: []const *std.Io.Writer, produced: []usize,
errs: []?anyerror) !void
pub fn addSpecReference(self: *Self, tokens: []const usize) !void
pub fn enablePersistence(self: *Self, io: std.Io, path: []const u8) !usize
pub fn specStats(self: *const Self) ?speculative.Stats
Semantics:
initresolves the stop id astokenizer.tokenId(template.stopMarker()) orelse tokenizer.eosId(), builds the KV cache via the model's owninitKvCache, and — withspeculationon — heap-allocates the speculative state (aSpeculationIndexcascade plus aSpeculativeDecoder(Model), §13.9), wiring the stop id intospec_options.stop_tokenand aligning the cascade'saccounting_min_draftwith the decoder'smin_draft.stop_sequencescompose withspeculation: the turn-boundary gate scans the decoded reply bytes in the plain loop's exact order, the completing token is neither streamed nor committed (the turn trim discards it from history and KV), and spec == plain — reply bytes, fired index, post-turn state — is proven inchat_tests.zig. TheConversationborrowsctx,model,tokenizer,system,extra_stop_ids, andstop_sequences— they must outlive it;deinitreleases the cache, history, stream decoder, and speculative state.sendrenders the turn, tokenizes it (error.ContextFullif the prefix does not fit the remaining KV capacity), prefills at the current cache position (one KV cache persists across turns — each turn prefills only the new tokens), then decodes token by token: sample → stop check → stream through theStreamDecoder(flushed per token) → forward. It returns the number of response tokens produced. A turn ends on the template stop marker, any ofextra_stop_ids, themax_response_tokensbudget, or KV exhaustion. Withstop_sequences, generation stops before streaming the token whose decoded reply text completes a sequence (the completing token is not committed), andfired_stoprecords the index (intostop_sequences) of the sequence that fired — null when the turn ended any other way, reset per turn — the stop-attribution seam servers report from (lmserve's Anthropicstop_sequencefield).sendRenderedissendover caller-provided pre-rendered template text — the stateless-API entry:Template.renderMessagesrenders a full message history and a FRESH conversation prefills it in one turn (Options.systemis not consulted; the caller rendered everything). Streaming, stop handling, speculation, and the logit processor behave exactly as insend; the equivalence with an incrementally driven conversation is proven inchat_tests.zig.sendRenderedReuseissendRenderedwith cross-request KV reuse — the stateless-server seam (lmserve; llama.cpp'scache_prompt).renderedmust be a FULL-history render (never arenderTurnsuffix). The conversation may hold a previous request's committed state, adopted at construction viainitWarm(…, warm):warm.cacheownership transfers on the call (error paths included;options.capacityis ignored — the adopted capacity governs) andwarm.tokens— the token shadow describing the cache's positions — is borrowed, copied, and clamps the cache. The reconcile keeps the longest common token prefix between the render's ids and the committed history — zero prefill for that span;reused_prefixreports its length (the OpenAIcached_tokensnumber) — capped atids.len - 1because logits are not cached state; everything past it is rewound (KvCache.truncate+ history shrink) and prefilled. Token-level LCP absorbs every render divergence (stripped reasoning blocks, edited history, another client) by reusing less; on a fresh conversation the entry degenerates tosendRenderedexactly. After the request,takeCachereleases the cache to the caller (snapshot the shadow fromhistory.items[0..cache.len]BEFORE taking — the bound trims the one committed-but-unforwarded token an aborted turn can leave); deinit skips a taken cache.sendTokensReuseis the same entry over pre-encoded ids — for a server that already tokenized the render to score candidate slots by common prefix. Speculation composes on both sides (the lmserve--specseam): the SpeculationIndex mirrors committed history append-only, so a warm adoption or a reconcile rewind rebuilds it in place from the reconciled history before the turn; the turn then ends with the same catch-up forward the plain loop issues, so the released slot's token shadow describes every committed token (spec == plain reuse output and state, and warm-reuse == fresh-stateless equivalence, are proven inchat_tests.zig). Only the batched entry (sendBatchTokensReuse) requires speculation off on every stream (error.SpeculationWithReuse).- With speculation on,
sendroutes through the decoder with a turn-boundary gate that stops streaming/index-learning at the stop marker and trims any verify-batch overshoot from history and the KV cache — unconditionally, on error paths included — so the post-turn state matches the plain path's exactly. The equivalence (token-for-token, draw-for-draw across a persistent sampler, greedy and sampled) is proven inchat_tests.zig. logit_processorinstalls a §13.6 processor (e.g. allm.llguidancegrammar constraint) on the conversation's sampler and re-arms it via itsresethook at every turn start, so the same constraint governs each assistant reply independently — on the plain, speculative, andsendBatchpaths alike (constrained plain == constrained speculative is part of thechat_tests.zigequivalence proofs). When the processor exposes the structural hooks (hasStructure()), speculation automatically routes through the grammar-aware draft source (13.9.6): forced grammar spans draft themselves.sendBatchrequires one processor instance per stream (a shared pointer iserror.SharedBatchProcessor— single-stream state; llguidance streams useConstraint.clone).sendBatchdecodes one message on each of N sibling conversations in lockstep: every step forwards one token per live stream throughforwardStepBatch(one m=N weight pass instead of N GEMVs), then samples each stream from its own logits row with its own sampler/history. Per-stream semantics match a plainsendexactly; below the m-dependent kernel thresholds the produced tokens are bit-identical to N sequential sends, beyond them rows can differ by ~1e-6 reassociation drift. Requirements, checked up front: non-empty batch (error.EmptyBatch), equal slice lengths (error.BatchLengthMismatch), speculation off on every stream (error.SpeculationWithBatch), one sharedctx/model(error.MixedBatchModels), distinct conversations (error.DuplicateBatchConversation). Ownership contract on error: the batch aborts,producedis left unwritten, and every stream's history is trimmed back to its cache's token-backed length (prefix-aware, §13.10's preloaded-prefix conversations included), so healthy siblings of the failing stream remain internally consistent and resendable; bytes already streamed are not recalled. Turn prefills run per stream.sendBatchTokensReuseissendTokensReuseover N sibling conversations in lockstep — the server batching entry (lmserve--batch). Per stream it runs the reuse reconcile, prefills the un-reused suffix, and samples the first token, then joins thesendBatchlockstep loop. UnlikesendBatch, per-stream failures are ISOLATED: a stream whose sink write (client gone), prologue (error.ContextFull), or sampler (error.AllTokensMasked) fails finishes with the error recorded inerrs[i]while the remaining streams keep decoding; only a shared-compute failure (the batched forward itself) aborts the whole batch.produced[i]anderrs[i]are always written, and on every path out each stream's history is trimmed back to its cache's token-backed length, so every conversation stays consistent and resendable. Speculation must be off on every stream (error.SpeculationWithReuse); the other up-front checks matchsendBatch.notePrefixRows(rows)declares the firstrowscache positions a PRELOADED KV prefix with no token shadow — the cartridge serving seam (§13.10):writeToCacheinto the fresh conversation's cache, then this, once, before any send; the cache must hold exactly the prefix and speculation must be off (error.InvalidPrefixotherwise). The reuse reconcile never rewinds into the prefix, KV persistence records the prefix shape, andWarmState.prefix_rowsadopts the same declaration on a warm start.addSpecReference(tokens)injects a tokenized reference document into the speculation index (the RAG seam);error.SpeculationDisabledwhen speculation is off.specStatsreturns the decoder's lifetimespeculative.Stats(null when off).enablePersistence(io, path)arms KV persistence (kv_persist.zig) on a fresh conversation — once, before any send. A compatible saved conversation atpathresumes into it (token history and KV cache restored, zero re-prefill); otherwise the file is reset so a stale or foreign prefix cannot become this conversation's. It returns the number of resumed positions (0 = fresh start). Every subsequentsend/sendRenderedturn appends its new positions to the append-only sidecar — the record count is published last, so a crash mid-append leaves a consistent prefix.- A
Conversationis single-threaded mutable state;sendBatchruns on the caller's thread over all streams.
fn snippetConversation(ctx: *fucina.ExecContext, io: std.Io, out: *std.Io.Writer) !void {
const alloc = ctx.allocator;
var file = try fucina.gguf.File.loadMmap(alloc, io, "qwen3-0.6b.gguf");
defer file.deinit();
var model = try llm.qwen3.model.Model.loadGgufFromFile(ctx, &file, try llm.qwen3.model.Config.fromGguf(&file));
defer model.deinit();
var tok = try llm.tokenizer.Tokenizer.initFromGguf(alloc, &file, .{});
defer tok.deinit();
const template = llm.chat.Template.detect(file.getString("tokenizer.chat_template")) orelse
llm.chat.Template{ .format = .chatml };
const Convo = llm.chat.Conversation(llm.qwen3.model.Model, llm.tokenizer);
var convo = try Convo.init(ctx, &model, &tok, template, .{
.capacity = 4096,
.sampler = .{ .temperature = 0.7, .top_k = 20, .seed = 42 },
.speculation = true, // lossless draft-free speculative decoding
});
defer convo.deinit();
_ = try convo.send("Why is the sky blue?", out); // streams tokens to `out`
} // requires model assets to run
13.9 Speculative decoding (src/llm/speculative/)¶
Training-free, draft-model-free speculative decoding: drafts come from
cheap deterministic indexes over text the model has already seen — no extra
weights. SPECULATIVE.md is the full design record (proof
obligations, verify economics, adjudicated alternatives); this section covers
the public surface.
The lossless contract (core.zig header is normative): because every
draft source is deterministic (a one-hot proposal distribution), rejection
sampling degenerates to running the FULL sampling pipeline on the target
logits at each verified position, conditioned on the hypothetical prefix —
accept while sampled == draft[i]; at the first mismatch the sampled token
IS the correction token; on full acceptance the (k+1)-th row yields a free
bonus token. Greedy is the same code path (temperature ≤ 0 makes the sampler
an argmax). Token IDs are compared, never probabilities. Exactly one RNG
draw is consumed per committed token — the same pattern as a plain run —
and committing Options.stop_token ends the verify row loop immediately, so
a persistent sampler never desyncs. Given bitwise-identical logits the output
stream equals the non-speculative run's. Logits are computed in verify
batches of m = 1+draft rows, and byte-identity with a plain run rests on
two legs: Options.pin_kernels (the default) runs the verify forward
under ExecContext.pinRowwiseKernels (§6.1), so every batched quant
matmul reproduces the m = 1 numerics bitwise — the verify logits AND the
KV rows the verify leaves behind for committed positions — and the caller
must prefill both runs identically (prefill kernels are
batch-shape-dependent; the chat layer's speculative turn prefills exactly
as the plain turn does). Unpinned, the m-dependent kernel switches can
drift the logits ~1e-6 and flip a near-tied sample ("same distribution
always; same sample stream whenever the logits match bitwise").
13.9.1 Core (speculative/core.zig)¶
pub const TopKRow = struct { token: usize, topk: []const u32 }; // borrowed per call
pub const DraftSource = struct {
ptr: *anyopaque,
vtable: *const VTable,
pub const VTable = struct {
suggest: *const fn (ptr, context: []const usize, buf: []usize) usize,
observe: *const fn (ptr, committed: []const usize) void,
observeTopK: ?*const fn (ptr, positions: []const TopKRow) void = null,
truncatePending: ?*const fn (ptr, new_len: usize) void = null,
};
pub fn suggest(...) usize / observe(...) void / observeTopK(...) void
pub fn wantsTopK(self) bool
pub fn truncatePending(self, new_len: usize) void
};
DraftSource is the injectable proposer interface: suggest writes up to
buf.len continuation tokens for the committed context (0 = no draft),
observe feeds newly committed tokens back, and the optional observeTopK
receives per-position top-K candidates from the verification logits — when
null, the decoder skips computing the top-k entirely. Sources must be
deterministic; the decoder clamps a lying suggest return value at runtime.
The optional truncatePending lets a wrapper (the chat turn-boundary gate,
the 13.9.6 grammar filter) tell the source its just-returned draft was
shortened, so pending acceptance accounting shrinks to the prefix the
decoder will actually verify.
pub const Options = struct {
max_draft: usize = 16,
min_draft: usize = 2, // shorter drafts fall back to a plain step
enabled: bool = true,
topk_feedback: usize = 8, // candidates per verified position
stop_token: ?usize = null,
pin_kernels: bool = true, // verify forward under pinned rowwise kernels: m == 1 numerics bitwise
// cost-aware auto-off gate:
rate_window: usize = 16, min_window_drafted: usize = 8,
min_speedup: f32 = 1.0, probe_margin: f32 = 0.10,
reprobe_after: usize = 128, reprobe_max: usize = 1024, probe_steps: usize = 4,
cost_table: []const CostPoint = &default_cost_table,
adapt_budget: bool = true,
};
pub const CostPoint = struct { draft_len: usize, cost: f32 };
pub const default_cost_table: [4]CostPoint; // measured Qwen3-0.6B-Q4_K_S economics
pub fn tableCost(table: []const CostPoint, draft_len: usize) f32;
CostGate (public type; driven internally by the decoder, estSpeedup() is
its public read) estimates the rolling true speedup — committed tokens per
plain-step-equivalent of verify cost — over a window of verify steps, turns
speculation off when it drops below min_speedup, re-probes with exponential
backoff (reprobe_after → reprobe_max) and probe_margin hysteresis, and
adapts the draft budget to the rolling acceptance rate. With a clock
(io set on the decoder), measured verify/plain ratios continuously rescale
the static cost_table through a clamped EWMA; without one, the table
applies as-is. Gating decides WHEN speculation runs, never WHAT is committed.
pub const Stats = struct {
steps, spec_steps, fallback_steps, disabled_steps,
drafted, accepted, rejected_steps, bonus, committed: usize,
pub fn tokensPerStep(self) f64 / acceptanceRate(self) f64
pub fn writeSummary(self, writer: *std.Io.Writer) !void
};
pub const TokenSink = struct { ptr: *anyopaque, func: *const fn (ptr, token: usize) anyerror!void, pub fn emit(...) };
pub const VerifyRowHook = struct { ... }; // test/debug: every pre-penalty logits row
pub fn SpeculativeDecoder(comptime Model: type) type {
// fields: source, options, stats, gate, io: ?std.Io = null, on_verify_row
pub fn init(allocator, source: DraftSource, options: Options) !Self
pub fn deinit(self: *Self) void
pub fn step(self, ctx, model: *const Model, kv: *KvCache,
sampler: *Sampler, history: *std.ArrayList(usize), sink: TokenSink) !usize
pub fn bootstrapStep(self, ctx, kv: *const KvCache, sampler: *Sampler,
history: *std.ArrayList(usize), sink: TokenSink, logits: *Logits) !usize
}
initvalidates the configuration loudly instead of leaving ReleaseFast UB: a top-K-consuming source withtopk_feedback == 0iserror.TopKFeedbackDisabled; degenerate gate options areerror.RateWindowTooSmall/error.ProbeStepsZero/error.CostTableEmpty/error.ReprobeAfterZero.stepruns one decode iteration under the invarianthistory.items.len == kv.len + 1(every committed token inhistory, the last one not yet forwarded into the cache); a violated invariant iserror.InvalidDecodeStateat runtime.historymust be allocated withctx.allocator— the decoder appends committed tokens to it. Each committed token is emitted throughsink; returns the number committed (≥ 1). Verify passes run one batchedforwardStepAllLogitsover[carried token, draft...], andkv.truncatedrops rejected rows — on error-unwind paths too (errdeferrestores the invariant).bootstrapStepcommits ONE token from caller-computed logits — the prefill bootstrap. The caller prefills the whole pending span in one batch (the byte-identity contract's caller leg), leaving the cache flush with history (history.len == kv.len, elseerror.InvalidDecodeState) and the span's last-row logits in hand; the entry samples them through the exact plain-step machinery (sampler, history, sink, observe hook, stats) and restores thestepinvariant. The chat layer's speculative turn opens with it, so plain and speculative turns build their caches from call-for-call identical forwards.Modelis duck-typed:forwardStep+forwardStepAllLogitsover the sharedKvCache(qwen3 and gemma4 today; qwen35's recurrent cache cannot rewind and is out of scope).
13.9.2 Suffix-automaton index (speculative/sam_index.zig)¶
SamIndex is an online suffix automaton over a token stream — the
exact-match draft source (SAM-Decoding/SuffixDecoding lineage). It gives O(1)
amortized online extension, an exact self-match-excluded longest-suffix-match
length (drafts follow the most recent prior occurrence, never the current
one), and doubles as a frozen index over reference documents.
pub const SamIndex = struct {
pub const max_stream_len: usize = 1 << 29;
min_match: usize = 2, max_draft: usize = 16, // policy fields
pub fn init(allocator: Allocator) !SamIndex / deinit(self) void
pub fn append(self, new_tokens: []const usize) !void // O(1) amortized per token
pub fn matchLen(self) usize
pub fn draft(self, buf: []usize) usize
pub fn tokenCount/stateCount/transitionCount(self) usize
// frozen mode (RAG):
pub fn freeze(self) void
pub const Cursor = struct { state: u32 = 0, len: u32 = 0 };
pub fn advance(self, cursor: *Cursor, token: usize) void
pub fn draftFrom(self, cursor: Cursor, buf: []usize) usize
// DraftSource method shapes:
pub fn suggest(self, context, buf) usize / observe(self, committed) void / observeTopK(...)
};
pub const FrozenSource = struct { index: *const SamIndex, cursor: Cursor,
pub fn suggest/observe/observeTopK };
A failed append poisons the index (degraded): all queries return 0
forever and further appends fail — a half-applied append must never serve
drafts (observe swallows errors into this degradation instead of
propagating). freeze() ends appends and makes external Cursors safe
(appends can split states; only the internal cursor gets the clone fix-up).
FrozenSource owns a per-conversation cursor over one frozen document and
borrows the index.
test "SamIndex: longest self-excluded suffix match drives the draft" {
const alloc = std.testing.allocator;
var sam = try llm.speculative.sam_index.SamIndex.init(alloc);
defer sam.deinit();
try sam.append(&.{ 5, 6, 7, 5, 6 });
// Longest suffix with an occurrence ending strictly before the end: [5,6].
try std.testing.expectEqual(@as(usize, 2), sam.matchLen());
var buf: [8]usize = undefined;
const n = sam.draft(&buf); // tokens after the prior occurrence: {7,5,6}
try std.testing.expectEqualSlices(usize, &.{ 7, 5, 6 }, buf[0..n]);
}
compiled & run in CI ✓
13.9.3 Token recycling (speculative/recycling.zig)¶
pub fn TokenRecycling(comptime K: usize) type {
pub const k = K;
pub const sentinel: u32 = std.math.maxInt(u32);
pub fn init(allocator, vocab: usize) !Self / deinit(self) void
pub fn topkOf(self, token: usize) []const u32
pub fn update(self, token: usize, topk: []const u32) void
pub fn draftChain(self, last_token: usize, buf: []usize) usize
pub fn suggest/observe/observeTopK // DraftSource method shapes
}
pub const Recycling = TokenRecycling(8);
A vocab × K adjacency matrix (Token Recycling, Luo et al. 2024): row t
holds the most recent top-K next-token candidates observed at any verified
position whose input token was t (≈4.6 MiB for the Qwen3 vocab at K=8).
Drafting walks the top-1 chain until an unseen row or the budget stops it.
observe promotes each committed bigram to slot 0 (ground truth beats stale
logits); observeTopK overwrites whole rows from verification feedback. The
struct owns m; update/observe* copy, no slice is retained.
test "TokenRecycling: top-1 chain drafting" {
const alloc = std.testing.allocator;
var rec = try llm.speculative.recycling.Recycling.init(alloc, 32); // vocab 32, K = 8
defer rec.deinit();
rec.update(3, &.{ 7, 9 }); // most recent top-K observed after token 3
rec.update(7, &.{5});
var buf: [4]usize = undefined;
try std.testing.expectEqual(@as(usize, 2), rec.draftChain(3, &buf)); // 3 -> 7 -> 5, then unseen
try std.testing.expectEqualSlices(usize, &.{ 7, 5 }, buf[0..2]);
}
compiled & run in CI ✓
13.9.4 Cascade (speculative/cascade.zig)¶
SpeculationIndex is the user-facing orchestrator behind one DraftSource:
it composes (1) an online conversation SamIndex over every committed token,
(2) any number of frozen reference SamIndex documents injected via
addReference (the RAG seam), and (3) the Recycling matrix as the
self-draft fallback and observeTopK consumer.
pub const gate_window: usize = 64;
pub const Gate = struct { ..., pub fn muted(self) bool }; // per-source rolling acceptance
pub const FrozenRef = struct { index: SamIndex, cursor: SamIndex.Cursor, gate: Gate };
pub const SpeculationIndex = struct {
// policy fields (defaults): beta = 2, min_match = 2, recycling_chain = 8,
// mute_acceptance = 0.20, mute_commits = 128, accounting_min_draft = 2
pub fn init(allocator: Allocator, vocab: usize) !SpeculationIndex / deinit(self) void
pub fn addReference(self, tokens: []const usize) !void
pub fn clearReferences(self) void
pub fn suggest(self, context, buf) usize / observe(self, committed) void / observeTopK(...)
pub fn asDraftSource(self: *SpeculationIndex) DraftSource
pub fn truncatePending(self, new_len: usize) void
pub fn writeSourceSummary(self, writer: *std.Io.Writer) !void
};
Suggest policy: the source with the longest current match wins (ties break
toward the conversation; among references, first-added), with draft budget
min(buf.len, beta * (1 + match_len)); matches shorter than min_match fall
back to the recycling top-1 chain (≤ recycling_chain tokens); an unseen
recycling row drafts 0 and the decoder takes a plain step. Each source keeps
a rolling acceptance gate over its last gate_window drafted tokens: below
mute_acceptance it is muted for mute_commits committed tokens, then
re-probed — muted sources keep observing so they stay in sync. Acceptance is
settled at the suggest→observe seam (longest common prefix of draft and
next committed slice); accounting_min_draft mirrors the decoder's
min_draft so unverified short drafts never skew the gates.
addReference catches the new document's cursor up over the already-observed
stream, so a mid-conversation injection can match existing context
immediately.
test "SpeculationIndex: observe committed tokens, suggest a draft" {
const alloc = std.testing.allocator;
var index = try llm.speculative.cascade.SpeculationIndex.init(alloc, 1024);
defer index.deinit();
index.observe(&.{ 1, 2, 3, 4, 1, 2 }); // [1,2] recurred; earlier it was followed by [3,4]
var buf: [16]usize = undefined;
const n = index.suggest(&.{ 1, 2, 3, 4, 1, 2 }, &buf);
try std.testing.expect(n >= 2);
try std.testing.expectEqualSlices(usize, &.{ 3, 4 }, buf[0..2]);
try index.addReference(&.{ 7, 8, 9, 10 }); // RAG seam: frozen document index
const source = index.asDraftSource(); // hand this to SpeculativeDecoder
try std.testing.expect(source.wantsTopK()); // the recycling matrix consumes logits feedback
}
compiled & run in CI ✓
13.9.5 Enabling speculation in a runner¶
The turnkey path is chat.Options{ .speculation = true } (§13.8), which owns
the index/decoder lifecycle, stop-token wiring, and turn trimming. A custom
runner drives the decoder directly:
fn snippetDecoderLoop(
ctx: *fucina.ExecContext,
model: *const llm.qwen3.model.Model,
kv: *llm.kv_cache.KvCache,
index: *llm.speculative.cascade.SpeculationIndex,
history: *std.ArrayList(usize),
sink: llm.speculative.core.TokenSink,
) !void {
const Decoder = llm.speculative.core.SpeculativeDecoder(llm.qwen3.model.Model);
var decoder = try Decoder.init(ctx.allocator, index.asDraftSource(), .{ .max_draft = 16 });
defer decoder.deinit();
var sampler = llm.sampler.Sampler.init(.{});
// Invariant: history.len == kv.len + 1 (last committed token not yet forwarded).
while (history.items.len < 128) {
_ = try decoder.step(ctx, model, kv, &sampler, history, sink);
}
} // requires model assets to run
13.9.6 Grammar-constrained drafting (speculative/constrained.zig)¶
ConstrainedSource makes a grammar constraint accelerate speculation
instead of muting it (CONSTRAINED-DECODING.md §5
is the design record). It wraps any inner DraftSource with a
LogitProcessor that exposes the §13.6 structural hooks
(hasStructure()), and must sit on the same processor instance installed
on the stream's sampler:
pub const ConstrainedSource = struct {
pub fn init(processor: LogitProcessor, inner: DraftSource) ConstrainedSource
pub fn source(self: *ConstrainedSource) DraftSource
};
- Forced spans draft themselves. When the grammar mandates a unique
continuation (JSON structure, a forced literal),
suggestproposes it directly — and because the sampler's mask allows exactly that token at each row, the whole span verifies with acceptance probability 1. - Certainly-rejected drafts die early. Otherwise the inner source
proposes and the draft is truncated at its first grammar-invalid token
(
validPrefixLen) — those tokens would be masked to-infat their verify row, so proposing them only wastes verify compute and drags the inner source's acceptance gates down. The truncation is forwarded throughDraftSource.truncatePendingso the inner accounting matches what is actually verified.
Losslessness is untouched (drafts never decide WHAT is committed, and both
hooks are deterministic lookaheads); the constrained speculative stream is
token-for-token identical to the constrained plain stream, proven greedy +
sampled in chat_tests.zig. Wiring is automatic everywhere: with
chat.Options{ .speculation = true, .logit_processor = ... } the
conversation wraps its cascade in a ConstrainedSource whenever the
processor has structure, and the qwen3 runner does the same for
--spec + a grammar flag. Measured effect on Qwen3-0.6B-Q8_0 JSON-schema
chat: draft acceptance 0% → 83%, with the cost gate staying on (§13.6).
test "constrained source: forced spans preempt, invalid drafts truncate" {
const Fixed = struct {
// state = 1: the grammar forces {7, 8}; state = 0: free choice with
// ids >= 3 invalid. process/commit are irrelevant on the suggest side.
fn process(_: *anyopaque, _: []f32, _: []const usize) anyerror!void {}
fn commit(_: *anyopaque, _: usize) anyerror!void {}
fn forcedTokens(ptr: *anyopaque, buf: []usize) usize {
const forcing: *u8 = @ptrCast(ptr);
if (forcing.* == 0) return 0;
buf[0] = 7;
buf[1] = 8;
return 2;
}
fn validPrefixLen(_: *anyopaque, tokens: []const usize) usize {
for (tokens, 0..) |t, i| if (t >= 3) return i;
return tokens.len;
}
fn suggest(_: *anyopaque, _: []const usize, buf: []usize) usize {
buf[0] = 1; // an inner source that always drafts {1, 5}
buf[1] = 5;
return 2;
}
fn observe(_: *anyopaque, _: []const usize) void {}
};
var forcing: u8 = 1;
const processor = llm.logit_processor.LogitProcessor{ .ptr = &forcing, .vtable = &.{
.process = Fixed.process,
.commit = Fixed.commit,
.forcedTokens = Fixed.forcedTokens,
.validPrefixLen = Fixed.validPrefixLen,
} };
const inner = llm.speculative.core.DraftSource{ .ptr = &forcing, .vtable = &.{
.suggest = Fixed.suggest,
.observe = Fixed.observe,
} };
var cs = llm.speculative.constrained.ConstrainedSource.init(processor, inner);
var buf: [8]usize = undefined;
// Forced state: the grammar span wins over the inner source.
try std.testing.expectEqual(@as(usize, 2), cs.source().suggest(&.{0}, &buf));
try std.testing.expectEqualSlices(usize, &.{ 7, 8 }, buf[0..2]);
// Free choice: the inner draft {1, 5} truncates at the invalid 5.
forcing = 0;
try std.testing.expectEqual(@as(usize, 1), cs.source().suggest(&.{0}, &buf));
try std.testing.expectEqual(@as(usize, 1), buf[0]);
}
compiled & run in CI ✓
13.10 Cartridges (src/llm/cartridge.zig)¶
pub const Kv = fucina.Tensor(.{ .seq, .kv_head, .d }); // KV-cache row layout
pub const LayerKv = struct { k_sink: ?Kv, v_sink: ?Kv, k: Kv, v: Kv };
pub const Cartridge = struct { layers: []LayerKv, p: usize, frozen_prefix: usize, ... };
pub const DistillTargets = struct { positions: []const usize, tokens: []const usize, logprobs: []const f32 };
pub const Error = error{ InvalidCartridge, InvalidTargets, ExecScopeRequired };
A cartridge (arXiv 2506.06266, HazyResearch/cartridges semantics)
compresses a corpus into the KV cache of a virtual p-token prefix: per layer,
a [p, kv_head, d] K/V pair living in the exact space of KV-cache rows —
keys post q/k-norm and post-RoPE at positions 0..p-1, never re-rotated —
trained offline by self-study distillation and served as a reusable prefix at
a fraction of the ICL cache size. Row 0 is a frozen constant by default (the
paper's attention-sink freeze; training it destabilizes the run), the rest
are leaf variables. Attention over concat(cartridge, tokens) with the
end-aligned causal kernel (source_offset = kv_seq − q_seq, §4.13)
reproduces the reference mask exactly: every query sees the whole prefix and
is causal over the real tokens, which sit at RoPE positions p...
Construction and lifecycle (create OUTSIDE any exec scope, like LoRA A/B):
Cartridge.initFromRows(ctx, allocator, frozen_prefix, p, kv_heads, head_dim, k_rows, v_rows)— from captured per-layer rows (the trainers'captureKv/initCartridgeproduce them; the paper's winning "first p corpus tokens" initialization).initFromRowsVariedtakes PER-LAYER kv_heads/head_dims (heterogeneous geometries like gemma-4's mixed SWA/global shapes;initFromStateDictrecovers per-layer shapes from the header).initRandom(...)is the random-vector ablation baseline.registerParams(opt)— trainable rows onto any optimizer withaddParamNamed(sinks are frozen registry entries and are skipped);zeroGrad().saveState(writer)/loadState(reader)— safetensors state dict underlayers.<i>.{k,v}[_sink]names (strict name+shape match on load);initFromStateDict(ctx, allocator, bytes)rebuilds a cartridge from the bytes alone (geometry recovered from the safetensors header).setDraftReference(ctx, tokens)— embed the corpus token ids in the artifact (frozen i64draft_referenceentry; set-once, beforesaveState): the serving-side speculation reference, so--spec-servebuilds the corpus suffix automaton ONCE at cartridge load with no--corpusre-read.initFromStateDictrecovers it intocart.draft_reference: ?[]usize; artifacts without the entry load unchanged.LayerKv.catK/catV(ctx, tokens)—concat(sink?, trainable, tokens)along.seq, the per-layer attention input;fullK/fullV(ctx)— the serving payload (sink ++ trainable, no tokens).writeToCache(ctx, cache)— serve: fill an EMPTYKvCachewith all p rows (converted to the cache dtype) and advance it to p, so a normalforwardStepdecode continues at position p, the training-time layout.
distillLoss(ctx, logits, targets, options) is the reference training
objective: the teacher top-k cross-entropy
mean(-exp(logprob_i) · log_softmax(logits)[positions_i − 1, tokens_i]) over
sparse (position, token, logprob) entries — gradient-identical to forward
KL(teacher ‖ student) since the teacher entropy is constant. positions[i]
is the packed index of the TARGET token (the student's prediction is read
from the previous row); truncated teacher tail mass is dropped, NOT
renormalized; entries are averaged uniformly (.sum + loss_scale compose
with gradient accumulation, §11). Composite-op contract: MUST run inside an
open exec scope (Error.ExecScopeRequired). TargetsBuilder.appendRow
extracts targets from raw teacher logits rows host-side (descending top-k
until min_prob_mass cumulative probability, the crossing entry included);
appendTopKRow is its tensor-side counterpart, fed by core
topK/logsumexp over the vocab axis so only [rows, k] values/indices
reach the host — selection identical (lowest-index ties both ways),
logprobs equal up to the core reduction's summation order (pinned by a
unit test).
The qwen3 trainer hosts the training loop (§14 / llm/qwen3/train.zig):
ForwardOptions.cartridge threads the prefix through every layer (tokens
shift to RoPE positions p..; gradients flow into the cartridge rows through
the frozen stack), ForwardOptions.capture copies token K/V rows out of a
forward, ForwardOptions.packed_segments batches several independent
sequences as contiguous segments of ONE forward (the GEMMs pack; RoPE
restarts per segment via a transient table — pair with
Trainer.freeTransientRope() between optimizer steps; attention runs per
segment over zero-copy narrows so gradients keep flowing through the fused
backward and accumulate into shared leaves — packed vs sequential gradient
equality is pinned by a trainer test), and Trainer.{initCartridge,
captureKv, distillLoss, evalLogitsExt, evalLogitsRows} are the
training/eval entries — instantiate Trainer(.{ .q = false, .v = false })
so the cartridge rows are the only parameters. Capture, packed, and
composed forwards are plain-path only
(Error.CartridgeCheckpointUnsupported under checkpoint_layers); a
SINGLE cartridge on a no-adapter trainer checkpoints — its rows ride as
block inputs and the recompute is pinned bitwise (loss + row gradients)
by a trainer test. The
cartridge example (zig build cartridge,
README) runs the whole flow on a real
GGUF: --equiv (a zero-training corpus-init cartridge must match the real
prefill — bitwise at tiled-attention shapes on Qwen3-0.6B-f16), self-study
training (paper Sec 4, k = 1, fully in-process), and --load/--ask
serving. Design record: docs/CARTRIDGES.md.
Composition (Cartridges at Scale, arXiv 2606.04557): independently
trained cartridges compose by concatenation — part 0's rows, then part 1's,
…, with real tokens at RoPE positions composedP(parts)..; every part
keeps the rotations it was trained at (post-RoPE keys are frozen vectors,
so overlapping nominal positions across parts are fine). cartridge.zig
provides the free functions:
pub fn composedP(parts: []const *const Cartridge) usize;
pub fn validateComposition(parts: []const *const Cartridge) Error!void; // layer-for-layer geometry match
pub fn composedCatK(ctx, parts, layer_i: usize, k_tokens: *const Kv) !Kv; // concat(sink?, k, sink?, k, ..., tokens)
pub fn composedCatV(ctx, parts, layer_i: usize, v_tokens: *const Kv) !Kv;
pub fn writeComposedToCache(ctx, parts, cache: *KvCache) !void; // serve: parts in order into an EMPTY cache
// Cartridge.appendToCache(ctx, cache) — writeToCache without the empty-cache
// requirement, the primitive writeComposedToCache chains.
The qwen3 and gemma4 trainers thread a composition through
ForwardOptions.cartridges
(mutually exclusive with cartridge; plain-path only): ONE concat per layer,
so the existing fused attention backward routes gradients into EVERY part's
trainable rows — the joint-training seam. Trainer.distillLossExt(ctx,
tokens, fwd, targets, options) is distillLoss with full ForwardOptions
(composition + packing). A single-part composition is op-for-op the
single-cartridge forward (pinned bitwise), a two-part composition built
from one capture reproduces the real prefill exactly (the composition
oracle — bitwise on Qwen3-0.6B-f16 at p = 256 via cartridge-fleet
--equiv; gemma4 arms, SWA cutting the composed prefix included, in
gemma4_train_tests.zig), and serving through writeComposedToCache is
cache-level, so compositions serve on ANY family
(train_cartridge_compose_tests.zig).
Fleets (src/llm/cartridge_fleet.zig): the scale layer around
composition — one cartridge per document instead of one monolith per
corpus. Manifest is the fleet's on-disk record (fleet.json: per-doc
cartridge/optimizer files, token counts, optimizer-step counters); Fleet
is the RAM/disk budget manager (at most policy.budget cartridges
resident, each with its own AdamW whose moments travel through
evict/reload — an evict/reload cycle continues training bit-identically,
pinned by a fleet test; every policy.every rounds the most-trained
residents rotate to disk and the least-trained absentees rotate in, with a
per-cartridge lr warm-up on entry); EmbedIndex is the cartridge-RAG
selector — L2-normalized chunk embeddings centered by their centroid at
finalize (the all-but-the-top correction; raw causal-LM embeddings are
anisotropic and mis-rank documents without it), hand-rolled cosine top-k
in topDocs, persisted as index.safetensors. Artifact retrieval goes
through mmapFile (read-only page-backed mappings, no whole-file heap
copy). The cartridge-fleet example (zig build cartridge-fleet,
README) drives
mixed-visibility joint self-study (each round targets one resident
document; with probability --p-iso its cartridge trains alone, otherwise
distractor cartridges from other residents co-load in shuffled order — the
paper's recipe against composition collapse), builds the retrieval index
through the model itself (topic-instruction-suffixed final-norm last
hidden state, Trainer.embedLastHidden + embed_suffix), and serves
--ask by cosine selection over chunks → document cartridges →
writeComposedToCache → decode; qwen3 and gemma4 GGUFs (gemma uses the
flat per-conversation backward). lmserve serves fleets over HTTP
(--fleet DIR, per-request selection with conversation-sticky slot
reuse — LMSERVER.md).
test "cartridge: trainable KV prefix + teacher top-k distillation" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
// One layer, p = 2 rows: a frozen sink + one trainable row.
var cart = try llm.cartridge.Cartridge.initRandom(&ctx, alloc, 1, 1, 2, 1, 2, 42, 0.1);
defer cart.deinit();
try std.testing.expect(!cart.layers[0].k_sink.?.requiresGrad());
try std.testing.expect(cart.layers[0].k.requiresGrad());
const scope = ctx.openExecScope();
defer ctx.closeExecScope(scope);
// kv_seq = p + 2 > q_seq = 2: every query attends the whole prefix,
// then causally over the tokens (end-aligned kernel, §4.13).
var q = try fucina.Tensor(.{ .seq, .head, .d }).fromSlice(&ctx, .{ 2, 2, 2 }, &.{ 0.2, -0.4, 0.5, 0.1, -0.3, 0.7, 0.05, -0.6 });
defer q.deinit();
var k_tok = try llm.cartridge.Kv.fromSlice(&ctx, .{ 2, 1, 2 }, &.{ 0.1, 0.5, -0.35, 0.2 });
defer k_tok.deinit();
var v_tok = try llm.cartridge.Kv.fromSlice(&ctx, .{ 2, 1, 2 }, &.{ -0.2, 0.4, 0.55, -0.5 });
defer v_tok.deinit();
var k_cat = try cart.layers[0].catK(&ctx, &k_tok);
defer k_cat.deinit();
var v_cat = try cart.layers[0].catV(&ctx, &v_tok);
defer v_cat.deinit();
const map = [_]usize{ 0, 0 };
var attn = try q.groupedAttention(&ctx, &k_cat, &v_cat, map[0..], .attn, 0.7, .{});
defer attn.deinit();
// Stand-in logits: one full-mass teacher entry == plain cross-entropy.
var logits = try attn.withTags(&ctx, .{ .seq, .vocab });
defer logits.deinit();
var loss = try llm.cartridge.distillLoss(&ctx, &logits, .{
.positions = &.{1},
.tokens = &.{2},
.logprobs = &.{0.0},
}, .{});
defer loss.deinit();
try loss.backward(&ctx);
// The gradient reached the trainable row through concat + attention.
var grad = (try cart.layers[0].k.grad(&ctx)).?;
defer grad.deinit();
try std.testing.expectEqual(@as(usize, 2), grad.asRawTensor().dataConst().len);
}
compiled & run in CI ✓
13.11 Engram (src/llm/engram.zig)¶
pub const Config = struct { hidden_size: usize, hc_mult: usize = 1, max_ngram_size: usize = 3, ... };
pub const HashPlan = struct { multipliers: []i64, head_mods: []i64, head_offsets: []usize, table_rows: []usize, ... };
pub const Layer = struct { table: Table, key_w: []Proj, value_w: Proj, conv_w: ConvKernel, ... };
pub const Engram = struct { plan: HashPlan, layers: []Layer, registry: fucina.ParamRegistry, ... };
pub const Hidden = fucina.Tensor(.{ .seq, .stream, .d });
pub const Error = error{ InvalidConfig, InvalidHashInput, ExecScopeRequired };
Engram (arXiv 2601.07372, deepseek-ai/Engram demo semantics, pinned in
tools/fetch_refs.sh) is conditional n-gram memory: capacity bought with
LOOKUP instead of FLOPs. Per selected layer, suffix n-grams of the
(compressed) token ids are hashed by multiplicative-XOR heads into
prime-sized embedding tables; the retrieved rows, gated per
hyper-connection stream by an RMS-normed key·query dot with a signed-sqrt
squash, join the residual stream after a dilated causal depthwise
ShortConv (§4.16). Because every table address is a pure function of token
ids, all lookups are known BEFORE the forward pass — tables can live
out-of-core and be prefetched with zero speculation.
The split of responsibilities:
HashPlan— pure integer geometry, no tensors: per-layer odd multipliers (native draws, or injected viainitWithMultipliersfor bit-parity with reference artifacts; persisted with the checkpoint either way), the global-seen-set prime chain for head table sizes, the optional tokenizer-compressionlookup, and the hash itself.hashIntois the host fast path;hashTensoris the same computation as integer tensor ops (wrappingmul,bitXor, flooredmod, broadcastadd— §4.19), pinned bit-equal in tests. Row indices come back offsets-included, ready forgatheralong the table's row axis.Layer— the parameters and the differentiable forward ([seq, stream, d]in and out; the caller adds the residual).forwardResidualwraps thehc_mult == 1case for plain residual-stream models.conv_statestreams the ShortConv across chunks exactly likecausalDepthwiseConv1d's state (§4.16).Engram— the whole-model wrapper: shared plan, oneLayerperlayer_idsentry, every parameter in aParamRegistry(engram.layers.<id>.*; the multipliers as a frozen i64 entry), state dict save/load.
InitOptions.graft_zero_init zero-initializes the value projection so
the module's output is EXACTLY zero: grafting Engram onto a frozen
pretrained model is bitwise identity at step 0, while gradients still
reach every parameter through the value path — the cheap-experiment mode
for adding memory to an existing checkpoint. Numerical parity with the
reference mechanism (forward + every gradient) is pinned by
src/llm/engram_golden_tests.zig, generated by
tools/gen_engram_goldens.py (an independent PyTorch/numpy
implementation; integer geometry compares EXACTLY, floats under the
shared golden tolerance). Design record: docs/ENGRAM.md.
The qwen3 trainer carries the graft seam: ForwardOptions.engram =
.{ .model, .rows } injects each configured layer's memory output into
the residual stream before attention (plain path only; composes with
cartridge; rejected with packed_segments — the ShortConv is causal
over the packed row), and lossForwardExt(ctx, tokens, labels, fwd,
loss_opts) is the CE loss entry taking full ForwardOptions.
examples/engram/main.zig (zig build engram,
README) drives it: --equiv
(bitwise zero-init gate on a real GGUF), --train/--eval (frozen
trunk, held-out chunk CE, --lora N, --no-engram control,
--gate-bias F), --probes N (verbatim-recall spans, teacher-forced
CE + exact-match, engram detached vs attached).
test "engram: hashed n-gram memory with a zero-init graft gate" {
const alloc = std.testing.allocator;
var ctx: fucina.ExecContext = undefined;
ctx.init(alloc);
defer ctx.deinit();
const cfg = llm.engram.Config{
.hidden_size = 8,
.hc_mult = 1,
.n_embed_per_ngram = 4,
.n_head_per_ngram = 2,
.engram_vocab_size = &.{ 11, 13 },
.kernel_size = 2,
.pad_id = 0,
};
var plan = try llm.engram.HashPlan.init(alloc, cfg, &.{0}, 42, null);
defer plan.deinit();
// Addresses are a pure function of token ids — known pre-forward.
const ids = [_]i64{ 3, 1, 4, 1, 5 };
var rows: [5 * 4]usize = undefined;
try plan.hashInto(0, &ids, &rows);
for (rows) |row| try std.testing.expect(row < plan.table_rows[0]);
var layer = try llm.engram.Layer.initRandom(&ctx, alloc, cfg, plan.table_rows[0], 7, .{ .graft_zero_init = true });
defer layer.deinit();
var hidden = try fucina.Tensor(.{ .seq, .d }).fromSlice(&ctx, .{ 5, 8 }, &([_]f32{0.25} ** 40));
defer hidden.deinit();
const scope = ctx.openExecScope();
defer ctx.closeExecScope(scope);
var out = try layer.forwardResidual(&ctx, &hidden, &rows, null);
defer out.deinit();
// Zero-init value projection: grafting is exact identity at step 0.
for (out.asRawTensor().dataConst()) |v| try std.testing.expectEqual(@as(f32, 0), v);
}
compiled & run in CI ✓