|
15 | 15 | is_fp8_available, |
16 | 16 | is_mxfp8_available, |
17 | 17 | is_fp8_block_scaling_available, |
| 18 | + is_nvfp4_available, |
18 | 19 | ) |
19 | 20 | from transformer_engine.pytorch.quantization import RecipeState |
20 | 21 | from transformer_engine.debug.pytorch.debug_state import TEDebugState |
|
29 | 30 | fp8_block_scaling_available, reason_for_no_fp8_block_scaling = is_fp8_block_scaling_available( |
30 | 31 | return_reason=True |
31 | 32 | ) |
| 33 | +nvfp4_available, reason_for_no_nvfp4 = is_nvfp4_available(return_reason=True) |
32 | 34 |
|
33 | 35 | LOG_QUANTIZED_CONFIG_BASE = """ |
34 | 36 | log: |
@@ -363,6 +365,124 @@ def test_log_every_3_or_5_layers(layer, configs_dir, feature_dirs): |
363 | 365 | TEDebugState._reset() |
364 | 366 |
|
365 | 367 |
|
| 368 | +# NVFP4 tests |
| 369 | +LOG_NVFP4_CONFIG_BASE = """ |
| 370 | +log: |
| 371 | + layers: |
| 372 | + layer_name_regex_pattern: .* |
| 373 | + enabled: |
| 374 | + True |
| 375 | + transformer_engine: |
| 376 | + LogNvfp4TensorStats: |
| 377 | + enabled: True |
| 378 | + stats: [ |
| 379 | + {stats} |
| 380 | + ] |
| 381 | + tensors: [activation, gradient, weight] |
| 382 | + freq: 2 |
| 383 | + start_step: 0 |
| 384 | + end_step: 10 |
| 385 | +""" |
| 386 | + |
| 387 | + |
| 388 | +def test_nvfp4_numeric(feature_dirs): |
| 389 | + """Test that NVFP4 underflows% and MSE stats are computed correctly with known values.""" |
| 390 | + if not nvfp4_available: |
| 391 | + pytest.skip(reason_for_no_nvfp4) |
| 392 | + |
| 393 | + log_nvfp4_config = LOG_NVFP4_CONFIG_BASE.format(stats="underflows%, mse") |
| 394 | + |
| 395 | + with debug_session(log_nvfp4_config, feature_dirs) as log_dir: |
| 396 | + from transformer_engine.pytorch.tensor.nvfp4_tensor import NVFP4Quantizer |
| 397 | + from transformer_engine.pytorch.quantization import RecipeState |
| 398 | + |
| 399 | + recipe_state = RecipeState.create( |
| 400 | + recipe.NVFP4BlockScaling(), |
| 401 | + mode="forward", |
| 402 | + num_quantizers=3, |
| 403 | + ) |
| 404 | + |
| 405 | + # Create test tensor with known distribution |
| 406 | + torch.manual_seed(42) |
| 407 | + tensor = torch.randn(128, 128, dtype=torch.bfloat16).cuda() |
| 408 | + # Add some small values that should underflow to zero in FP4 |
| 409 | + tensor[0, :16] = 0.0001 |
| 410 | + |
| 411 | + quantizer = recipe_state.make_quantizers()[0] |
| 412 | + quantized_tensor = quantizer(tensor) |
| 413 | + |
| 414 | + debug_api.transformer_engine.inspect_tensor( |
| 415 | + layer_name="test_layer", |
| 416 | + tensor_name="activation", |
| 417 | + iteration=0, |
| 418 | + tp_group=None, |
| 419 | + tensor=tensor, |
| 420 | + quantizer=quantizer, |
| 421 | + rowwise_quantized_tensor=quantized_tensor, |
| 422 | + columnwise_quantized_tensor=quantized_tensor, |
| 423 | + ) |
| 424 | + debug_api.step() |
| 425 | + |
| 426 | + dequantized_tensor = quantized_tensor.dequantize() |
| 427 | + output = read_log(log_dir) |
| 428 | + |
| 429 | + # Validate both stats are present |
| 430 | + assert "nvfp4_underflows%" in output, "underflows% stat missing" |
| 431 | + assert "nvfp4_mse" in output, "mse stat missing" |
| 432 | + |
| 433 | + # Extract values and validate numerics |
| 434 | + underflows_value = None |
| 435 | + mse_value = None |
| 436 | + |
| 437 | + for line in output.splitlines(): |
| 438 | + if "nvfp4_underflows%" in line and "value=" in line: |
| 439 | + underflows_value = float(line.split("value=")[1].split()[0]) |
| 440 | + if "nvfp4_mse" in line and "value=" in line: |
| 441 | + mse_value = float(line.split("value=")[1].split()[0]) |
| 442 | + |
| 443 | + # Compute expected underflows: non-zero elements that became zero after quantization |
| 444 | + orig_nonzero_mask = tensor != 0 |
| 445 | + dequant_zero_mask = dequantized_tensor == 0 |
| 446 | + expected_underflows = ( |
| 447 | + (orig_nonzero_mask & dequant_zero_mask).sum().float() / tensor.numel() * 100 |
| 448 | + ) |
| 449 | + |
| 450 | + # Allow some tolerance |
| 451 | + assert underflows_value == pytest.approx(expected_underflows.cpu().item(), abs=1e-4) |
| 452 | + |
| 453 | + # Compute expected MSE |
| 454 | + expected_mse = torch.nn.functional.mse_loss( |
| 455 | + dequantized_tensor.float(), tensor.float(), reduction="mean" |
| 456 | + ) |
| 457 | + |
| 458 | + assert mse_value == pytest.approx(expected_mse.cpu().item(), abs=1e-4) |
| 459 | + |
| 460 | + |
| 461 | +def test_fp8_stats_allows_nvfp4_with_recipe_prefix(feature_dirs): |
| 462 | + """Test that LogFp8TensorStats allows recipe-prefixed stats with NVFP4 for what-if analysis.""" |
| 463 | + if not nvfp4_available: |
| 464 | + pytest.skip(reason_for_no_nvfp4) |
| 465 | + |
| 466 | + # Use recipe-prefixed stat with NVFP4 - should work (computes MXFP8 separately) |
| 467 | + log_fp8_config = LOG_QUANTIZED_CONFIG_BASE.format(stats="mxfp8_mse") |
| 468 | + |
| 469 | + with debug_session(log_fp8_config, feature_dirs) as log_dir: |
| 470 | + model = te.Linear(128, 128, params_dtype=torch.bfloat16) |
| 471 | + inp = torch.randn(128, 128, dtype=torch.bfloat16).cuda() |
| 472 | + |
| 473 | + # Should work - recipe-prefixed stats compute MXFP8 separately for comparison |
| 474 | + for _ in range(2): |
| 475 | + with te.autocast(recipe=recipe.NVFP4BlockScaling()): |
| 476 | + output = model(inp) |
| 477 | + loss = output.sum() |
| 478 | + loss.backward() |
| 479 | + debug_api.step() |
| 480 | + |
| 481 | + output = read_log(log_dir) |
| 482 | + # Should have logged MXFP8 MSE stat (what-if scenario) |
| 483 | + assert "mxfp8_mse" in output |
| 484 | + |
| 485 | + |
366 | 486 | def test_log_grouped_gemm(feature_dirs): |
367 | 487 | if not fp8_available: |
368 | 488 | pytest.skip(reason_for_no_fp8) |
|
0 commit comments