Continuous batching perf: fused tick NIF, cross-slot prefix caching, server-routed chat completions, cancellation#66
Merged
Conversation
…1.1) One dirty-CPU NIF per Server tick: decode + per-seq sampler sample + token_to_piece + EOG check. On llama_decode==1, purges caller-designated idle seqs then recursively halves the batch, sampling each half before the next decode invalidates logits.
One dirty NIF per tick replaces batch_eval + per-slot sampler_sample_at + per-token token_to_piece + vocab_is_eog. Streaming sends move out of strategy side effects into post-NIF result handling and fire one tick earlier. Slots track pending_eog from the NIF; max_tokens completion no longer feeds the discarded final token. KV-pressure purges surface as [:llama_cpp_ex, :server, :kv_pressure] telemetry.
sampler_sample, sampler_sample_at, chat_apply_template_jinja, json_schema_to_grammar_nif, and speculative_init all exceed the ~1ms normal-scheduler guideline (full-vocab softmax, grammar eval, minja rendering, context probing).
LlamaContext now owns a grow-on-demand batch used by batch_eval, batch_eval_sample, decode_token, and prefill instead of llama_batch_init/free per call. Single-driving-process invariant documented in llama_nif.h.
Completes the llama-server KV-pressure policy: after purging idle caches and halving the batch, a sequence that cannot fit one token fails alone with {:error, :context_full} — other slots keep generating and the server stays up. Previously any decode failure killed every active request.
Bounds worst-case tick latency instead of letting one huge prompt monopolize a full-n_ctx pass; matches llama.cpp's common default. Tradeoff documented on the option.
Request opts flow through calls, the queue, and into the slot (llama-server semantics: per-request, default true). A cache_prompt: false request neither reuses nor retains slot KV.
Replaces pure longest-LCP with llama-server's rule: best cached match wins only when LCP/prompt_len > 0.1, else the least-recently-used idle slot takes the request — protecting long caches from tiny unrelated prompts. Adds t_last_used slot metadata; dedups the all-slots failure path through fail_slot/3.
New :session request option; the server keeps a session→slot map, acquire prefers the session's slot when idle (overriding similarity), and freed slots serve queued requests session-first before FIFO. Verified: two interleaved conversations each stick to their slot and hit their cached prefix on turn 2.
Plumbs kv_unified through context_create (Context :kv_unified option); Server defaults it to true and, when the model also supports partial seq_rm, adopts the longest matching prefix from ANY slot (idle or generating) with a metadata-only seq_cp before prefilling the remainder. A shared system prompt now prefills once, ever, instead of once per slot. Gating: split-mode partial cross-stream seq_cp GGML-aborts and hybrid-GDN recurrent state cannot be partially copied, so sharing requires kv_unified AND seq_rm :part; other configs keep per-slot caching. Verified: busy-donor adoption (279/288 tokens) on Llama-3.2-1B; hybrid Qwen3.5 unaffected; kv_unified true shows no throughput regression on a zero-shared-prefix concurrent workload (653 vs 672 ms).
New state_seq_get_size/get_data/set_data NIFs (dirty; zero-copy binary in/out) and an opt-in Server byte budget (:prompt_cache_ram_mb, default 0). A slot cache about to be destroyed or truncated is serialized to RAM (min 32 tokens, dedup against covering entries, skip if larger than the whole budget); restores compete with own-slot and donor matches by prefix length and require llama-server's f_keep >= 0.25. FIFO eviction; [:llama_cpp_ex, :server, :ram_cache] telemetry + get_stats fields. Found during testing: a tiny incidental own-match (2 tokens) used to trim away a 147-token cache and block restores — own/donor/RAM candidates now compete by match length with cost-ordered tie-breaking.
A cache-eligible request matching 10-50% of a slot's cached history signals a template rewriting earlier turns (thinking-strip etc.) — previously a silent near-full re-prefill. Per-request [:llama_cpp_ex, :server, :prefix_instability] event; once-per-server log warning.
…e shape 8 interleaved conversations x 4 turns on 4 slots with a shared system prompt. Phase 2 result on Llama-3.2-1B: TTFT median 108.7 -> 27.8 ms, 76.2% mean prefix-cache hit ratio (0% with cache off). StubServer updated for the req_opts call tuple from 2.1.
Samplers are now created at slot-init from request opts merged over server defaults — :temp, :seed, :grammar etc. work per request, with clean grammar state and a fresh seed each time (the old per-slot sampler is dropped to GC). Also fixes a latent hang exposed by cache_prompt defaulting true: a prompt matching its cached prefix ENTIRELY left nothing to prefill and no logits to sample — the slot stalled until the call timeout. All three cache sources (own/donor/RAM) now cap reuse at prompt_len - 1 so the last prompt token is always re-decoded (llama-server's n_past-- rule).
The Server caches its %Model{} in :persistent_term at init (erased in terminate; trap_exit for reliable cleanup). Text-API generate/stream now encode client-side and use the pre-tokenized path — tokenization runs parallel across callers instead of serializing on the server mailbox, and the text handle_call variants are gone.
chat_completion and stream_chat_completion accept a running Server (pid/name): templating + tokenization happen in the caller, generation uses the batching server with prefix caching and :session affinity. New Server.complete_tokens/3 returns {text, completion_tokens, finish_reason}; the stream protocol now carries {:done, stop_reason}. ModelManager gains chat_completion/stream_chat_completion routing. Stateless %Model{} paths unchanged.
Verified: turn-2 completion reused 55/60 prompt tokens from the server prefix cache; streamed chunks carry correct finish_reason.
The documented-but-never-enforced :max_queue bound now rejects calls with {:error, :queue_full} immediately (1 ms in test vs silence-then-timeout before), and streams emit a single {:error, reason} element. Stream error handling unified in shared stream_next/stream_cleanup helpers; mid-generation failures also surface as error elements now. Default stays 0 (unlimited) for compatibility.
Server: consumers are monitored per slot — a dead or explicitly cancelled consumer (Server.cancel/2, called automatically by stream cleanup on early halt) frees the slot immediately with stop_reason :cancelled instead of generating to max_tokens; queued requests from dead consumers are dropped; a slot cancelled mid-prefill caches only its fed prefix. Stateless: new CancelFlag NIF resource (atomic bool) threaded through generate, generate_tokens, and generate_mtp_tokens — polled per iteration and installed as llama_set_abort_callback so even a long prefill aborts mid-decode (ret==2). Stream halts set the flag before killing the generator process, freeing the dirty scheduler promptly. Verified: halted server stream cancelled after 4/400 tokens; dead consumer detected via monitor; stateless halt returned in 40 ms.
…s to caller-side tokenization Phase 3 numbers on Llama-3.2-1B: 4-turn chat_completion 345 ms stateless vs 219 ms server-routed (1.6x); 8 requests with half abandoned after 3 chunks complete in 346 ms (pre-cancellation, each abandoned stream would have decoded 200 tokens to completion). ModelManager routing unit tests now assert via route/1 + the raw tokens protocol since Server.generate tokenizes in the caller.
…ces (4.1, 4.2) get_embeddings returns one refc binary (16 KB for 4096 dims) instead of ~100 KB of boxed doubles; the Elixir wrapper decodes to a list by default and format: :binary passes it through for Nx.from_binary. embed_decode/embed_batch_decode now memory_seq_rm only the sequences they are about to reuse instead of clearing the whole context — safe if ever pointed at a shared context. Verified: L2 norm exactly 1.0, 3-text batch, 4096-byte binary.
New UTF8Stream pending-buffer helper: emits only whole codepoints, holds back a split multibyte tail, passes never-valid bytes through, flushes on completion. Wired into the Server's per-slot piece emission (client streams and the thinking parser now always see valid UTF-8) and both stateless Stream.resources. Verified: 30-chunk emoji stream with zero invalid chunks and byte-identical stream-vs-generate output; 7 unit tests.
Ports decode from llama_batch_get_one (logits for the last token of EVERY n_batch chunk) to the explicit persistent batch with logits only on the final token. Positions continue seq 0 from seq_pos_max, preserving append semantics for repeated calls.
W1: stream_tokens now rejects empty token lists with {:error, :empty_prompt} like generate_tokens (previously hung the consumer until stream timeout). W2: failure replies normalized to atoms/tagged tuples ({:inference_failed, reason}, :context_full, :queue_full, :empty_prompt). W3: RAM-cache eviction gets a defensive empty-cache clause.
Tests: new test/server_test.exs (12 unit tests over the pure slot-pick/session/donor/RAM-cache logic, helpers promoted to @doc false public like common_prefix_length), and test/server_smoke_test.exs (6 :smoke integration tests promoted from session smoke scripts: empty-prompt guard, per-request cache_prompt semantics, session affinity, max_queue rejection, stream-halt cancellation, context-overflow isolation). Full suite: 252 passed.
CHANGELOG gains the v0.9.0 entry (Added/Changed/Fixed with headline numbers). README: prefix-caching section rewritten for cross-slot sharing/session affinity/RAM cache defaults, new sections for server-routed chat completions and backpressure/cancellation.
Replaces the 0.9.0 bump: version goes to 0.8.33 per maintainer preference; changelog section retitled v0.8.33; README install snippet restored to ~> 0.7.5.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
End-to-end performance and robustness work on the batching
Serverand the statelessgeneration paths, implemented from the
perf-batching-prefix-cacheplan (22 tasks,4 phases + benchmarks). Baseline and final numbers are committed under
bench/results/.Headline (Llama-3.2-1B, 8 interleaved conversations × 4 turns on 4 slots, shared
system prompt —
bench/prefix_cache_concurrent.exs):Also: multi-turn
chat_completionthrough the Server is 1.6× faster than thestateless path (346 → 211 ms / 4 turns); abandoned streams now cost ~4 tokens instead
of decoding to
max_tokens; server_concurrent throughput +11–21% vs baseline.Phase 1 — Server hot loop & scheduler hygiene
batch_eval_sample: one dirty-CPU call per tick does decode,per-slot sampling (per-entry sampler resources — no shared-sampler resets),
detokenization, and EOG checks. Replaces
batch_eval+ 2 normal-scheduler NIFs pergenerating slot + per-token
token_to_piece/vocab_is_eog. Streaming sends movedout of strategy side effects to post-NIF handling and fire one tick earlier.
llama_decode == 1, purge idleslots' cached KV, then recursively halve the batch; a sequence that can't fit one
token fails alone with
{:error, :context_full}— other slots keep generating.llama_batchreused across decode NIFs;sampler_sample,sampler_sample_at,chat_apply_template_jinja,json_schema_to_grammar,speculative_initdirty-flagged; Servern_batchdefault →min(n_ctx, 2048).Phase 2 — Prefix caching that hits under concurrency
cache_prompt, server default flipped totrue(llama-server parity).memory_seq_cpunder unified KV (kv_unified: truenew Server default; plumbed through
Context.create): a shared system promptprefills once, ever — any slot adopts it with a metadata-only copy, even from a
still-generating donor. Gated on
kv_unified+ partial-seq_rmsupport: split-modepartial cross-stream
seq_cphard-aborts (verified in vendored source) and hybrid-GDNrecurrent state can't be partially copied, so those configs keep per-slot caching.
request no longer evicts a long cache. Session affinity:
session:requestoption pins conversations to their slot (queue served session-first).
prompt_cache_ram_mb, default 0 = off): evicted slotstates are serialized via new
llama_state_seq_*NIFs and restored later instead ofre-prefilling; byte budget checked before copying (degrades to disabled, never OOMs).
history (template rewrote history, e.g. thinking-strip) emit
[:llama_cpp_ex, :server, :prefix_instability]— previously a silent full re-prefill.Phase 3 — Chat completions through the Server; load protection
:temp,:seed,:grammar, …): fresh sampler perrequest from request opts merged over server defaults.
chat_completion/stream_chat_completionaccept a running Server (also viaModelManager): templating + tokenization in the caller, generation on the batchingserver with prefix caching. New
Server.complete_tokens/3returns{text, completion_tokens, finish_reason}.%Model{}is cached in:persistent_term;get_model/1no longer round-trips the server mailbox.:max_queuebackpressure (finally enforced): bounded queue rejects with{:error, :queue_full}in ~1 ms; streams emit a single{:error, reason}elementinstead of silence-then-timeout.
free their slot immediately (
Server.cancel/2, auto-called by stream cleanup).Stateless loops (
generate,generate_tokens, MTP) poll a newCancelFlagNIFresource and install it as
llama_set_abort_callback, so even a long prefill abortsmid-decode.
Phase 4 — Copies, allocation, streaming correctness
floats per 4096-dim call; list API preserved,
format: :binaryforNx.from_binary.LlamaCppEx.UTF8Stream) at everyemission point — consumers never see a codepoint split across token pieces.
decodeported offllama_batch_get_one(no more logits for the last token of everychunk). The i32-token-binary spike measured 0.012% and was skipped per the plan's
own ≥-a-few-% criterion.
Notable behavior changes
cache_promptdefaults totrue;kv_unifieddefaults totrue(slots share thefull
n_ctxbudget instead ofn_ctx/n_paralleleach);n_batchdefaults tomin(n_ctx, 2048).:context_full,:queue_full,:empty_prompt,{:inference_failed, reason}.String.t() | {:error, term()}; a hard error kills only theaffected request, not all active slots.
Verification
mix test --include smokewith real models),--warnings-as-errors,mix format,credo --strictclean.UTF8Streamunit tests, pure slot-pick/donor/RAM-cache unit tests(
test/server_test.exs), and 6:smokeintegration tests (cache semantics, sessionaffinity, backpressure, cancellation, overflow isolation).
findings fixed in the final commit. Review doc:
.claude/plans/perf-batching-prefix-cache/reviews/perf-batching-prefix-cache-review.md.bench/results/v0.8.32-e6e1ef1-baseline-perf-m1max.md) andfinal (
bench/results/v0.8.32-perf-branch-final-m1max.md), plus two new benchscripts (
prefix_cache_concurrent.exs,chat_completion_server.exs).Deferred (per plan non-goals)
Multi-seq-token prefill,
cache_reusechunk splicing, context shift on overflow,on-disk prompt cache, GPU backend sampling, stateless-path context pooling.