Skip to content

feat: --no-cache-thoughts for reasoning-model multi-turn cache alignment#29

Draft
DavidBellamy wants to merge 11 commits into
prodfrom
feat/no-cache-thoughts-llm360-fork
Draft

feat: --no-cache-thoughts for reasoning-model multi-turn cache alignment#29
DavidBellamy wants to merge 11 commits into
prodfrom
feat/no-cache-thoughts-llm360-fork

Conversation

@DavidBellamy

@DavidBellamy DavidBellamy commented May 26, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a --no-cache-thoughts server flag that, for reasoning requests, inserts the answer slice into the prefix cache at the original RoPE positions (skipping the thought slice) instead of caching the entire generated trajectory at contiguous positions. Lets a TITO multi-turn rollout reuse turn N's answer KV as a prefix match on turn N+1, without polluting the radix tree with request-specific reasoning content.

End-to-end validated on bbq-8b-mid3 in the agentic-rl image:

  • Baseline server (no flag) — turn 2 cached_tokens = 22 (prompt prefix only)
  • --no-cache-thoughts — turn 2 cached_tokens = 191 (prompt + answer, delta = answer length)

How it works (server side)

Stage Where What
Detect </think> Req.update_reasoning_tokens (schedule_batch.py) Sets req.answer_start_position when the reasoning parser emits its end token
Build the split split_kv_for_no_cache_thoughts (common.py) Returns (virtual_token_ids, virtual_kv_indices, virtual_positions, thought_kv_indices_to_free) — input prompt + post-</think> answer, with answer carrying its original (non-contiguous) positions; thought slots earmarked for free
Route at finish release_kv_cache (common.py) When the flag is on and the request has reasoning, builds the split and calls cache_finished_req(split=…)
Insert + free RadixCache.cache_finished_req (radix_cache.py) Inserts virtual token ids into the radix with original_positions attached, frees the thought slice directly
Round-trip positions RadixCache.{insert,_split_node,_match_prefix_helper,match_prefix}, TreeNode.positions, InsertParams.original_positions, MatchResult.original_positions Per-token position metadata travels through the tree; match_prefix returns it on cache hits
Plumb to Req Req.init_next_round_input (schedule_batch.py) Stores match_result.original_positions on req.cached_positions
Aggregate per batch collect_cached_positions, prepare_for_extend ScheduleBatch.cached_positions_per_req + ScheduleBatch.position_offsets populated
Forward batch positions build_extend_positions, clamp_position (forward_batch_info.py) Prefill and decode positions both honor non-contiguous cached positions; new tokens continue from max(cached) + 1
Backend stubs ChunkCache, SWARadixCache, MambaRadixCache, RadixCacheCpp, LMCRadixCache, SessionAwareCache Accept the new split= kwarg; non-RadixCache backends log a one-time warning and fall back to standard cache_finished_req (graceful no-op)

Integration spec for callers (TITO multi-turn rollouts)

The chat-completions text path cannot deliver multi-turn answer reuse even with this flag, because the chat template re-tokenizes prior assistant content via BPE and the first answer token ID after the assistant header doesn't match the model's emitted ID (leading-whitespace BPE merges differ). Callers must use the TITO protocol:

  1. Turn 1: send via /v1/chat/completions with return_prompt_token_ids=True, return_completion_token_ids=True, return_meta_info=True. Capture from the response:

    • choices[0].prompt_token_ids — turn 1's input IDs
    • choices[0].completion_token_ids — turn 1's output IDs (full, including thoughts)
    • usage.reasoning_tokens — count of thought tokens (including </think>)
  2. Strip the thoughts before appending to the running buffer:
    ```python
    answer_ids = completion_token_ids[reasoning_tokens:]
    running_ids = prompt_token_ids + answer_ids
    ```
    The buffer must NOT contain the thought slice; the cached entry doesn't.

  3. Turn N+1: construct input_ids = running_ids + tokenize(env_delta) where env_delta is the new user message + new assistant priming, including the K2V3 boundary \n (model stops on <|im_end|> but the chat template emits <|im_end|>\n, so the delta starts with \n). Send via /v1/chat/completions with input_ids set in the request body (chat template is bypassed when input_ids is provided).

  4. Verify cache hits: turn N+1's usage.prompt_tokens_details.cached_tokens should grow turn-over-turn by the prior answer's length.

For RL360: this is the same tito-patched flow already used by miles/rollout/generate_hub/agentic_tool_call.py; no client changes needed beyond ensuring the thought-stripping step runs for --no-cache-thoughts-enabled servers.

Tests

  • 34 unit tests under test/registered/radix_cache/ (1 env-skipped for C++ extension). Covers: CLI flag, Req.answer_start_position, split helper, radix position round-trip, release_kv_cache routing, compute_position/clamp_position non-contiguous support, derive helpers, Req.cached_positions, ScheduleBatch.cached_positions_per_req, and the 6 non-RadixCache backend signature acceptance.
  • TITO E2E at test/manual/test_no_cache_thoughts_e2e.py. Launches two bbq-8b-mid3 servers (with/without flag), drives a 2-turn protocol via raw input_ids, asserts the cached_tokens delta on turn 2. Passes on agentic-rl image.
  • The pre-existing test/registered/distributed/test_parallelism_context_integration.py lacks register_cuda_ci(...) and breaks test/run_suite.py collection — separate concern, not introduced by this PR.

Known limitations / follow-up

  • Chat-completions text path doesn't benefit (BPE retokenization breaks alignment regardless of the flag). The flag is a TITO-protocol feature; PR description above documents the required client behavior.
  • 6 non-RadixCache backends only stub the split= kwarg (warn + fall back). Full per-backend implementation deferred until empirical results justify the engineering.
  • The mid3-bundled chat_template.jinja is stale relative to LLM360/bbq-chat-template:main (the bundled copy has a 'think' default for think_tag that the upstream removed). E2E uses an explicit --chat-template override pointing at a vendored upstream copy under test/manual/chat_templates/bbq_upstream.jinja. If/when Mid3 ships with the upstream template, the override can be dropped.

Benchmark repro

Forced-decode A/B results are in the PR comments (terminal-bench-lite on GLM-4.7-Flash; think-heavy tb2 rollouts on bbq-8b-mid3). The harness lives in RL360 scripts/bench/nct/; the tb2 data converter (rollout JSONL -> teacher-forced replay parquet, with NCT cache-alignment checks) is here: https://gist.github.com/DavidBellamy/9a643fc21ee177bbc5b37ec3e66007cc

…x entries

Adds the data-model and helper pieces needed to skip reasoning tokens from the
shared prefix cache while preserving thought-infused answer K/V across turns.

* server_args: --no-cache-thoughts boolean flag
* schedule_batch.Req: answer_start_position field, set by update_reasoning_tokens
  when the </think> boundary is detected
* base_prefix_cache: original_positions on InsertParams and MatchResult
  (backwards-compat default: None == today's contiguous-position behavior)
* radix_cache (RadixCache only): round-trip per-token positions through insert,
  match_prefix, _split_node, and TreeNode
* common.split_kv_for_no_cache_thoughts: pure helper that splits a finished
  request's KV into the radix-bound slice (prompt + post-</think> answer with
  original positions) and the thought slice to free directly

11 unit tests cover the new behavior. Remaining work (not in this commit):
wiring release_kv_cache to invoke the helper, non-contiguous positions in the
scheduler's compute_position/clamp_position paths, schema propagation to the
6 non-RadixCache backends, and the e2e server-fixture test.
Extends the foundation toward --no-cache-thoughts being end-to-end functional:

* RadixCache.cache_finished_req(split=...): when a NoCacheThoughtsSplit is
  passed, use its virtual token ids / kv_indices / positions for the radix
  insert instead of looking them up from the per-request KV slot; free the
  thought slice directly.
* common.release_kv_cache: when --no-cache-thoughts is on and the request has
  a recorded answer_start_position, build the split via
  split_kv_for_no_cache_thoughts and route it into cache_finished_req.
* common.derive_extend_position_start: helper that turns per-request cached
  RoPE positions (returned by match_prefix on cache hits) into the per-request
  starting position for extend tokens. Returns None when no request has cached
  positions (signals legacy contiguous behavior).
* forward_batch_info.compute_position(_torch): new extend_position_start kwarg
  overrides the implicit "positions start at extend_prefix_lens" assumption,
  enabling non-contiguous positions on cache hits. Triton path falls through
  to the torch path when the override is set; native triton support deferred.

8 new unit tests (19 total, all green). Not yet wired: ForwardBatch.init_new
call site that would actually pass extend_position_start, Req-side cached
positions tracking, and the decode-path clamp_position changes.
Wires the call site so per-request cached non-contiguous positions feed into
the positions tensor handed to the model. Behavior is identical to today when
batch.cached_positions_per_req is None (the default), since
build_extend_positions falls through to compute_position in that case.

* build_extend_positions: new helper in forward_batch_info that bridges
  scheduler state (per-req cached positions, prefix lens) to compute_position's
  extend_position_start tensor. Lives next to compute_position so the call
  site only needs to swap which function it calls.
* ForwardBatch.init_new: extend-mode path now calls build_extend_positions,
  reading getattr(batch, "cached_positions_per_req", None). Uses getattr so
  pre-existing ScheduleBatch instances without the field continue to work.

The matching field on ScheduleBatch (cached_positions_per_req) and its
population from match_prefix results is the next integration step; this
commit is a no-op until that field is wired.

2 new unit tests on build_extend_positions (21 total, all green). The
test_compute_position_noncontig hard-coded backend name moved from "aiter"
to "torch_native" because support_triton() returns True for everything except
{torch_native, intel_amx, ascend} and the test must force the torch path.
Closes the gap between the radix tree (which returns non-contiguous positions on
cache hits) and ForwardBatch.init_new (which consumes them via the previously
wired build_extend_positions helper).

* Req.cached_positions: new field. init_next_round_input now stores
  match_result.original_positions on the Req alongside the existing prefix_indices
  unpacking. None when the cache hit was on a legacy entry without positions.
* ScheduleBatch.cached_positions_per_req: new field. prepare_for_extend
  populates it via collect_cached_positions(reqs), which aggregates per-req
  cached_positions or returns None if no req has any (signaling that
  ForwardBatch should take the legacy contiguous-positions path).
* collect_cached_positions: module-level helper, isolated so it can be unit-
  tested without standing up a real ScheduleBatch.

3 new unit tests (24 total, all green). The prefill cache-hit path is now
wired end-to-end. Remaining: decode-path clamp_position must use max_position
+ 1 (not seq_len - 1) so per-token positions during decode continue from
where the cached prefix left off.
After a prefill cache hit whose cached entry carried non-contiguous RoPE
positions, the request's decode tokens must continue from max(cached) + extend
+ 1, not from seq_len - 1. This commit closes that gap.

* clamp_position(seq_lens, position_offsets=None): new optional offsets arg.
  When provided, output is clamp(seq_lens - 1) + offsets. Behavior unchanged
  when offsets is None. The CUDA-path wrapper is a thin Python add on top of
  the existing jit_kernel; the CUDA kernel itself is untouched.
* common.derive_position_offsets: helper computing per-request offset as
  max(cached_positions) - (prefix_len - 1), or 0 for requests without cached
  positions. Returns None if no req in the batch needs an offset.
* ScheduleBatch.position_offsets: new tensor field, populated in
  prepare_for_extend alongside cached_positions_per_req.
* ForwardBatch.init_new (decode path): passes batch.position_offsets through
  to clamp_position via getattr (legacy callers still work).

4 new unit tests covering clamp_position offsets and the offsets helper
(28 total, all green).
Every prefix-cache backend's cache_finished_req now accepts the split kwarg so
release_kv_cache won't raise TypeError when --no-cache-thoughts fires on a
non-RadixCache backend. The behavior on those backends is a one-time warning
plus fall-through to the default cache_finished_req (thoughts get cached as
usual). Full split-insertion support per backend is deferred until empirical
results justify the engineering.

* chunk_cache.ChunkCache, swa_radix_cache.SWARadixCache,
  mamba_radix_cache.MambaRadixCache, radix_cache_cpp.RadixCacheCpp: accept
  split=None; warn-and-ignore if non-None.
* lmc_radix_cache.LMCRadixCache: accept split=None, forward to super (which
  is RadixCache, fully implemented); warn that the LMCache offload that
  follows reads req.fill_ids directly and may offload more tokens than the
  radix retained.
* session_aware_cache.SessionAwareCache: already accepted **kwargs and forwards
  to inner, so it works transparently once the inner backend handles split.
* common.warn_split_unsupported_once: module-level one-time warning helper so
  the noise per backend is bounded.

6 new tests across 2 files (34 total; 2 skipped because C++ extensions can't
JIT-compile in the test env — source verified by py_compile and reads).
* test/manual/test_bbq_smoke.py: confirms BBQ-8B-Mid3 loads in the agentic-rl
  container image, the K2-v3 reasoning parser separates <think>...</think>
  from the answer (290 reasoning tokens, 222 answer tokens on a sample prompt),
  and the /v1/chat/completions endpoint applies the chat template (which
  primes the assistant turn with <think>\n).
* test/manual/test_no_cache_thoughts_e2e.py: two-server cached_tokens-delta
  test. Confirms the --no-cache-thoughts split-insert path fires correctly
  (logged "split fired rid=... input_len=18 output_len=128 answer_start=144
  virtual_tokens=20 thoughts_freed=126" during diagnostic runs).

E2E test currently fails the assertion (cached_tokens=22 on both servers).
Root cause: the BBQ chat template renders a prior assistant message with an
EMPTY <think>\n</think>\n block even when reasoning_content is empty, instead
of omitting the block. So turn 2's tokens after the user prompt are
[<think>, \n, </think>, \n, answer...], while the cached path is
[prompt..., answer...] with no <think></think> tokens between them. Both
servers' prefix match diverges at the same slot — at the empty <think> marker
— so neither sees the answer slice as cached.

The feature implementation is correct; the chat template doesn't align with
the no-cache-thoughts cached path for multi-turn rendering. Resolution:
either (a) modify the chat template to drop the empty <think></think> block
when thinking content is empty, or (b) extend the split path to cover the
<think>...</think> boundary tokens with synthetic positions. Tracked as
follow-up.
Adds the missing piece for multi-turn cache alignment: the chat template's
priming tail (typically <think>\n added by add_generation_prompt) is part of
turn 1's input but NOT of turn 2's chat-template-rendered history. Caching it
breaks the cache match at the assistant header. The fix scans the tail of
origin_input_ids for the last <think> token and excludes everything from
there onward from the virtual cached prompt.

* split_kv_for_no_cache_thoughts: new think_start_id kwarg. When provided
  scans backward through origin_input_ids for the last occurrence and uses
  that index as prompt_keep_len; tokens beyond are dropped from the cached
  entry. thought_kv_indices_to_free is also restricted to positions
  [input_len .. answer_start) so the priming slots (which are still owned by
  the radix entry that cache_unfinished_req inserted at prefill time) are
  not double-freed — that would trigger "token_to_kv_pool_allocator memory
  leak detected" on the next free check.
* scheduler.Scheduler: encode <think> alongside </think> at init and stash
  self._think_start_id; copy onto each Req at construction so release_kv_cache
  (which has no scheduler reference) can read it.
* release_kv_cache: pass req._think_start_id to split_kv_for_no_cache_thoughts.
* Tests: new TDD cycle covering the priming-strip path on both the helper
  (test_no_cache_thoughts_split.py) and the routing wrapper
  (test_release_kv_cache_routing.py). All 35 unit tests green; the 14
  new-style unit tests under test/registered/radix_cache/ now carry
  register_cuda_ci entries so they're picked up by test/run_suite.py.

E2E test (test_no_cache_thoughts_e2e.py): now runs cleanly end-to-end (no
crash, no leak) inside the agentic-rl image with the upstream BBQ chat
template (test/manual/chat_templates/bbq_upstream.jinja) overriding Mid3's
stale bundled copy. The assertion still fails because of a remaining
tokenization-alignment issue between turn-1 model-generated answer tokens
(possibly starting with leading whitespace/newline after </think>) and
turn-2 chat-template-rendered answer tokens — separate concern from the
implementation completed here.
… TITO E2E

The earlier priming-strip logic (stash think_start_id on Req, scan
origin_input_ids for the last <think>, drop the tail from the virtual
cached prompt) was the wrong call for the production rollout path.

In TITO ("Token-In, Token-Out") multi-turn rollouts, turn N+1's input is
constructed from turn N's prompt_token_ids verbatim — the priming
<think>\n at the end of the input is carried over as-is. The cached
entry from no-cache-thoughts must therefore also include the priming
tail; otherwise turn N+1's input has [...prompt, <think>, \n, answer...]
while the cached path has [...prompt, answer...] — match dies right
where we want it to extend through.

Stripping the priming was an attempt to align with the OpenAI chat-
completions text path, where the chat template strips reasoning from
historical assistant messages. But that path also has a BPE retokenize
mismatch that prevents the answer slice from aligning anyway (see TITO
doc). So the chat-template path can't deliver multi-turn answer reuse
under any priming-strip variant. The correct integration path is TITO,
where keeping the priming in the cache is the right answer.

Net change:
* split_kv_for_no_cache_thoughts: removes think_start_id kwarg and the
  scan-for-priming logic. prompt_keep_len is always input_len.
* common.release_kv_cache: drops the think_start_id propagation.
* scheduler.Scheduler: drops self._think_start_id init and the per-Req
  _think_start_id assignments at the three Req-construction sites.
* Test removals: the priming-strip-specific cases in
  test_no_cache_thoughts_split.py and test_release_kv_cache_routing.py.
* New TITO E2E: test/manual/test_no_cache_thoughts_e2e.py now sends
  turn 2 with input_ids in the chat-completions body (bypassing chat
  template), strips output_token_ids[:reasoning_tokens] from the prior
  turn before appending to the buffer, and tokenizes only the env-delta
  (new user msg + assistant generation prompt). Assertion now passes:
  baseline cached_tokens=22, --no-cache-thoughts cached_tokens=191
  (delta = answer length, exactly the multi-turn cache reuse the
  feature is supposed to deliver).

All 34 unit tests still green (1 env-skipped for the C++ extension).
@DavidBellamy

DavidBellamy commented May 29, 2026

Copy link
Copy Markdown
Collaborator Author

Mapping this to the trainer side: agentic inference looks like: prefill phase -> decode phase -> environment -> prefill phase -> decode phase -> environment -> ...

For turn i in [0, 1, 2, ...] let P_i := prefill phase, D_i := decode phase, E_i := environment phase for turn i.

Example:
P_0 = [sys_msg, usr_prompt], more broadly P_i is the full trajectory history up to but not including D_i
D_0 = [think, ans] conditioned on P_0. Note that tool calls are parsed from ans.
[tool_output0] appended to [P_0, D_0]. The result becomes P_1.
P_1 = [P_0, D_0, tool_output0]
D_1 = [think, ans] conditioned on P_1.

In inference engines like sglang and vllm, when the prefill phase is entered, the global KV pool (shared across all concurrent inference requests) is checked to see if those tokens' KV vectors are cached. The engine matches the largest prefix from P_i that it can find in the KV pool and returns that, then prefills the remaining suffix of P_i.

D_i then begins, conditioned on P_i. During decode, a local KV cache (scoped per inference request) is constructed for the growing decoded sequence. The global KV pool is not added to during decode. Under standard protocol - once decode finishes, the full decoded sequence is inserted into the global KV pool.

#29 alters only that last step. Once decode finishes, only the ans (i.e. non-think) tokens are added to the global KV pool. So the global KV pool for each request is updated to contain P_i + ans after one request's decode phase completes.

Note that the positional encodings (e.g. RoPE) for the ans token activations are not shifted in my current implementation. So if there are N think tokens in the first decode step, the first ans token's activation will have its original position of '6' embedded into it.

Also note that the decode phase within each turn is conditioning on the think tokens from that same turn during generation, as in standard inference.

So on the trainer side, let's say we have an agentic trajectory and want to attention mask it so the model learns how to handle this inference strategy. For all tokens in turn N, all think tokens from turns N-1, N-2, ..., 0 should be masked. That should do the trick!

@DavidBellamy

Copy link
Copy Markdown
Collaborator Author

Real-search trajectories: baseline vs --no-cache-thoughts on the search-SFT 7B

Follow-up to the synthetic bench plan in #31. This is the real question + real web-search run: 3 BrowseComp questions, each generated once with and once without --no-cache-thoughts, on the search-SFT checkpoint. Two things we wanted to know: (a) does the answer-KV-reuse mechanism actually hold in a live multi-turn search loop (not just the synthetic E2E in this PR's description), and (b) how does a model that was not trained with the thought-masking behave under the flag.

TL;DR

  • The mechanism holds in a real search loop. On both long trajectories, NCT's cumulative cache-hit% matches/exceeds baseline (91.5% vs 93.4%; 93.5% vs 90.2%), and per-step cached_tokens climbs smoothly turn-over-turn — i.e. each turn's post-</think> answer KV is reused as the next turn's prefix, exactly as designed.
  • Untrained-model behavior under NCT is unstable. 1 of 3 NCT runs (soccer) collapsed into a </think>-repetition loop and terminated after 3 steps; the other 2 were coherent and searched more than baseline. This is the OOD-RoPE-position shift expected for a model not yet SFT'd with the mask, and is the main argument for doing that SFT before treating NCT as a drop-in inference win.
  • Net: promising and mechanically correct, not production-safe on an un-retrained checkpoint.

Results

query                        backend                   wall  steps  srch    cached    prompt   hit%
================================================================================================
browsecomp_soccer_brazilian  baseline                 53.9s     17    14    100858    114887  87.8%
browsecomp_soccer_brazilian  no_cache_thoughts        24.8s      3     2      1923      4112  46.8%
browsecomp_fourth_wall_char  baseline                 72.5s     25    14    210133    224941  93.4%
browsecomp_fourth_wall_char  no_cache_thoughts        88.2s     24    23    264413    288877  91.5%
browsecomp_research_pub_cul  baseline                 38.4s     16    11    131659    145967  90.2%
browsecomp_research_pub_cul  no_cache_thoughts       106.5s     27    26    392138    419427  93.5%

Per-step cache-hit progression (NCT, research_pub) climbing turn-over-turn — the cached prefix grows by each prior answer:

step   NCT cached / prompt = hit%
  2     502 /   1278 = 39.3%
  5    3849 /   4635 = 83.0%
 10   11033 /  11880 = 92.9%
 20   20351 /  22638 = 89.9%
 27   27813 /  28643 = 97.1%

Per-question behavior

  1. soccer_brazilian_refbaseline: coherent, 14 searches → identified the 1990 World Cup Round-of-16 Ireland v Romania match, referee José Ramiz Wright. NCT: degenerate — collapsed into a repeated 1992 Brazilian referee yellow cards".\n</think> loop, 2 searches, terminated at 3 steps. Clear failure case.
  2. fourth_wall_characterbaseline: ran 25 steps but ended mid-search (final emission was another web_search tool call, no clean Exact Answer). NCT: clean, well-formed answer after 23 searches → Plastic Man (slapstick humor + fourth-wall asides + rescued/healed by ascetics + 1979–81 animated series under 50 episodes). Here NCT produced the better-formed answer.
  3. research_pub_culinaryboth answered "The Fundamentals of Bread Making: The Science of Bread." Note: the model recognized the prompt as a BrowseComp paper example and surfaced its posted answer rather than deriving it cold. NCT did 26 searches vs baseline's 11.

Setup (reproducible)

  • Checkpoint: /mnt/weka/shrd/k2m/willis.guo/k2v3-7b_sft-ablations/search-open-src-filtered/checkpoints_hf/checkpoint_0000750 (search-SFT, default tool format)
  • Container: /mnt/weka/shrd/k2pta/agentic_rl_images/agentic-rl-19312bf.sqsh
  • sglang fork: LLM360/sglang @ feat/stream-completion-token-ids (commit 26f54736b) — descends from the --no-cache-thoughts branch (feat/no-cache-thoughts-llm360-fork @ c7a329fd2), so it carries both this PR's flag and the raw-token streaming the search agent's /search SSE feed needs.
  • Harness: LLM360/search-agent-service @ feat/sglang-tito-no-cache-thoughts (commit 9d52d44) — TITO client (input_ids=, thoughts stripped client-side) so turn N+1 actually prefix-hits turn N.
  • Two sglang servers on one node, 2 GPUs (GPU0 --no-cache-thoughts, GPU1 baseline):
    python3 -m sglang.launch_server --model-path <ckpt> \
        --reasoning-parser k2_v3 --tool-call-parser multi_format \
        --enable-cache-report --trust-remote-code --mem-fraction-static 0.80 \
        --port 30000 --no-cache-thoughts     # GPU0; omit flag on GPU1/30001
    
  • search-agent + bench:
    # each sglang server fronted by server.py with:
    #   --tool-format default --temperature 1.0 --max-tokens 16384 \
    #   --max-steps 32 --search-top-k 10 --fetch-max-chars 8000
    python3 bench_no_cache_thoughts.py \
        --no-cache-thoughts $(cat .endpoint_no_cache_thoughts) \
        --baseline          $(cat .endpoint_baseline) \
        --queries queries_browsecomp.jsonl --max-steps 32 \
        --output-dir bench_output_browsecomp
    
    Driver serve_sglang_bench_slurm.sh brings both servers + both agents up in one SLURM job.

Caveats

  • n=1 per cell, temperature=1.0. Trajectory length is a confounder, so the wall deltas are noisy — the structural signals are cache-hit% and the per-step progression, not wall-clock. The soccer/NCT degeneration is a single sample; whether it reproduces is an open question (cheap to re-run).
  • read_url used Jina Reader's keyless tier (no JINA_API_KEY set); web_search via Serper.
  • The model is not trained with the NCT attention mask; behavior shifts are expected and are the point of the test.

Trajectories

Full per-step SSE logs (every token, every tool call + result) for all 6 runs, committed at 6c55c9e:

question baseline --no-cache-thoughts
soccer_brazilian_ref baseline no_cache_thoughts
fourth_wall_character baseline no_cache_thoughts
research_pub_culinary baseline no_cache_thoughts

Per-run rollup: summary.json · queries: queries_browsecomp.jsonl. Each JSONL line is one SSE event (thinking_token / content_token / tool_call / tool_result / step_meta / answer_draft / final). On-cluster originals: m2:~/tito-bench/search-agent-service/bench_output_browsecomp/ (fs-mbz-gpu-095, job 1710582).

…committed length

--no-cache-thoughts crashed the server under concurrent load. Two causes:

1. Page misalignment. Dropping the <think> span lowers each answer token's index
   in the cached sequence, but its KV stays in the slot decode assigned, so
   slot % page_size no longer equals index % page_size. The re-inserted answer
   was then unextendable and tripped alloc_extend's page-alignment assert
   (concurrency >= 32). Relocate the answer's KV into the page-congruent slots
   the thoughts vacate (new MLATokenToKVPool.move_kv_cache). The thought slots
   are no longer freed separately; they are reused by the relocation and
   reclaimed by a single page-aligned dead-tail free, removing the prior
   boundary double-free.

2. Read past committed length. The split walked input_len + len(output_ids), one
   token beyond kv_committed_len. The last generated token's KV can be
   uncommitted (overlap scheduling), leaving its req_to_token entry at the zero
   sentinel (reserved page 0); freeing it returned page 0 to the free list
   repeatedly across requests, a page double-free (concurrency >= 64). Cap the
   split at kv_committed_len, as the normal cache_finished_req path does.

Validated on GLM-4.7-Flash (TP=4) with SGLANG_DEBUG_MEMORY_POOL=1 under
forced-decode replay, concurrency 32-512: no allocator asserts, no double-free,
no request failures, throughput parity with baseline.
@DavidBellamy

DavidBellamy commented Jun 9, 2026

Copy link
Copy Markdown
Collaborator Author

Below are results of a 'forced decoding' benchmark comparing the --no-cache-thoughts inference to vanilla inference.

220 glm-4.7-flash trajectories on terminal bench lite were gathered. A random subsample of 100 were used for the benchmark. glm-4.7-flash was used in both settings. After each decode step, the next token was replaced with the next token from the saved trajectories. This way, each setting followed an identical inference workload with the only difference that --no-cache-thoughts drops the think tokens from previous turns.

sglang engines were run with a memory fraction of 0.3 (rather than the more common 0.85) to induce pressure on the KV pool. This is because without pressure on the KV pool, vanilla inference can handle the same number of concurrent inference requests as no-cache-thoughts. The same effect can be achieved by tuning the concurrent request number upwards.

Next is the token composition of the benchmark data.

Token length distributions of the benchmark workload

prompt tokens
     400-800 |  14.5%  ###
    800-1600 |  85.5%  #####################

think tokens
        0-50 |  28.0%  #######
      50-100 |  24.1%  ######
     100-200 |  29.6%  #######
     200-400 |  15.4%  ####
     400-800 |   2.3%  #
    800-1600 |   0.4%
   1600-3200 |   0.2%

answer tokens
      50-100 |   9.1%  ##
     100-200 |  38.7%  #########
     200-400 |  40.1%  ##########
     400-800 |   3.7%  #
    800-1600 |   3.7%  #
   1600-3200 |   3.8%  #
      3200+  |   1.0%

observation (e.g. tool output) tokens
        0-50 |   3.7%  #
      50-100 |  20.0%  #####
     100-200 |  20.4%  #####
     200-400 |  22.3%  #####
     400-800 |  16.8%  ####
    800-1600 |   9.1%  ##
   1600-3200 |   6.6%  ##
      3200+  |   1.2%

Tabulated token composition of the benchmark

220 trajectories · 3.33M total tokens · ~15 steps/trajectory.

type what it is count % of tokens
prompt initial task + terminal state (turn 0) 220 5.8%
think reasoning inside <think>…</think> (incl. </think>) 3,257 12.5%
answer assistant action/JSON after </think> 3,257 36.2%
observation terminal output + role markers fed back between steps 3,037 45.4%

Per-unit length distribution (tokens)

type min p10 p25 median mean p75 p90 p99 max
prompt 755 787 828 885 881 944 966 1058 1060
think 8 24 43 95 128 168 257 590 3036
answer 62 102 150 204 370 267 560 3198 7994
observation 7 56 107 238 498 539 1339 3239 4807

Benchmark: vanilla vs no-cache-thoughts

GLM-4.7-Flash TP=4 · forced decode (byte-identical outputs) · mem-fraction 0.3

conc base tok/s nct tok/s Δ throughput base p50 nct p50 cache_hit (base→nct)
64 929.5 950.8 +2.3% 7.65 7.51 0.9182 → 0.9216
128 934.6 957.9 +2.5% 8.42 8.28 0.9189 → 0.9224
256 933.8 958.7 +2.7% 8.40 8.16 0.9188 → 0.9224

Prompt tokens fed: base 18.55M vs no-cache-thoughts 16.51M (~11% fewer) - the carried think tokens no-cache-thoughts drops.

Note this workload is not very heavy on 'think' tokens so the advantage of no-cache-thoughts is not pronounced.

@DavidBellamy

DavidBellamy commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator Author

Below are results of a second forced-decoding benchmark of --no-cache-thoughts, this time on a think-heavy agentic workload: the tb2 midtraining rollouts (tb2_midtraining_v3_128k), served with bbq-8b-mid3-final. The earlier terminal-bench-lite workload was light on think tokens (12.5% of all tokens), which capped the effect at +2-3%. This workload is 34% think tokens and the effect is large: +25% throughput with no KV pressure, +70-77% under pressure.

Benchmark data

128 trajectories sampled uniformly (seed 0) from the 36,692 in the dataset. The rollouts are message-form JSONL with no token ids, so each conversation was re-tokenized with bbq's own chat template into the replay format (tokens / loss_mask / response_length); the reconstructed think-token counts match the dataset's recorded token_count_think exactly. 9.16M tokens total, ~27 assistant turns per trajectory.

type what it is % of tokens
prompt initial task + system prompt with tools (turn 0) 14.9%
think reasoning inside <think>...</think> 34.0%
answer assistant action / tool call after </think> 13.0%
observation tool output + role markers fed back between steps 38.1%

Per-trajectory token counts:

type min p10 median mean p90 max
turns 9 12 25 27.1 46 61
total 33627 37805 69432 71588 106718 128737
think 1625 5518 20276 24366 55517 99426
answer 940 3474 8198 9278 16786 30635
observation 7097 13173 24320 27277 47736 98230

Setup

Same forced-decoding design as the previous benchmark: both arms replay identical recorded token sequences (a custom logit processor pins each decode step to the recorded token), so outputs are byte-identical and the only variable is how the carried context is handled. The baseline keeps prior-turn thoughts in context; the nct arm drops them client-side (TITO) and serves with --no-cache-thoughts. Over a full pass the baseline feeds 160.1M prompt tokens vs 115.2M for nct (28% fewer); the difference is exactly the carried thoughts.

bbq-8b-mid3-final, TP=4 per arm, both arms on the same H200 node run sequentially, page size 64, mem-fraction 0.75, concurrency swept {32, 64, 128} over the 128 sessions, 2 reps averaged (rep-to-rep spread is under 1%).

Results: vanilla vs no-cache-thoughts

conc base tok/s nct tok/s Δ throughput base p50 nct p50 cache_hit (base→nct)
32 1126.2 1411.3 +25.3% 7.8s 6.4s 0.9454 → 0.9515
64 976.3 1654.8 +69.5% 18.7s 10.0s 0.6702 → 0.9511
128 730.2 1288.8 +76.5% 84.7s 28.8s 0.2845 → 0.5760

Failures are ≤18 of 3,463 turns per cell (client timeouts on the longest turns at c=128); no server crashes.

Why the delta grows with concurrency: at c=32 both arms fit the KV pool and the +25% comes from nct's shorter prefixes alone (fewer prefill and decode-attention FLOPs). At c≥64 the baseline's think-laden KV oversubscribes the pool, radix eviction destroys its cross-turn reuse (cache hit 0.95 → 0.67 → 0.28) and it re-prefills whole contexts, while nct's 28%-smaller working set mostly stays resident. A corroborating sweep at mem-fraction 0.85 (larger pool) shows the same shape with later onset: +25.6% at c=32, +31.3% at c=64 (baseline cache hit still 0.95 there), and at c=128 the baseline server OOM'd outright (sampler allocation on the 250k vocab with 63MB free) while the nct server finished the same workload.

Repro

  • Harness: RL360 scripts/bench/nct/ (drives the shared replayer scripts/bench/routing/replay_bench.py); tb2 data converter: https://gist.github.com/DavidBellamy/9a643fc21ee177bbc5b37ec3e66007cc
  • Converted workload: /mnt/weka/shrd/k2pta/rl360/rollout_data/tb2-mid-v3-128k-bbq/
  • Runs: /mnt/weka/shrd/k2pta/rl360/logs/nct-bench-tb2-mf075-20260610-184842/ (headline, mem 0.75) and /mnt/weka/shrd/k2pta/rl360/logs/nct-bench-tb2-20260610-171857/ (mem 0.85 corroboration)

MHATokenToKVPool.move_kv_cache requires _kv_copy_config, which is only
set when the pool is constructed with enable_kv_cache_copy=True
(previously only the speculative-decoding path did). With
--no-cache-thoughts on any MHA/GQA model, the first finished reasoning
request hits the relocation path and asserts:

  AssertionError: KV copy not initialized. Set enable_kv_cache_copy=True

Enable it when no_cache_thoughts is set, for both the standard and FP4
MHA pools. Validated on bbq-8b (GQA, TP=4): 216-turn agentic replay,
0 failures, no asserts with SGLANG_DEBUG_MEMORY_POOL=1, cross-turn
cache hit 0.955. The MLA path (GLM) is unaffected; its move_kv_cache
is self-contained.
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