Skip to content

Continuous batching perf: fused tick NIF, cross-slot prefix caching, server-routed chat completions, cancellation#66

Merged
nyo16 merged 32 commits into
masterfrom
perf/batching-prefix-cache
Jul 6, 2026
Merged

Continuous batching perf: fused tick NIF, cross-slot prefix caching, server-routed chat completions, cancellation#66
nyo16 merged 32 commits into
masterfrom
perf/batching-prefix-cache

Conversation

@nyo16

@nyo16 nyo16 commented Jul 6, 2026

Copy link
Copy Markdown
Owner

Summary

End-to-end performance and robustness work on the batching Server and the stateless
generation paths, implemented from the perf-batching-prefix-cache plan (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):

Mode TTFT median Hit ratio
cache off 115.0 ms 0%
per-slot cache 39.0 ms 75.6%
cross-slot + session affinity 35.5 ms (3.2×) 74.8%

Also: multi-turn chat_completion through the Server is 1.6× faster than the
stateless 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

  • Fused tick NIF 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 per
    generating slot + per-token token_to_piece/vocab_is_eog. Streaming sends moved
    out of strategy side effects to post-NIF handling and fire one tick earlier.
  • KV-pressure policy (llama-server parity): on llama_decode == 1, purge idle
    slots' 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.
  • Persistent llama_batch reused across decode NIFs; sampler_sample,
    sampler_sample_at, chat_apply_template_jinja, json_schema_to_grammar,
    speculative_init dirty-flagged; Server n_batch default → min(n_ctx, 2048).

Phase 2 — Prefix caching that hits under concurrency

  • Per-request cache_prompt, server default flipped to true (llama-server parity).
  • Cross-slot prefix sharing via memory_seq_cp under unified KV (kv_unified: true
    new Server default; plumbed through Context.create): a shared system prompt
    prefills once, ever — any slot adopts it with a metadata-only copy, even from a
    still-generating donor. Gated on kv_unified + partial-seq_rm support: split-mode
    partial cross-stream seq_cp hard-aborts (verified in vendored source) and hybrid-GDN
    recurrent state can't be partially copied, so those configs keep per-slot caching.
  • Slot pick: best cached match only when LCP/prompt_len > 0.1, else LRU — a tiny
    request no longer evicts a long cache. Session affinity: session: request
    option pins conversations to their slot (queue served session-first).
  • Level-2 RAM prompt cache (prompt_cache_ram_mb, default 0 = off): evicted slot
    states are serialized via new llama_state_seq_* NIFs and restored later instead of
    re-prefilling; byte budget checked before copying (degrades to disabled, never OOMs).
  • Prefix-instability telemetry: requests matching only 10–50% of a slot's cached
    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

  • Per-request sampling params (:temp, :seed, :grammar, …): fresh sampler per
    request from request opts merged over server defaults.
  • chat_completion/stream_chat_completion accept a running Server (also via
    ModelManager): templating + tokenization in the caller, generation on the batching
    server with prefix caching. New Server.complete_tokens/3 returns
    {text, completion_tokens, finish_reason}.
  • Caller-side tokenization: the Server's %Model{} is cached in :persistent_term;
    get_model/1 no longer round-trips the server mailbox.
  • :max_queue backpressure (finally enforced): bounded queue rejects with
    {:error, :queue_full} in ~1 ms; streams emit a single {:error, reason} element
    instead of silence-then-timeout.
  • Cancellation: consumers are monitored per slot; dead or halted stream consumers
    free their slot immediately (Server.cancel/2, auto-called by stream cleanup).
    Stateless loops (generate, generate_tokens, MTP) poll a new CancelFlag NIF
    resource and install it as llama_set_abort_callback, so even a long prefill aborts
    mid-decode.

Phase 4 — Copies, allocation, streaming correctness

  • Embeddings as f32 binaries: one 16 KB refc binary instead of ~100 KB of boxed
    floats per 4096-dim call; list API preserved, format: :binary for Nx.from_binary.
  • Embedding decodes clear only their own sequences (no more whole-context clears).
  • UTF-8-safe streaming: a shared pending-buffer (LlamaCppEx.UTF8Stream) at every
    emission point — consumers never see a codepoint split across token pieces.
  • decode ported off llama_batch_get_one (no more logits for the last token of every
    chunk). The i32-token-binary spike measured 0.012% and was skipped per the plan's
    own ≥-a-few-% criterion.

Notable behavior changes

  • cache_prompt defaults to true; kv_unified defaults to true (slots share the
    full n_ctx budget instead of n_ctx/n_parallel each); n_batch defaults to
    min(n_ctx, 2048).
  • Error replies normalized to atoms/tagged tuples: :context_full, :queue_full,
    :empty_prompt, {:inference_failed, reason}.
  • Stream elements are String.t() | {:error, term()}; a hard error kills only the
    affected request, not all active slots.

Verification

  • 252 tests pass, 0 failures (mix test --include smoke with real models),
    --warnings-as-errors, mix format, credo --strict clean.
  • New tests: UTF8Stream unit tests, pure slot-pick/donor/RAM-cache unit tests
    (test/server_test.exs), and 6 :smoke integration tests (cache semantics, session
    affinity, backpressure, cancellation, overflow isolation).
  • Parallel specialist review: PASS WITH WARNINGS, requirements 22/22 MET; all
    findings fixed in the final commit. Review doc:
    .claude/plans/perf-batching-prefix-cache/reviews/perf-batching-prefix-cache-review.md.
  • Benchmarks: baseline (bench/results/v0.8.32-e6e1ef1-baseline-perf-m1max.md) and
    final (bench/results/v0.8.32-perf-branch-final-m1max.md), plus two new bench
    scripts (prefix_cache_concurrent.exs, chat_completion_server.exs).

Deferred (per plan non-goals)

Multi-seq-token prefill, cache_reuse chunk splicing, context shift on overflow,
on-disk prompt cache, GPU backend sampling, stateless-path context pooling.

nyo16 added 30 commits July 4, 2026 16:26
…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.
nyo16 added 2 commits July 5, 2026 22:50
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.
@nyo16 nyo16 merged commit 55a06ef into master Jul 6, 2026
4 checks passed
@nyo16 nyo16 deleted the perf/batching-prefix-cache branch July 6, 2026 03:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant