Analysis only (2026-09-11). Nothing here is implemented or tested unless it says so; the
measurements come from the boots recorded in logs/profile-2026-09-10/REPORT.md §17-17b.
On the head (rank 0, ~4.5-5.3 GB free after boot) a prefill's memory peak is set by the
DeepSeek-V4 low-ratio indexer, not by KV. For every prefill chunk, each of the ratio-1/2
source layers scores the chunk's T query rows against the whole compressed prefix L
(deepseek_v4_backend._low_ratio_index_topk_dense → _dense_fp4_mqa_logits, output
[T, max_seqlen_k] fp32, max_seqlen_k = the longest compressed length in the batch). Around
that one buffer the same function keeps, at the same moment:
| buffer | bytes per (row, key) | note |
|---|---|---|
logits fp32 |
4 | the kernel output |
candidate-block copy (select_candidate_blocks, layer 20 only) |
up to 4 | "pads and pools a copy of its rows", capped at _TORCH_INDEXER_SCORE_BUDGET_BYTES = 1 GiB per call |
candidate_masks bool |
1 | published by layer 20, consumed by every later low-ratio layer, lives for the whole chunk |
j arange, compress_lens broadcast, top-k scratch |
small |
So the live peak of one chunk is roughly c × T × L with c ≈ 14 B measured (REPORT §17b:
2.97 GB at T=2048, L=100k; 2.6 GB at T=1024, L=208k; both ≈ 3.5 × 4 B × T × L, plus
~0.2-0.35 GB of touched KV pages), and the allocator's cache is emptied after each chunk by
adapter/prefill_empty_cache.py, so the sum over chunks no longer accumulates.
With a 1.5 GB guard and ~0.5 GB of margin the usable budget is about 2.5-3 GB, which gives the rule that every measurement so far obeys:
T × L ≲ 2.0e8 token² (T = chunk, L = prefix at that chunk)
2048 × 100k = 2.05e8 ok 2048 × 133k = 2.7e8 guard fired (boot 11, 128k)
1024 × 208k = 2.13e8 ok 1024 × 256k = 2.6e8 guard fired by 20 MB (boot 12)
Everything below is a way to raise T without letting T × L cross that line, or to move
the line.
The scheduler already asks a dynamic_chunk_sizer for the size of every continuation chunk
(scheduler.py get_new_batch_prefill: if self.chunked_req is not None and
self.dynamic_chunk_sizer is not None: chunked_prefill_size = sizer.predict(history_len),
where history_len is the prefix in tokens). SGLang only installs that sizer for pipeline
parallelism (maybe_init_dynamic_chunk_sizer, pp_size > 1), but the attribute is plain and
the contract is one method: predict(history_len) -> Optional[int].
An adapter hook (sitecustomize on sglang.srt.managers.scheduler, ~30 lines) can install an
object whose predict returns
chunk(L) = clamp( round_down_to_page( BUDGET / L ), CHUNK_MIN, CHUNK_MAX )
BUDGET = 2.0e8 token² (env knob), CHUNK_MAX = 2048, CHUNK_MIN = 256, page = 256
which yields a schedule of
| prefix so far | chunk | prefill rate (measured or extrapolated) |
|---|---|---|
| 0 - 98k | 2048 | 2.0-2.2k tok/s (measured) |
| 98k - 195k | 1024 | 1.5-1.7k tok/s (measured) |
| 195k - 390k | 512 | ~1.1-1.3k tok/s (extrapolated; boot 13 was stopped before measuring) |
| 390k - 780k | 256 | ~0.8k tok/s (extrapolated) |
Consequences: every prompt under ~100k tokens prefills at the full 2048 speed (today all of
them run at 1024, 15% slower); a 200k prompt takes about the same as today; 256k-500k
prompts become possible at all (est. 500k ≈ 7 min, vs. impossible), and CONTEXT_LENGTH
could go back to 512000 with the same memory guarantee. Decode is untouched (the chunk
size never affects decode steps).
Edge to handle: the first chunk of a request is sized statically (the sizer is consulted
only for chunked_req, i.e. continuation chunks). A brand-new request has L ≈ chunk,
fine; a request that lands on a long cached prefix (radix hit of, say, 150k tokens) gets
its first chunk at 2048 with L = 150k, over budget. Two fixes: clamp inside
PrefillAdder.add_one_req (it knows req.extend_input_len and the prefix length), or set
the static size to 1024 and let the sizer raise it to 2048 when L is small (the sizer's
return value overrides the static size in both directions). The second needs no adder hook.
Cost: none on decode; on prefill only the chunks beyond 98k get smaller than today's 1024
beyond 195k. Validation: one boot, ramp 64k / 128k / 200k / 256k / 400k / 500k with
scripts/verify/memguard.py, check prefill tok/s per band and that lowest MemAvailable
stays above 2 GB at every size.
_TORCH_INDEXER_SCORE_BUDGET_BYTES = 1 << 30 bounds the padded copy that layer 20 makes for
block selection; at large T × L that copy is a full extra [T, L] fp32 (0.8 GB at
2048 × 100k). It is a module constant, so the adapter can set it to 256 MiB at import
(module._TORCH_INDEXER_SCORE_BUDGET_BYTES = 256 << 20); the loop then processes fewer
rows per step with identical results. Expected: c drops from ~14 B to ~11 B per (row,
key), i.e. the budget rises from ~2.0e8 to ~2.6e8 token² and 2048-token chunks reach
~130k prefix instead of ~100k. Free to try; combine with 2.1 by raising BUDGET.
The exact answer: score the T rows against key blocks of B_k (e.g. 32k), keep the
per-block top-512 (values + indices), then take the top-512 of the concatenated candidates.
topk(union) == topk(∪ per-block topk) holds exactly, so outputs do not change. Memory
becomes c × T × B_k, independent of the prefix: 2048-token chunks (or 4096) at any
context. The candidate-source layer (block pooling over 8-key blocks, top-2048 blocks) is
decomposable the same way, and the consumer layers' masked_fill_ of non-candidates can
be applied per block. Touch points: _low_ratio_index_topk_dense,
_publish_or_consume_candidates, topk_transform_ragged_v2 (needs per-block offsets or a
second pass), _mask_topk_scores. Effort: a day or two in a 180 KB file plus a correctness
diff against the unblocked path on the 32k sweep. This is the upstream-quality fix; 2.1 is
what to run in the meantime.
The engine carries a DSA prefill context-parallel mode (--enable-dsa-prefill-context-parallel,
dsa_prefill_cp_mode round-robin-split, attn_cp_size; backend paths
_forward_low_ratio_sources_cp, _late_layer_tail_cp_layout). It splits the query rows of a
prefill across ranks, so each rank scores T / cp_size rows: at cp_size = 3 the per-rank
buffer is a third, i.e. the same T × L line moves to 6e8 and 2048-token chunks reach
~300k. Unknowns on this stack: whether CP composes with TP3 padding (rank 2's all-padding
attention shard), the late-layer tail, DSpark's per-chunk hidden-state injection, and
whether the gathers cost more than the GEMMs save. Worth one boot with the flags and the
32k sweep for correctness before any ramp; not something to enable blind.
MAX_TOTAL_TOKENS frees nothing at rest and costs concurrency.-S fix; it used to bite exactly during long prefills.expandable_segments:True (would coalesce the fragmentation) returns NaN logits on every
>64-token prefill on this stack (REPORT §17). Banned.garbage_collection_threshold is a no-op: SGLang never calls
set_per_process_memory_fraction, which that option requires.Implement 2.1 with 2.2 folded in (two adapter hooks, no engine edit, no rebuild of anything
but the image layer that copies adapter/), set CHUNKED_PREFILL_SIZE=2048 as the maximum
and let the sizer descend, restore CONTEXT_LENGTH=512000, and verify with the ramp above.
Expected result: 2048-speed prefill for all prompts up to ~100-130k, long prompts to 500k
without touching the guard, decode unchanged. Keep 2.3 as the follow-up that removes the
prefix dependence for good.