diff --git a/docs/design_docs/flashinfer_moe_api.md b/docs/design_docs/flashinfer_moe_api.md index 28fd666b78d..ac1fc4b69eb 100644 --- a/docs/design_docs/flashinfer_moe_api.md +++ b/docs/design_docs/flashinfer_moe_api.md @@ -825,8 +825,9 @@ Repro: `benchmarks/flashinfer_benchmark.py --routine {trtllm_fp4_block_scale_moe > `MoETensors` cluster drop ("Two packs, two lifetimes" + the pack rationale in > `api.py`). The Reviewer 14 `_select_winner` thread remains tracked under Post-MVP > Carryover (a deferred design decision, not part of this pass). The *open* MoE -> work is now the two fuzzer-filed bugs — gh #3547 / #3548 — described under -> "Test Harness" above. +> work includes the remaining fuzzer-filed global-scale gap, gh #3548, +> described under "Test Harness" above. The earlier gh #3547 EP-offset bug is +> fixed and retained as regression coverage. ### Test Harness — Forward-Compatible Fuzzer (PR #6, merged 2026-06-09) @@ -870,32 +871,22 @@ local + `local_expert_offset` — the real deployment shape, in scope for the single-GPU harness; the EP *collective* is not), all under a weight-memory budget so one config never hogs the GPU. -**Known-failure ledger** (`_KNOWN_FAILURES`): a filed-and-tracked bug is `xfail`ed -by `(backend_key, predicate)` — the case is **still run**, so the suite stays -green yet flags loudly (`xpass` → "remove this entry") the day the bug is fixed. -A crash is never tolerated, only a wrong answer. - -**CI-safety gate (waived, opt-in).** The ledger tolerates a *wrong answer* but -cannot absorb a *process abort*, and a single-process run of this suite on SM100 -hit `CUDA error: device-side assert triggered` → `Fatal Python error: Aborted` -(triage 2026-06-09) — which would block B200 CI. Per-config isolation passes -68/86 incl. EP `offset>0`, so the abort is **not** cleanly attributable to one -config (the #3547 EP case returns tolerated zeros under `synchronize`, no -assert); it surfaces only in the accumulated single-process run CI uses, and -`--forked` can't isolate it (CUDA inits at collection). So the suite is gated -behind `FLASHINFER_UMOE_FUZZ` (`pytestmark` skip): **unset (CI default) → -collected-and-skipped, launches no kernel, cannot abort the job**; set → runs -(developer / nightly). The follow-up PR fixes #3547, root-causes the abort, and -removes the gate. +**Known-failure ledger** (shared `tests/test_helpers/fuzz_ledger.py`): +wrong-answer findings still run and report `xfail` after healthy backends; +crash-class findings are quarantined before launch. Unexpected passes fail +strictly so stale waivers are removed. + +**CI gate (default-on).** The accumulated fuzzer runs by default and uses a +`shard_group` marker to keep all configurations in one pytest process. +`FLASHINFER_UMOE_FUZZ=0` remains an emergency waiver. **Bugs this fuzzer found + filed** (the EP/scale regimes the prior suite never exercised end-to-end): -- **gh #3547** — `trtllm_fp4_routed` returns all-zeros for EP shards - (`local_expert_offset > 0`): the offset is applied twice (pre-subtracted in - `pack_inputs` *and* forwarded to the kernel). `cute_dsl_nvfp4` is correct - (passes global ids + offset, kernel localizes once). Encoded as the current - `_KNOWN_FAILURES` entry. Fix = stop pre-subtracting (pass global ids), then - delete the ledger entry so the case flips to passing. +- **gh #3547 (fixed)** — `trtllm_fp4_routed` returned all-zeros for EP shards + (`local_expert_offset > 0`) because the offset was applied twice + (pre-subtracted in `pack_inputs` and forwarded to the kernel). The fix passes + global IDs and lets the kernel localize once. Its ledger entry has been + removed; the EP-offset cases remain as regression coverage. - **gh #3548** — activation **global-scale** gap: `prepare_*_weights` hardcodes `gs=1.0`/`fc2_input_scale=1.0`/`alpha=ones` and `MoEActivationPack` has no global-scale field, so calibrated-checkpoint scales are silently dropped diff --git a/flashinfer/fused_moe/__init__.py b/flashinfer/fused_moe/__init__.py index 90033c80707..20438cdc8ce 100644 --- a/flashinfer/fused_moe/__init__.py +++ b/flashinfer/fused_moe/__init__.py @@ -51,6 +51,7 @@ CutlassBf16Runner, CutlassW4A16Runner, CuteDslNvfp4Runner, + TrtllmBf16RoutedRunner, TrtllmFp4RoutedRunner, TrtllmFp8BlockRunner, TrtllmFp8PerTensorRunner, @@ -170,6 +171,7 @@ "MoEFinalizeConfig", "MoELayer", "MoEWeightPack", + "TrtllmBf16RoutedRunner", "TrtllmFp4RoutedRunner", "TrtllmFp8BlockRunner", "TrtllmFp8PerTensorRunner", diff --git a/flashinfer/fused_moe/layer.py b/flashinfer/fused_moe/layer.py index 1dfbb611a8a..6f57d170d86 100644 --- a/flashinfer/fused_moe/layer.py +++ b/flashinfer/fused_moe/layer.py @@ -147,6 +147,25 @@ def __init__(self, config: MoEConfig, device: Optional[torch.device] = None): f"experts are implemented only by [{supporting}], which must " f"also be configured and supported on this arch." ) + local_num_experts = ( + config.experts.local_num_experts or config.routing.num_experts + ) + if config.experts.local_expert_offset != 0 or ( + local_num_experts != config.routing.num_experts + ): + supporting = ", ".join( + r.__name__ + for r in _BACKEND_RUNNERS.values() + if r.supports_expert_parallelism + ) + hint += ( + f" Note the config is an expert-parallel shard " + f"(local_expert_offset={config.experts.local_expert_offset}, " + f"local_num_experts={local_num_experts} of " + f"{config.routing.num_experts}): expert parallelism is " + f"implemented only by [{supporting}], which must also be " + f"configured and supported on this arch." + ) raise RuntimeError( f"MoELayer: none of the configured backends " f"{[type(c).__name__ for c in config.backend]} are usable on " diff --git a/flashinfer/fused_moe/runners.py b/flashinfer/fused_moe/runners.py index 9137beda253..5769de830dc 100644 --- a/flashinfer/fused_moe/runners.py +++ b/flashinfer/fused_moe/runners.py @@ -225,6 +225,9 @@ class MoERunner(TunableRunner): supported_quant_variants: ClassVar[tuple[QuantVariant, ...]] = () # Set to True only after S is wired through validation and launch. supports_fused_shared_experts: ClassVar[bool] = False + # Cleared by backends whose kernels cannot map global expert ids onto a + # local shard (local_expert_offset / local_num_experts != num_experts). + supports_expert_parallelism: ClassVar[bool] = True config: MoEConfig @@ -245,6 +248,7 @@ def _check_support(self) -> None: f"{type(self).__name__} does not support QuantVariant.{variant.name}." ) self._assert_shared_experts_supported() + self._assert_expert_parallelism_supported() def _assert_shared_experts_supported(self) -> None: """Reject S > 0 for backends that have not opted in.""" @@ -255,6 +259,22 @@ def _assert_shared_experts_supported(self) -> None: f"(num_fused_shared_experts={s})." ) + def _assert_expert_parallelism_supported(self) -> None: + """Reject EP shards for backends that cannot compute a local expert subset.""" + if self.supports_expert_parallelism: + return + experts = self.config.experts + local_num_experts = experts.local_num_experts or self.config.routing.num_experts + if experts.local_expert_offset != 0 or ( + local_num_experts != self.config.routing.num_experts + ): + raise NotImplementedError( + f"{type(self).__name__} does not support expert parallelism " + f"(local_expert_offset={experts.local_expert_offset}, " + f"local_num_experts={local_num_experts} of " + f"{self.config.routing.num_experts})." + ) + def build(self) -> None: if getattr(self, "_built", False): return @@ -334,6 +354,7 @@ class _CutlassRunnerBase(MoERunner): """ supported_routing_modes = (RoutingInputMode.PackedPrecomputed,) + supports_expert_parallelism = False _supported_archs: ClassVar[tuple[int, ...]] _weight_dtype: ClassVar[torch.dtype] _use_w4_group_scaling: ClassVar[bool] @@ -354,14 +375,6 @@ def _check_support(self) -> None: raise NotImplementedError( f"{type(self).__name__} requires do_finalize=True." ) - experts = self.config.experts - local_num_experts = experts.local_num_experts or self.config.routing.num_experts - if experts.local_expert_offset != 0 or ( - local_num_experts != self.config.routing.num_experts - ): - raise NotImplementedError( - f"{type(self).__name__} does not yet support expert parallelism." - ) if self._device_arch not in self._supported_archs: raise RuntimeError( f"{type(self).__name__} does not support " @@ -2625,6 +2638,7 @@ class _B12xRunner(MoERunner): """Shared unified adapter over ``B12xMoEWrapper``.""" backend_key: ClassVar[str] = "" + supports_expert_parallelism = False required_weight_keys: ClassVar[tuple[str, ...]] = () def _check_support(self) -> None: @@ -2644,14 +2658,6 @@ def _check_support(self) -> None: f"b12x unified MoE requires SM120 or SM121, got SM{major}{minor}." ) - experts = self.config.experts - local_num_experts = experts.local_num_experts or self.config.routing.num_experts - if experts.local_expert_offset != 0 or ( - local_num_experts != self.config.routing.num_experts - ): - raise NotImplementedError( - "b12x unified MoE does not support expert parallelism." - ) if not self.config.finalize.do_finalize: raise NotImplementedError("b12x unified MoE requires do_finalize=True.") diff --git a/tests/gemm/test_bmm_bf16.py b/tests/gemm/test_bmm_bf16.py index 9c71ed2b3a2..66b0cd0e43f 100644 --- a/tests/gemm/test_bmm_bf16.py +++ b/tests/gemm/test_bmm_bf16.py @@ -1,3 +1,8 @@ +# NOTE for future contributors (incl. AI agents): keep this file a SMALL curated +# smoke set. New coverage (shapes, dtypes, backends, randomized breadth) belongs in +# tests/gemm/test_unified_gemm_fuzz.py -- extend an adapter/axis there. Add cases +# here only as deliberate regression anchors or for paths the fuzzer cannot express. + import pytest import torch import torch.nn.functional as F @@ -12,12 +17,24 @@ from flashinfer.utils import get_compute_capability -@pytest.mark.parametrize("b", [1, 16]) -@pytest.mark.parametrize("m", [48, 128]) -@pytest.mark.parametrize("n", [80, 64]) -@pytest.mark.parametrize("k", [64, 256]) -@pytest.mark.parametrize("res_dtype", [torch.bfloat16, torch.float16, torch.float32]) -@pytest.mark.parametrize("backend", ["cutlass", "cudnn", "cutile", "tgv", "auto"]) +# Curated smoke set. Randomized breadth over {b,m,n,k} x backends (tight elementwise +# oracle, determinism, autotune-winner validation) lives in +# tests/gemm/test_unified_gemm_fuzz.py's bmm_bf16 adapter; keep one deliberate case +# per backend with each out dtype covered at least once. +_SMOKE_CASES = [ + # b, m, n, k, res_dtype, backend + (16, 48, 80, 256, torch.bfloat16, "cutlass"), + (1, 128, 64, 64, torch.float32, "cutlass"), + (16, 128, 64, 256, torch.float16, "cudnn"), + (1, 48, 80, 64, torch.bfloat16, "cudnn"), + (16, 48, 64, 64, torch.bfloat16, "cutile"), + (1, 128, 80, 256, torch.bfloat16, "tgv"), + (16, 128, 80, 64, torch.bfloat16, "auto"), + (1, 48, 64, 256, torch.float32, "auto"), +] + + +@pytest.mark.parametrize("b,m,n,k,res_dtype,backend", _SMOKE_CASES) def test_bmm_bf16(b, m, n, k, res_dtype, backend): compute_capability = get_compute_capability(torch.device(device="cuda")) compute_capability_number = compute_capability[0] * 10 + compute_capability[1] diff --git a/tests/gemm/test_bmm_fp8.py b/tests/gemm/test_bmm_fp8.py index b004ebadc2a..d15838fe1e3 100644 --- a/tests/gemm/test_bmm_fp8.py +++ b/tests/gemm/test_bmm_fp8.py @@ -1,3 +1,8 @@ +# NOTE for future contributors (incl. AI agents): keep this file a SMALL curated +# smoke set. New coverage (shapes, dtypes, backends, randomized breadth) belongs in +# tests/gemm/test_unified_gemm_fuzz.py -- extend an adapter/axis there. Add cases +# here only as deliberate regression anchors or for paths the fuzzer cannot express. + import pytest import torch import torch.nn.functional as F @@ -7,15 +12,84 @@ from tests.utils_fp8 import to_float8 -@pytest.mark.parametrize("b", [1, 16]) -@pytest.mark.parametrize("m", [1, 48, 128]) -@pytest.mark.parametrize("n", [64, 80, 10304]) -@pytest.mark.parametrize("k", [64, 256, 2688]) -@pytest.mark.parametrize("input_dtype", [torch.float8_e4m3fn, torch.float8_e5m2]) -@pytest.mark.parametrize("mat2_dtype", [torch.float8_e4m3fn, torch.float8_e5m2]) -@pytest.mark.parametrize("res_dtype", [torch.bfloat16, torch.float16]) -@pytest.mark.parametrize("backend", ["cudnn", "cublas", "cutlass", "auto"]) -@pytest.mark.parametrize("auto_tuning", [True, False]) +# Curated smoke set. Randomized breadth over {b,m,n,k} x {e4m3,e5m2} x backends +# (with a tight elementwise oracle, determinism and autotune-winner checks) lives in +# tests/gemm/test_unified_gemm_fuzz.py's bmm_fp8 adapter; this file keeps one +# deliberate case per backend / dtype-mix / autotune mode for fast bisection. +_SMOKE_CASES = [ + # b, m, n, k, input_dtype, mat2_dtype, res_dtype, backend, auto_tuning + ( + 16, + 48, + 80, + 256, + torch.float8_e4m3fn, + torch.float8_e4m3fn, + torch.bfloat16, + "cudnn", + True, + ), + ( + 1, + 128, + 10304, + 2688, + torch.float8_e4m3fn, + torch.float8_e4m3fn, + torch.float16, + "cublas", + False, + ), + ( + 16, + 1, + 64, + 2688, + torch.float8_e5m2, + torch.float8_e4m3fn, + torch.bfloat16, + "cudnn", + False, + ), + ( + 1, + 48, + 80, + 64, + torch.float8_e4m3fn, + torch.float8_e5m2, + torch.float16, + "cublas", + True, + ), + ( + 16, + 128, + 80, + 256, + torch.float8_e4m3fn, + torch.float8_e4m3fn, + torch.bfloat16, + "cutlass", + True, + ), + ( + 1, + 1, + 10304, + 256, + torch.float8_e4m3fn, + torch.float8_e4m3fn, + torch.bfloat16, + "auto", + False, + ), +] + + +@pytest.mark.parametrize( + "b,m,n,k,input_dtype,mat2_dtype,res_dtype,backend,auto_tuning", _SMOKE_CASES +) def test_bmm_fp8(b, m, n, k, input_dtype, mat2_dtype, res_dtype, backend, auto_tuning): compute_capability = get_compute_capability(torch.device("cuda")) if backend == "cutlass" and compute_capability[0] not in [10, 11, 12]: diff --git a/tests/gemm/test_bmm_mxfp8.py b/tests/gemm/test_bmm_mxfp8.py index ea9b88f5e57..250624e3cd2 100644 --- a/tests/gemm/test_bmm_mxfp8.py +++ b/tests/gemm/test_bmm_mxfp8.py @@ -1,3 +1,8 @@ +# NOTE for future contributors (incl. AI agents): keep this file a SMALL curated +# smoke set. New coverage (shapes, dtypes, backends, randomized breadth) belongs in +# tests/gemm/test_unified_gemm_fuzz.py -- extend an adapter/axis there. Add cases +# here only as deliberate regression anchors or for paths the fuzzer cannot express. + import warnings import pytest @@ -10,18 +15,27 @@ from flashinfer.utils import get_compute_capability -@pytest.mark.parametrize("b", [1, 16]) -@pytest.mark.parametrize("m", [128, 256, 512]) -@pytest.mark.parametrize("n", [128, 256, 512]) -@pytest.mark.parametrize("k", [128, 256, 512, 1024]) -@pytest.mark.parametrize("input_dtype", [torch.bfloat16]) -@pytest.mark.parametrize("is_sf_swizzled_layout", [True, False]) -@pytest.mark.parametrize("res_dtype", [torch.bfloat16]) -@pytest.mark.parametrize("backend", ["cudnn", "cutlass"]) -@pytest.mark.parametrize("auto_tuning", [True, False]) -def test_bmm_mxfp8( - b, m, n, k, input_dtype, is_sf_swizzled_layout, res_dtype, backend, auto_tuning -): +# Curated smoke set. Randomized breadth over {b,m,n,k} x backend (swizzled scales, +# tight elementwise oracle, determinism, autotune-winner validation, and the tracked +# #3604 b>1/M%128 ledger) lives in tests/gemm/test_unified_gemm_fuzz.py's bmm_mxfp8 +# adapter; keep autotune on/off per backend. Linear scale metadata cannot currently +# be represented by the public 3D BMM API, so every executable row uses swizzled scales. +_SMOKE_CASES = [ + # b, m, n, k, is_sf_swizzled_layout, backend, auto_tuning + (16, 128, 256, 1024, True, "cudnn", True), + (1, 512, 128, 256, True, "cudnn", False), + (16, 256, 512, 128, True, "cudnn", True), + (16, 128, 128, 512, True, "cutlass", False), + (1, 256, 512, 1024, True, "cutlass", True), +] + + +@pytest.mark.parametrize( + "b,m,n,k,is_sf_swizzled_layout,backend,auto_tuning", _SMOKE_CASES +) +def test_bmm_mxfp8(b, m, n, k, is_sf_swizzled_layout, backend, auto_tuning): + input_dtype = torch.bfloat16 + res_dtype = torch.bfloat16 compute_capability = get_compute_capability(torch.device("cuda")) if backend == "cudnn" and compute_capability[0] != 10: pytest.skip("bmm_mxfp8 cudnn backend requires SM10x.") @@ -39,6 +53,11 @@ def test_bmm_mxfp8( # Block size is 32 in MXFP8 assert input_mxfp8.numel() == (input_scale.numel() * 32) + # The cuDNN override-shape path requires B as a COLUMN-major [b, k, n] VIEW + # (K contiguous): quantize the contiguous [b, n, k] weight and pass the + # transpose of the quantized tensor. A .contiguous() on the transpose flips + # it back to row-major and is rejected once cudnn-frontend >= 1.24 enables + # the override path (CI's older frontend hid this). weight = torch.randn([b, n, k], device="cuda", dtype=input_dtype) weight_mxfp8, weight_scale = mxfp8_quantize(weight, is_sf_swizzled_layout) mat2_mxfp8 = weight_mxfp8.transpose(-2, -1) diff --git a/tests/gemm/test_mm_bf16.py b/tests/gemm/test_mm_bf16.py index b8c32707bae..5f52801a4ce 100644 --- a/tests/gemm/test_mm_bf16.py +++ b/tests/gemm/test_mm_bf16.py @@ -1,3 +1,8 @@ +# NOTE for future contributors (incl. AI agents): keep this file a SMALL curated +# smoke set. New coverage (shapes, dtypes, backends, randomized breadth) belongs in +# tests/gemm/test_unified_gemm_fuzz.py -- extend an adapter/axis there. Add cases +# here only as deliberate regression anchors or for paths the fuzzer cannot express. + import pytest import torch import torch.nn.functional as F @@ -8,17 +13,33 @@ from flashinfer.utils import get_compute_capability, is_sm100a_supported -@pytest.mark.parametrize("m", [1, 8, 16, 25, 32, 64]) -@pytest.mark.parametrize("n", [1024, 2048, 4096]) -@pytest.mark.parametrize("k", [1024, 2048, 3072]) -@pytest.mark.parametrize("res_dtype", [torch.bfloat16, torch.float16, torch.float32]) -@pytest.mark.parametrize("enable_bias", [True, False]) -@pytest.mark.parametrize("pdl", [True, False]) +# Curated smoke set. Randomized breadth over {m,n,k} x out-dtype x backend (tight +# elementwise oracle, determinism, autotune-winner validation) lives in +# tests/gemm/test_unified_gemm_fuzz.py's mm_bf16 adapter. The bias and pdl epilogue +# axes are NOT fuzzed -- each supported backend keeps a bias=True and pdl=True case +# here; plus one plain case per backend / out dtype / autotune mode. +_SMOKE_CASES = [ + # m, n, k, res_dtype, enable_bias, pdl, backend, auto_tuning + (1, 1024, 2048, torch.bfloat16, True, False, "cudnn", False), + (16, 4096, 1024, torch.float16, False, True, "cudnn", True), + (64, 2048, 3072, torch.float32, True, True, "cudnn", False), + (8, 1024, 1024, torch.bfloat16, False, False, "cutlass", True), + (32, 4096, 3072, torch.float32, False, False, "cutlass", False), + (1, 2048, 1024, torch.bfloat16, True, True, "tgv", False), + (64, 1024, 2048, torch.bfloat16, False, True, "tgv", True), + (16, 2048, 2048, torch.bfloat16, False, False, "cublaslt", True), + (8, 4096, 3072, torch.float16, False, False, "cublaslt", False), + (1, 1024, 3072, torch.bfloat16, True, False, "tinygemm", False), + (32, 1024, 1024, torch.bfloat16, False, False, "cutile", False), + (25, 2048, 1024, torch.bfloat16, False, False, "cute-dsl", False), + (64, 4096, 2048, torch.bfloat16, False, False, "auto", True), + (1, 2048, 3072, torch.float16, False, False, "auto", False), +] + + @pytest.mark.parametrize( - "backend", - ["cudnn", "cutlass", "tgv", "cublaslt", "tinygemm", "cutile", "cute-dsl", "auto"], + "m,n,k,res_dtype,enable_bias,pdl,backend,auto_tuning", _SMOKE_CASES ) -@pytest.mark.parametrize("auto_tuning", [False, True]) def test_mm_bf16( m: int, n: int, diff --git a/tests/gemm/test_mm_fp4.py b/tests/gemm/test_mm_fp4.py index ad3cded0f00..5af59d6c04b 100644 --- a/tests/gemm/test_mm_fp4.py +++ b/tests/gemm/test_mm_fp4.py @@ -1,3 +1,8 @@ +# NOTE for future contributors (incl. AI agents): keep this file a SMALL curated +# smoke set. New coverage (shapes, dtypes, backends, randomized breadth) belongs in +# tests/gemm/test_unified_gemm_fuzz.py -- extend an adapter/axis there. Add cases +# here only as deliberate regression anchors or for paths the fuzzer cannot express. + import pytest import torch import torch.nn.functional as F @@ -115,18 +120,35 @@ def _test_mm_fp4( pytest.fail(str(e)) -# TODO: Consdier splitting this function up for the various backends +# Curated smoke set. Randomized breadth over m x {n,k} x fp4-type x backend (nvfp4 + +# mxfp4, 128x4 layout, tight elementwise oracle, determinism, autotune-winner +# validation) lives in tests/gemm/test_unified_gemm_fuzz.py's mm_nvfp4/mm_mxfp4 +# adapters. This file keeps, per backend: one odd-M + one large-M case, the 8x4 +# scale-factor layout (NOT fuzzed yet -- fuzzer TODO C1/#2861), the trtllm +# weight-shuffle path, and each fp4_type at least once. +_SMOKE_CASES = [ + # m, n, k, res_dtype, backend, use_128x4_sf_layout, auto_tuning, fp4_type + # (every case must actually run on its target arch: trtllm is the only 8x4-layout + # backend and is bf16-out only; b12x is nvfp4+128x4 only; mxfp4 runs on + # cudnn/cute-dsl/auto only -- see the skip matrix in _test_mm_fp4) + (7, 128, 256, torch.bfloat16, "trtllm", True, False, "nvfp4"), + (512, 512, 512, torch.bfloat16, "trtllm", False, True, "nvfp4"), + (48, 256, 512, torch.bfloat16, "trtllm", False, False, "nvfp4"), + (13, 256, 128, torch.bfloat16, "cudnn", True, True, "nvfp4"), + (256, 512, 256, torch.float16, "cudnn", True, False, "mxfp4"), + (9, 512, 256, torch.bfloat16, "cudnn", True, True, "mxfp4_alpha"), + (1, 128, 512, torch.bfloat16, "cutlass", True, False, "nvfp4"), + (31, 256, 256, torch.float16, "cutlass", True, True, "nvfp4"), + (48, 512, 128, torch.float16, "cute-dsl", True, True, "nvfp4"), + (3, 256, 512, torch.bfloat16, "cute-dsl", True, False, "mxfp4"), + (17, 128, 128, torch.bfloat16, "b12x", True, False, "nvfp4"), + (128, 512, 512, torch.bfloat16, "b12x", True, True, "nvfp4"), +] + + @pytest.mark.parametrize( - "m", - [1, 2, 3, 4, 5, 7, 8, 9, 12, 13, 15, 16, 17, 20, 24, 31, 32, 48, 64, 128, 256, 512], + "m,n,k,res_dtype,backend,use_128x4_sf_layout,auto_tuning,fp4_type", _SMOKE_CASES ) -@pytest.mark.parametrize("n", [128, 256, 512]) -@pytest.mark.parametrize("k", [128, 256, 512]) -@pytest.mark.parametrize("res_dtype", [torch.bfloat16, torch.float16]) -@pytest.mark.parametrize("backend", ["trtllm", "cudnn", "cutlass", "cute-dsl", "b12x"]) -@pytest.mark.parametrize("use_128x4_sf_layout", [False, True]) -@pytest.mark.parametrize("auto_tuning", [False, True]) -@pytest.mark.parametrize("fp4_type", ["nvfp4", "mxfp4", "mxfp4_alpha"]) def test_mm_fp4( m, n, k, res_dtype, backend, use_128x4_sf_layout, auto_tuning, fp4_type ): @@ -136,19 +158,20 @@ def test_mm_fp4( ) -# Split tests for checking auto functionality -@pytest.mark.parametrize("m", [1, 48, 256, 512]) -@pytest.mark.parametrize("n", [256, 512]) -@pytest.mark.parametrize("k", [256, 512]) -@pytest.mark.parametrize("res_dtype", [torch.bfloat16, torch.float16]) -@pytest.mark.parametrize("use_128x4_sf_layout", [True]) -@pytest.mark.parametrize("auto_tuning", [False, True]) -@pytest.mark.parametrize("fp4_type", ["nvfp4", "mxfp4", "mxfp4_alpha"]) -def test_mm_fp4_backend_auto( - m, n, k, res_dtype, use_128x4_sf_layout, auto_tuning, fp4_type -): +# Auto backend: one case per fp4_type, autotune on/off and boundary M covered. +_AUTO_SMOKE_CASES = [ + # m, n, k, res_dtype, auto_tuning, fp4_type + (1, 256, 512, torch.bfloat16, False, "nvfp4"), + (512, 512, 256, torch.float16, True, "nvfp4"), + (48, 512, 512, torch.bfloat16, True, "mxfp4"), + (256, 256, 256, torch.bfloat16, False, "mxfp4_alpha"), +] + + +@pytest.mark.parametrize("m,n,k,res_dtype,auto_tuning,fp4_type", _AUTO_SMOKE_CASES) +def test_mm_fp4_backend_auto(m, n, k, res_dtype, auto_tuning, fp4_type): # Some test cases for auto backend. - _test_mm_fp4(m, n, k, res_dtype, "auto", use_128x4_sf_layout, auto_tuning, fp4_type) + _test_mm_fp4(m, n, k, res_dtype, "auto", True, auto_tuning, fp4_type) # Regression (#3560): b12x must accept ragged K (real floor K%32==0, not tile_k=128). diff --git a/tests/moe/test_trtllm_gen_fused_moe.py b/tests/moe/test_trtllm_gen_fused_moe.py index 487ffead18c..cdfb9ca06a9 100644 --- a/tests/moe/test_trtllm_gen_fused_moe.py +++ b/tests/moe/test_trtllm_gen_fused_moe.py @@ -1,3 +1,9 @@ +# NOTE for future contributors (incl. AI agents): keep this file lean. Randomized +# breadth (shapes, token counts) belongs in tests/moe/test_unified_moe_fuzz.py -- +# extend its axes/adapters. This file exists for the quant x routing x layout +# kernel-selection matrix and for paths the fuzzer cannot express; add cases only +# as deliberate regression anchors. + """ Copyright (c) 2025 by FlashInfer team. @@ -53,9 +59,12 @@ def cache_permute_indices(): # Test: Sigmoid routing (Sigmoid -> TopK) and TopKSigmoid routing (TopK -> Sigmoid), # neither of which renormalizes the top-K weights. -@pytest.mark.parametrize("num_tokens", [8, 768, 3072]) +# Shape fan-out kept minimal (boundary tokens/intermediate only): the quant x +# weight-layout x activation matrix is the coverage; shape breadth lives in +# tests/moe/test_unified_moe_fuzz.py. +@pytest.mark.parametrize("num_tokens", [8, 3072]) @pytest.mark.parametrize("hidden_size", [1024]) -@pytest.mark.parametrize("intermediate_size", [1024, 768, 512, 384]) +@pytest.mark.parametrize("intermediate_size", [1024, 768, 384]) @pytest.mark.parametrize( "moe_impl", [ @@ -189,7 +198,12 @@ def test_sigmoid_routing( # Test: DeepSeekV3 routing -@pytest.mark.parametrize("num_tokens", [8, 768, 3072]) +# Shape fan-out kept minimal: intermediate values form a hitting set of every +# routing config's compatible_intermediate_size (so each config still runs), and +# token counts are the boundary pair. Shape breadth lives in +# tests/moe/test_unified_moe_fuzz.py; the impl x routing x layout matrix is the +# coverage this grid exists for. +@pytest.mark.parametrize("num_tokens", [8, 3072]) @pytest.mark.parametrize("hidden_size", [1024]) @pytest.mark.parametrize("intermediate_size", [2688, 2048, 1024, 768, 512, 384]) @pytest.mark.parametrize( diff --git a/tests/moe/test_trtllm_gen_fused_moe_routing_renormalize_bf16.py b/tests/moe/test_trtllm_gen_fused_moe_routing_renormalize_bf16.py index b18de3077d3..150879b6eaa 100644 --- a/tests/moe/test_trtllm_gen_fused_moe_routing_renormalize_bf16.py +++ b/tests/moe/test_trtllm_gen_fused_moe_routing_renormalize_bf16.py @@ -1,3 +1,9 @@ +# NOTE for future contributors (incl. AI agents): keep this file lean. Randomized +# breadth (shapes, token counts) belongs in tests/moe/test_unified_moe_fuzz.py -- +# extend its axes/adapters. This file exists for the quant x routing x layout +# kernel-selection matrix and for paths the fuzzer cannot express; add cases only +# as deliberate regression anchors. + """ Copyright (c) 2025 by FlashInfer team. diff --git a/tests/moe/test_trtllm_gen_fused_moe_routing_renormalize_fp4.py b/tests/moe/test_trtllm_gen_fused_moe_routing_renormalize_fp4.py index 97af0c6df2f..de4307333be 100644 --- a/tests/moe/test_trtllm_gen_fused_moe_routing_renormalize_fp4.py +++ b/tests/moe/test_trtllm_gen_fused_moe_routing_renormalize_fp4.py @@ -1,3 +1,9 @@ +# NOTE for future contributors (incl. AI agents): keep this file lean. Randomized +# breadth (shapes, token counts) belongs in tests/moe/test_unified_moe_fuzz.py -- +# extend its axes/adapters. This file exists for the quant x routing x layout +# kernel-selection matrix and for paths the fuzzer cannot express; add cases only +# as deliberate regression anchors. + """ Copyright (c) 2025 by FlashInfer team. diff --git a/tests/moe/test_trtllm_gen_fused_moe_routing_renormalize_fp8.py b/tests/moe/test_trtllm_gen_fused_moe_routing_renormalize_fp8.py index 74370de3945..df0efcad2a5 100644 --- a/tests/moe/test_trtllm_gen_fused_moe_routing_renormalize_fp8.py +++ b/tests/moe/test_trtllm_gen_fused_moe_routing_renormalize_fp8.py @@ -1,3 +1,9 @@ +# NOTE for future contributors (incl. AI agents): keep this file lean. Randomized +# breadth (shapes, token counts) belongs in tests/moe/test_unified_moe_fuzz.py -- +# extend its axes/adapters. This file exists for the quant x routing x layout +# kernel-selection matrix and for paths the fuzzer cannot express; add cases only +# as deliberate regression anchors. + """ Copyright (c) 2025 by FlashInfer team. diff --git a/tests/moe/test_trtllm_gen_per_token_moe.py b/tests/moe/test_trtllm_gen_per_token_moe.py index fe5b2861809..f8f858d5e28 100644 --- a/tests/moe/test_trtllm_gen_per_token_moe.py +++ b/tests/moe/test_trtllm_gen_per_token_moe.py @@ -1,3 +1,9 @@ +# NOTE for future contributors (incl. AI agents): keep this file lean. Randomized +# breadth (shapes, token counts) belongs in tests/moe/test_unified_moe_fuzz.py -- +# extend its axes/adapters. This file exists for the quant x routing x layout +# kernel-selection matrix and for paths the fuzzer cannot express; add cases only +# as deliberate regression anchors. + """ Copyright (c) 2025 by FlashInfer team. @@ -51,9 +57,11 @@ cache_permute_indices: Dict[tuple, torch.Tensor] = {} -@pytest.mark.parametrize("num_tokens", [1, 8, 1024]) -@pytest.mark.parametrize("hidden_size", [1024, 2048, 4096]) -@pytest.mark.parametrize("intermediate_size", [1024, 2048, 4096]) +# The 4-over-6 x per-token-scaling matrix is the coverage here (only file exercising +# it); shape fan-out kept to boundary corners. +@pytest.mark.parametrize("num_tokens", [1, 1024]) +@pytest.mark.parametrize("hidden_size", [1024, 4096]) +@pytest.mark.parametrize("intermediate_size", [2048]) @pytest.mark.parametrize("num_experts", [32]) @pytest.mark.parametrize("top_k", [4]) @pytest.mark.parametrize("use_4over6", [False, True]) diff --git a/tests/moe/test_trtllm_gen_routed_fused_moe.py b/tests/moe/test_trtllm_gen_routed_fused_moe.py index 750b0ddb438..7c53bc54ed7 100644 --- a/tests/moe/test_trtllm_gen_routed_fused_moe.py +++ b/tests/moe/test_trtllm_gen_routed_fused_moe.py @@ -1,3 +1,9 @@ +# NOTE for future contributors (incl. AI agents): keep this file lean. Randomized +# breadth (shapes, token counts) belongs in tests/moe/test_unified_moe_fuzz.py -- +# extend its axes/adapters. This file exists for the quant x routing x layout +# kernel-selection matrix and for paths the fuzzer cannot express; add cases only +# as deliberate regression anchors. + """ Copyright (c) 2025 by FlashInfer team. @@ -264,11 +270,14 @@ def _run_trtllm_gen_routed_fused_moe_case( assert mismatch_pct < 6, f"Mismatch percentage is {mismatch_pct:.2f}" -@pytest.mark.parametrize("num_tokens", [1, 8, 1024]) -@pytest.mark.parametrize("hidden_size", [1024, 2048, 3072, 4096]) -@pytest.mark.parametrize("intermediate_size", [1024, 2048, 3072, 4096]) +# Routed-vs-logits parity: the coverage is routing_method x quant x routing_format; +# shape fan-out kept to the boundary corners (shape breadth is fuzzed in +# tests/moe/test_unified_moe_fuzz.py). +@pytest.mark.parametrize("num_tokens", [1, 1024]) +@pytest.mark.parametrize("hidden_size", [1024, 4096]) +@pytest.mark.parametrize("intermediate_size", [3072]) @pytest.mark.parametrize("num_experts", [128, 256]) -@pytest.mark.parametrize("top_k", [4, 8]) +@pytest.mark.parametrize("top_k", [8]) @pytest.mark.parametrize( "routing_method_type", [ diff --git a/tests/moe/test_unified_moe.py b/tests/moe/test_unified_moe.py index 06cdd9fa951..79b1d114173 100644 --- a/tests/moe/test_unified_moe.py +++ b/tests/moe/test_unified_moe.py @@ -41,41 +41,37 @@ from flashinfer.autotuner.autotuner import ProfilingCacheKey from flashinfer.fused_moe.layer import _BACKEND_RUNNERS from flashinfer.fused_moe import ( - MoEActivationPack, - MoELayer, - MoEWeightPack, - RoutingInputMode, - TrtllmFp4RoutedRunner, -) -from flashinfer.fused_moe.runners import ( - CuteDslNvfp4Runner, - MoERunner, - TrtllmBf16RoutedRunner, - TrtllmFp8BlockRunner, - TrtllmFp8PerTensorRunner, - TrtllmMxInt4RoutedRunner, -) -from flashinfer.fused_moe.api import ( ActivationConfig, ActivationType, BackendOptions, CuteDslConfig, + CuteDslNvfp4Runner, CutlassConfig, CutlassBf16Config, ExecutionConfig, MoEFinalizeConfig, ExpertConfig, + MoEActivationPack, MoEConfig, + MoELayer, + MoEWeightPack, QuantConfig, QuantVariant, RoutingConfig, + RoutingInputMode, RoutingMethodType, TrtllmBf16Config, + TrtllmBf16RoutedRunner, TrtllmFp4Config, + TrtllmFp4RoutedRunner, TrtllmFp8BlockConfig, + TrtllmFp8BlockRunner, TrtllmFp8PerTensorConfig, + TrtllmFp8PerTensorRunner, TrtllmMxInt4Config, + TrtllmMxInt4RoutedRunner, ) +from flashinfer.fused_moe.runners import MoERunner from flashinfer.utils import get_compute_capability diff --git a/tests/moe/test_unified_moe_fuzz.py b/tests/moe/test_unified_moe_fuzz.py index cc36aea5e1b..f2b49570a3f 100644 --- a/tests/moe/test_unified_moe_fuzz.py +++ b/tests/moe/test_unified_moe_fuzz.py @@ -19,9 +19,9 @@ larger-end shapes. Large expert counts are reached with small H/I and/or **expert-parallel shards** (global>local + ``local_expert_offset``, the real deployment shape), not by filling the GPU. -A small ``_KNOWN_FAILURES`` ledger xfails already-filed bugs (e.g. the since-fixed trtllm EP -offset>0 all-zero bug, gh #3547): the case is still *run* so the suite stays green on a tracked bug -yet flags loudly the day it starts passing (fixed). A crash is never tolerated -- only a wrong answer. +A shared ledger (``tests/test_helpers/fuzz_ledger.py``) manages tracked +wrong-answer and crash-class findings. It is currently empty because gh #3547 +and #3957 are fixed and covered by regressions. Verification model (single mode, uniform -- every config that runs gets the same checks): 1. **no crash / no NaN-Inf** where the reference is finite. @@ -81,15 +81,14 @@ dispatch to capable TRTLLM runners. MxInt4 covers packed and BF16-FromLogits routing. -OPT-IN: this suite is gated behind FLASHINFER_UMOE_FUZZ (see the pytestmark below) and is -SKIPPED unless that env var is set -- waived in CI pending root-cause of a -whole-process device-side-assert abort that would block B200 CI. Run it explicitly: - FLASHINFER_UMOE_FUZZ=1 CUDA_HOME= CUDA_VISIBLE_DEVICES= \ - pytest tests/moe/test_unified_moe_fuzz.py +ENABLED BY DEFAULT: this suite runs like any other test. Unsupported configurations skip at the +no-wired-backend check. FLASHINFER_UMOE_FUZZ=0 remains the emergency waiver. +Run it explicitly: + CUDA_HOME= CUDA_VISIBLE_DEVICES= pytest tests/moe/test_unified_moe_fuzz.py NOTE: `pytest --forked` does NOT work here (CUDA inits at collection -> "Cannot re-initialize CUDA in forked subprocess"); for crash-isolated enumeration run each test id in its own process instead (see var/03-ssh-docker-workflow.md). -Env: FLASHINFER_UMOE_FUZZ_NUM_TESTS (default 80), FLASHINFER_UMOE_FUZZ_SEED (default 0), +Env: FLASHINFER_UMOE_FUZZ_NUM_TESTS (default 160), FLASHINFER_UMOE_FUZZ_SEED (default 0), FLASHINFER_UMOE_FUZZ_ONLY_SEED (comma-separated seeds -> run ONLY those configs; the perfect-repro hook printed on every test). @@ -102,9 +101,10 @@ ------------------------------------------------------------------------------------------------ EXTENDING (cheap, by design): - * New backend -> nothing to do: it is auto-discovered from ``_BACKEND_RUNNERS`` the moment its - runner registers and ``supported(sm)`` is true. If it ships with a tracked bug, add one - ``_KNOWN_FAILURES`` entry (the case still RUNS; an xpass then flags the fix). + * New backend -> add its config class to the matching dtype handler's ``candidate_configs``; + the live ``_BACKEND_RUNNERS`` registry then supplies the runner and architecture gate. If it + ships with a tracked bug, add one ledger ``Finding`` (a non-quarantine case still RUNS; an + xpass then hard-fails until the entry is removed). * New dtype -> add ONE ``DTypeHandler`` to ``_DTYPE`` (snap / make_act_pack / reference / poison / tolerances). Everything else (config gen, all 7 checks, the cache test) is dtype-generic. @@ -154,9 +154,10 @@ POINTERS for future agents (point me at this file and I know the rest): * Full context (this fuzzer + the older adapter/GEMM fuzzers + the audit + findings): cuDNN- project auto-memory ``flashinfer_quality_fuzzers.md``. - * Bugs THIS fuzzer found + filed: gh #3547 (trtllm EP offset>0 all-zero -- tracked in the - ``_KNOWN_FAILURES`` ledger below until fixed) and - gh #3548 (activation global-scale gap == roadmap #5's scale-policy fix). + * Bugs THIS fuzzer found + filed: + - fixed gh #3547 (trtllm EP offset>0 all-zero). + - fixed gh #3957 (cumulative corruption). + - open gh #3548 (activation global-scale gap == roadmap #5's scale-policy fix). * Findings writeups: flashinfer_triage/EP_OFFSET_FINDING.md, flashinfer_triage/WEIGHT_SCALE_FINDING.md. * The unified API under test: PR #3093 (branch ``moe_api``); this fuzzer is PR aleozlx/flashinfer#6 (branch ``yanxu/unified-moe-api-fuzzer``). @@ -166,7 +167,6 @@ import os import random -import warnings from dataclasses import dataclass from typing import Callable @@ -185,6 +185,8 @@ from flashinfer.fused_moe.api import ( ActivationConfig, BackendOptions, + CutlassBf16Config, + CutlassW4A16Config, CuteDslConfig, ExecutionConfig, ExpertConfig, @@ -199,40 +201,56 @@ TrtllmMxInt4Config, ) from flashinfer.fused_moe.layer import _BACKEND_RUNNERS +from flashinfer.fused_moe.prepare import _quantize_mxfp4_linear from flashinfer.quantization import e2m1_and_ufp8sf_scale_to_float from flashinfer.quantization.fp8_quantization import mxfp8_quantize from flashinfer.tllm_enums import RoutingMethodType from flashinfer.utils import get_compute_capability -NUM_TESTS = int(os.environ.get("FLASHINFER_UMOE_FUZZ_NUM_TESTS", "80")) +from tests.test_helpers.fuzz_ledger import FuzzLedger + +NUM_TESTS = int(os.environ.get("FLASHINFER_UMOE_FUZZ_NUM_TESTS", "160")) +# Debug knob: comma-separated backend_key allowlist (e.g. "cute_dsl_nvfp4") to run a +# backend-scoped sequence -- used to bisect cross-call state corruption by backend (gh #3957). +_BACKEND_FILTER = { + b for b in os.environ.get("FLASHINFER_UMOE_FUZZ_BACKENDS", "").split(",") if b +} +# Debug knob: skip the autotune(True) production-path step entirely -- used to isolate whether +# cross-call corruption accumulates in the profiling path (cudagraph captures) or the plain +# forward/tactic path (gh #3957). +_NO_AUTOTUNE = os.environ.get("FLASHINFER_UMOE_FUZZ_NO_AUTOTUNE", "0") not in ("", "0") BASE_SEED = int(os.environ.get("FLASHINFER_UMOE_FUZZ_SEED", "0")) # Perfect-repro hook: if set (comma-separated seeds), the suite runs ONLY those configs. A curated # seed maps to its hand-written Cfg; any other seed is regenerated via the deterministic _gen(seed), # so a single seed reproduces exactly one config. The repro command printed on every test uses this. _ONLY_SEEDS = os.environ.get("FLASHINFER_UMOE_FUZZ_ONLY_SEED", "") -# --- CI-safety gate: OPT-IN ---------------------------------------------------------------- -# Waived in CI pending root-cause of a whole-process abort. Running the SM100 fuzzer -# in a single `pytest` process can hit `CUDA error: device-side assert triggered` -> -# `Fatal Python error: Aborted`, which would BLOCK B200 CI (an abort fails the whole job, not one -# test). Notes from triage (2026-06-09): per-config isolation (one process each) passes 68/86 -# incl. EP offset>0 -- so the abort is NOT cleanly attributable to one config (the since-fixed -# gh #3547 EP case returned tolerated zeros, no assert, under torch.cuda.synchronize); it surfaces -# only in the accumulated single-process run that CI uses. `pytest --forked` can't isolate it -# either (CUDA inits at collection -> "Cannot re-initialize CUDA in forked subprocess"). Until the -# abort is root-caused, this suite is opt-in: set FLASHINFER_UMOE_FUZZ=1 -# to run it (developer / nightly / SM100 box). Unset (CI default) -> collected-and-skipped, so it -# never launches a kernel and cannot abort the job. +# --- CI gate: ON by default (FLASHINFER_UMOE_FUZZ=0 is the emergency waiver) ---------------- +# History: this suite was opt-in (FLASHINFER_UMOE_FUZZ=1) while (a) gh #3547 was open and (b) the +# accumulated single-process run could hit `CUDA error: device-side assert triggered` -> +# `Fatal Python error: Aborted` (2026-06-09 triage). Both are now understood (2026-07-14, full +# default run on a B200-class SM100): #3547 is fixed (its EP-offset configs pass), and the abort +# is root-caused mechanically -- an async device-side assert from one config poisons the CUDA +# context and the pending c10 error escapes a destructor at interpreter shutdown +# (std::terminate). It is not a separate Heisenbug: any assert-class *finding* ends this fuzzer's +# pytest process after the failure is reported. The shard_group marker keeps the accumulated +# sequence together in one pytest invocation, preserving the regression while the sharding runner +# can still isolate failures in other groups. The historical gh #3957 finding +# (a silent OOB write with a moving victim) was fixed by gh #4186; keeping this accumulated +# sequence enabled is its regression coverage. +# Set FLASHINFER_UMOE_FUZZ=0 to disable in an emergency; FLASHINFER_UMOE_FUZZ=1 (the old opt-in +# value) still enables and is now a no-op. pytestmark = pytest.mark.skipif( - not os.environ.get("FLASHINFER_UMOE_FUZZ"), - reason="opt-in fuzzer (set FLASHINFER_UMOE_FUZZ=1); waived in CI pending " - "root-cause of the whole-process device-side-assert abort", + os.environ.get("FLASHINFER_UMOE_FUZZ", "1") == "0", + reason="unified MoE fuzzer disabled via FLASHINFER_UMOE_FUZZ=0", ) # Per-backend determinism contract, established empirically (CRC across reruns) + confirmed against # code. A "True" backend MUST reproduce bitwise; flip to False only with evidence (and ideally an # upstream note), because a deterministic->non-deterministic regression is exactly a bug to catch. _DETERMINISTIC = { + "cutlass_bf16": True, + "cutlass_w4a16": True, "trtllm_fp4_routed": True, # bitwise-stable across reruns in calibration "cute_dsl_nvfp4": False, # atomic scatter-add finalize -> non-bit-exact by design "trtllm_bf16_routed": True, # same trtllm-gen finalize path as fp4_routed; bitwise-stable in calibration @@ -240,21 +258,20 @@ "trtllm_fp8_per_tensor": True, } -# Known-bug ledger: (backend_key, predicate(cfg)) -> reason. A matching (backend, config) is run but -# its correctness failure is TOLERATED (xfail) -- this keeps the suite green on a filed-and-tracked -# bug while still EXERCISING it, so the day the bug is fixed the case starts passing and we get a loud -# "unexpectedly passed -> remove this entry" signal. A crash is never tolerated (only wrong answers). -_KNOWN_FAILURES = [ - # Entries: (backend_key, predicate(cfg), "reason; gh #NNNN"). - # Empty since the gh #3547 EP-offset double-subtraction fix. -] - - -def _known_failure(backend_key, cfg): - for bk, predicate, reason in _KNOWN_FAILURES: - if bk == backend_key and predicate(cfg): - return reason - return None +# Known-bug ledger (shared mechanism: tests/test_helpers/fuzz_ledger.py). Two severities: +# quarantine=False entries are RUN with a tolerated wrong answer (xpass flags the fix); +# quarantine=True entries are xfailed up front and never launch (crash / device-state class -- +# one such config poisons the CUDA context for every later test in the process). +LEDGER = FuzzLedger( + "unified-moe", + findings=( + # Finding(match=..., reason="...; gh #NNNN", quarantine=..., backend=...) + # Wrong-answer entries: empty since the gh #3547 EP-offset double-subtraction fix. + # The historical gh #3957 cross-call corruption had a moving victim and could not be + # quarantined by config predicate. It was fixed by gh #4186, so no ledger entry remains; + # this accumulated sequence is the regression test. + ), +) # --------------------------------------------------------------------------- @@ -378,14 +395,15 @@ def _bf16_snap(t: torch.Tensor) -> torch.Tensor: def _bf16_act_pack(x, selected_experts, final_scales): - # Raw bf16 activations; the bf16 runner reads hidden_states_q directly and - # ignores hidden_states_scale. + # Raw bf16 activations. Use BF16-grid routing weights represented as FP32 + # so TRTLLM's packed-id path and CUTLASS's separate-weight path share one + # exact semantic input. return MoEActivationPack( hidden_states_q=x, hidden_states_scale=None, routing_input_mode=RoutingInputMode.PackedPrecomputed, topk_ids=selected_experts, - topk_weights=final_scales, + topk_weights=final_scales.to(torch.bfloat16).float(), ) @@ -453,24 +471,20 @@ def _mxfp8_quant_matrix(x): def _mxfp4_quant_dequant_matrix(x): - """Quantize/dequantize one logical MXFP4 matrix with linear UE8M0 scales.""" - one = torch.tensor([1.0], device=x.device) - q, sf = fp4_quantize( - x.to(torch.bfloat16), - global_scale=one, - sf_vec_size=32, - sf_use_ue8m0=True, - is_sf_swizzled_layout=False, + """Torch MXFP4 round-trip that is valid on both Hopper and Blackwell.""" + q, sf = _quantize_mxfp4_linear(x.to(torch.bfloat16).contiguous()) + low = q & 0xF + high = q >> 4 + codes = torch.stack((low, high), dim=-1).reshape(x.shape).to(torch.long) + magnitudes = torch.tensor( + [0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0], + device=x.device, + dtype=torch.float32, ) - deq = e2m1_and_ufp8sf_scale_to_float( - q.cpu(), - sf.cpu().view(torch.uint8).reshape(-1), - (1.0 / one).cpu(), - 32, - 0, - False, - ) - return deq.reshape(x.shape).to(x.device) + values = magnitudes[codes & 0x7] + values = torch.where((codes & 0x8) != 0, -values, values) + scales = torch.exp2(sf.to(torch.int16).to(torch.float32) - 127) + return values * scales.repeat_interleave(32, dim=-1) def _mxfp4_snap(t: torch.Tensor, *, bf16_activation: bool) -> torch.Tensor: @@ -491,7 +505,10 @@ def _mxfp4_act_pack(x, selected_experts, final_scales, *, variant: QuantVariant) hidden_states_scale=sf, routing_input_mode=RoutingInputMode.PackedPrecomputed, topk_ids=selected_experts, - topk_weights=final_scales, + # TRTLLM's packed-id ABI rounds routing weights through BF16, whereas + # CUTLASS consumes FP32. Supplying BF16-grid values in FP32 gives both + # backends one exact routing-weight contract. + topk_weights=final_scales.to(torch.bfloat16).float(), ) @@ -764,7 +781,7 @@ def _mxint4_reference( ), QuantVariant.BF16: DTypeHandler( variant=QuantVariant.BF16, - candidate_configs=(TrtllmBf16Config,), + candidate_configs=(TrtllmBf16Config, CutlassBf16Config), snap=_bf16_snap, make_act_pack=_bf16_act_pack, make_act_pack_logits=_bf16_act_pack_logits, @@ -838,7 +855,7 @@ def _mxint4_reference( ), QuantVariant.W4A16: DTypeHandler( variant=QuantVariant.W4A16, - candidate_configs=(TrtllmFp4Config,), + candidate_configs=(TrtllmFp4Config, CutlassW4A16Config), snap=lambda t: _mxfp4_snap(t, bf16_activation=True), make_act_pack=lambda x, ids, weights: _mxfp4_act_pack( x, ids, weights, variant=QuantVariant.W4A16 @@ -861,8 +878,9 @@ def _mxint4_reference( reference=_mxint4_reference, poison=_poison_bf16_out, out_dtype=torch.bfloat16, - # Curated FromLogits observes max|diff| / ||ref||inf ~= 0.0335. - atol_frac=0.04, + # The full 160-seed SM100 sweep observes max|diff| / ||ref||inf + # ~= 0.0535 for a 4096-token FromLogits case. + atol_frac=0.06, rtol=0.3, ), } @@ -938,6 +956,16 @@ def _handler_for(cfg): RoutingMethodType.MiniMax2, # sigmoid+bias -> top_k -> scaled sum-norm RoutingMethodType.Llama4, # top1 -> sigmoid (top_k forced to 1) ] +# Compiled in-kernel routing tiers are method-specific. Pre-routed modes bypass +# these limits, but FromLogits must not generate a shape that the selected +# routing policy cannot dispatch. +_FROMLOGITS_MAX_EXPERTS = { + RoutingMethodType.Default: 256, + RoutingMethodType.TopK: 256, + RoutingMethodType.Sigmoid: 256, + RoutingMethodType.SigmoidRenorm: 256, + RoutingMethodType.Llama4: 128, +} # Routing logits dtype axis: fp32 router logits are the #2796 class; bf16 is the common case. _LOGITS_DTYPE = {"bf16": torch.bfloat16, "fp32": torch.float32} @@ -954,6 +982,14 @@ def _handler_for(cfg): for cfg_cls, runner_cls in _BACKEND_RUNNERS.items() if RoutingInputMode.UnpackedPrecomputed in runner_cls.supported_routing_modes } +# Backend config classes whose runner can compute an EP shard (a local expert subset with +# a nonzero offset), likewise derived from the runners' capability declaration. CUTLASS and +# b12x kernels compute the full routed set only, so an EP config restricts to these. +_EP_BACKENDS = { + cfg_cls + for cfg_cls, runner_cls in _BACKEND_RUNNERS.items() + if runner_cls.supports_expert_parallelism +} _UNPACKED_VARIANT_IDS = tuple( variant.name.lower() for variant, handler in _DTYPE.items() @@ -1060,8 +1096,16 @@ def _gen(seed): # GPU footprint). Routing mode is chosen BEFORE this loop on purpose: non-EP-forced # configs (FromLogits / DeepSeekV3) hold the FULL expert set, so budgeting a sharded # `local` and flipping to non-EP afterwards would admit up to shards x the budget. + eligible_experts = _EXPERTS + if fromlogits and method in _FROMLOGITS_MAX_EXPERTS: + max_experts = _FROMLOGITS_MAX_EXPERTS[method] + eligible_experts = [ne for ne in _EXPERTS if ne <= max_experts] for _ in range(64): - ne, h, i = rng.choice(_EXPERTS), rng.choice(_HIDDEN), rng.choice(_INTERMED) + ne, h, i = ( + rng.choice(eligible_experts), + rng.choice(_HIDDEN), + rng.choice(_INTERMED), + ) # ~30%: expert-parallel shard -- split the global experts and pick a shard (offset>0). This # is how large MoE actually runs (no rank holds all experts) and exercises the offset path. local, offset = ne, 0 @@ -1072,6 +1116,11 @@ def _gen(seed): offset = local * rng.randrange(shards) if _weight_elems(local, h, i) <= _WEIGHT_ELEM_BUDGET: break + else: + raise RuntimeError( + f"seed {seed} could not generate a unified-MoE fuzz shape within " + f"the {_WEIGHT_ELEM_BUDGET}-element weight budget after 64 attempts" + ) # Method-specific top_k + group params. n_group = topk_group = 0 @@ -1388,7 +1437,7 @@ def _gen(seed): 4, "mxfp4", "uniform", - 900_032, + 900_042, routing_input_mode="unpacked", ), Cfg( @@ -1399,7 +1448,7 @@ def _gen(seed): 4, "w4a16", "imbalanced", - 900_036, + 900_043, routing_input_mode="unpacked", unpacked_weights_dtype="fp32", ), @@ -1478,16 +1527,24 @@ def _gen(seed): num_fused_shared_experts=num_shared, ) for variant, num_shared, seed in ( - ("nvfp4", 1, 900_042), - ("mxfp4", 2, 900_043), + ("nvfp4", 1, 900_045), + ("mxfp4", 2, 900_046), ("w4a16", 1, 900_044), ) ], ] +_CURATED_BY_SEED = {} +for _cfg in _CURATED: + if _cfg.seed in _CURATED_BY_SEED: + raise ValueError( + "duplicate curated unified-MoE fuzz seed " + f"{_cfg.seed}: {_CURATED_BY_SEED[_cfg.seed].label} and {_cfg.label}" + ) + _CURATED_BY_SEED[_cfg.seed] = _cfg + if _ONLY_SEEDS: # perfect-repro: run only the named seed(s) - _curated_by_seed = {c.seed: c for c in _CURATED} _CONFIGS = [ - _curated_by_seed.get(s) or _gen(s) + _CURATED_BY_SEED.get(s) or _gen(s) for s in (int(t) for t in _ONLY_SEEDS.split(",") if t.strip()) ] else: @@ -1732,10 +1789,14 @@ def _fail(cfg: Cfg, tag: str, why: str, out=None, ref=None): pytest.fail("\n".join(parts)) +@pytest.mark.shard_group("unified-moe-accumulated") @pytest.mark.parametrize("cfg", _CONFIGS, ids=[c.label for c in _CONFIGS]) def test_unified_moe_fuzz(cfg): if not torch.cuda.is_available(): pytest.skip("no CUDA") + # Crash-class quarantine gate: MUST precede any kernel launch (a quarantined config would + # poison the CUDA context for every later test in this process). + LEDGER.xfail_if_quarantined(cfg) # Full per-config determinism so any failure reproduces from the seed alone. Shapes # (random.Random(seed)) and input tensors (a per-config torch.Generator) are already seeded; # this pins the global RNG (the device probe), and the output buffer is initialized from the @@ -1778,7 +1839,36 @@ def test_unified_moe_fuzz(cfg): # Backends that accept separate tensors through their own ABI are not # implementations of RoutingInputMode.UnpackedPrecomputed. wired_backends = [B for B in wired_backends if B in _UNPACKED_BACKENDS] + if cfg.is_ep: + # An EP shard needs the runner to map global ids onto a local expert subset; + # backends without that capability (CUTLASS, b12x) would fail MoELayer's + # check_support, and with no other candidate the case must SKIP, not FAIL. + wired_backends = [B for B in wired_backends if B in _EP_BACKENDS] + if _BACKEND_FILTER: + wired_backends = [ + B + for B in wired_backends + if _BACKEND_RUNNERS[B].backend_key in _BACKEND_FILTER + ] + # A backend-scoped crash quarantine must take effect before backend-native + # weight preparation or MoELayer construction: both can load modules and + # launch CUDA preparation kernels. Keep the findings so the overall case + # still reports XFAIL after all healthy backends have run. + quarantined_backends = [] + healthy_backends = [] + for BackendCfg in wired_backends: + backend_key = _BACKEND_RUNNERS[BackendCfg].backend_key + quarantine = LEDGER.skip_backend(cfg, backend_key) + if quarantine: + quarantined_backends.append((quarantine, backend_key)) + else: + healthy_backends.append(BackendCfg) + wired_backends = healthy_backends if not wired_backends: + LEDGER.report_expected_failures( + quarantined_backends, + context=f"all candidate backends quarantined for {cfg.label}", + ) mode = ( "in-kernel-routing " if cfg.is_fromlogits @@ -1808,7 +1898,8 @@ def test_unified_moe_fuzz(cfg): cfg.intermediate, cfg.expert_offset, ) - atol = handler.atol_frac * ref.abs().max().item() + 1e-3 + ref_abs_max = ref.abs().max().item() + atol = handler.atol_frac * ref_abs_max + 1e-3 rtol = handler.rtol # One activation pack + one weight pack with each backend's native view, all built from the @@ -1874,7 +1965,13 @@ def test_unified_moe_fuzz(cfg): backend=BackendOptions( candidates=tuple(BackendCfg() for BackendCfg in wired_backends) ), - execution=ExecutionConfig(tune_max_num_tokens=max(cfg.num_tokens, 8192)), + execution=ExecutionConfig( + tune_max_num_tokens=( + cfg.num_tokens + if os.environ.get("FLASHINFER_UMOE_FUZZ_TUNE_REAL_SHAPE") + else max(cfg.num_tokens, 8192) + ) + ), ) try: @@ -1921,6 +2018,18 @@ def run(runner, poison=False): return out def assert_correct(out, tag): + if ( + handler.variant is QuantVariant.W4A16 + and ref_abs_max > 0 + and out.abs().max().item() == 0 + ): + _fail( + cfg, + tag, + "all-zero W4A16 output for a nonzero reference", + out, + ref, + ) # (1) no NaN/Inf where the reference is finite. n_bad = int(((~torch.isfinite(out)) & torch.isfinite(ref)).sum().item()) if n_bad != 0: @@ -1949,6 +2058,8 @@ def assert_correct(out, tag): def check_backend(runner, out, tag): # (1)+(2) no-NaN + numeric vs the authoritative reference, on a clean run. assert_correct(out, tag) + if os.environ.get("FLASHINFER_UMOE_FUZZ_LEAN"): + return # debug: minimal per-config work for sanitizer runs (gh #3957) # (3) determinism per the backend's contract: deterministic backends must reproduce # bitwise; non-deterministic ones (atomic-scatter finalize) are exempt. if _DETERMINISTIC.get(runner.backend_key, False): @@ -1980,6 +2091,7 @@ def check_backend(runner, out, tag): assert_correct(o, f"{tag} [tactic={tactic}]") n_ran = 0 + expected_failures = [] for runner in layer.runners: try: out = run(runner) @@ -1990,29 +2102,35 @@ def check_backend(runner, out, tag): tag = f"{runner.backend_key} {cfg.label}" n_ran += 1 - known = _known_failure(runner.backend_key, cfg) + known = LEDGER.find(cfg, backend=runner.backend_key) if known: # tracked bug -> run it, tolerate a wrong answer, but flag if it starts passing try: check_backend(runner, out, tag) except (AssertionError, pytest.fail.Exception): + expected_failures.append((known, tag)) continue - warnings.warn( - f"{tag}: KNOWN-FAILURE unexpectedly PASSED -- fixed? remove from " - f"_KNOWN_FAILURES ({known})", - stacklevel=2, - ) + LEDGER.flag_xpass(known, tag) else: check_backend(runner, out, tag) if n_ran == 0: + LEDGER.report_expected_failures( + quarantined_backends, + context=f"all candidate backends quarantined for {cfg.label}", + ) pytest.skip(f"no runner ran {cfg.label} on SM{sm}") # (6) autotune-ON: drive the REAL production path -- MoELayer._select_winner profiles every # tactic of every runner (the #3168 profiling-IMA class) then selects + caches a winner; the # autotuned output must match the authoritative reference. Gated to a subset (profiling is slow) # and skipped if a candidate has a known failure (the tuner could pick the broken backend). - autotune_due = cfg.seed % 4 == 0 and not any( - _known_failure(_BACKEND_RUNNERS[B].backend_key, cfg) for B in wired_backends + autotune_due = ( + not _NO_AUTOTUNE + and cfg.seed % 4 == 0 + and not any( + LEDGER.find(cfg, backend=_BACKEND_RUNNERS[B].backend_key) + for B in wired_backends + ) ) if autotune_due: with autotune(True): @@ -2030,6 +2148,10 @@ def check_backend(runner, out, tag): assert torch.isfinite(probe).all(), ( f"{cfg.label}: CUDA context corrupted after MoE run" ) + LEDGER.report_expected_failures( + [*expected_failures, *quarantined_backends], + context=f"tracked backend failures for {cfg.label}", + ) # --------------------------------------------------------------------------- diff --git a/tests/moe/trtllm_gen_fused_moe_utils.py b/tests/moe/trtllm_gen_fused_moe_utils.py index 10aa6f505fb..ec98c0671ae 100644 --- a/tests/moe/trtllm_gen_fused_moe_utils.py +++ b/tests/moe/trtllm_gen_fused_moe_utils.py @@ -3361,7 +3361,11 @@ def cache_permute_indices(): pytest.param(False, id="RandomHiddenStates"), ] -RENORMALIZE_NUM_TOKENS = [8, 768, 3072] +# Shape fan-out is deliberately SMALL (boundary token counts + boundary intermediate +# sizes only): the quant x routing x weight-layout matrix below is the coverage that +# matters for kernel selection, and randomized shape breadth lives in +# tests/moe/test_unified_moe_fuzz.py. Extend the fuzzer, not these lists. +RENORMALIZE_NUM_TOKENS = [8, 3072] RENORMALIZE_HIDDEN_SIZES = [1024] RENORMALIZE_INTERMEDIATE_SIZES = [1024, 768, 512, 384] diff --git a/tests/test_fuzz_ledger.py b/tests/test_fuzz_ledger.py new file mode 100644 index 00000000000..f4773c37edb --- /dev/null +++ b/tests/test_fuzz_ledger.py @@ -0,0 +1,75 @@ +from dataclasses import dataclass + +import pytest + +from tests.test_helpers.fuzz_ledger import Finding, FuzzLedger + + +@dataclass(frozen=True) +class Config: + value: int + + +def _matches_one(cfg): + return cfg.value == 1 + + +def test_fuzz_ledger_requires_tracking_issue(): + with pytest.raises(ValueError, match="tracking issue"): + FuzzLedger("test", (Finding(_matches_one, "missing issue"),)) + + +def test_fuzz_ledger_quarantine_precedes_tolerated_overlap(): + tolerated = Finding(_matches_one, "wrong answer #100") + quarantined = Finding( + _matches_one, + "illegal access #101", + quarantine=True, + backend="broken", + ) + ledger = FuzzLedger("test", (tolerated, quarantined)) + cfg = Config(1) + + assert ledger.find(cfg) is tolerated + assert ledger.find(cfg, backend="healthy") is tolerated + assert ledger.find(cfg, backend="broken") is quarantined + + +def test_fuzz_ledger_backend_scope_is_exact(): + scoped = Finding(_matches_one, "backend bug #102", backend="broken") + ledger = FuzzLedger("test", (scoped,)) + cfg = Config(1) + + assert ledger.find(cfg) is None + assert ledger.find(cfg, backend="healthy") is None + assert ledger.find(cfg, backend="broken") is scoped + + +def test_fuzz_ledger_global_quarantine_xfails_before_launch(): + ledger = FuzzLedger( + "test", + (Finding(_matches_one, "crash #103", quarantine=True),), + ) + + with pytest.raises(pytest.xfail.Exception, match="quarantined"): + ledger.xfail_if_quarantined(Config(1)) + + +def test_fuzz_ledger_reports_accumulated_expected_failures_as_xfail(): + finding = Finding(_matches_one, "known mismatch #104", backend="broken") + ledger = FuzzLedger("test", (finding,)) + + ledger.report_expected_failures([], context="nothing failed") + with pytest.raises(pytest.xfail.Exception, match=r"broken.*#104"): + ledger.report_expected_failures( + [(finding, "broken backend")], + context="completed healthy backends", + ) + + +def test_fuzz_ledger_unexpected_pass_is_strict_failure(): + finding = Finding(_matches_one, "known mismatch #105") + ledger = FuzzLedger("test", (finding,)) + + with pytest.raises(pytest.fail.Exception, match="unexpectedly PASSED"): + ledger.flag_xpass(finding, "backend") diff --git a/tests/test_helpers/fuzz_ledger.py b/tests/test_helpers/fuzz_ledger.py new file mode 100644 index 00000000000..1f811b73aff --- /dev/null +++ b/tests/test_helpers/fuzz_ledger.py @@ -0,0 +1,155 @@ +"""Shared known-failure / quarantine ledger for the quality fuzzers (gh #3605). + +Every fuzz-style suite (unified MoE, scaled GEMM/BMM, and future sampling / +norm-RoPE / attention testers) needs the same two escape hatches for +already-filed bugs, with opposite semantics: + +* ``quarantine=False`` (a *tolerated wrong answer*): the case still RUNS; a + failing invariant is converted to xfail so the suite stays green on a + tracked bug, and an unexpected PASS is flagged loudly ("fixed? remove the + entry") so entries cannot silently outlive their bug. + +* ``quarantine=True`` (the *crash class*: device-side assert / IMA / + device-state corruption): the case is xfailed UP FRONT and the kernel is + never launched. One such config poisons the CUDA context for every later + test in the process (see gh #3957, gh #3604), so "run and tolerate" is not + an option. Because a quarantined case never runs, it cannot announce its + own fix -- which is why ``reason`` MUST name a tracking issue: the issue, + not an xpass, is the re-enable signal. + +Governance rules (enforced here so every fuzzer inherits them): + +1. Every entry's ``reason`` must reference a tracker (``#NNNN``) -- + construction fails otherwise. No untracked waivers. +2. Entries are declared next to the fuzzer that owns them (context lives with + the input model), but always through this module, so the project's entire + waived surface is one grep away: ``grep -rn "FuzzLedger(" tests/``. +3. Quarantine scope should be as narrow as the current knowledge allows + (backend-scoped once the culprit backend is known; config-predicate + otherwise), and each entry should say how to probe fixed-ness. +""" + +import re +from dataclasses import dataclass, field +from typing import Any, Callable, List, Optional, Sequence + +import pytest + +_ISSUE_RE = re.compile(r"#\d+") + +# Instances register here so future report tooling (e.g. the weekly xfails +# report) can enumerate the full waived surface without importing each fuzzer. +_REGISTRY: List["FuzzLedger"] = [] + + +@dataclass(frozen=True) +class Finding: + """One filed-and-tracked bug affecting a slice of a fuzzer's config space. + + Attributes: + match: predicate over the owning fuzzer's config object. + reason: human-readable description; MUST reference the tracking issue + (``#NNNN``) and should say how to probe whether it is fixed. + quarantine: True -> xfail up front, never launch (crash class); + False -> run, tolerate a wrong answer, flag an unexpected pass. + backend: optionally scope the entry to one backend key (None = any). + """ + + match: Callable[[Any], bool] + reason: str + quarantine: bool = False + backend: Optional[str] = None + + +@dataclass +class FuzzLedger: + """Per-fuzzer view over its :class:`Finding` entries (validated).""" + + op: str # e.g. "unified-moe", "scaled-gemm" -- for reports/messages + findings: Sequence[Finding] = field(default_factory=tuple) + + def __post_init__(self): + for f in self.findings: + if not _ISSUE_RE.search(f.reason): + raise ValueError( + f"FuzzLedger({self.op!r}): every entry must reference a " + f"tracking issue ('#NNNN'); offending reason: {f.reason!r}" + ) + _REGISTRY.append(self) + + def find(self, cfg, backend: Optional[str] = None) -> Optional[Finding]: + """Best entry matching this config (and backend, if scoped). + + Scope contract: a backend-scoped entry matches ONLY when queried with + that exact backend — with ``backend=None`` (a config-level query, + before backends are even enumerated) scoped entries do NOT match, so + a backend-scoped quarantine never takes out the other, healthy + backends of the same config. Query per backend to apply scoped + entries (see ``skip_backend`` / the fuzzer's runner loop). + + Quarantine entries take priority over tolerated-wrong-answer entries + regardless of declaration order: if any matching entry says "never + launch", that is the safety contract that must win. + """ + best: Optional[Finding] = None + for f in self.findings: + if f.backend is not None and f.backend != backend: + continue + if f.match(cfg): + if f.quarantine: + return f + if best is None: + best = f + return best + + def skip_backend(self, cfg, backend: str) -> Optional[Finding]: + """Backend-scoped quarantine check for a per-runner loop. + + Returns the quarantine Finding if this (config, backend) must not + launch, else None. Unlike :meth:`xfail_if_quarantined` (which xfails + the whole test and is meant for GLOBAL entries), the caller should + skip just this backend and keep testing the others. + """ + f = self.find(cfg, backend=backend) + return f if (f is not None and f.quarantine) else None + + def xfail_if_quarantined(self, cfg, backend: Optional[str] = None) -> None: + """Call at the top of a fuzz case, BEFORE any kernel launch. + + With ``backend=None`` this applies GLOBAL (unscoped) quarantine + entries only — backend-scoped entries are applied per-runner via + :meth:`skip_backend`. + """ + f = self.find(cfg, backend) + if f is not None and f.quarantine: + pytest.xfail(f"[{self.op}] quarantined (never run): {f.reason}") + + def flag_xpass(self, finding: Finding, tag: str) -> None: + """Hard-fail when a tolerated-wrong-answer entry unexpectedly passes. + + xfail_strict semantics: a stale waiver silently outliving its bug is + exactly what the mandatory issue reference exists to prevent, so the + fix must be acknowledged by removing the entry. + """ + pytest.fail( + f"[{self.op}] {tag}: KNOWN-FAILURE unexpectedly PASSED -- the bug " + f"appears fixed; remove its ledger entry ({finding.reason})", + pytrace=False, + ) + + def report_expected_failures( + self, + failures: Sequence[tuple[Finding, str]], + *, + context: str, + ) -> None: + """Report tracked failures after all healthy backends have run. + + Delaying the XFAIL lets a multi-backend fuzz case continue validating + unaffected runners while ensuring tolerated failures and quarantined + backends cannot make the overall test appear as PASS or SKIP. + """ + if not failures: + return + details = "; ".join(f"{tag}: {finding.reason}" for finding, tag in failures) + pytest.xfail(f"[{self.op}] {context}: {details}")