Skip to content

fix(scheduler): cap the admitted ceiling instead of refusing the request - #335

Merged
justinchuby merged 6 commits into
mainfrom
fix/scheduler-budget-regression
Jul 28, 2026
Merged

justinchuby merged 6 commits into
mainfrom
fix/scheduler-budget-regression

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

The failure

Justin, running a reasoning model for several turns on the ORT backend with CUDA:

INFO onnx_genai_ort::session: allocating shared GQA KV on CUDA device memory device_id=0 error: scheduler did not admit the session generate request

Root cause — a regression we shipped earlier today

#277 changed the CLI default max_new_tokens from 128 to the remaining context window.

The scheduler reserves the worst-case footprint prompt + max_tokens up front. That is deliberate, not an oversight — it is what guarantees an admitted sequence can never push the shared cross-session KV budget over its ceiling (DESIGN.md §26.4/§26.11). Fine when the ceiling was 128; unsatisfiable once the ceiling is the whole window.

Fix — cap the ceiling, keep the reservation conservative

The conservative reservation stays, because it is the thing that guarantees an admitted sequence can run to completion. Instead the admitted ceiling is capped to what the budget can actually serve, so the request is served with a smaller bound rather than refused.

The invariant holds: the ceiling was reduced to fit, not the reservation weakened.

The cap is observable — this was the harder half

A silent cap would have been a fifth entry in today's list of "the system quietly did something other than what was asked": --temperature ignored, cuda documented as silently falling back to CPU, context exhaustion writing an empty turn, --backend reporting the request rather than the resolution.

It matters more here than elsewhere, because this CLI is used for benchmarking — an invisibly shortened token budget corrupts a measurement without ever looking wrong.

So the cap is reported:

  • on stderr (piped stdout stays byte-stable)
  • as GenerateResult.budget_cap
  • in the stats and profile output, text and JSON

Callers can distinguish "the model finished" from "we shortened your budget". No new FinishReason variant — budget_cap carries the distinction, which is proportionate and keeps the enum stable.

Confirms the asymmetry Justin observed

"我也测了native runtime不会遇到这个问题,只有ort会遇到这个分配错误"

Native generate bypasses scheduler admission entirely; ORT generation goes through it. Both load paths do set bytes_per_token, so the difference is the code path, not configuration. Worth knowing independently of this fix.

Also

  • Cancel cleanup so a failed admission cannot leak queued or running entries — a leak there produces the same symptom and would have been a nastier bug.
  • The admission error is rewritten to name the cause (batch-full vs KV budget), the numbers (requested/available/used/limit/shortfall, running vs max batch), and a next step (--vram-limit, or an explicit --max-new-tokens). The old message said only that admission failed, and cost real diagnosis time today.
  • Removes a stray .msg.txt that leaked into the tree from my own scratch workflow, and ignores the pattern.

Gates

Scheduler tests, CLI lib tests, build, fmt, clippy all pass. Engine lib shows the known local ORT mismatch baseline (178 passed / 64 failed), unchanged.

Copilot AI added 3 commits July 27, 2026 21:54
WIP checkpoint. Justin hit 'scheduler did not admit the session generate
request' on the ORT backend with CUDA, right after the shared GQA KV
allocation, running a reasoning model for several turns.

Root cause: PR #277 changed the CLI default max_new_tokens from 128 to the
remaining context window. The scheduler reserves the worst-case footprint
prompt + max_tokens up front, deliberately, so that an admitted sequence
can never push the shared cross-session KV budget over its ceiling
(DESIGN.md 26.4/26.11). That reservation was fine when the ceiling was 128
and becomes unsatisfiable when the ceiling is the whole window.

The conservative reservation is kept because it is what guarantees an
admitted sequence can run to completion. Instead the admitted ceiling is
capped to what the budget can actually guarantee, so the request is served
with a smaller bound rather than refused outright.

Confirmed the native-vs-ORT asymmetry Justin observed: native generate
bypasses scheduler admission entirely, so it never meets this gate. Both
load paths do set bytes_per_token, so the difference is the code path, not
the config.

Also adds cancel cleanup so a failed admission cannot leak queued or
running entries, and rewrites the error to name the cause, the numbers, and
a next step.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 023d5a3f-c551-4aa8-afbe-a329af15fbdd
Justin hit "scheduler did not admit the session generate request" on the
ORT backend with CUDA, running a reasoning model for several turns, right
after the shared GQA KV allocation.

Root cause: PR #277 changed the CLI default max_new_tokens from 128 to the
remaining context window. The scheduler reserves the worst-case footprint
prompt + max_tokens up front, deliberately, so an admitted sequence can
never push the shared cross-session KV budget over its ceiling
(DESIGN.md 26.4/26.11). That was fine when the ceiling was 128 and becomes
unsatisfiable when the ceiling is the whole window.

The conservative reservation is kept, because it is what guarantees an
admitted sequence can run to completion. Instead the admitted ceiling is
capped to what the budget can actually serve, so the request is served
with a smaller bound rather than refused outright. The invariant holds:
the ceiling was reduced to fit, not the reservation weakened.

The cap is observable rather than silent, which was the harder half. It is
reported on stderr, exposed as GenerateResult.budget_cap, and carried into
the stats and profile output in both text and JSON. Callers can therefore
distinguish "the model finished" from "we shortened your budget" -- which
matters because this CLI is used for benchmarking, and an invisibly
shortened token budget corrupts a measurement without ever looking wrong.
No new FinishReason variant: budget_cap carries the distinction, which is
proportionate and keeps the enum stable.

Also confirms an asymmetry Justin observed: the native backend never hits
this because native generate bypasses scheduler admission entirely, while
ORT generation goes through it. Both load paths do set bytes_per_token, so
the difference is the code path rather than configuration.

Adds cancel cleanup so a failed admission cannot leak queued or running
entries, and rewrites the admission error to name the cause, the numbers,
and a next step, per RULES.md rule 1.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 023d5a3f-c551-4aa8-afbe-a329af15fbdd
.msg.txt was used to pass a long commit message to git commit -F and was
committed by accident. Removes it and ignores that pattern so the mistake
cannot repeat.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 023d5a3f-c551-4aa8-afbe-a329af15fbdd
@codecov

codecov Bot commented Jul 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 92.11196% with 31 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.48%. Comparing base (bfc8628) to head (bf829c2).
⚠️ Report is 3 commits behind head on main.

Files with missing lines Patch % Lines
crates/onnx-genai-scheduler/src/lib.rs 92.85% 19 Missing and 2 partials ⚠️
crates/onnx-genai-cli/src/output.rs 65.38% 5 Missing and 4 partials ⚠️
crates/onnx-genai-scheduler/src/byte_budget.rs 97.36% 1 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main     #335      +/-   ##
==========================================
- Coverage   81.19%   80.48%   -0.71%     
==========================================
  Files         308      308              
  Lines      118086   118446     +360     
  Branches   118086   118446     +360     
==========================================
- Hits        95876    95334     -542     
- Misses      18276    19178     +902     
  Partials     3934     3934              
Flag Coverage Δ
cli-ort-linux 82.03% <85.24%> (+0.07%) ⬆️
cli-ort-windows 81.41% <85.24%> (+0.07%) ⬆️
mlas 77.91% <ø> (-0.82%) ⬇️
offline 80.46% <93.37%> (-0.74%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
crates/onnx-genai-cli/src/profile.rs 89.87% <100.00%> (+0.57%) ⬆️
crates/onnx-genai-scheduler/src/byte_budget.rs 96.62% <97.36%> (+0.25%) ⬆️
crates/onnx-genai-cli/src/output.rs 69.10% <65.38%> (-0.59%) ⬇️
crates/onnx-genai-scheduler/src/lib.rs 90.72% <92.85%> (+5.42%) ⬆️

... and 7 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@github-actions

github-actions Bot commented Jul 28, 2026 •

Copy link
Copy Markdown

🔴 Benchmark Regression Detected

Comparison of criterion micro-benchmarks: PR head vs merge-base, measured on the same runner in the same job (base first → PR second).

ℹ️ Absolute times are informational only — they vary with runner load. The % change column is the reliable signal because both sides ran under identical conditions.

Status Scenario Base PR Change
🔴 matmul/small_generic_f32_threads=8/1x256x256 34.65 µs 65.99 µs +90.4%
🔴 matmul/large_generic_f32_threads=8/32x1024x1024 3.86 ms 7.26 ms +88.4%
🔴 gather/large_bf16_threads=1-internal/131072 12.79 µs 23.40 µs +83.0%
🔴 matmul/large_generic_bf16_threads=8/32x1024x1024 1.35 ms 2.40 ms +78.1%
🔴 matmul/small_generic_f16_threads=1/1x256x256 31.43 µs 50.09 µs +59.4%
🔴 matmul/small_generic_f32_threads=1/1x256x256 36.65 µs 54.70 µs +49.2%
🔴 matmul/medium_generic_f16_threads=8/32x512x512 29.84 µs 43.81 µs +46.8%
🔴 grammar_masking/llguidance_compute_mask/32 76.12 µs 111.34 µs +46.3%
🔴 gather/large_f16_threads=1-internal/131072 12.27 µs 17.43 µs +42.0%
🔴 matmul/small_generic_bf16_threads=8/1x256x256 31.52 µs 44.65 µs +41.7%
🔴 sampling_latency/top_p_per_token 979.61 µs 1.34 ms +36.7%
🔴 sampling_latency/min_p_per_token 347.44 µs 472.23 µs +35.9%
🔴 kv_cache/alloc_dealloc_pages 36.66 µs 48.44 µs +32.2%
🔴 matmul/large_generic_bf16_threads=1/32x1024x1024 2.01 ms 2.63 ms +30.4%
🔴 logit_processing/seven_processor_chain_per_step 1.17 ms 1.52 ms +30.2%
⚠️ matmul/medium_generic_bf16_threads=8/32x512x512 391.37 µs 467.47 µs +19.4%
⚠️ sampling_latency/greedy_per_token 3.20 µs 3.78 µs +18.2%
✅ matmul/medium_generic_f32_threads=8/32x512x512 939.86 µs 1.08 ms +14.7%
✅ matmul/medium_generic_f16_threads=1/32x512x512 30.68 µs 35.16 µs +14.6%
✅ matmul/small_generic_bf16_threads=1/1x256x256 32.33 µs 37.05 µs +14.6%
✅ tokenization/encode_tokens_per_second 377.63 µs 432.45 µs +14.5%
✅ matmul/large_generic_f16_threads=1/32x1024x1024 81.21 µs 92.63 µs +14.1%
✅ matmul/small_generic_f16_threads=8/1x256x256 32.02 µs 36.14 µs +12.9%
✅ matmul/medium_generic_f32_threads=1/32x512x512 2.33 ms 2.62 ms +12.8%
✅ sampling_latency/top_k_per_token 467.63 µs 525.22 µs +12.3%
✅ matmul/medium_generic_bf16_threads=1/32x512x512 534.54 µs 594.09 µs +11.1%
✅ tokenization/decode_tokens_per_second 6.28 ms 6.83 ms +8.8%
✅ matmul/large_generic_f32_threads=1/32x1024x1024 9.25 ms 9.89 ms +7.0%
✅ matmul/large_generic_f16_threads=8/32x1024x1024 85.71 µs 90.19 µs +5.2%
✅ reduce_mean/small_f32_threads=1-internal/4096 15.24 µs 15.90 µs +4.4%
✅ gather/medium_bf16_threads=1-internal/32768 2.33 µs 2.41 µs +3.4%
✅ add/large_f16_threads=1-internal/4194304 44.73 ms 44.96 ms +0.5%
✅ add/small_f16_threads=1-internal/1024 13.26 µs 12.85 µs -3.1%
✅ add/medium_f32_threads=1-internal/262144 2.73 ms 2.60 ms -4.8%
✅ gather/small_f16_threads=1-internal/4096 506.9 ns 480.7 ns -5.2%
✅ add/medium_bf16_threads=1-internal/262144 2.78 ms 2.63 ms -5.5%
✅ add/large_bf16_threads=1-internal/4194304 45.24 ms 42.55 ms -5.9%
✅ add/small_bf16_threads=1-internal/1024 13.58 µs 12.74 µs -6.2%
✅ reduce_mean/medium_f32_threads=1-internal/65536 263.28 µs 245.93 µs -6.6%
✅ add/medium_f16_threads=1-internal/262144 2.73 ms 2.55 ms -6.7%
✅ reduce_mean/large_f32_threads=1-internal/262144 1.05 ms 981.19 µs -7.0%
✅ gather/small_bf16_threads=1-internal/4096 508.0 ns 472.2 ns -7.0%
✅ gather/small_f32_threads=1-internal/4096 724.0 ns 662.7 ns -8.5%
✅ add/large_f32_threads=1-internal/4194304 44.26 ms 40.49 ms -8.5%
✅ gather/large_f32_threads=1-internal/131072 35.74 µs 31.73 µs -11.2%
✅ gather/medium_f16_threads=1-internal/32768 2.69 µs 2.34 µs -13.3%
🟢 add/small_f32_threads=1-internal/1024 251.1 ns 191.1 ns -23.9%
🟢 gather/medium_f32_threads=1-internal/32768 5.78 µs 3.75 µs -35.2%

Visual flags: ⚠️ ≥ 15% slower, 🔴 ≥ 30% slower — calibrated against measured runner noise (~27% worst-case on multi-threaded matmul)

Host info
CPU: Apple M1 (Virtual)
Cores: 3
OS: Darwin 25.4.0 arm64
Rust: rustc 1.97.1 (8bab26f4f 2026-07-14)
Load avg: { 5.97 6.01 8.35 }
What this cannot catch
  • Regressions in code paths not covered by these benchmarks (e.g., end-to-end decode with a real model)
  • Sub-threshold regressions that compound over multiple PRs
  • Performance changes that only manifest under GPU execution
  • Latency changes in the ORT integration path (these benchmarks exercise the native Rust kernels)

@justinchuby
justinchuby requested a review from Copilot July 28, 2026 08:38
justinchuby pushed a commit that referenced this pull request Jul 28, 2026
Drops the stray .msg.txt scratch file, which leaked onto main from my own
commit-message workflow and is removed there by #335.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 023d5a3f-c551-4aa8-afbe-a329af15fbdd

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes a scheduler admission regression where requests could be refused after the CLI began defaulting max_new_tokens to the remaining context window. Instead of weakening the conservative KV-byte reservation invariant, the scheduler now caps the admitted generation ceiling to what the shared KV byte budget can guarantee, and makes that cap observable end-to-end (engine result + CLI stderr + stats/profile output).

Changes:

  • Scheduler: add admission-time ceiling capping (ScheduledBudgetCap), richer admission failure reasons, and cancellation cleanup for failed/aborted requests.
  • Engine: route ORT generation through the scheduler admission result API, propagate budget_cap via GenerateResult, and ensure max token ceilings reflect scheduler admission.
  • CLI: surface the cap on stderr (stdout remains stable) and include it in stats/profile text + JSON; remove/ignore scratch commit-message files.

Reviewed changes

Copilot reviewed 13 out of 14 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
crates/onnx-genai/src/lib.rs Re-export GenerationBudgetCap from the top-level crate API.
crates/onnx-genai-scheduler/src/lib.rs Implement admitted-ceiling capping, admission error reporting, and request cancel cleanup; add tests.
crates/onnx-genai-engine/src/session.rs Track optional budget_cap in active generation state.
crates/onnx-genai-engine/src/pipeline/nested_autoregressive.rs Initialize GenerateResult.budget_cap in a pipeline result constructor.
crates/onnx-genai-engine/src/lib.rs Re-export GenerationBudgetCap from the engine crate API.
crates/onnx-genai-engine/src/engine/runtime.rs Use scheduler admission result API for ORT generation; propagate admitted max tokens + budget_cap; add a unit test for scheduler usage behavior.
crates/onnx-genai-engine/src/engine/mod.rs Wire scheduler/engine types needed for cap propagation (ScheduledBudgetCap, GenerationBudgetCap).
crates/onnx-genai-engine/src/decode_loop.rs Ensure decode-loop GenerateResult initializer sets budget_cap: None.
crates/onnx-genai-engine/src/config.rs Define GenerationBudgetCap and add GenerateResult.budget_cap.
crates/onnx-genai-cli/src/profile.rs Add budget-cap fields to stats/profile and expose them in JSON output; add tests.
crates/onnx-genai-cli/src/output.rs Emit stderr notice when the scheduler caps max_new_tokens; propagate cap into profiling; add tests.
.squad/agents/batty/history.md Update agent history notes for this change set.
.msg.txt Remove stray scratch file from the repo.
.gitignore Ignore common scratch commit-message files (.msg.txt, .pr-body.md, .git-msg.txt).

Comment on lines +368 to +370
if scheduled.request_id != request_id || scheduled.seq_id != session_id {
self.scheduler.cancel_request(scheduled.request_id);
self.scheduler.complete(session_id);
Comment thread crates/onnx-genai-scheduler/src/lib.rs Outdated
Comment on lines +340 to +364
Err(error) => {
let available_bytes = error.available;
let Some(admitted_max_tokens) =
self.max_tokens_for_available_bytes(request.prompt_tokens, error.available)
else {
return Err(self.admission_byte_error(request, requested_bytes, error));
};
admitted.max_tokens = admitted_max_tokens.min(request.max_tokens);
let capped_bytes =
self.estimated_bytes(admitted.prompt_tokens, admitted.max_tokens);
match self.try_reserve_bytes(capped_bytes) {
Ok(()) => Ok((
admitted,
capped_bytes,
Some(ScheduledBudgetCap {
requested_max_tokens: request.max_tokens,
admitted_max_tokens: admitted_max_tokens.min(request.max_tokens),
requested_bytes,
admitted_bytes: capped_bytes,
available_bytes,
}),
)),
Err(error) => Err(self.admission_byte_error(request, requested_bytes, error)),
}
}
Comment thread crates/onnx-genai-scheduler/src/lib.rs
Copilot AI added 3 commits July 28, 2026 03:25
…on race

Three follow-ups from review of the capping fix.

The cap was cleared on swap-in while the capped ceiling stayed in effect,
so a sequence swapped out and back in generated under a shortened budget
while reporting it was not capped. That is worse than never annotating it,
because it is confidently wrong rather than silent -- and it lands exactly
where it hurts, since a long benchmark run is the one most likely to be
swapped and the one most in need of the annotation. The cap is a property
of the admitted sequence, so it now travels with it.

The capped retry read available bytes and then reserved, but ByteBudget is
shared across schedulers by design, so another reservation could land in
between and the capped retry could fail too. Adds
ByteBudget::try_reserve_at_most, which computes the capped amount and
reserves it under a single hold of the shared budget. An atomic
reserve-adjust rather than a retry loop, because retrying against a
contended budget trades a clear failure for a stall.

The mismatched-admission branch cancelled the admitted request but left
the original enqueued, where it could be admitted later unexpectedly, and
called complete() where it should not have. Cleanup is now symmetric
across every exit from that path.

Each fix has a regression test, including budget_cap_survives_swap_out_and
_swap_in.

Engine lib tests now run clean locally against a conda ORT following #315.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 023d5a3f-c551-4aa8-afbe-a329af15fbdd
Resolves an additive conflict in output.rs: #325 added
should_emit_stats_line and its test while this branch added
budget_cap_notice and its test, in the same two places. Both are kept.

Verified after resolution: build, 96 CLI lib tests, clippy, fmt.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 023d5a3f-c551-4aa8-afbe-a329af15fbdd
Resolves import-list conflicts in engine/mod.rs and lib.rs: #291 added the
checkpoint and rewind types while this branch added GenerationBudgetCap.
Both sets are kept.

Verified after resolution: engine builds, 253 engine lib tests pass,
scheduler tests pass, clippy and fmt clean.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 023d5a3f-c551-4aa8-afbe-a329af15fbdd
@justinchuby
justinchuby merged commit 427d18f into main Jul 28, 2026
12 checks passed
@justinchuby
justinchuby deleted the fix/scheduler-budget-regression branch July 28, 2026 11:43
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.

3 participants