Skip to content

Engine: share prefill/decode work buffers across sessions#515

Open
iCreil wants to merge 1 commit into
antirez:mainfrom
iCreil:feat/engine-shared-scratch
Open

Engine: share prefill/decode work buffers across sessions#515
iCreil wants to merge 1 commit into
antirez:mainfrom
iCreil:feat/engine-shared-scratch

Conversation

@iCreil

@iCreil iCreil commented Jul 7, 2026

Copy link
Copy Markdown

What

Split ds4_gpu_graph into per-session state and a new engine-owned ds4_gpu_scratch that holds the transient work buffers: the one-token decode tensors, the per-layer stage tensors, the MTP draft tensors and the batched prefill buffers. Everything in the scratch is written and consumed inside a single ds4_session_sync()/ds4_session_eval() call — no content survives across calls — so sessions sharing one engine can also share one scratch, as long as they are driven from one thread at a time. That is exactly the serialization the server's single graph worker already provides, and the contract is now documented in ds4.h next to ds4_session_create().

Persistent state stays where it was, in the graph owned by each session: KV caches, compressor/indexer frontiers and rows, MTP raw cache and verification/prefix1 state.

Why

The scratch is what makes a second live session affordable. The batched prefill buffers dominate a session's non-KV footprint (~1.8 MiB per prefill-chunk token, ≈3.6 GiB at --prefill-chunk 2048), and today every session pays for its own copy. With the scratch on the engine, a second session costs only its KV cache and raw SWA rows.

This is intended as the first groundwork step toward the "stateful session-based protocol" direction mentioned in the README: it makes N sessions per engine practical without touching the server at all (a follow-up PR builds on it).

How

  • ds4_gpu_scratch is created lazily by the first ds4_session_create() and grown to the union of the attached sessions' capacities (prefill_cap, comp_cap, MTP on/off); it is freed with the engine. A refcount tracks attached sessions (the engine warns if closed while sessions are live).
  • metal_graph_alloc_raw_cap() takes the scratch as a parameter and only allocates per-session state; sizes and allocation order are unchanged, only ownership moved.
  • The standalone diagnostics (metal_graph_decode_test, first_token_full_test, prompt_logits_test, generate_metal_graph_raw_swa, ds4_engine_collect_imatrix) use a local scratch, same lifetime as their local graph.

No behavior change with a single session: same tensors, same sizes, same execution order.

Testing

  • New tests/two_sessions_smoke.c (+ Makefile target): loads one engine, runs two prompts isolated, then runs both again on two sessions of the same engine interleaved — one greedy token each, alternating on one thread — and asserts both token streams match the isolated runs exactly.
  • Verified on CUDA (RTX PRO 6000 Blackwell 96 GB, DeepSeek V4 Flash q2-imatrix): MATCH for both sessions, and the isolated outputs are byte-identical to a pre-refactor build.
  • make cuda-generic clean; server smoke (greedy chat completion, stream and non-stream) byte-identical to the previous build.

Metal note: the split is backend-agnostic (ownership only), but I could only run Metal compile-path checks indirectly — I don't have Apple hardware. The scratch tensors are allocated with the same ds4_gpu_tensor_alloc() calls as before.

Split ds4_gpu_graph into per-session state (KV caches, compressor and
indexer frontiers, MTP verification state) and a new engine-owned
ds4_gpu_scratch holding the transient work buffers: one-token decode
tensors, per-layer stage tensors, MTP draft tensors and the batched
prefill buffers. Everything in the scratch is written and consumed
inside a single sync/eval call, so sessions sharing an engine can share
one scratch as long as they are driven from one thread at a time --
which is exactly the serialization the server's single graph worker
already provides.

This is what makes a second live session affordable: the prefill batch
buffers dominate a session's non-KV footprint (~1.8 MiB per
prefill-chunk token), and they are now allocated once per engine
instead of once per session. The scratch grows on demand to the union
of the attached sessions' capacities and is freed with the engine.

No behavior change with a single session: tensors, sizes and the
execution order are unchanged, only their ownership moved.

Add tests/two_sessions_smoke: drives two sessions of one engine
interleaved (one greedy token each, alternating on one thread) and
checks both streams match isolated single-session runs token by token.
Verified on CUDA (RTX PRO 6000, DeepSeek V4 Flash q2): MATCH for both
sessions, and isolated outputs identical to the pre-refactor build.
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