← Architecture map · Backend

Inference Backend

Owns the model. The only place in the system that knows what a tensor is. Everything above it talks in token IDs.

The InferenceBackend interface

Defined in backends/base.py. Anything the scheduler does to "the model" goes through these methods.

Continuous-batch primitives (the new path) prefill_lookup(tokens, sid) cache check only — no forward pass → (matched_tokens, partial_kv | None) prefill_chunk(chunk_tokens, partial_kv) push N more prompt tokens through the model → (extended_kv, last_sampled_token, kv_len) prefill_store(tokens, full_kv, matched, sid) register the new KV in the cache for the suffix decode_step_batched(curr_tokens, kv, mask, pos) one batched forward pass for every active row → (next_tokens [B], updated_kv) splice_into_batched(...) · remove_row_from_cache(...) add or remove a row from the batched KV in-place kv_length(kv) · is_eos(token_id) · device_str Legacy single-request methods (kept for tests, simulator non-batched mode) load_model(model_name) called once at startup generate(token_ids, max_tokens, sid) whole response in one call generate_batch(batch_tokens, max_tokens, sids) old fixed-window batched path — still used by some scripts stream(token_ids, max_tokens, sid) async generator yielding tokens one at a time set_cache_adapter(cache_manager) handed the cache reference at startup → The scheduler only uses the primitives on the left. The methods on the right exist so older tests / scripts don't break. They route through the same backend.

Prefill primitives — what each one means

prompt tokens arrive say [t1, t2, t3, t4, t5, t6, t7] prefill_lookup "how many of these are already cached?" say first 3 are cached partial_kv covers [t1, t2, t3] need to add [t4..t7] that's the work to do prefill_chunk(chunk, partial_kv) runs one forward pass on the chunk starts with partial_kv as past_key_values extends it with the chunk's K/V state if PREFILL_CHUNK_SIZE = 2: tick N: chunk = [t4, t5] → kv covers t1..t5 tick N+1: chunk = [t6, t7] → kv covers t1..t7 also returns the sampled last token → that's the first generated token, ready for decode prefill_store(tokens, full_kv, matched=3, sid) writes the suffix's KV into the cache skip the first matched tokens (already cached) slice K and V tensors from the model's pool format into block-sized chunks copy each chunk into the BlockManager's pre-allocated layer pools at the allocated block IDs register in radix tree + exact-match index next time anyone asks for [t1..t7], full hit last_cache_hit_tokens backend stamps this on the response so the user (and metrics) can see how much was a cache hit.

decode_step_batched — the hot path

This is what runs every tick. Make it fast and the whole engine gets fast.

_active rows B requests, each with its own KV state at different sequence lengths stack_caches_left_padded aligns KV from rows of different lengths into one rectangular tensor build attention mask shape [B, S] zeros at the padding, ones elsewhere build position_ids shape [B, 1] each row's real next position model.forward(input_ids=[B,1], past_key_values=batched_kv, attention_mask, position_ids) one big forward pass — every active request gets one new token at the same time sample per-row (temp/top-k/top-p) → next_tokens [B] each row gets its token streamed back to its client; metrics record TPOT why left-padding if row A's KV has length 50 and row B's has length 200, we need them in one tensor of shape [B, 200]. pad row A on the left with 150 zeros (and mask them out). this makes new positions line up on the right edge, which is what causal attention assumes. right-padding would put padding in the middle of useful tokens. remove_row_from_cache then just slices the row out of the batched KV when its request finishes.

Batch operations (in-place row management)

MethodWhat it does
splice_into_batched(batched_kv, new_kv, kv_len)Adds a row to the batched KV (after prefill finishes). Left-pads to fit.
remove_row_from_cache(batched_kv, row_idx)Drops a row in-place when a request finishes. The remaining rows stay aligned.
stack_caches_left_padded(rows)Builds the batched KV from a list of per-row KVs at the start of decode_step.
kv_length(kv)How many real (non-padded) tokens this KV represents.

Hardware detection

Auto-pick the best available accelerator at startup. Manual override via DEVICE env var.

CUDA available? torch.cuda.is_available() → best, NVIDIA GPUs MPS available? Apple Silicon GPU → dev default on M-series CPU fallback always works, slow → tests and CI override: DEVICE=... force a specific device for benchmarks or to compare backends head-to-head at startup the backend prints what it picked plus available memory Phase 7 will extend this to auto-size KV cache to fit the detected memory budget

Custom-forward Torch backend (M1–M2.4 + paged-attention kernel)

backends/custom_torch_backend.py + models/gemma4.py + models/paged_attention_kernel.py. Hand-written Gemma 4 forward — byte-identical to HF on short prompts, correct (incl. sliding window) on long. Scheduler-driven with paged KV, prefix sharing, and a Triton decode kernel.

HF-Transformers Torch backend (original)

backends/torch_backend.py. Wraps a HuggingFace causal LM.

MLX backend (Apple-native, limited)

backends/mlx_backend.py. Built on mlx_lm.stream_generate.

Known limitation: mlx_lm.stream_generate owns its own internal KV cache and exposes no hook to plug ours in. The MLX backend accepts a session_id but ignores cache lookup. Continuous-batch primitives are not implemented here. MPS via the torch backend is the primary path on Apple Silicon. A real MLX continuous-batch backend would have to be written directly against mlx.core — listed as a future extension in CLAUDE.md.

See also: Scheduler for who calls these methods · KV Cache for how blocks become DynamicCache and back.