Skip to content

[OPD] Add Qwen3.5-35B-A3B single-node self-distillation example - #1488

Merged
maocheng23 merged 6 commits into
mainfrom
opd-qwen3p5-35b-selfdistill
Jul 20, 2026
Merged

[OPD] Add Qwen3.5-35B-A3B single-node self-distillation example#1488
maocheng23 merged 6 commits into
mainfrom
opd-qwen3p5-35b-selfdistill

Conversation

@maocheng23

Copy link
Copy Markdown
Contributor

What

A reproducible two-phase on-policy-distillation example for Qwen3.5-35B-A3B on a single 8×H200 node, using the in-process Megatron teacher (--opd-type megatron).

  • Phase 1 (phase1_rlvr_teacher.sh): RLVR-train the base 35B into a teacher that is measurably better and more concise — eval/dapo_heldout 0.828 → 0.887, response length ~14k → ~6k (lr 1e-5).
  • Phase 2 (phase2_opd_selfdistill.sh): distill that teacher into the base student.
    • pure mode: training reward = 0, so the only signal is the teacher's reverse-KL — clean attribution. Result: eval length −57% (14k→6.1k) with accuracy preserved/slightly up (0.840→0.852), opd_reverse_kl 0.045→0.013 (student converging onto the teacher).
    • grounded mode: keep a correctness reward (so rollout/raw_reward is meaningful and climbs) plus the teacher reverse-KL.

Why it's distinct from the existing examples

  • run-qwen3-8B-opd-megatron.sh uses teacher == base — a mechanism demo where opd_reverse_kl ≈ 0. This example trains a genuinely diverged teacher first, which is the prerequisite for OPD to actually move the student.
  • Real MoE at scale on one node: the 2-node/16-GPU recipe is re-tiled to 8 GPUs (TP2/PP1/CP2/EP8/ETP1, world=8), --colocate + --optimizer-cpu-offload, ≈124/143 GB per GPU.
  • Self-distillation is required: Qwen3.5's tokenizer (vocab 248320) is not token-compatible with the smaller Qwen3 models (vocab 151936).

Contents

file purpose
README.md full pipeline, single-node parallelism derivation, results, gotchas
phase1_rlvr_teacher.sh Phase-1 RLVR teacher training
phase2_opd_selfdistill.sh Phase-2 OPD (pure / grounded via MODE)
rm.py format-agnostic correctness reward + pure-OPD reward
make_split.py seeded, disjoint train/eval split of dapo-math-17k
eval_dapo_heldout.yaml held-out eval config

Gotchas documented (each cost a wasted run)

  • --rm-type deepscaler needs a </think> tag → scores Qwen3.5 (inline reasoning) as 0; math/dapo each read only one answer format. The example uses a format-agnostic reward and always sets --label-key label.
  • The 35B's CoT is ~14–17k tokens; an 8k response cap truncates ~95% of rollouts → reward 0. Use ≥24k (CP2 makes it fit).
  • --opd-teacher-load must point at the checkpoint parent dir (with latest_checkpointed_iteration.txt), not an iter_* subdir — otherwise it silently falls back to base and opd_reverse_kl ≈ 0.
  • The teacher must actually diverge: lr 1e-6 / few steps ≈ base (inert); lr 1e-5 diverges fast.
  • with_ref = (--use-kl-loss or --kl-coef≠0) — dropping --use-kl-loss keeps only student+teacher (2×35B) in memory and avoids a 3rd-model OOM.

🤖 Generated with Claude Code

Two-phase on-policy-distillation example for Qwen3.5-35B-A3B on a single
8xH200 node using the in-process Megatron teacher (--opd-type megatron):

  Phase 1 (phase1_rlvr_teacher.sh): RLVR-train the base into a teacher that is
    measurably better and more concise (eval 0.83 -> 0.89, length ~14k -> ~6k).
  Phase 2 (phase2_opd_selfdistill.sh): distill that teacher into the base
    student. Pure mode (reward=0, only reverse-KL) cleanly attributes the change
    to OPD; grounded mode keeps a correctness reward so raw_reward is meaningful.

Unlike run-qwen3-8B-opd-megatron.sh (teacher == base, reverse-KL ~ 0), this
example trains a genuinely diverged teacher first, which is what makes OPD move
the student. Self-distillation is required: Qwen3.5's tokenizer (vocab 248320)
is not compatible with the smaller Qwen3 models (vocab 151936).

Includes the single-node parallelism derivation (TP2/PP1/CP2/EP8/ETP1, world=8),
a format-agnostic reward that avoids the deepscaler/math/dapo grader pitfalls, a
seeded disjoint train/eval split, and a README documenting the gotchas
(context-length truncation, --opd-teacher-load parent-dir requirement, the
ref-model memory interaction, and teacher divergence vs learning rate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a reproducible two-phase self-distillation pipeline for the Qwen3.5-35B-A3B MoE model on a single node, including training scripts, evaluation configurations, dataset splitting utilities, and custom format-agnostic reward functions. The feedback suggests improving the robustness of the prompt parsing logic in make_split.py to handle missing keys or malformed structures, and double-quoting array expansions in the Bash scripts (phase1_rlvr_teacher.sh and phase2_opd_selfdistill.sh) to prevent unexpected word splitting.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +18 to +20
def prompt_text(d):
p = d["prompt"]
return "\n".join(m.get("content", "") for m in p) if isinstance(p, list) else str(p)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To improve robustness and adhere to defensive programming practices, handle cases where d might not contain the "prompt" key, or where elements in the prompt list are None or not dictionaries (which would cause AttributeError or KeyError).

Suggested change
def prompt_text(d):
p = d["prompt"]
return "\n".join(m.get("content", "") for m in p) if isinstance(p, list) else str(p)
def prompt_text(d):
p = d.get("prompt", "")
if isinstance(p, list):
return "\\n".join(m.get("content", "") if isinstance(m, dict) else str(m) for m in p if m is not None)
return str(p)

Comment on lines +102 to +103
${MODEL_ARGS[@]} ${CKPT_ARGS[@]} ${ROLLOUT_ARGS[@]} ${OPTIMIZER_ARGS[@]} ${GRPO_ARGS[@]} \
${WANDB_ARGS[@]} ${PERF_ARGS[@]} ${EVAL_ARGS[@]} ${SGLANG_ARGS[@]} ${MISC_ARGS[@]} ${RM_ARGS[@]}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Bash, expanding arrays without double quotes (e.g., ${MODEL_ARGS[@]}) can lead to unexpected word splitting and glob expansion if any of the arguments contain spaces or special characters. Always use double quotes around array expansions: "${ARRAY[@]}".

Suggested change
${MODEL_ARGS[@]} ${CKPT_ARGS[@]} ${ROLLOUT_ARGS[@]} ${OPTIMIZER_ARGS[@]} ${GRPO_ARGS[@]} \
${WANDB_ARGS[@]} ${PERF_ARGS[@]} ${EVAL_ARGS[@]} ${SGLANG_ARGS[@]} ${MISC_ARGS[@]} ${RM_ARGS[@]}
"${MODEL_ARGS[@]}" "${CKPT_ARGS[@]}" "${ROLLOUT_ARGS[@]}" "${OPTIMIZER_ARGS[@]}" "${GRPO_ARGS[@]}" \\
"${WANDB_ARGS[@]}" "${PERF_ARGS[@]}" "${EVAL_ARGS[@]}" "${SGLANG_ARGS[@]}" "${MISC_ARGS[@]}" "${RM_ARGS[@]}"

Comment on lines +117 to +118
${MODEL_ARGS[@]} ${CKPT_ARGS[@]} ${OPD_ARGS[@]} ${ROLLOUT_ARGS[@]} ${OPTIMIZER_ARGS[@]} ${GRPO_ARGS[@]} \
${WANDB_ARGS[@]} ${PERF_ARGS[@]} ${EVAL_ARGS[@]} ${SGLANG_ARGS[@]} ${MISC_ARGS[@]} ${RM_ARGS[@]}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In Bash, expanding arrays without double quotes (e.g., ${MODEL_ARGS[@]}) can lead to unexpected word splitting and glob expansion if any of the arguments contain spaces or special characters. Always use double quotes around array expansions: "${ARRAY[@]}".

Suggested change
${MODEL_ARGS[@]} ${CKPT_ARGS[@]} ${OPD_ARGS[@]} ${ROLLOUT_ARGS[@]} ${OPTIMIZER_ARGS[@]} ${GRPO_ARGS[@]} \
${WANDB_ARGS[@]} ${PERF_ARGS[@]} ${EVAL_ARGS[@]} ${SGLANG_ARGS[@]} ${MISC_ARGS[@]} ${RM_ARGS[@]}
"${MODEL_ARGS[@]}" "${CKPT_ARGS[@]}" "${OPD_ARGS[@]}" "${ROLLOUT_ARGS[@]}" "${OPTIMIZER_ARGS[@]}" "${GRPO_ARGS[@]}" \\
"${WANDB_ARGS[@]}" "${PERF_ARGS[@]}" "${EVAL_ARGS[@]}" "${SGLANG_ARGS[@]}" "${MISC_ARGS[@]}" "${RM_ARGS[@]}"

maocheng23 and others added 3 commits June 26, 2026 02:18
Grounded OPD (correctness reward + teacher reverse-KL): rollout/raw_reward
climbs 0.637 -> 0.910 in one step while the student adopts the teacher's
concise responses (18.8k -> 7.7k) and opd_reverse_kl shrinks 0.045 -> 0.014.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- README: "Run on GB200/GB300 (CUDA 13, Blackwell)" — 2×4 tiling, the
  flashinfer_cutlass MoE runner + trtllm_mha attn + flex dispatcher
  (default triton fused-MoE mis-shards routed experts on the
  megatron->sglang weight sync; FA3 is SM<=90 only), NCCL_NVLS_ENABLE=0,
  and the PROMETHEUS_PORT k8s-Service collision.
- README: "Run Phase 2 only (skip Phase 1)" — point --opd-teacher-load at
  an existing torch_dist teacher; HF->torch_dist via convert_gb200.sh.
- phase2_gb200.sh: GB200 variant of phase2_opd_selfdistill.sh (2 nodes x
  4 GPU, Blackwell sglang/MoE backends, NVLS-off + PROMETHEUS_PORT in the
  Ray runtime env). convert_gb200.sh: convert_hf_to_torch_dist wrapper
  carrying the Qwen3.5 MODEL_ARGS.
- mbridge/qwen3_5.py: autodetect unfused per-expert main-layer weights
  (mirrors the existing MTP-expert autodetect) so a teacher exported with
  split experts converts without manual re-fusing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the public HF checkpoint (cm00cm/Qwen3.5-35B-A3B-DAPO-RLVR-teacher,
weights only) in the Phase-1 results and References, so the trained teacher
used in the example is directly available.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@Shi-Dong Shi-Dong left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

maocheng23 and others added 2 commits July 20, 2026 12:51
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@maocheng23
maocheng23 merged commit 4b6aa9c into main Jul 20, 2026
37 checks passed
@maocheng23
maocheng23 deleted the opd-qwen3p5-35b-selfdistill branch July 20, 2026 20:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants