Repository navigation
Bound the shape-keyed kernel cache per node (#1362) - #1388
Merged
Merged
Conversation
The executor's kernel cache is keyed by node *and* input shapes and never evicted, so every distinct prompt length compiled a fresh kernel for every node in the graph. A compiled kernel owns device workspaces (attention scratch, dequantization scratch, broadcast metadata), so a server whose requests each bring a new prompt length accumulated those workspaces without limit until the device ran out of memory. The bytes were invisible to the resource governor, which accounts for weights, KV pools, and bindings but not for memory a kernel allocates for itself. That is why the failure read as "activations scale with prompt length": VRAM grew with the number of *distinct* shapes seen, not with the size of any one request. Replaying the same prompt lengths in descending order still grew memory monotonically, which is what separated an accumulating cache from a per-request peak. Keep at most a bounded number of shape variants per node, evicting the least recently used beyond it. The bound is per node rather than global so eviction stays proportional to what actually varies: a graph keeps its hot prefill and decode variants and only a node that has genuinely seen many shapes gives one up. The EP's captured device graph is reset before an eviction frees a kernel, because a capture can have baked the pointer of a workspace the eviction is about to release. Measured on a 30B int4 model on the native CUDA backend, 12 requests with prompt lengths from 469 to 5502 tokens: device memory climbed 38 GiB to 72.7 GiB with three out-of-memory failures, and now stays flat at 45-53 GiB with every request succeeding and no change in latency. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0190e2eb-abe4-451f-b36d-44a035a99b7e
This was referenced Aug 19, 2026
justinchuby
added a commit
that referenced
this pull request
Aug 19, 2026
## What A model directory can declare a prefill chunk size, and the ORT backend splits a prompt into that many tokens per forward. The native backend read the metadata and then never applied it — a prompt arrived at the decode session as one tensor and ran as one forward. The two backends therefore disagreed about the shapes a given model produces. This applies the declared size in the native session. ## How A prefill longer than the chunk runs as a sequence of forwards, each appending to the KV cache, keeping only the final chunk's logits (the earlier ones describe tokens the caller already has). Chunking has to slice whatever the caller passed, not just token ids. A multimodal prompt arrives as `inputs_embeds`, where the sequence lives on the middle axis of a `[1, tokens, hidden]` tensor, so step inputs are sliced along that axis alongside the ids. A tensor that does not expose a sequence axis that way is left whole rather than guessed at — slicing the wrong axis would silently corrupt the prompt instead of failing. The size is injected on **both** paths that build a native decoder: the direct engine load, and the pipeline builder. The latter matters because it constructs its decoder lazily on the first request and would otherwise never see the metadata. ## Not a memory fix This came out of investigating #1362, but it is not the fix for it. The pipeline caller already chunked its own prefill before reaching the decode session, so the accumulating device memory on long prompts came from elsewhere (see #1388). This PR stands on its own as backend parity. ## Tests `prefill_chunk_tests` covers the slicing directly: which tensors expose a sequence axis, that a slice takes exactly its row range out of the middle axis, and that a slice past the end is refused rather than truncated. `cargo test -p onnx-genai-engine --lib --features native-backend` — 544 passed. fmt and clippy clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 0190e2eb-abe4-451f-b36d-44a035a99b7e
|
| Status | Scenario | Base | PR | Change |
|---|---|---|---|---|
grammar_masking/llguidance_compute_mask/32 |
77.02 µs | 95.72 µs | +24.3% | |
matmul/medium_generic_f16_threads=8/32x512x512 |
46.95 µs | 57.80 µs | +23.1% | |
qwen3_sampling_processors/top_k_top_p_fast |
675.04 µs | 824.52 µs | +22.1% | |
logit_processing/seven_processor_chain_per_step |
324.59 µs | 384.57 µs | +18.5% | |
| ✅ | qwen3_sampling_processors/top_k_top_p_full_sort_baseline |
5.81 ms | 6.40 ms | +10.1% |
| ✅ | kv_cache/alloc_dealloc_pages |
40.10 µs | 43.27 µs | +7.9% |
| ✅ | qwen3_sampling_processors/top_k_partial_selection |
146.11 µs | 153.35 µs | +5.0% |
| ✅ | gather/small_bf16_threads=1-internal/4096 |
491.5 ns | 515.1 ns | +4.8% |
| ✅ | add/large_bf16_threads=1-internal/4194304 |
1.73 ms | 1.80 ms | +3.7% |
| ✅ | gather/small_f32_threads=1-internal/4096 |
659.8 ns | 678.2 ns | +2.8% |
| ✅ | matmul/large_generic_bf16_threads=1/32x1024x1024 |
2.07 ms | 2.12 ms | +2.6% |
| ✅ | matmul/medium_generic_f16_threads=1/32x512x512 |
38.22 µs | 39.02 µs | +2.1% |
| ✅ | matmul/large_generic_f32_threads=1/32x1024x1024 |
10.19 ms | 10.19 ms | +0.0% |
| ✅ | matmul/medium_generic_bf16_threads=1/32x512x512 |
606.75 µs | 601.93 µs | -0.8% |
| ✅ | reduce_mean/small_f32_threads=1-internal/4096 |
17.60 µs | 17.43 µs | -1.0% |
| ✅ | reduce_mean/medium_f32_threads=1-internal/65536 |
272.08 µs | 268.50 µs | -1.3% |
| ✅ | qwen3_sampling_processors/top_p_fast_after_top_k |
523.86 µs | 506.88 µs | -3.2% |
| ✅ | qwen3_sampling_processors/top_k_full_sort_baseline |
2.15 ms | 2.05 ms | -4.5% |
| ✅ | matmul/large_generic_f16_threads=8/32x1024x1024 |
102.30 µs | 95.11 µs | -7.0% |
| ✅ | qwen3_sampling_processors/top_p_full_sort_after_top_k_baseline |
3.70 ms | 3.43 ms | -7.3% |
| ✅ | sampling_latency/top_k_per_token |
54.88 µs | 49.25 µs | -10.3% |
| ✅ | gather/medium_f32_threads=1-internal/32768 |
4.22 µs | 3.76 µs | -10.8% |
| ✅ | gather/small_f16_threads=1-internal/4096 |
545.9 ns | 484.0 ns | -11.3% |
| ✅ | sampling_latency/top_p_per_token |
410.33 µs | 362.82 µs | -11.6% |
| ✅ | gather/medium_f16_threads=1-internal/32768 |
2.74 µs | 2.42 µs | -11.7% |
| ✅ | sampling_latency/min_p_per_token |
221.38 µs | 193.82 µs | -12.4% |
| ✅ | sampling_latency/greedy_per_token |
3.43 µs | 2.97 µs | -13.4% |
| ✅ | add/small_f32_threads=1-internal/1024 |
234.9 ns | 202.9 ns | -13.6% |
| ✅ | add/medium_bf16_threads=1-internal/262144 |
119.44 µs | 102.79 µs | -13.9% |
| 🟢 | tokenization/encode_tokens_per_second |
412.21 µs | 349.65 µs | -15.2% |
| 🟢 | gather/medium_bf16_threads=1-internal/32768 |
2.75 µs | 2.31 µs | -16.0% |
| 🟢 | add/large_f32_threads=1-internal/4194304 |
802.51 µs | 672.18 µs | -16.2% |
| 🟢 | matmul/small_generic_f32_threads=1/1x256x256 |
42.82 µs | 35.50 µs | -17.1% |
| 🟢 | tokenization/decode_tokens_per_second |
7.06 ms | 5.78 ms | -18.1% |
| 🟢 | matmul/large_generic_f32_threads=8/32x1024x1024 |
6.92 ms | 5.57 ms | -19.5% |
| 🟢 | reduce_mean/large_f32_threads=1-internal/262144 |
1.24 ms | 973.72 µs | -21.4% |
| 🟢 | matmul/small_generic_f16_threads=1/1x256x256 |
37.07 µs | 28.67 µs | -22.7% |
| 🟢 | add/medium_f32_threads=1-internal/262144 |
35.03 µs | 26.87 µs | -23.3% |
| 🟢 | add/small_f16_threads=1-internal/1024 |
581.2 ns | 444.2 ns | -23.6% |
| 🟢 | matmul/small_generic_bf16_threads=1/1x256x256 |
38.87 µs | 28.81 µs | -25.9% |
| 🟢 | add/small_bf16_threads=1-internal/1024 |
636.2 ns | 463.1 ns | -27.2% |
| 🟢 | add/large_f16_threads=1-internal/4194304 |
2.31 ms | 1.68 ms | -27.5% |
| 🟢 | matmul/large_generic_f16_threads=1/32x1024x1024 |
110.09 µs | 79.74 µs | -27.6% |
| 🟢 | add/medium_f16_threads=1-internal/262144 |
144.35 µs | 103.30 µs | -28.4% |
| 🟢 | matmul/small_generic_f16_threads=8/1x256x256 |
40.79 µs | 29.05 µs | -28.8% |
| 🟢 | gather/large_f32_threads=1-internal/131072 |
43.91 µs | 30.53 µs | -30.5% |
| 🟢 | matmul/medium_generic_f32_threads=8/32x512x512 |
1.87 ms | 1.29 ms | -30.8% |
| 🟢 | matmul/medium_generic_f32_threads=1/32x512x512 |
3.16 ms | 2.18 ms | -31.1% |
| 🟢 | matmul/small_generic_bf16_threads=8/1x256x256 |
47.15 µs | 31.88 µs | -32.4% |
| 🟢 | matmul/large_generic_bf16_threads=8/32x1024x1024 |
2.60 ms | 1.71 ms | -34.5% |
| 🟢 | block_quantized_moe_cached_dense/mxfp4_uncached_expert_dequant_each_call/rows=1,H=256,I=256,E=4,top_k=1 |
594.41 µs | 372.12 µs | -37.4% |
| 🟢 | matmul/medium_generic_bf16_threads=8/32x512x512 |
699.37 µs | 427.96 µs | -38.8% |
| 🟢 | gather/large_bf16_threads=1-internal/131072 |
18.35 µs | 10.63 µs | -42.0% |
| 🟢 | matmul/small_generic_f32_threads=8/1x256x256 |
56.57 µs | 32.43 µs | -42.7% |
| 🟢 | block_quantized_matmul_cached_dense/mxfp4_uncached_dequant_each_call/1x1024x1024 |
1.29 ms | 740.52 µs | -42.8% |
| 🟢 | gather/large_f16_threads=1-internal/131072 |
18.19 µs | 10.28 µs | -43.5% |
| 🟢 | block_quantized_matmul_cached_dense/mxfp4_preexpanded_dense_oncelock_like_proxy/1x1024x1024 |
96.38 µs | 45.98 µs | -52.3% |
| 🟢 | block_quantized_moe_cached_dense/mxfp4_cached_dense_expert_repeated_call/rows=1,H=256,I=256,E=4,top_k=1 |
135.61 µs | 48.95 µs | -63.9% |
| 🟢 | block_quantized_matmul_cached_dense/mxfp4_cached_dense_repeated_call/1x1024x1024 |
132.01 µs | 45.90 µs | -65.2% |
Visual flags:
Host info
CPU: Apple M1 (Virtual)
Cores: 3
OS: Darwin 25.5.0 arm64
Rust: rustc 1.97.1 (8bab26f4f 2026-07-14)
Load avg: { 4.19 3.59 5.21 }
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)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The executor's kernel cache is keyed by node and input shapes and never evicted. Every distinct prompt length therefore compiled a fresh kernel for every node in the graph, and each compiled kernel owns device workspaces (attention scratch, dequantization scratch, broadcast metadata). A server whose requests each bring a new prompt length accumulated those workspaces without limit until the device ran out of memory.
This bounds the cache to a small number of shape variants per node, evicting the least recently used beyond it.
Why it looked like something else
The bytes are invisible to the resource governor, which accounts for weights, KV pools and bindings but not for memory a kernel allocates for itself.
/v1/resourcesreportedvram.used= weights only whilenvidia-smishowed 70+ GiB. That is why the failure first read as "prefill activations scale with prompt length".What separated the two hypotheses: replaying the same prompt lengths in descending order still grew memory monotonically (43 → 72 GiB). A per-request peak would have shown its maximum on the first, largest request.
Attributing live
cuMemAllocblocks to their allocation sites showed the growth as thousands of retained per-node workspaces (MatMulNBitsKernel::run,GqaWorkspace::reserve,BroadcastMetadataCache::prepare) growing in lockstep at one group per graph node per distinct shape.Design
ONNX_RUNTIME_KERNEL_CACHE_VARIANTS_PER_NODEoverrides the default of 4.Measured
30B int4 model, native CUDA backend, 12 requests with prompt lengths from 469 to 5502 tokens:
Before: three out-of-memory failures. After: every request succeeds, memory is flat, and latency is unchanged (43.7s vs 48.1s on the longest prompt).
Tests
kernel_cache_bounds_the_shape_variants_it_keeps_per_nodefeeds one node 24 distinct shapes and asserts the cache evicts rather than keeping one entry per shape, while every run still returns the correct result.cargo test -p onnx-runtime-session(all green),cargo test -p onnx-genai-engine --lib(427 passed), fmt and clippy clean.Fixes #1362