Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -157,3 +157,4 @@ clients/java/target/
clients/java/build.sbt
clients/java/gradle.properties
openapitools.json
scripts/generate_sim/profiles/*.local.json
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[workspace]
members = ["model_gateway", "crates/protocols", "crates/reasoning_parser", "crates/tool_parser", "crates/workflow", "crates/tokenizer", "crates/auth", "crates/mcp",
"crates/external_router", "crates/kv_index", "crates/data_connector", "crates/multimodal", "crates/mm_rdma", "crates/wasm", "crates/mesh", "crates/grpc_client", "crates/engine_zmq_client", "bindings/python", "bindings/golang", "clients/rust", "clients/openapi-gen", "crates/mock_worker", "crates/rl", "crates/radix_tree"]
"crates/external_router", "crates/kv_index", "crates/data_connector", "crates/multimodal", "crates/mm_rdma", "crates/wasm", "crates/mesh", "crates/grpc_client", "crates/engine_zmq_client", "bindings/python", "bindings/golang", "clients/rust", "clients/openapi-gen", "crates/mock_worker", "crates/rl", "crates/radix_tree", "crates/sim_loadgen"]
resolver = "2"

[workspace.dependencies]
Expand Down
68 changes: 66 additions & 2 deletions crates/mock_worker/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,8 @@ impl SchedulerState {
});
}

/// Tokens currently resident in KV.
/// Tokens physically resident in KV (admission control and eviction
/// watermarks); see [`Self::pinned_tokens`] for what is REPORTED.
fn used_tokens(&self, p: &EngineParams) -> u64 {
if p.prefix_cache {
// KV holds the shared radix cache (blocks persist across requests
Expand All @@ -460,8 +461,24 @@ impl SchedulerState {
}
}

/// Tokens pinned by running requests (prompt + generated so far): the
/// KV a scheduler cannot evict. This — not physical occupancy — is what
/// engines report as `num_used_tokens` / `token_usage` (SGLang: used =
/// total − available − evictable), so a warm radix cache that keeps
/// KV physically full does not read as an overloaded worker. Reporting
/// occupancy instead trips a gateway's token-usage overload gate on
/// every warm worker and makes its cache-aware routing avoid exactly
/// the workers holding the prefixes (measured: same-worker follow-ups
/// fell from 0.87 to 0.15 over a 100 s run at 0.9 threshold).
fn pinned_tokens(&self) -> u64 {
self.running
.iter()
.map(|r| u64::from(r.prompt_tokens + r.generated))
.sum()
}

fn snapshot(&self, p: &EngineParams) -> LoadSnapshot {
let used = self.used_tokens(p);
let used = self.pinned_tokens().min(p.kv_capacity_tokens);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: pinned_tokens sums per request, so a prefix shared by concurrent requests is counted once per request — not once, as the SGLang formula quoted in the doc comment does. The over-report scales with max_running and lands on the same 0.9 gate this change exists to get off.

In SGLang, used = total − available − evictable where the non-evictable set is the union of radix nodes with lock_ref > 0. A block held by 80 running requests is one ref-counted node counted once. pinned_tokens walks self.running and adds prompt_tokens + generated for each, so every shared block is multiplied by the number of requests holding it.

Failure scenario, with the committed local-medium.json numbers (system_prefix_tokens: 2048, max_running: 80, kv_tokens: 1200000, --worker-overload-token-usage 0.9): every session prepends the same 2048-token system prefix, so at full batch width pinned_tokens charges 80 × 2048 ≈ 164k tokens for KV that physically holds 2048 — 13.7 points of token_usage on a 1.2M pool. Mean prompt over prompt_cdf is ~13.5k, so a full batch is already ~0.8 correctly accounted; the double count pushes it past 0.9 and re-trips the overload gate on exactly the warm, high-concurrency workers. Turn-2 requests make it worse: turn2_ingress: "same" routes the follow-up to the worker already holding the turn-1 prefix, so the requests most likely to be concurrent on one worker are the ones sharing the most tokens.

The .min(p.kv_capacity_tokens) clamp on this line hides it rather than bounding it — token_usage reads a flat 1.0 once the sum crosses capacity, with no signal that it is a counting artifact rather than real pressure.

used_tokens's prefix-cache branch already computes the union (cache.len() * block_size + pending tails); the reportable quantity is that same union restricted to blocks a running request references. If tracking per-request refcounts is more machinery than the harness wants, subtracting each request's cached_tokens (already stored on RunningReq) and adding the matched prefix once would remove the bulk of the double count, since the shared prefix is what cached_tokens measures.

let waiting_uncached: i64 = self.waiting.iter().map(|w| w.uncached_tokens as i64).sum();
LoadSnapshot {
num_running_reqs: self.running.len() as i32,
Expand Down Expand Up @@ -866,6 +883,53 @@ mod tests {
panic!("no token produced");
}

/// A warm prefix cache keeps KV physically occupied, but the reported
/// usage must fall back to the running requests' pinned tokens once
/// they finish — otherwise every warm worker reads as overloaded.
#[test]
fn reported_usage_excludes_evictable_cache() {
let p = EngineParams {
prefix_cache: true,
block_size: 4,
kv_capacity_tokens: 4096,
prefill_chunk_tokens: 1_000_000,
..Default::default()
};
let mut st = SchedulerState::new();
let (r, mut rx) = req("a", vec![3; 256], 8);
st.enqueue(r, &p);
let mut done = false;
for _ in 0..10_000 {
let step = st.step(&p);
for (tx, ev) in step.sends {
let _ = tx.send(ev);
}
while let Ok(ev) = rx.try_recv() {
if matches!(ev, GenEvent::Done { .. }) {
done = true;
}
}
if done {
break;
}
}
assert!(done, "request did not finish");
// Drain the completed request out of the batch.
let _ = st.step(&p);
assert!(
st.cache.len() >= 256 / 4,
"prefix cache retained the prompt blocks"
);
assert!(
st.used_tokens(&p) >= 256,
"KV is physically occupied by the cache"
);
let snap = st.snapshot(&p);
assert_eq!(snap.num_running_reqs, 0);
assert_eq!(snap.num_used_tokens, 0, "no running request pins KV");
assert_eq!(snap.token_usage, 0.0);
}

#[test]
fn ttft_scales_with_uncached_prompt_length() {
let p = EngineParams {
Expand Down
Loading
Loading