Skip to content

fix(qwen3): cap PerToken CUDA Graph buckets - #848

Open
RicardoMin wants to merge 4 commits into
pegainfer-project:mainfrom
RicardoMin:fix/qwen3-pertoken-graph-memory-780
Open

fix(qwen3): cap PerToken CUDA Graph buckets#848
RicardoMin wants to merge 4 commits into
pegainfer-project:mainfrom
RicardoMin:fix/qwen3-pertoken-graph-memory-780

Conversation

@RicardoMin

Copy link
Copy Markdown

Description

Fixes #780

PerToken routes projection GEMMs through a row-wise numerical path. CUDA Graph
capture records one GEMM node per token row, so graph executable memory grows
with larger decode buckets.

This change limits PerToken CUDA Graph capture to bucket 32. Batches above the
cap keep the PerToken numerical path but fall back to eager decode. Tensor
parallel startup uses the same rule when performing graph pre-capture and
finalization.

Type of Change

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Documentation update

Validation

  • cargo fmt --all --check
  • git diff --check
  • cargo check --release -p pegainfer-qwen3 --lib
  • Single-GPU batch_invariance_decode_gemm_graph
  • TP2 tp_pertoken_graph_cap
  • TP2 tp_concurrent_decode

Observed:

  • Single GPU: batch 32 replays the captured graph; batch 33 executes eager GEMMs twice.
  • TP2: bs=32 served 0->0; bs=33 served 506->1012.

Checklist

  • My code follows the style guidelines of this project.
  • I have performed a self-review of my own code.
  • I have formatted my commits according to Commitizen conventions.
  • I have run the complete local test suite and all tests pass.

@RicardoMin
RicardoMin marked this pull request as ready for review August 5, 2026 10:11

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c832603de

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread pegainfer-qwen3/src/batch_decode.rs Outdated
Signed-off-by: RicardoMin <17879681016@163.com>
@RicardoMin
RicardoMin force-pushed the fix/qwen3-pertoken-graph-memory-780 branch from 4c83260 to 5b77c70 Compare August 5, 2026 13:14
@RicardoMin

Copy link
Copy Markdown
Author

The CI workflow for this fork PR is still awaiting maintainer approval.
Could a maintainer approve the run when convenient?

@xiaguan

xiaguan commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

sure

Signed-off-by: RicardoMin <17879681016@163.com>
@RicardoMin

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1fd04d8111

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread pegainfer-qwen3/src/batch_decode.rs Outdated
Signed-off-by: RicardoMin <17879681016@163.com>
@RicardoMin

Copy link
Copy Markdown
Author

Could a maintainer please approve the pending CI workflow so the checks can run? Thank you!

@FeathBow FeathBow left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thank you for working on #780. The cap-and-eager direction is reasonable, but the current head does not yet close the issue.

The profile accounts for bucket 32 only. The other seven allowed buckets can still be captured lazily after KV sizing and remain resident alongside it, so the complete eight-graph set is not budgeted. Please account for the retained set and provide an exact-head base-fails/head-passes receipt for the original 16 GB, utilization-0.90 failure. The 32/33 counters prove dispatch mode, not memory closure.

PerToken+TP is not reachable through the public server contract: --batch-invariant selects Pin and rejects TP. Unless this low-level combination is intentionally supported, please remove its dedicated implementation and 199-line test. Most of the 50-line helper-return block tests local implementation details. Please remove the supplemental-profile matrix and retain, at most, a compact policy-boundary check for PerToken versus Tuned and Pin.

I’d appreciate it if you could give the full diff a careful self-review pass before requesting re-review. That would help keep the next review focused on the substantive remaining issues :)

Comment thread pegainfer-qwen3/src/unified_forward.rs Outdated
// instantiate. Capture that allowed bucket first on the single-GPU
// path so its executable memory is included in the KV budget. TP
// keeps this profile eager because ranks are not coordinated here.
let policy = numeric_policy();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bucket 32 is not a memory upper bound: BatchDecodeBuffers retains independent graph state for every allowed bucket, so buckets 1, 2, 4, 8, 16, 20, and 24 can still be captured lazily after KV sizing and remain live alongside bucket 32. #780 is specifically a cumulative-residency OOM; please profile or reserve the complete retained set and attach the original 16 GB base-fails/head-passes receipt.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

At the time of testing, I only had access to a 22 GB-class NVIDIA L4, so the results above were collected on that GPU with gpu_memory_utilization=0.90. If validation must be performed on a physical 16 GB GPU, I can rerun the same base/candidate tests on a 16 GB GPU when I am back home and provide the results.

Comment thread pegainfer-qwen3/src/executor.rs Outdated
};
run_phase(PrecapturePhase::Warmup)?;
for bucket_idx in 0..BATCH_BUCKETS.len() {
let graph_policy = pegainfer_kernels::ops::numeric_policy();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The public Qwen3 launch path maps --batch-invariant to Pin and rejects tensor parallelism for that mode; #780 is also explicitly scoped as verifiable on one consumer GPU. This TP branch is therefore exercised only by callers that directly set the diagnostic PerToken policy and construct the low-level executor, as the new test does.

If that combination is not a supported contract, the TP filtering and 199-line TP test are unnecessary scope for this fix. If it is intended to be supported, please state that contract and retain the following per-rank memory verification as part of the PR evidence.

160, 168, 176, 184, 192, 200, 208, 216, 224, 232, 240, 248, 256,
];
/// Largest batch bucket for which PerToken keeps a CUDA Graph
pub(crate) const PERTOKEN_GRAPH_MAX_BUCKET: usize = 32;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Bucket 32 is a plausible conservative boundary because #780 first observed visible drain around bucket 56, but the current counters only prove graph-versus-eager routing. They do not show that the complete retained allowed set [1, 2, 4, 8, 16, 20, 24, 32] closes the original 16 GB, utilization-0.90 OOM.

Please attach the exact-head command, GPU/model/CUDA configuration, and a base-fails/head-passes receipt from the same full bucket sweep. A post-sweep cuMemGetInfo reading would make the remaining headroom auditable. Please also report the decode-step or TPOT cost at the 32/33 boundary.

Comment thread pegainfer-qwen3/src/executor.rs Outdated
.filter(|&&bucket| per_token_graph_bucket_allowed(graph_policy, bucket))
.count();

for (bucket_idx, &bucket) in BATCH_BUCKETS.iter().enumerate() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

TP profiling is eager, and the KV pool has already been sized and allocated before this loop captures the eight allowed PerToken graphs on every rank. The new TP counter test proves which path executes, but it does not measure this post-budget residency.

Capping may be sufficient; it just needs evidence. Please either reserve the complete per-rank graph footprint or report the exact cumulative per-rank allocation/headroom under the configuration used for the TP claim.


/// Number of CUDA devices visible to this test process
fn cuda_device_count() -> usize {
cudarc::driver::CudaContext::device_count().map_or(0, |count| count.max(0) as usize)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

map_or(0, ...) makes a CUDA driver/context failure indistinguishable from a host with zero GPUs. The test then returns successfully, so an explicitly requested TP acceptance run can be green without executing any model code.

Please propagate or panic on device_count() errors. Only a successful query that reports fewer than two visible devices should take the availability-skip path.

Comment thread pegainfer-qwen3/src/batch_decode.rs Outdated
let use_cuda_graph = graphs_available && graph_use != DecodeGraphUse::Eager;
// PerToken records one GEMM node per now; cap Large buckets to bound graph memory.
// Above the cap, keep the PetToken arithmetic but execute the kernels eagerly
let mut use_cuda_graph = graphs_available && graph_use != DecodeGraphUse::Eager;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The cap is enforced here only for Serve; CaptureOnly and Replay still accept a disallowed PerToken bucket and rely on separate controller/lane/Finalize filters. The lane currently turns such a request into a silent successful no-op, which can hide a protocol mismatch.

Please give graph eligibility one owner. Serve may intentionally choose eager above the cap, while an explicit capture/replay request for a disallowed bucket should be impossible by construction or fail loudly.

NumericPolicy::Tuned,
*super::BATCH_BUCKETS.last().unwrap()
));
assert_eq!(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These assertions mostly restate a local helper and remain green despite the cumulative-accounting bug. The retained 32/33 integration probe already covers the serving boundary. Please remove the supplemental-profile matrix; if the unaffected-policy contract needs direct coverage, keep only a compact check for PerToken, Tuned, and Pin. A full-plan unit test is useful only if profiling and serving consume one canonical graph plan.

@@ -0,0 +1,199 @@
//! TP=2 regression for the PerToken CUDA-Graph bucket cap.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please remove this test unless direct PerToken+TP is a supported low-level contract. If it is supported, retain only the TP-specific pre-capture/barrier contract that the single-GPU gate cannot cover.

@RicardoMin

RicardoMin commented Aug 6, 2026 via email

Copy link
Copy Markdown
Author

@RicardoMin

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3420aaa8a4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread pegainfer-qwen3/src/batch_decode.rs
@RicardoMin

Copy link
Copy Markdown
Author

Qwen3 PerToken CUDA Graph memory validation

Thank you for the detailed review. I revised the implementation to account for the complete retained Graph set and validated the memory and performance behavior with a same-GPU base/candidate comparison.

Current PR head: 3420aaa8a4c8c9768f6ce04f7588e8c56ba1adaa
Comparison base: 57ee51f71e45b3745050fd8c39c6dd88b4fbde5e

Test environment

Item Value
GPU NVIDIA L4, 23,034 MiB
Driver / CUDA 550.163.01 / 12.4
CUDA target sm_89
Model Qwen3-4B
Numeric policy PerToken
CUDA Graph enabled
GPU memory utilization 0.90

Implementation

  • PerToken retains CUDA Graphs only for [1, 2, 4, 8, 16, 20, 24, 32].
  • All eight retained Graphs are captured cumulatively in the same BatchDecodeBuffers during memory profiling, with synchronization before every memory sample.
  • Batches above bucket 32 preserve PerToken arithmetic but execute eagerly at their exact batch size.
  • Serving and profiling consume the same DecodeGraphPlan; explicit ineligible capture/replay requests fail loudly.
  • The supplemental profile matrix and the unsupported PerToken+TP implementation/test were removed.

Full-bucket memory sweep

The same diagnostic exercised all 36 decode buckets twice in one executor and sampled cuMemGetInfo after synchronization. Request KV state was dropped after every bucket, while captured Graph executables remained resident.

Measurement Base Candidate Change
Start free memory 2,522.12 MiB 2,528.12 MiB +6.00 MiB
Maximum Graph-eligible buckets 36 8 -77.78%
Completed sweep buckets 20/36 (55.56%) 36/36 (100%) +44.44 pp
Retained memory after bucket 32 16.00 MiB 16.00 MiB 0%
Retained memory after bucket 128 2,482.00 MiB 16.00 MiB -99.36%
Free memory after bucket 128 40.12 MiB 2,512.12 MiB +2,472.00 MiB
Bucket 136 CUDA OOM during the following prefill Completed eagerly OOM eliminated
Full 36-bucket sweep FAIL PASS Regression closed
Final free memory Not reached 2,512.12 MiB -

Candidate dispatch was:

buckets 1..32   -> 8 CUDA Graph buckets
buckets 40..256 -> 28 exact-size eager buckets

The base failure surfaced during the bucket-136 prefill allocation after the preceding Graph captures had reduced free memory to 40.12 MiB. The candidate completed all 36 buckets with a final retained delta of only 16.00 MiB.

Batch 32/33 boundary

The direct execute_decode probe used a 256-token prompt, 10 warm-up rounds, and 100 paired measurement rounds with alternating execution order and matched context progression.

Metric Batch 32 Graph Batch 33 eager Relative change
Counter delta 0 253 Confirms Graph / eager dispatch
Mean Decode-step latency 445.0217 ms 462.6259 ms +3.956%
p50 Decode-step latency 445.1284 ms 462.3701 ms +3.873%
p99 Decode-step latency 450.2924 ms 465.6003 ms +3.400%
Aggregate throughput 71.91 tok/s 71.33 tok/s -0.799%

Across 100 paired rounds, the mean latency delta was +17.6042 ms with a 95% CI of [17.2299, 17.9785] ms.

Validation

Check Result
cargo fmt --all --check PASS
git diff --check PASS
Qwen3 release library check PASS
Canonical graph-plan unit test PASS
batch_invariance_decode_gemm_graph PASS
Full candidate bucket sweep PASS, 36/36
Boundary performance probe PASS, 100 paired rounds

Conclusion

The base run confirms that the OOM is caused by cumulative PerToken Graph-executable residency, not by one largest Graph. The revision accounts for the complete eight-Graph retained set before KV sizing and prevents additional persistent Graph allocation above bucket 32. All 36 buckets complete successfully, while the measured aggregate throughput change at the 32/33 boundary is -0.799%.

PerToken+TP remains outside the supported public contract and is not claimed by this PR.

Signed-off-by: RicardoMin <17879681016@163.com>
@RicardoMin
RicardoMin force-pushed the fix/qwen3-pertoken-graph-memory-780 branch from 3420aaa to 824f5d8 Compare August 8, 2026 03:38
@RicardoMin

Copy link
Copy Markdown
Author

I've fixed the DCO sign-off issue. The CI workflow is currently awaiting maintainer approval. Thanks!

@FeathBow
FeathBow self-requested a review August 14, 2026 14:54

@FeathBow FeathBow left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Thanks for the rework.

Imo, two blockers remain.

PerToken+TP is described as unsupported but is not rejected at the public low-level constructor. It loads and profiles the ranks, allocates KV, spawns workers and arms the TP watchdog before failing at bucket 40. Returning that error drops the success sender, while the watchdog remains armed and aborts the process at its 600-second deadline. Please reject this combination before model loading and watchdog creation.

Profiling reserves only the full-SM graphs cache. Decode overlap captures into the independent graphs_split cache, while enable_decode_overlap() rejects Pin but still allows PerToken. Please reject PerToken+overlap at that guard or account for both graph sets.

Btw, the remaining documentation and cleanup items are non-blocking, but it would be great if you could address them in the same pass.

);
Ok(None)
}
DecodeGraphUse::CaptureOnly | DecodeGraphUse::Replay => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This part is reached by the unconditional TP sweep at bucket 40, after model/KV allocation, worker startup and watchdog creation. Returning Err drops sweep_done_tx; the watchdog deliberately remains armed and later calls std::process::abort(), so even a caller that handles the error can lose the process ten minutes later.

If PerToken+TP is unsupported, please reject it at the start of from_runtime_with_lora_options(). This does not require TP-specific cap handling or restoring the deleted TP test.

&& self.tensor_parallel.world_size == 1
&& graph_plan.requires_cumulative_profile()
{
for graph_rows in graph_plan.retained_buckets() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This loop reserves only the full-SM graphs cache. SplitConcurrent captures into the independent graphs_split cache; enable_decode_overlap() rejects Pin but allows PerToken, and the PerToken GEMM accepts the override stream. One executor can therefore retain two eight-graph sets while profiling reserves one.

Please reject PerToken+overlap at the existing guard or profile both caches. The guard is the smaller fix if this diagnostic combination is unsupported.

fn assert_pertoken_graph_cap_behavior(model_path: &str) {
set_numeric_policy(NumericPolicy::PerToken);

let mut ex = Qwen3Executor::from_runtime(model_path, true, &[0]).expect("build probe executor");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The test already constructs a PerToken executor in run_policy(), drops it, then loads a fourth model for this probe. Please run the 32/33 counter check on the existing PerToken executor. Keep the counter transition; the extra model startup, LOGPROBS=64, diagnostic print and helper-name comments add no coverage.

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.

qwen3: PerToken decode-graph memory grows linearly with batch bucket and is not budgeted

3 participants