Execution contract
How a loaded .model turns into output tokens / audio samples /
image pixels. The runtime's public API and internal dispatch rules.
Load phase
model = cyb_llm::load(path)
Steps:
- mmap the
.modelfile (read-only). - Parse text sections (config, tensors, vocab).
- Validate config schema and tensor completeness.
- Identify execution path:
model_typematches curated family → curated- else if
graphsection present → graph - else → error
- Instantiate curated family code OR graph executor.
- Upload weights to backend (or defer to first forward for lazy GPU).
- Pre-compute position caches (cos/sin for RoPE).
Load must be idempotent: loading same file twice produces equivalent models (weights may or may not share GPU memory depending on backend).
Forward phase
Text generation
generator = model.generator()
for tokens in generator.generate(prompt, max_tokens=N, sampling=...):
...
Under the hood:
tokens = tokenizer.encode(apply_chat_template(prompt))
for t in tokens:
logits = model.forward(&[t]) # prefill: one call per prompt token
for step in 0..max_tokens:
next_token = sample(logits, sampling)
if eos_tokens.contains(next_token): break
emit(next_token)
logits = model.forward(&[next_token]) # decode: append to KV
Prefill and decode use the same forward function with seq_len=1. Optimization: bulk prefill (seq_len > 1) available but must produce identical output to token-by-token (ε tolerance in ops.md).
Encoder (embeddings / classification)
h = model.encode(tokens) # returns final hidden states
emb = pool(h, method) # cls / mean / max
# or
logits = model.classify(tokens) # returns classification logits
Whisper (ASR)
mel = audio_to_mel(samples, model.config)
h_enc = model.encoder(mel)
tokens = model.decode(h_enc, prompt_tokens=None) # autoregressive decoder
text = tokenizer.decode(tokens)
Diffusion (image/video)
latent = randn(shape=model.config.latent_shape)
for t in noise_schedule:
noise_pred = model.forward(latent, t, text_emb)
latent = denoise_step(latent, noise_pred, t)
image = vae.decode(latent)
TTS
phonemes = text_frontend(text)
mel = model.text_to_mel(phonemes, speaker_id=...)
audio = model.mel_to_audio(mel) # vocoder
Backend dispatch
Each op dispatched to a backend. The chosen backend for a given op:
Platform default
| Platform | Default |
|---|---|
| macOS + Apple Silicon | honeycrisp |
| macOS + Intel | wgpu+rs |
| Linux / Windows / Android / Web | wgpu+rs |
Fallback chain
For a single op, given the chosen default backend:
- Try default backend's
supports(op, inputs). If yes → execute there. - If default is honeycrisp and rejects, try wgpu+rs.
- If wgpu+rs rejects, fall back to CPU reference library.
- CPU reference implements every op — step 3 always succeeds.
The runtime may report per-op dispatch decisions to observability so heavy CPU fallback is visible.
User override
cyb-llm run --backend=wgpu+rs|honeycrisp|nox forces the entire
execution to a single backend. Override skips steps 1-2; if op is
not supported, fall-through is still to CPU library (step 3).
--backend=nox only works if model has ~~~graph section (graph
executor is Tier 1 for nox, not curated).
Backend trait
Memory lifecycle
Weights (long-lived)
Allocated at load time. Persistent for the lifetime of the model instance. Freed when model dropped.
KV cache (per-conversation)
Allocated at conversation start, sized to max context. Appended to
on each decode step. Cleared on reset_kv_cache().
Activations (per-step)
Frame allocator: per-backend pool of buffers keyed by (size, usage).
On each forward call, the allocator hands out buffers from the pool
or allocates new ones. At the start of the NEXT forward call, all
buffers from the previous call become available for reuse (they're
guaranteed unused by pending GPU work if dispatch was complete).
Sizing policy:
- Buckets by exact byte size. No over-allocation to "next power of 2" — different shapes in different layers produce many bucket sizes, but reuse within a size is perfect.
- On reset, all in-use buffers move back to the pool. Pool grows monotonically to the steady-state size, then flat.
- Typical footprint for a 7B decoder at bs=1: ~200 MB of pool.
Separate pools per BufferUsage (STORAGE vs UNIFORM on wgpu;
IOSurface vs shared memory on honeycrisp).
See llm/src/backend/wgpu/alloc.rs for the existing wgpu
implementation. Honeycrisp allocator follows the same design with
IOSurface-backed buffers from aruminium.
Position caches
Cos/sin cache for RoPE: allocated at load, sized to
max_position_embeddings × half_dim. Read-only during forward.
Error semantics
Errors are structured, not strings:
Every error is actionable — the user can tell which backend, which
op, which tensor. No "internal error" or generic panic.
Silent corruption is a BUG. If a backend returns wrong output without erroring, that's higher severity than a missing-op error (which at least surfaces).
Invariants across paths
Running the same model with the same input through different paths must produce outputs within the tolerances in ops.md:
output_curated ≈ output_graph ≈ output_nox (within ε)
output_wgpu+rs ≈ output_honeycrisp (within ε)
A divergence larger than ε between paths or backends indicates a bug in one of them. The CPU reference is the arbiter.
Concurrency
Multiple conversations
Each conversation has its own KV cache. Multiple cached conversations can share one loaded model (same weights, separate KV).
Not supported in v1: batched inference (multiple prompts processed in one forward pass). Future.
Thread safety
Backend is Send + Sync. Running inference from multiple threads
on different models is safe. Running multiple concurrent forwards on
the same model is ALLOWED only if each has its own KV cache; shared
KV is a race.
Observability
Every forward exposes timing breakdown (load-time, prefill-ms, per-step-ms) for performance analysis. Backends log which op went to which device (e.g., "layer 5 attention: honeycrisp.ane, FFN: honeycrisp.gpu").
Shutdown
drop(model)
Frees all GPU/backend memory. Must be idempotent. No leaks.
Fused op policy
Fused ops (ops.md) are optional optimizations. Policy:
Curated paths
Each curated family codepath explicitly emits fused ops where beneficial. The author knows the architecture and writes fused calls directly:
let = fused_skip_norm;
No auto-detection. Curated code is hand-tuned; the author chose the fusion.
Graph path
The graph executor runs a pre-execution fusion pass that rewrites the graph. Recognized patterns:
| Pattern | Fused op |
|---|---|
RmsNorm → Matmul |
FusedNormMatmul |
Add → RmsNorm (both outputs used) |
FusedSkipNorm |
Matmul → Silu followed by Matmul + Mul |
FusedSwiGlu |
Sdpa (when backend has FlashAttention) |
FlashAttention |
Detection is purely structural: a subgraph matches the pattern's
shape. If the backend reports supports(FusedSkipNorm, ...) is
true, the fused node replaces the sub-DAG. Else stays unfused.
Fusion is applied at graph-load time, once. Subsequent forwards use the fused graph.
Correctness
Every fused op must produce the same output as its unfused equivalent within ε (ops.md). Violation is a backend bug — the graph executor can fall back to unfused.
Extension points
Adding a new backend: implement Backend trait, register in dispatch
table. The runtime automatically routes supported ops. Curated
codepaths opt in per-family.
Adding a new op: add Op enum variant, math spec in ops.md,
CPU reference implementation, at least one backend implementation.
Version bump per scope.md.
Adding a new model family: write a curated codepath implementing the
graph from arch.md. Register the model_type string(s)
that dispatch to it.