Pull requests / #2527

#2527 Keep conversations in pipelined batch slots (1/2, policy in #2528): active-row windows, park=, cache-aware placement behind a policy interface

open · draft · @andyhrc · 0 comentários · No GitHub

BenchmarksServer & APIAMD / HIPNVIDIA / CUDAModels & quantsWindowsLinux

Descrição

## Title
Issue: none open that I found; happy to open one first if you prefer.

## How this PR relates to #2528 (please read first)

This is **part 1 of 2**.

- **This PR**: the engine changes (active-row windows, `park=`) and the server side of cache-aware placement, with
  `serve/slot_policy.py` as an **interface only** - its policy functions raise `NotImplementedError`.
- **Part 2 (#2528)**: our placement policy - the real `serve/slot_policy.py` and the tests that need it. Its branch
  contains this PR's commits with its own on top.
- **Merge order**: this PR first, then part 2. With both merged, the code is byte for byte what we run in production.
- **If you prefer other rules than ours**: merge this PR and write your own `serve/slot_policy.py` against the
  interface instead of part 2; the server side does not change.
- **This PR alone**: nothing changes until `STRATA_SLOT_CACHE_AFFINITY=1` or `STRATA_CACHE_TRACE=1` is set, and
  those then need a policy.

## Summary

On a layer split with `--batch`, conversations are lost between turns in two ways: a pipelined group's window runs
every slot up to its last active one, so an idle slot below an active one is a pad row and loses its state; and a long
conversation on the solo path is overwritten by the next, unrelated request. With agent clients (Claude Code, Codex) -
long conversations in many short turns, short side requests in between - that meant re-reading tens of thousands of
tokens on a large share of turns.

This PR adds the mechanism, all opt-in: the engine keeps idle slots and can park the solo path's conversation, and
the server places requests by what the slots hold. **Which history to give up is left to a placement policy behind an
interface** (`serve/slot_policy.py`): this PR ships the interface only, so you can plug in the rules you prefer. Ours
is #2528; the two together are exactly what we run in production.

## The problem, measured

3 x RTX A4000 16 GB (PCIe 4.0 x16, layer split), AMD EPYC 7543, 62 GB RAM, Ubuntu; Qwen3.8-Flash-Next IQ3_S
(GSQ-RCO), 200K context, int8 KV, `--kv-resident 32768`, `--batch` 3-6 in 3 groups; two people working with Claude Code
and Codex. The engine log of 2026-10-09 to 2026-10-14: of 3,027 prompts of 8K tokens or more, 538 reused less than half
of their previous turn and 40.7M prompt tokens were read again. Classified by prompt lengths (the log has no content,
so this is a heuristic):

| Why the previous turn was gone | Prompts | Tokens read again |
| --- | ---: | ---: |
| it was in a batch slot that another conversation took | 170 | 16.4M |
| it ran on the solo path, and the next request (often a short side request) replaced the main session | 244 | 15.0M |
| the client changed the history (not addressed here) | 63 | 6.1M |
| no earlier turn (a first turn, or a restart) | 66 | 3.1M |

At ~2,900 tok/s for long prompts on this machine, the first two rows are about three hours of prompt reading.

## What changed

1. **engine: `STRATA_PIPELINE_ACTIVE_ROWS=1`** - a pipelined group's window lists only its active slots
   (`include/strata/program/pipeline_rows.hpp`, `Verifier::batch_launch_rows` for CUDA and SYCL): no pad row, an idle
   slot keeps its conversation, `INFO slot_padding_safe=1`; after a prompt's restore, `CACHE_SOURCE <slot or -1>
   <tokens>`. `tests/core/pipeline_rows_test.cpp`, registered in CMake.
2. **engine: `park=B`** (`INFO slot_park=1 turn_token=<id>`, with the variable above) - when a prompt is another
   conversation (it does not share what the main session holds up to its last turn, the last `<|im_start|>`: two chats
   of one client share a long system prompt, a next turn without the reply's thinking shares the history), the engine
   first copies the main session's conversation into idle slot B and says `PARKED B <tokens>`. The server's claim uses
   the same test.
3. **server: `STRATA_SLOT_CACHE_AFFINITY=1`** - with pipelined groups `pick_slot` asks `slot_policy.place()`, which
   always answers a free slot (a request waits only when every slot is busy, as before); the server tracks the main
   session and asks `slot_policy.park_destination()` before sending `park=`; without `slot_padding_safe=1` it tracks
   from `BADM`/`BT`/`BDONE` which idle slot a later window has padded over. `/metrics` gains `live.cache_events` (the
   last 96 placement events), request keys and client addresses (the socket's, not `X-Forwarded-For`), `live.solo_*`
   and per-slot cache fields; `STRATA_CACHE_TRACE=1` adds a per-request snapshot of what each slot shared with the
   prompt (`serve/cache_trace.py`, lengths only).

The interface (`serve/slot_policy.py`): `place`, `park_destination`, `cache_state`, `superseded_slots`,
`reliable_flags`, `configure` / `settings` - their docstrings state the
contract. Without a policy, `STRATA_SLOT_CACHE_AFFINITY=1` and `STRATA_CACHE_TRACE=1` raise `NotImplementedError`.

With every variable unset the engine's output and the generation path are unchanged. The new `/metrics` fields are
present with the variables unset too (additive only); if you prefer them behind a variable, say so.

## Results (same machine, with our policy from #2528)

| | Without | With |
| --- | ---: | ---: |
| 37,359-token conversation, next turn after a side request: tokens reused | 0 | 37,352 |
| ... time of that turn | 14 s | about 1 s |
| parking 36-37K tokens (five runs) | - | 414-451 ms |

The greedy output (temperature 0) after the restore was the same as before the side request. Alternating
conversations under load: see #2528.

Throughput of commit 1 (the same binary with `STRATA_PIPELINE_ACTIVE_ROWS` off / on, `tools/serve_load.py`, 6 clients
x 2048 tokens, temperature 0, one run each): 177.4 / 173.5 tok/s total, first token p50 2.4 / 3.7 s; one client 67.3 /
66.7 tok/s. With all six slots busy the windows hold the same rows; other runs of this configuration with other builds
measured 173.9-178.2 tok/s, so this is close to the run-to-run spread, but it was not repeated.

## How it was checked

- Each commit: `generate.cpp` and `verify.cpp` compile with the CUDA build's flags (sm_86, Linux);
  `tests/core/pipeline_rows_test` passes; `python -m unittest discover -s serve -t .` passes except
  `serve.test_responses.NoLeakedSampler`, which fails on upstream main on this machine too (5 of 6 runs).
- Not built: SYCL (no oneAPI here) and HIP; the SYCL change mirrors the CUDA one.
- This code runs in production on the machine above since 2026-10-15 (with part 2 on top).

## Extra Notes

A client that rewrites history (for example one that drops the reasoning of earlier turns) cannot match a cached
prefix whatever the placement does; not addressed.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

No site

Links install, modelos, releases.