|
30 | 30 | from _test_utils.torch.megatron.models import get_mcore_gpt_model, get_mcore_hybrid_model |
31 | 31 | from _test_utils.torch.megatron.utils import get_forward |
32 | 32 | from _test_utils.torch.transformers_models import ( |
| 33 | + create_tiny_deepseek_v3_dir, |
33 | 34 | create_tiny_llama_dir, |
34 | 35 | create_tiny_nemotron_dir, |
35 | 36 | create_tiny_nemotron_h_dir, |
@@ -394,6 +395,51 @@ def test_megatron_iq_export_rejects_tensor_parallelism(qformat): |
394 | 395 | exporter.save_pretrained("unused", "unused") |
395 | 396 |
|
396 | 397 |
|
| 398 | +def test_megatron_export_rejects_sequential_experts_at_ep(): |
| 399 | + """SequentialMLP experts are numbered by local position, so EP>1 export must refuse them.""" |
| 400 | + experts = torch.nn.Module() |
| 401 | + experts.local_experts = torch.nn.ModuleList() |
| 402 | + exporter = object.__new__(GPTModelExporter) |
| 403 | + exporter.model = torch.nn.Sequential(experts) |
| 404 | + |
| 405 | + with ( |
| 406 | + patch.object(exporter, "_is_sidecar_writer_rank", return_value=False), |
| 407 | + patch.object(GPTModelExporter, "_any_rank", staticmethod(lambda local: local)), |
| 408 | + patch.object(uem.torch.distributed, "is_initialized", return_value=True), |
| 409 | + patch.object(uem, "get_expert_model_parallel_world_size", return_value=2), |
| 410 | + patch.object(uem, "get_pipeline_model_parallel_rank", return_value=0), |
| 411 | + patch.object(uem, "get_pipeline_model_parallel_world_size", return_value=1), |
| 412 | + patch.object(uem, "get_tensor_model_parallel_rank", return_value=0), |
| 413 | + pytest.raises(NotImplementedError, match="grouped-GEMM experts"), |
| 414 | + ): |
| 415 | + exporter.save_pretrained("unused", "unused") |
| 416 | + |
| 417 | + |
| 418 | +def _test_mla_export_keeps_hf_head_dim(model_dir, rank, size): |
| 419 | + model = get_mcore_gpt_model( |
| 420 | + tensor_model_parallel_size=size, |
| 421 | + pipeline_model_parallel_size=1, |
| 422 | + initialize_megatron=True, |
| 423 | + num_layers=2, |
| 424 | + hidden_size=128, |
| 425 | + num_attention_heads=2, |
| 426 | + vocab_size=128, |
| 427 | + max_sequence_length=128, |
| 428 | + transformer_impl="transformer_engine", |
| 429 | + multi_latent_attention=True, |
| 430 | + ).cuda() |
| 431 | + hf_head_dim = transformers.AutoConfig.from_pretrained(model_dir).head_dim |
| 432 | + # kv_channels is MLA's V head dim, which the exporter used to write as HF's head_dim. |
| 433 | + assert model.config.kv_channels != hf_head_dim |
| 434 | + exporter = GPTModelExporter(model, str(model_dir)) |
| 435 | + assert exporter._hf_text_config.head_dim == hf_head_dim |
| 436 | + |
| 437 | + |
| 438 | +def test_mla_export_keeps_hf_head_dim(dist_workers_size_1, tmp_path): |
| 439 | + model_dir = create_tiny_deepseek_v3_dir(tmp_path) |
| 440 | + dist_workers_size_1.run(partial(_test_mla_export_keeps_hf_head_dim, model_dir)) |
| 441 | + |
| 442 | + |
397 | 443 | @pytest.mark.parametrize("qformat", IQ_FORMAT_NAMES) |
398 | 444 | def test_megatron_iq_export_rejects_pipeline_parallelism(qformat): |
399 | 445 | """IQ packing requires PP=1 so the fused-MoE rejection reaches every rank. |
@@ -424,6 +470,27 @@ def test_megatron_iq_export_rejects_pipeline_parallelism(qformat): |
424 | 470 | exporter.save_pretrained("unused", "unused") |
425 | 471 |
|
426 | 472 |
|
| 473 | +def _verify_exported_metadata(export_dir: Path, model_type, quant_config, extra_module): |
| 474 | + # The router bias decides expert routing: FP32 like Megatron's buffer and HF's checkpoints. |
| 475 | + bias_dtypes = set() |
| 476 | + for shard in export_dir.glob("*.safetensors"): |
| 477 | + with safe_open(str(shard), framework="pt") as f: |
| 478 | + for key in f.keys(): # noqa: SIM118 - safe_open is not iterable |
| 479 | + if key.endswith(("expert_bias", "e_score_correction_bias")): |
| 480 | + bias_dtypes.add(f.get_slice(key).get_dtype()) |
| 481 | + assert bias_dtypes <= {"F32"}, bias_dtypes |
| 482 | + if model_type == "qwen3_moe": |
| 483 | + assert bias_dtypes |
| 484 | + if model_type == "qwen3vl" and quant_config: |
| 485 | + # The vision tower is copied through unquantized. |
| 486 | + quant = json.loads((export_dir / "hf_quant_config.json").read_text())["quantization"] |
| 487 | + assert "model.visual*" in quant["exclude_modules"] |
| 488 | + if model_type == "llama" and quant_config is None and extra_module is None: |
| 489 | + # The source generation config (top_p without do_sample) is copied through as-is. |
| 490 | + generation_config = json.loads((export_dir / "generation_config.json").read_text()) |
| 491 | + assert generation_config["top_p"] == 0.5 |
| 492 | + |
| 493 | + |
427 | 494 | def _test_unified_export_megatron( |
428 | 495 | tmp_path, |
429 | 496 | model_type, |
@@ -585,6 +652,9 @@ def _test_unified_export_megatron( |
585 | 652 | if quant_config: |
586 | 653 | _verify_model_quant_config(tmp_export_dir, quant_config, kv_cache_quant_cfg) |
587 | 654 |
|
| 655 | + if rank == 0: |
| 656 | + _verify_exported_metadata(tmp_export_dir, model_type, quant_config, extra_module) |
| 657 | + |
588 | 658 | if rank == 0 and extra_module is None: |
589 | 659 | # Names / shapes only: these Megatron weights are random, not loaded from model_dir. |
590 | 660 | allow_missing = () |
@@ -650,6 +720,10 @@ def test_unified_export_megatron( |
650 | 720 | ): |
651 | 721 | if model_type == "llama": |
652 | 722 | model_dir = create_tiny_llama_dir(tmp_path) |
| 723 | + # Valid to load, but rejected by GenerationConfig.save_pretrained's validation. |
| 724 | + (model_dir / "generation_config.json").write_text( |
| 725 | + json.dumps({"top_p": 0.5, "do_sample": False}) |
| 726 | + ) |
653 | 727 | elif model_type == "qwen3vl": |
654 | 728 | model_dir = create_tiny_qwen3vl_dir(tmp_path) |
655 | 729 | elif model_type == "nemotron": |
|
0 commit comments