feat: model converter scripts - #3348
Conversation
scripts/dcp_to_hf.py converts a DCP trainer checkpoint into HF sharded safetensors offline, mirroring the broadcast save path. The model config is auto-read from the run's resolved config; output defaults to <ckpt_dir>/weights. Full fine-tunes only. scripts/bf16_to_fp8.py quantizes an HF checkpoint to blockwise FP8 (DeepSeek/GLM format, weight_scale_inv) and writes the quantization_config vLLM loads natively. scripts/fp8_to_bf16.py is the inverse, for training from fp8-only releases.
--cpu (default without a GPU) reads the checkpoint's model entries
straight into host memory via _load_state_dict_from_keys and converts
with a meta-device model - no process group, no CUDA. Copy local
custom-code files (*.py) next to the weights so trust_remote_code dirs
stay loadable. Use step_{n} in docs.
The run's resolved config is the single source of truth; the checkpoint must live in its run directory.
Matches the fp8 converters' CLI shape; pydantic-config was only needed for the removed --model.* sub-configs.
samsja
left a comment
There was a problem hiding this comment.
we should write test for this, we should have one mini moe class that does
random-weight -> hf -> load -> dcp -> hf ->load ? maybe even with fp8
Also note the tools-argparse / entrypoints-pydantic-config convention in AGENTS.md.
…at/conversion-scripts
dcp_to_fp8 chains the two converters: bf16 export at <ckpt_dir>/weights, fp8 next to it at <ckpt_dir>/weights-FP8.
Non-master ranks otherwise idle with a live NCCL group while the master quantizes, risking watchdog timeouts on the multi-rank path.
Each rank quantizes its own gathered slice on GPU and only fp8 shards are written - the chained version wrote and re-read the full bf16 export and quantized serially on one rank.
The helper refactor materialized whole shards on GPU before quantizing; load to host and move one tensor at a time so peak device memory stays near the largest single weight.
Per mini_moe arch: random tiny prime model -> source HF dir -> DCP ->
{bf16, fp8} exports -> dequant. bf16 export and both fp8 paths are
byte-identical; the lossy dequant hop checks byte-identity on skipped
tensors and bounded error on quantized ones. Fixes the mini_moe VLM
preset mrope_section for the shrunken head_dim and wires the suite
(plus the moved SFT-checkpoint test) into the GPU CI matrix.
runpy executes each converter's CLI entrypoint in-process, so the chain pays the heavy imports once per arch instead of five times; the CI matrix shrinks to a single converters entry. dcp_to_bf16's main now tears down its process group (lets the next converter re-init). Also fixes the fixture's leaked global default dtype (bugbot).
The SFT and RL integration tests already write a DCP checkpoint at their final step; convert it to bf16 and fp8 there and assert the exports reload in HF (RL also asserts direct dcp->fp8 == chained bf16->fp8 byte-for-byte). Replaces the per-arch tests/converters suite and its CI matrix entries. Also adapt dcp_to_bf16 to the configs/resolved layout from main via get_config_dir.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 1627ce8. Configure here.
|
One high-level question I have is whether it's overly annoying to require gpus for model conversion. I think the more usual pattern is to do the conversion on CPU. Can be a future work item, though. |
Resolved training kernel and DeepEP settings can fail when the converter runs with a smaller world size or different optional kernels. Use portable model setup values because export does not run a forward pass.
|
ya agreed, i think for really large models conversion on cpu might be tough? or can you stream it so you dont materialize the full thing in ram? |
Yeah, that's the platonic ideal to shoot for I think: CPU only and realizing only single weights (maybe layers) in memory at a time. I thought that this is what Looks like NVIDIA's megatron bridge achieves this, though: https://github.com/NVIDIA-NeMo/Megatron-Bridge/blob/babf9533db1c492c0eb0ad9267f26e5028de6123/docs/bridge-tech-details.md Not a goal for this PR, but a future one. |

Summary
tools/convert_dcp_to_bf16.py: convert a DCP trainer checkpoint to HF sharded safetensors offline. Recovers the converter deferred out of feat: remove weight checkpointing #3316 and adapts it: required positionalckpt_dir(accepts<run>/checkpoints/step_{n}or.../step_{n}/trainer), output defaults to<ckpt_dir>/weights(or a second positional arg), model/tokenizer configs read from the run's resolvedconfigs/trainer.json/sft.json(the single source of truth — the checkpoint must live in its run directory), single-process (uv run python) and multi-rank (torchrun) invocations. Localtrust_remote_codemodels get their custom code files copied next to the weights. Full fine-tunes only — LoRA checkpoints are rejected up front. Export replaces training-only attention, fused-MoE, and DeepEP settings with portable model-construction values.tools/convert_bf16_to_fp8.py: quantize an HF safetensors dir to blockwise FP8 (DeepSeek/GLM format: e4m3 weights +weight_scale_invper-128×128-block scales,quantization_configblock vLLM loads natively). Streams shard-by-shard; norms, embeddings, lm_head, router gates, GatedDeltaNet low-rank projections, MTP projection, and vision towers stay bf16. Reusesquantize_to_fp8_blockwisefromprime_rl.trainer.models.fp8. Inspired by slime/milestools/convert_hf_to_fp8.py.tools/convert_dcp_to_fp8.py: DCP straight to fp8 — same gather asdcp_to_bf16, but each rank quantizes its slice on GPU and only fp8 shards are written to<ckpt_dir>/weights-FP8(no intermediate bf16 export on disk).tools/convert_fp8_to_bf16.py: the inverse (dequantize blockwise-fp8 releases like GLM-5-FP8 to bf16 for training). Inspired by slime/milestools/fp8_cast_bf16.py.dcp_to_fp8output is byte-identical to chainedbf16_to_fp8on a real trainer checkpoint. Shared helpersconvert_checkpoint/check_hf_loadintests/utils.py. (A standalone per-arch byte-exactness matrix over the mini_moe archs — glm4_moe, laguna, minimax_m2, qwen3.5-MoE VLM; bf16 exports byte-identical to their random-init sources, both fp8 paths byte-identical — was validated during development and dropped from CI in favor of these hooks.) Also fixes the mini_moe VLM presetmrope_sectionfor its shrunken head_dim.configs/resolved/layout viaget_config_dir(adapted after merging main).docs/training.mdandskills/training/start-run/SKILL.md, and note the CLI convention inAGENTS.md:tools/scripts use plain argparse,[project.scripts]entrypoints use pydantic-config.Verification
All run on 2× RTX PRO 6000 Blackwell (SM120), vLLM 0.26.
dcp→hf, real RL run (qwen3 dense) — 20-step reverse-text RL run with
--ckpt.interval 10 --weight-broadcast.type filesystem:torchrun --nproc-per-node 2.lm_head.weightis dropped per HF convention and verified equal toembed_tokens.uv run inference --vllm.model .../step_10/weightsserves the export; reverse-text eval (uv run eval, n=64 r=4) scores 0.754 vs 0.744 training reward at step 10. Step 20 export scores 0.801 vs ~0.80 at end of training.dcp→hf, architecture matrix — per arch: tiny random model (
scripts/mini_moe.py) → 3-step fake-data SFT → convert (exercisesconfigs/sft.jsonauto-detection) → verify:trust_remote_code=True)experts.gate_up_projand re-inits experts it doesn't recognize — seeconverting_laguna.pydocstring)Qwen3_5MoeForConditionalGeneration)bf16→fp8 + fp8→bf16 — on the real step-20 Qwen3-0.6B export and all four minis: dequant error bounded (worst median relative error ~2.2%, e4m3-consistent), skip-list modules bit-identical, index/
quantization_configconsistent; fp8→bf16 round trip drops all*_scale_invkeys andquantization_config. Structure matches the officialQwen/Qwen3-0.6B-FP8release (same scale shapes/names). Serving the fp8→bf16 round-tripped weights scores 0.798 (vs 0.801 bf16 baseline), confirming the stored values.fp8 serving on SM120 (vLLM kernel bug, not the script): serving the fp8 checkpoint scores 0.31 with vLLM's default
CutlassFp8BlockScaledMMKernelon this box, but 0.790 with the same checkpoint after forcing the Triton kernel (VLLM_DISABLED_KERNELS=...CutlassFp8BlockScaledMMKernel,MarlinFP8ScaledMMLinearKernel→TritonFp8BlockScaledMMKernel), and vLLM's online fp8 of the same weights scores 0.79. vLLM's CUTLASS blockwise-fp8 GEMM silently degrades on SM120 (RTX PRO 6000); upstream already tracks other SM120 blockwise-fp8 breakage (vllm-project/vllm#47990, #47436). Noted the workaround inskills/training/start-run/SKILL.md; H100/H200/B200 (SM90/SM100) use different kernels.Not ported from slime/miles
Megatron converters (
convert_torch_dist_to_hf*,convert_hf_to_torch_dist) — prime-rl is FSDP/DCP; int4 converters (Kimi-K2-specific); mxfp8/nvfp4 (no checkpoint-level use case yet);param_name_remap.py(covered by prime conversion chains); profiling tools (prime-rl has its own monitors).🤖 Generated with Claude Code
Note
Medium Risk
Converters touch distributed gather, HF weight layout, and quantization paths that must match live training/broadcast behavior; mistakes could ship broken checkpoints, though integration tests and LoRA rejection reduce exposure.
Overview
Adds offline checkpoint export and FP8 conversion tooling under
tools/convert_*.py, plus docs/skills updates and integration-test coverage.DCP → Hugging Face:
convert_dcp_to_bf16.pyloads FSDP DCP trainer checkpoints using the run’s resolvedconfigs/resolved/trainer.jsonorsft.json, gathers weights in parallel (single GPU ortorchrun), writes sharded bf16 safetensors and tokenizer/config assets to<ckpt_dir>/weights, and rejects LoRA checkpoints.FP8 round-trip:
convert_bf16_to_fp8.pyblockwise-quantizes HF safetensors (e4m3 +weight_scale_inv, vLLM-nativequantization_config);convert_dcp_to_fp8.pyquantizes each rank’s gathered slice straight to<ckpt_dir>/weights-FP8without a bf16 export on disk;convert_fp8_to_bf16.pydequantizes fp8-only releases for training.CI: Reverse-text RL and SFT integration tests convert the final checkpoint, assert clean
AutoModelForCausalLMloads, and (RL) byte-identity between directdcp_to_fp8and chainedbf16_to_fp8. Shared helpersconvert_checkpoint/check_hf_loadlive intests/utils.py.scripts/mini_moe.pyfixesmrope_sectionfor the shrunk VLM preset.Docs:
docs/training.mdand training skills document the export flow;AGENTS.mdnotestools/scripts use plain argparse vs pydantic-config entrypoints. Run artifact paths usestep_{n}consistently.Reviewed by Cursor Bugbot for commit fe1241d. Bugbot is set up for automated code reviews on this repo. Configure here.