# SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project import pytest import torch from vllm import _custom_ops as ops from vllm.platforms import current_platform from vllm.scalar_type import scalar_types from vllm.utils.torch_utils import set_random_seed if not current_platform.has_device_capability(100): pytest.skip( reason="Nvfp4 Requires compute capability of 10 or above.", allow_module_level=True, ) DTYPES = [torch.float16, torch.bfloat16] SHAPES = [(128, 64), (128, 128), (256, 64), (256, 128)] PAD_SHAPES = [ (90, 64), (150, 64), (128, 48), (128, 80), (150, 80), (90, 48), (90, 128), (150, 128), (150, 48), (90, 80), (128, 512), (128, 1024), (128, 2048), (64, 7168), (64, 7152), (32, 14336), ] SEEDS = [42] CUDA_DEVICES = ["cuda:0"] FLOAT4_E2M1_MAX = scalar_types.float4_e2m1f.max() FLOAT8_E4M3_MAX = torch.finfo(torch.float8_e4m3fn).max # E2M1 to float # 0111 -> 6 # 0110 -> 4 # 0101 -> 3 # 0100 -> 2 # 0011 -> 1.5 # 0010 -> 1 # 0001 -> 0.5 # 0000 -> 0 E2M1_TO_FLOAT32 = [ 0.0, 0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 6.0, 0.0, -0.5, -1.0, -1.5, -2.0, -3.0, -4.0, -6.0, ] BLOCK_SIZE = 16 def cast_from_fp4(x, m, n): # The fp4 values are packed in uint8 as [v_1st | v_2nd] v_2nd = x & 0xF v_1st = (x >> 4) & 0xF c = torch.stack((v_2nd, v_1st), dim=-1) out = torch.tensor([E2M1_TO_FLOAT32[x] for x in c.flatten()]) out = out.reshape(m, n).to(torch.float32) return out def cast_to_fp4(x): sign = torch.sign(x) x = torch.abs(x) x[(x >= 0.0) & (x <= 0.25)] = 0.0 x[(x > 0.25) & (x < 0.75)] = 0.5 x[(x >= 0.75) & (x <= 1.25)] = 1.0 x[(x > 1.25) & (x < 1.75)] = 1.5 x[(x >= 1.75) & (x <= 2.5)] = 2.0 x[(x > 2.5) & (x < 3.5)] = 3.0 x[(x >= 3.5) & (x <= 5.0)] = 4.0 x[x > 5.0] = 6.0 return x * sign def get_reciprocal(x): if isinstance(x, torch.Tensor): return torch.where(x == 0, torch.tensor(0.0, dtype=x.dtype), 1.0 / x) elif isinstance(x, (float, int)): return 0.0 if x == 0 else 1.0 / x else: raise TypeError("Input must be a float, int, or a torch.Tensor.") def ref_nvfp4_quant(x, global_scale): assert global_scale.dtype == torch.float32 assert x.ndim == 2 m, n = x.shape x = torch.reshape(x, (m, n // BLOCK_SIZE, BLOCK_SIZE)) vec_max = torch.max(torch.abs(x), dim=-1, keepdim=True)[0].to(torch.float32) scale = global_scale * (vec_max * get_reciprocal(FLOAT4_E2M1_MAX)) scale = scale.to(torch.float8_e4m3fn).to(torch.float32) output_scale = get_reciprocal(scale * get_reciprocal(global_scale)) scaled_x = x.to(torch.float32) * output_scale clipped_x = torch.clamp(scaled_x, -6.0, 6.0).reshape(m, n) return cast_to_fp4(clipped_x), scale.squeeze(-1) def recover_swizzled_scales(scale, m, n): round_up = lambda x, y: (x + y - 1) // y * y rounded_m = round_up(m, 128) scale_n = n // BLOCK_SIZE rounded_n = round_up(scale_n, 4) # Recover the swizzled scaling factor to linear layout tmp = torch.reshape(scale, (1, rounded_m // 128, rounded_n // 4, 32, 4, 4)) tmp = torch.permute(tmp, (0, 1, 4, 3, 2, 5)) result = torch.reshape(tmp, (rounded_m, rounded_n)).to(torch.float32) return result[:m, :scale_n] @pytest.mark.parametrize("dtype", DTYPES) @pytest.mark.parametrize("shape", SHAPES) @pytest.mark.parametrize("seed", SEEDS) @pytest.mark.parametrize("device", CUDA_DEVICES) @torch.inference_mode() def test_quantize_to_fp4( dtype: torch.dtype, shape: tuple[int, int], seed: int, device: str, ) -> None: set_random_seed(seed) torch.set_default_device(device) m, n = shape x = torch.randn((m, n), dtype=dtype) tensor_amax = torch.abs(x).max().to(torch.float32) global_scale = FLOAT8_E4M3_MAX * FLOAT4_E2M1_MAX / tensor_amax out_ref, scale_ref = ref_nvfp4_quant(x, global_scale) out, out_scale = ops.scaled_fp4_quant(x, global_scale) scale_ans = recover_swizzled_scales(out_scale, m, n) out_ans = cast_from_fp4(out, m, n) torch.testing.assert_close(out_ans, out_ref) torch.testing.assert_close(scale_ans, scale_ref) @pytest.mark.parametrize( "shape", [(32, 4096), (128, 4096), (1, 64), (127, 1024), (256, 16384)], ) @pytest.mark.parametrize("is_sf_swizzled_layout", [True, False]) @torch.inference_mode() def test_python_util_matches_cpp_allocation( shape: tuple[int, int], is_sf_swizzled_layout: bool, ) -> None: """ Verify that the Python utility (create_fp4_output_tensors) allocates tensors with the same shapes and dtypes as the C++ functional variant (scaled_fp4_quant_func). """ from vllm._custom_ops import create_fp4_output_tensors torch.set_default_device("cuda:0") m, n = shape input_tensor = torch.randn((m, n), dtype=torch.bfloat16) input_scale = torch.tensor([1.0], dtype=torch.float32, device="cuda:0") # C++ functional variant allocates internally cpp_out, cpp_scale = torch.ops._C.scaled_fp4_quant( input_tensor, input_scale, is_sf_swizzled_layout ) # Python utility py_out, py_scale = create_fp4_output_tensors( m, n, torch.device("cuda:0"), is_sf_swizzled_layout ) assert py_out.shape == cpp_out.shape, ( f"Output shape mismatch: Python {py_out.shape} vs C++ {cpp_out.shape}" ) assert py_out.dtype == cpp_out.dtype, ( f"Output dtype mismatch: Python {py_out.dtype} vs C++ {cpp_out.dtype}" ) assert py_scale.shape == cpp_scale.shape, ( f"Scale shape mismatch: Python {py_scale.shape} vs C++ {cpp_scale.shape}" ) assert py_scale.dtype == cpp_scale.dtype, ( f"Scale dtype mismatch: Python {py_scale.dtype} vs C++ {cpp_scale.dtype}" ) @pytest.mark.parametrize("pad_shape", PAD_SHAPES) @torch.inference_mode() def test_quantize_to_fp4_padded(pad_shape: tuple[int, int]) -> None: dtype = torch.float16 set_random_seed(42) torch.set_default_device("cuda:0") m, n = pad_shape x = torch.randn((m, n), dtype=dtype) tensor_amax = torch.abs(x).max().to(torch.float32) global_scale = FLOAT8_E4M3_MAX * FLOAT4_E2M1_MAX / tensor_amax out_ref, scale_ref = ref_nvfp4_quant(x, global_scale) out, out_scale = ops.scaled_fp4_quant(x, global_scale) scale_ans = recover_swizzled_scales(out_scale, m, n) out_ans = cast_from_fp4(out, m, n) torch.testing.assert_close(out_ans, out_ref) torch.testing.assert_close(scale_ans, scale_ref) @pytest.mark.parametrize("pad_shape", PAD_SHAPES) @torch.inference_mode() def test_quantize_to_fp4_padded_no_sf_swizzled(pad_shape: tuple[int, int]) -> None: dtype = torch.float16 set_random_seed(42) torch.set_default_device("cuda:0") m, n = pad_shape x = torch.randn((m, n), dtype=dtype) tensor_amax = torch.abs(x).max().to(torch.float32) global_scale = FLOAT8_E4M3_MAX * FLOAT4_E2M1_MAX / tensor_amax out_ref, scale_ref = ref_nvfp4_quant(x, global_scale) out, out_scale = ops.scaled_fp4_quant(x, global_scale, is_sf_swizzled_layout=False) scale_ans = out_scale.to(torch.float32) out_ans = cast_from_fp4(out, m, n) torch.testing.assert_close(out_ans, out_ref) torch.testing.assert_close(scale_ans, scale_ref) # ============================================================================ # SM103-native quantization correctness # ============================================================================ _SM103_QUANT_SHAPES = SHAPES + PAD_SHAPES @pytest.mark.skipif( not hasattr(torch.ops._C, "scaled_fp4_quant_sm103"), reason="scaled_fp4_quant_sm103 op not available " "(rebuild without VLLM_USE_PRECOMPILED=1)", ) @pytest.mark.parametrize("dtype", DTYPES) @pytest.mark.parametrize("shape", _SM103_QUANT_SHAPES) @pytest.mark.parametrize("seed", SEEDS) @torch.inference_mode() def test_scaled_fp4_quant_sm103_matches_sm100( dtype: torch.dtype, shape: tuple[int, int], seed: int, ) -> None: """ Verify scaled_fp4_quant_sm103 (SM103-native layout) against the SM100 path. Two invariants: 1. Packed FP4 data is identical — both kernels quantize to the same e2m1 values; only the SF memory layout differs. 2. SM103 native SFs are byte-identical to SM100 SFs run through convert_sf_layout_sm100_to_sm103 — confirms the in-kernel swizzle matches the standalone conversion kernel. """ set_random_seed(seed) torch.set_default_device("cuda:0") m, n = shape x = torch.randn((m, n), dtype=dtype) tensor_amax = torch.abs(x).max().to(torch.float32) global_scale = FLOAT8_E4M3_MAX * FLOAT4_E2M1_MAX / tensor_amax # SM100 reference: ops wrapper returns fp4 (uint8) and sf (float8_e4m3fn) fp4_sm100, sf_sm100 = ops.scaled_fp4_quant( x, global_scale, is_sf_swizzled_layout=True ) # SM103 native: raw C++ op returns sf as int32; view as float8 to match fp4_sm103, sf_sm103_i32 = torch.ops._C.scaled_fp4_quant_sm103(x, global_scale) sf_sm103 = sf_sm103_i32.view(torch.float8_e4m3fn) # 1. FP4 quantized data must be identical assert torch.equal(fp4_sm103, fp4_sm100), ( f"FP4 data mismatch between SM100 and SM103 quant kernels " f"(shape={shape}, dtype={dtype})" ) # 2. SM103 native SFs must match SM100 SFs converted to SM103 layout sf_sm100_converted = torch.empty_like(sf_sm100) torch.ops._C.convert_sf_layout_sm100_to_sm103(sf_sm100_converted, sf_sm100) assert torch.equal(sf_sm103.view(torch.uint8), sf_sm100_converted.view(torch.uint8)), ( f"SM103 native SF layout doesn't match " f"convert_sf_layout_sm100_to_sm103(SM100 SFs) " f"(shape={shape}, dtype={dtype})" )