|
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, |
@@ -340,6 +341,51 @@ def test_megatron_iq_export_rejects_tensor_parallelism(qformat): |
340 | 341 | exporter.save_pretrained("unused", "unused") |
341 | 342 |
|
342 | 343 |
|
| 344 | +def test_megatron_export_rejects_sequential_experts_at_ep(): |
| 345 | + """SequentialMLP experts are numbered by local position, so EP>1 export must refuse them.""" |
| 346 | + experts = torch.nn.Module() |
| 347 | + experts.local_experts = torch.nn.ModuleList() |
| 348 | + exporter = object.__new__(GPTModelExporter) |
| 349 | + exporter.model = torch.nn.Sequential(experts) |
| 350 | + |
| 351 | + with ( |
| 352 | + patch.object(exporter, "_is_sidecar_writer_rank", return_value=False), |
| 353 | + patch.object(GPTModelExporter, "_any_rank", staticmethod(lambda local: local)), |
| 354 | + patch.object(uem.torch.distributed, "is_initialized", return_value=True), |
| 355 | + patch.object(uem, "get_expert_model_parallel_world_size", return_value=2), |
| 356 | + patch.object(uem, "get_pipeline_model_parallel_rank", return_value=0), |
| 357 | + patch.object(uem, "get_pipeline_model_parallel_world_size", return_value=1), |
| 358 | + patch.object(uem, "get_tensor_model_parallel_rank", return_value=0), |
| 359 | + pytest.raises(NotImplementedError, match="grouped-GEMM experts"), |
| 360 | + ): |
| 361 | + exporter.save_pretrained("unused", "unused") |
| 362 | + |
| 363 | + |
| 364 | +def _test_mla_export_keeps_hf_head_dim(model_dir, rank, size): |
| 365 | + model = get_mcore_gpt_model( |
| 366 | + tensor_model_parallel_size=size, |
| 367 | + pipeline_model_parallel_size=1, |
| 368 | + initialize_megatron=True, |
| 369 | + num_layers=2, |
| 370 | + hidden_size=128, |
| 371 | + num_attention_heads=2, |
| 372 | + vocab_size=128, |
| 373 | + max_sequence_length=128, |
| 374 | + transformer_impl="transformer_engine", |
| 375 | + multi_latent_attention=True, |
| 376 | + ).cuda() |
| 377 | + hf_head_dim = transformers.AutoConfig.from_pretrained(model_dir).head_dim |
| 378 | + # kv_channels is MLA's V head dim, which the exporter used to write as HF's head_dim. |
| 379 | + assert model.config.kv_channels != hf_head_dim |
| 380 | + exporter = GPTModelExporter(model, str(model_dir)) |
| 381 | + assert exporter._hf_text_config.head_dim == hf_head_dim |
| 382 | + |
| 383 | + |
| 384 | +def test_mla_export_keeps_hf_head_dim(dist_workers_size_1, tmp_path): |
| 385 | + model_dir = create_tiny_deepseek_v3_dir(tmp_path) |
| 386 | + dist_workers_size_1.run(partial(_test_mla_export_keeps_hf_head_dim, model_dir)) |
| 387 | + |
| 388 | + |
343 | 389 | @pytest.mark.parametrize("qformat", IQ_FORMAT_NAMES) |
344 | 390 | def test_megatron_iq_export_rejects_pipeline_parallelism(qformat): |
345 | 391 | """IQ packing requires PP=1 so the fused-MoE rejection reaches every rank. |
@@ -370,6 +416,27 @@ def test_megatron_iq_export_rejects_pipeline_parallelism(qformat): |
370 | 416 | exporter.save_pretrained("unused", "unused") |
371 | 417 |
|
372 | 418 |
|
| 419 | +def _verify_exported_metadata(export_dir: Path, model_type, quant_config, extra_module): |
| 420 | + # The router bias decides expert routing: FP32 like Megatron's buffer and HF's checkpoints. |
| 421 | + bias_dtypes = set() |
| 422 | + for shard in export_dir.glob("*.safetensors"): |
| 423 | + with safe_open(str(shard), framework="pt") as f: |
| 424 | + for key in f.keys(): # noqa: SIM118 - safe_open is not iterable |
| 425 | + if key.endswith(("expert_bias", "e_score_correction_bias")): |
| 426 | + bias_dtypes.add(f.get_slice(key).get_dtype()) |
| 427 | + assert bias_dtypes <= {"F32"}, bias_dtypes |
| 428 | + if model_type == "qwen3_moe": |
| 429 | + assert bias_dtypes |
| 430 | + if model_type == "qwen3vl" and quant_config: |
| 431 | + # The vision tower is copied through unquantized. |
| 432 | + quant = json.loads((export_dir / "hf_quant_config.json").read_text())["quantization"] |
| 433 | + assert "model.visual*" in quant["exclude_modules"] |
| 434 | + if model_type == "llama" and quant_config is None and extra_module is None: |
| 435 | + # The source generation config (top_p without do_sample) is copied through as-is. |
| 436 | + generation_config = json.loads((export_dir / "generation_config.json").read_text()) |
| 437 | + assert generation_config["top_p"] == 0.5 |
| 438 | + |
| 439 | + |
373 | 440 | def _test_unified_export_megatron( |
374 | 441 | tmp_path, |
375 | 442 | model_type, |
@@ -531,6 +598,9 @@ def _test_unified_export_megatron( |
531 | 598 | if quant_config: |
532 | 599 | _verify_model_quant_config(tmp_export_dir, quant_config, kv_cache_quant_cfg) |
533 | 600 |
|
| 601 | + if rank == 0: |
| 602 | + _verify_exported_metadata(tmp_export_dir, model_type, quant_config, extra_module) |
| 603 | + |
534 | 604 | if rank == 0 and extra_module is None: |
535 | 605 | # Names / shapes only: these Megatron weights are random, not loaded from model_dir. |
536 | 606 | allow_missing = () |
@@ -596,6 +666,10 @@ def test_unified_export_megatron( |
596 | 666 | ): |
597 | 667 | if model_type == "llama": |
598 | 668 | model_dir = create_tiny_llama_dir(tmp_path) |
| 669 | + # Valid to load, but rejected by GenerationConfig.save_pretrained's validation. |
| 670 | + (model_dir / "generation_config.json").write_text( |
| 671 | + json.dumps({"top_p": 0.5, "do_sample": False}) |
| 672 | + ) |
599 | 673 | elif model_type == "qwen3vl": |
600 | 674 | model_dir = create_tiny_qwen3vl_dir(tmp_path) |
601 | 675 | elif model_type == "nemotron": |
|
0 commit comments