[Bugfix][Spec Decode] Keep default CUDA graph sizes memory-safe (#54418)
Signed-off-by: Kevin Luu <51931015+khluu@users.noreply.github.com>
Kevin H. Luu committed
Aug 30, 2026 at 11:02 UTC
b2dc864bb668da328aee8a8b0b72a0ad13c82252
3 files changed
+73
-67
tests/compile/test_config.py
+64
-56
@@ -542,34 +542,36 @@ def _mock_config_for_cudagraph_sizes(
542
543
544
@pytest.mark.parametrize(
545
- ("max_num_seqs", "num_speculative_tokens"),
545
+ ("max_num_seqs", "num_speculative_tokens", "widest_is_captured"),
546
[
547
# No speculation: the 2x headroom under the platform ceiling, unchanged.
548
- (8, 0),
549
- (32, 0),
548
+ (8, 0, True),
549
+ (32, 0, True),
550
# Speculating, but the widest decode batch still fits under the ceiling.
551
- (64, 7),
552
- (256, 1),
553
- # Widest decode batch above the ceiling, which must not cut below it.
554
- (32, 16),
555
- (64, 16),
551
+ (64, 7, True),
552
+ (256, 1, True),
553
+ # Wider decode batches must not raise the memory-safety ceiling.
554
+ (32, 16, False),
555
+ (64, 16, False),
556
+ # Exact H200 regressions: DFlash (2176) and suffix decoding (6400).
557
+ (128, 16, False),
558
+ (256, 24, False),
559
# Widest decode batch off the capture stride, above the ceiling.
557
- (33, 16),
560
+ (33, 16, False),
561
# ... and off the stride while below it, where the ceiling stands but
562
# the generated sizes would otherwise stop at 400.
560
- (24, 16),
563
+ (24, 16, True),
564
],
565
)
563
-def test_default_cudagraph_capture_size_covers_widest_uniform_decode(
564
- max_num_seqs, num_speculative_tokens
566
+def test_default_cudagraph_capture_size_respects_platform_ceiling(
567
+ max_num_seqs, num_speculative_tokens, widest_is_captured
568
):
566
- """The widest uniform decode batch has to be inside the captured range.
569
+ """Uniform decode coverage must not raise the platform default ceiling.
570
571
A decode step presents up to `max_num_seqs * (1 + num_speculative_tokens)`
569
- tokens. If that size is not captured the decode path silently falls back to
570
- eager while the resolved mode still reports full decode graphs, so neither
571
- the platform's token-denominated ceiling nor the capture stride may stop
572
- short of it.
572
+ tokens. Off-stride sizes within the platform ceiling are captured exactly.
573
+ Larger batches fall back to eager execution instead of expanding the
574
+ default capture range and risking OOM during engine initialization.
575
"""
576
compilation_config = CompilationConfig(
577
cudagraph_mode=CUDAGraphMode.FULL_AND_PIECEWISE
@@ -592,20 +594,21 @@ def test_default_cudagraph_capture_size_covers_widest_uniform_decode(
594
default_max_graph_size,
595
)
596
widest_uniform_decode = max_num_seqs * decode_query_len
595
- expected_max_size = max(token_grid_max, widest_uniform_decode)
596
- assert compilation_config.max_cudagraph_capture_size == expected_max_size
597
+ assert compilation_config.max_cudagraph_capture_size == token_grid_max
598
assert (
599
compilation_config.max_cudagraph_capture_size
600
== compilation_config.cudagraph_capture_sizes[-1]
601
)
601
- assert widest_uniform_decode in compilation_config.cudagraph_capture_sizes
602
+ assert (
603
+ widest_uniform_decode in compilation_config.cudagraph_capture_sizes
604
+ ) is widest_is_captured
605
606
604
-def test_default_cudagraph_capture_sizes_keep_token_grid_bounded():
605
- """Keep the token grid bounded; only request counts may exceed the default.
607
+def test_default_cudagraph_capture_sizes_keep_all_sizes_bounded():
608
+ """Keep both token and uniform-decode grids under the platform default.
609
607
- This guards the measured 581 versus 100 capture-size regression at
608
- max_num_seqs=512 with 16 draft tokens.
610
+ This guards both the measured 581 versus 100 capture-count regression and
611
+ the graph-memory regression from capturing shapes up to 8704 tokens.
612
"""
613
max_num_seqs = 512
614
decode_query_len = 17
@@ -640,12 +643,19 @@ def test_default_cudagraph_capture_sizes_keep_token_grid_bounded():
643
request_counts += list(range(256, max_request_count + 1, 16))
644
request_counts.append(max_request_count)
645
expected_sizes = sorted(
643
- set(token_grid + [count * decode_query_len for count in request_counts])
646
+ set(
647
+ token_grid
648
+ + [
649
+ count * decode_query_len
650
+ for count in request_counts
651
+ if count * decode_query_len <= default_max_graph_size
652
+ ]
653
+ )
654
)
655
656
assert compilation_config.cudagraph_capture_sizes == expected_sizes
657
assert all(
648
- size <= default_max_graph_size or size % decode_query_len == 0
658
+ size <= default_max_graph_size
659
for size in compilation_config.cudagraph_capture_sizes
660
)
661
@@ -675,12 +685,13 @@ def test_cudagraph_capture_sizes_respect_sequence_parallelism():
685
VllmConfig._set_cudagraph_sizes(config)
686
687
assert all(size % 2 == 0 for size in compilation_config.cudagraph_capture_sizes)
678
- assert 544 in compilation_config.cudagraph_capture_sizes
688
+ assert 544 not in compilation_config.cudagraph_capture_sizes
689
+ assert compilation_config.max_cudagraph_capture_size == 512
690
691
692
@pytest.mark.skipif(not current_platform.is_cuda(), reason="Only test CUDA")
682
-def test_real_vllm_config_captures_widest_ngram_decode_batch():
683
- """Real config post-init preserves the widest static ngram decode batch."""
693
+def test_real_vllm_config_caps_widest_ngram_decode_batch():
694
+ """Real config post-init preserves the default capture ceiling."""
695
model_config = ModelConfig(model="facebook/opt-125m")
696
speculative_config = SpeculativeConfig(
697
prompt_lookup_min=1,
@@ -708,11 +719,11 @@ def test_real_vllm_config_captures_widest_ngram_decode_batch():
719
compilation_config=compilation_config,
720
)
721
711
- assert 544 in config.compilation_config.cudagraph_capture_sizes
722
+ assert 544 not in config.compilation_config.cudagraph_capture_sizes
723
assert (
724
config.compilation_config.max_cudagraph_capture_size
725
== config.compilation_config.cudagraph_capture_sizes[-1]
715
- == 544
726
+ == 512
727
)
728
729
@@ -750,13 +761,12 @@ def test_default_cudagraph_capture_size_unchanged_without_speculation(max_num_se
761
)
762
763
753
-def test_default_cudagraph_capture_size_covers_a_single_speculative_token():
754
- """One speculative token is already enough to lose the widest batch.
764
+def test_single_speculative_token_does_not_raise_default_capture_size():
765
+ """One speculative token must not raise the default capture ceiling.
766
767
MTP at depth 1 gives a query length of 2, so 300 requests is a 600-token
757
- decode batch against the platform's token ceiling. Gating the unit conversion on a
758
- deeper speculative width would leave this common configuration dispatching
759
- its largest decode steps eager.
768
+ decode batch against the platform's token ceiling. The widest batch falls
769
+ back to eager execution rather than expanding the capture range.
770
"""
771
compilation_config = CompilationConfig(
772
cudagraph_mode=CUDAGraphMode.FULL_AND_PIECEWISE
@@ -774,21 +784,19 @@ def test_default_cudagraph_capture_size_covers_a_single_speculative_token():
784
1024 if current_platform.is_device_capability_family(100) else 512
785
)
786
token_grid_max = min(300 * 2 * 2, default_max_graph_size)
777
- widest_uniform_decode = 300 * 2
778
- expected_max_size = max(token_grid_max, widest_uniform_decode)
779
- assert compilation_config.max_cudagraph_capture_size == expected_max_size
787
+ assert compilation_config.max_cudagraph_capture_size == token_grid_max
788
assert (
789
compilation_config.max_cudagraph_capture_size
790
== compilation_config.cudagraph_capture_sizes[-1]
791
)
784
- assert 600 in compilation_config.cudagraph_capture_sizes
792
+ assert 600 not in compilation_config.cudagraph_capture_sizes
793
794
787
-def test_default_cudagraph_capture_size_caps_requests_not_tokens():
788
- """The ceiling admits at most the platform default number of requests.
795
+def test_default_cudagraph_capture_size_caps_tokens():
796
+ """The ceiling remains a platform-bounded token count.
797
790
- `max_num_seqs` far above the platform default must not push the capture
791
- range up without bound just because each request is now several tokens wide.
798
+ `max_num_seqs` and speculative width must not push the default capture range
799
+ above the platform's memory-safety guard.
800
"""
801
compilation_config = CompilationConfig(
802
cudagraph_mode=CUDAGraphMode.FULL_AND_PIECEWISE
@@ -805,16 +813,15 @@ def test_default_cudagraph_capture_size_caps_requests_not_tokens():
813
default_max_graph_size = (
814
1024 if current_platform.is_device_capability_family(100) else 512
815
)
808
- decode_query_len = 17
809
- token_grid_max = min(1024 * decode_query_len * 2, default_max_graph_size)
810
- widest_covered_decode = min(1024, default_max_graph_size) * decode_query_len
811
- expected_max_size = max(token_grid_max, widest_covered_decode)
812
- assert compilation_config.max_cudagraph_capture_size == expected_max_size
816
+ assert compilation_config.max_cudagraph_capture_size == default_max_graph_size
817
assert (
818
compilation_config.max_cudagraph_capture_size
819
== compilation_config.cudagraph_capture_sizes[-1]
820
)
817
- assert expected_max_size == default_max_graph_size * 17
821
+ assert all(
822
+ size <= default_max_graph_size
823
+ for size in compilation_config.cudagraph_capture_sizes
824
+ )
825
826
827
def _widest_covered_request_count(capture_sizes, query_len, max_num_seqs):
@@ -844,20 +851,21 @@ def test_default_cudagraph_capture_sizes_cover_every_dynamic_decode_width():
851
cudagraph_mode=CUDAGraphMode.FULL_AND_PIECEWISE
852
)
853
config = _mock_config_for_cudagraph_sizes(
847
- max_num_seqs=256,
854
+ max_num_seqs=128,
855
num_speculative_tokens=16,
856
max_num_batched_tokens=32768,
857
compilation_config=compilation_config,
851
- # 16 draft tokens up to a batch of 16, then 2 out to 256.
852
- num_speculative_tokens_per_batch_size=[(1, 16, 16), (17, 256, 2)],
858
+ # 16 draft tokens up to a batch of 16, then 2 out to 128. Both
859
+ # tier maxima fit under the 512-token default capture ceiling.
860
+ num_speculative_tokens_per_batch_size=[(1, 16, 16), (17, 128, 2)],
861
)
862
863
VllmConfig._set_cudagraph_sizes(config)
864
865
sizes = compilation_config.cudagraph_capture_sizes
858
- # The wide tier only ever runs to a batch of 16, the narrow one to 256.
859
- assert _widest_covered_request_count(sizes, 17, 256) >= 16
860
- assert _widest_covered_request_count(sizes, 3, 256) == 256
866
+ # The wide tier only ever runs to a batch of 16, the narrow one to 128.
867
+ assert _widest_covered_request_count(sizes, 17, 128) >= 16
868
+ assert _widest_covered_request_count(sizes, 3, 128) == 128
869
870
871
def test_dynamic_decode_capture_clamps_configured_width():
vllm/config/compilation.py
+2
-3
@@ -703,9 +703,8 @@ class CompilationConfig:
703
If not specified, max_cudagraph_capture_size is capped at 512 by default,
704
or 1024 on data center Blackwell GPUs. This avoids OOM in tight memory
705
scenarios with small max_num_seqs, and limits capture of large graphs that
706
- increase startup time and memory usage. When the uniform decode query length
707
- exceeds one, uniform decode sizes are appended and can raise the final value
708
- above this default.
706
+ increase startup time and memory usage. Uniform decode sizes are appended
707
+ only within this default ceiling.
708
"""
709
710
dynamic_shapes_config: DynamicShapesConfig = field(
vllm/config/vllm.py
+7
-8
@@ -1953,10 +1953,9 @@ class VllmConfig:
1953
1954
`max_num_batched_tokens` is also appended to the list if it fits
1955
within `max_cudagraph_capture_size`, so the max batch size is captured
1956
- even when off-stride. Likewise, when the uniform decode query length
1957
- exceeds one, the widest uniform decode batch (`max_num_seqs *
1958
- decode_query_len`) is appended when it fits, since it need not land on
1959
- an 8- or 16-token stride.
1956
+ even when off-stride. Uniform decode sizes are appended when they fit
1957
+ within the platform's default capture ceiling, since they need not land
1958
+ on an 8- or 16-token stride.
1959
1960
In the end, `vllm_config.compilation_config.cudagraph_capture_sizes`
1961
will be the final sizes to capture cudagraph (in ascending order).
@@ -2085,6 +2084,7 @@ class VllmConfig:
2084
n * query_len
2085
for query_len, tier_max_reqs in decode_tiers
2086
for n in request_counts(tier_max_reqs)
2087
+ if n * query_len <= max_cudagraph_capture_size
2088
}
2089
)
2090
max_num_tokens = self.scheduler_config.max_num_batched_tokens
@@ -2134,10 +2134,9 @@ class VllmConfig:
2134
and max_num_tokens not in cudagraph_capture_sizes
2135
):
2136
cudagraph_capture_sizes.append(max_num_tokens)
2137
- # These extend past the token-strided ceiling, which counts one
2138
- # token per request. valid_max_size below raises the final
2139
- # max_cudagraph_capture_size to the widest of them. They remain
2140
- # filtered by max_num_tokens.
2137
+ # Preserve the platform's default capture ceiling. Larger
2138
+ # uniform decode batches fall back to eager execution unless
2139
+ # users explicitly configure wider capture sizes.
2140
cudagraph_capture_sizes += [
2141
size for size in uniform_decode_sizes if size <= max_num_tokens
2142
]