1212# See the License for the specific language governing permissions and
1313# limitations under the License.
1414
15+ import gc
16+
1517import pytest
1618import torch
1719
1820import flag_gems
21+ from flag_gems .fused .fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert import (
22+ fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert as _generic_impl ,
23+ )
1924from flag_gems .utils .device_info import get_device_capability
2025
2126from .conftest import QUICK_MODE
2227
28+ # NVIDIA gates FP8 E4M3 on sm_89+, and this check has historically been spelled
29+ # `get_device_capability() >= (8, 9)`. That number only means "Ada or newer" on
30+ # NVIDIA; other vendors report their own major/minor on a different scale, so
31+ # applying the threshold to them is a false negative that skips this whole file
32+ # on hardware that supports the dtype. Add a vendor here only after verifying
33+ # on it that a Triton `tl.float8e4nv` conversion matches `torch.float8_e4m3fn`
34+ # bit-for-bit -- MetaX C550 reports (8, 0) and does (verified 2026-08-05).
35+ _FP8E4NV_CAPABLE_VENDORS = frozenset ({"metax" })
36+
2337
2438def is_support_fp8e4nv ():
39+ if not hasattr (torch , "float8_e4m3fn" ):
40+ return False
41+ if flag_gems .vendor_name in _FP8E4NV_CAPABLE_VENDORS :
42+ return True
2543 major , minor = get_device_capability ()
2644 return major * 10 + minor >= 89
2745
@@ -39,6 +57,71 @@ def is_support_fp8e4nv():
3957SCALE_BYTES_PER_TOKEN = NUM_QUANT_BLOCKS + 1 # 8
4058HEAD_BYTES = TOKEN_DATA_BYTES + SCALE_BYTES_PER_TOKEN # 584
4159
60+ # Bytes of device memory the fp32 reference needs per element of
61+ # [num_tokens, n_heads, HEAD_DIM]. The op itself is cheap; the reference is what
62+ # blows up, because it upcasts q to fp32: q (bf16, 2B) + q_ref (bf16, 2B) + the
63+ # fp32 upcast (4B) + the fp32 result (4B) = 12 B/elem live at once, plus
64+ # allocator headroom. Two observations bracket the real figure:
65+ # > 13.2 -- 98304 x 128 OOMs on an 80GB H800 (the shape the old hardcoded
66+ # exclusion list was written for)
67+ # <=15.5 -- 65536 x 128, an identical element count, passes on a 63.59GB
68+ # MetaX C550
69+ REF_BYTES_PER_ELEM = 14
70+
71+
72+ def _free_device_memory ():
73+ """Free device bytes, or None if the backend cannot report it.
74+
75+ Two corrections matter, and both were found by getting them wrong on a
76+ MetaX C550:
77+
78+ 1. Collect before measuring. The previous parametrized case's tensors are
79+ still reachable from its frame, so without this a 64GB card reports
80+ ~38GB free and the guard skips shapes that in fact pass.
81+ 2. Count PyTorch's cached-but-unused blocks as available instead of calling
82+ `empty_cache` to release them. Releasing hands them back to the driver,
83+ and a marginal allocation then has to be re-served from possibly
84+ fragmented driver memory rather than reusing blocks already in hand --
85+ which is enough to turn 65536 x 128, a shape that otherwise passes,
86+ into an OutOfMemoryError.
87+ """
88+ try :
89+ gc .collect () # drop the previous case's tensors before measuring
90+ driver_free = torch .cuda .mem_get_info ()[0 ]
91+ cached = torch .cuda .memory_reserved () - torch .cuda .memory_allocated ()
92+ return driver_free + cached
93+ except Exception :
94+ pass
95+ try :
96+ return torch .cuda .get_device_properties (0 ).total_memory
97+ except Exception :
98+ return None
99+
100+
101+ def _skip_if_reference_wont_fit (num_tokens : int , n_heads : int ):
102+ """Skip shapes whose fp32 reference exceeds device memory.
103+
104+ Replaces a hardcoded exclusion list tuned for an 80GB H800, which still
105+ OOMs on 64GB cards -- MetaX C550 died at num_tokens=131072, n_heads=64,
106+ inside the reference and before the op was ever called. At
107+ REF_BYTES_PER_ELEM the rule reproduces the old H800 exclusion list exactly
108+ on an 80GB card, and on a 64GB C550 leaves 131072 x 64 to the
109+ OutOfMemoryError handler below rather than pre-skipping it.
110+
111+ This is a cheap pre-check that avoids allocating tens of GiB only to fail.
112+ It is deliberately not the whole story: whether a marginal shape fits also
113+ depends on allocator fragmentation (65536 x 128 passes on a C550 while
114+ 131072 x 64, an identical element count, does not), so the reference call
115+ itself also catches OutOfMemoryError.
116+ """
117+ needed = num_tokens * n_heads * HEAD_DIM * REF_BYTES_PER_ELEM
118+ free = _free_device_memory ()
119+ if free is not None and needed > free :
120+ pytest .skip (
121+ f"reference needs ~{ needed / 2 ** 30 :.0f} GiB for num_tokens={ num_tokens } , "
122+ f"n_heads={ n_heads } ; only { free / 2 ** 30 :.0f} GiB free"
123+ )
124+
42125
43126# ─── pytorch reference implementation from vllm ───
44127
@@ -266,9 +349,7 @@ def fused_impl(q, kv, k_cache, slot_mapping, positions, cos_sin_cache, eps, bs):
266349
267350
268351@pytest .mark .fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert
269- @pytest .mark .skipif (
270- not is_support_fp8e4nv (), reason = "Do not support fp8e4nv when capability < 89"
271- )
352+ @pytest .mark .skipif (not is_support_fp8e4nv (), reason = "Device does not support fp8e4nv" )
272353@pytest .mark .parametrize ("num_tokens" , [1 , 4 , 17 , 64 ])
273354@pytest .mark .parametrize ("n_heads" , [8 , 64 ])
274355def test_q_path_matches_reference (num_tokens : int , n_heads : int ):
@@ -326,9 +407,7 @@ def _ue8m0_per_block_scales(kv_roped_nope_f32: torch.Tensor, qblock: int):
326407
327408
328409@pytest .mark .fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert
329- @pytest .mark .skipif (
330- not is_support_fp8e4nv (), reason = "Do not support fp8e4nv when capability < 89"
331- )
410+ @pytest .mark .skipif (not is_support_fp8e4nv (), reason = "Device does not support fp8e4nv" )
332411@pytest .mark .parametrize ("num_tokens" , [1 , 4 , 17 , 64 ])
333412@pytest .mark .parametrize ("block_size" , [16 , 64 ])
334413def test_kv_path_matches_reference (num_tokens : int , block_size : int ):
@@ -374,9 +453,7 @@ def test_kv_path_matches_reference(num_tokens: int, block_size: int):
374453
375454
376455@pytest .mark .fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert
377- @pytest .mark .skipif (
378- not is_support_fp8e4nv (), reason = "Do not support fp8e4nv when capability < 89"
379- )
456+ @pytest .mark .skipif (not is_support_fp8e4nv (), reason = "Device does not support fp8e4nv" )
380457@pytest .mark .parametrize ("num_tokens" , [4 , 17 ])
381458@pytest .mark .parametrize ("pad" , [1 , 5 ])
382459@pytest .mark .parametrize ("block_size" , [16 , 64 ])
@@ -426,19 +503,15 @@ def test_kv_path_with_dp_padding(num_tokens: int, pad: int, block_size: int):
426503
427504
428505@pytest .mark .fused_deepseek_v4_qnorm_rope_kv_rope_quant_insert
429- @pytest .mark .skipif (
430- not is_support_fp8e4nv (), reason = "Do not support fp8e4nv when capability < 89"
431- )
506+ @pytest .mark .skipif (not is_support_fp8e4nv (), reason = "Device does not support fp8e4nv" )
432507@pytest .mark .parametrize (
433508 "num_tokens" ,
434509 [1 , 4 , 17 , 64 ] if QUICK_MODE else [1 , 4 , 17 , 64 , 8192 , 32768 , 65536 , 98304 , 131072 ],
435510)
436511@pytest .mark .parametrize ("n_heads" , [64 , 128 ])
437512@pytest .mark .parametrize ("block_size" , [16 , 64 ])
438513def test_combined_q_and_kv (num_tokens : int , n_heads : int , block_size : int ):
439- # out of memory for huge shape on H800
440- if (num_tokens == 98304 or num_tokens == 131072 ) and n_heads == 128 :
441- return
514+ _skip_if_reference_wont_fit (num_tokens , n_heads )
442515
443516 torch .manual_seed (2 )
444517 device = "cuda"
@@ -462,16 +535,37 @@ def test_combined_q_and_kv(num_tokens: int, n_heads: int, block_size: int):
462535 cos_sin_cache_ref = cos_sin_cache .clone ()
463536 slot_mapping_ref = slot_mapping .clone ()
464537
465- ref_impl (
466- q_ref ,
467- kv_ref ,
468- k_cache_ref ,
469- slot_mapping_ref ,
470- positions_ref ,
471- cos_sin_cache_ref ,
472- eps ,
473- block_size ,
474- )
538+ try :
539+ ref_impl (
540+ q_ref ,
541+ kv_ref ,
542+ k_cache_ref ,
543+ slot_mapping_ref ,
544+ positions_ref ,
545+ cos_sin_cache_ref ,
546+ eps ,
547+ block_size ,
548+ )
549+ except torch .OutOfMemoryError :
550+ # The reference is scaffolding, not the code under test, so its running
551+ # out of room says nothing about the op -- skip rather than fail.
552+ #
553+ # Dropping the tensors here is what keeps this from cascading. A failed
554+ # case's traceback is retained by pytest, the traceback references this
555+ # frame, and the frame keeps q/q_ref/... alive for the rest of the
556+ # session: one OOM at num_tokens=131072 pinned 32GiB on a 64GB C550 and
557+ # took out an unrelated case 20 tests later. Rebinding to None empties
558+ # the frame slots before the exception is raised, so nothing is held.
559+ # (`del` would do the same, but leaves the names undefined for the rest
560+ # of the scope as far as static analysis is concerned.)
561+ q = kv = k_cache = q_ref = kv_ref = k_cache_ref = None
562+ positions = positions_ref = cos_sin_cache = cos_sin_cache_ref = None
563+ slot_mapping = slot_mapping_ref = None
564+ torch .cuda .empty_cache ()
565+ pytest .skip (
566+ f"reference ran out of memory at num_tokens={ num_tokens } , "
567+ f"n_heads={ n_heads } "
568+ )
475569 fused_impl (q , kv , k_cache , slot_mapping , positions , cos_sin_cache , eps , block_size )
476570
477571 torch .testing .assert_close (q , q_ref , rtol = 1e-2 , atol = 1e-2 )
0 commit comments