Skip to content

feat(csd): add cross-tokenizer distillation and sub-token preparation - #4680

Open
avenkateshha wants to merge 33 commits into
avenkateshha/xtoken-csd-shared-collatorfrom
avenkateshha/csd
Open

avenkateshha wants to merge 33 commits into
avenkateshha/xtoken-csd-shared-collatorfrom
avenkateshha/csd

Conversation

@avenkateshha

@avenkateshha avenkateshha commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do ?

Adds CSD (Common-Support Distillation), a second cross-tokenizer off-policy distillation method next to X-Token. CSD aligns the student and teacher tokenizations of the same text into chunks and compares the two models only on events both tokenizers can express — shared tokens and shared token chains, looked up in bidirectional sub-token tables — with a REST bucket for the remaining probability mass. Student next-token CE is added to the KD term, and only the student is trained.

X-Token CSD
Selector algorithm: xtoken (default) algorithm: csd
Vocabulary artifact Projection matrix (tools/x_token/) Student-to-teacher and teacher-to-student sub-token tables (tools/csd/)
Entrypoint / exemplars run_xtoken_off_policy_distillation.py run_csd.py, csd.yaml, csd_multiteacher.yaml
Backends Automodel Automodel or Megatron, per model
Teacher transport Dense CUDA IPC Sparse top-K (default K=128) or dense CUDA IPC
Sequence packing Unpacked Unpacked, or optional lockstep packing

Main changes

  • Loss (nemo_rl/algorithms/loss/csd.py): CSDLossFn / CSDLossConfig. 1:1 chunks compare common tokens; 1:many and many:1 chunks compare prefix-support alternatives (or only the realized chains with csd_realized_only_mismatch); many:many chunks compare realized chains. Defaults: JSD (β=0.5), mismatch chunks weighted ×5, normalized by the DP-global valid chunk count × T², dynamic KD/CE scaling. Same-tokenizer teachers use the shared top-K KL from refactor(xtoken): move same-tokenizer KD math into shared helpers #4721. Multi-teacher sum / averaged_logits / select_teacher.
  • Sub-token tables (tools/csd/): a CPU builder (one direction per call; complete chains, role-mapped special tokens, no truncation) and a validator (fingerprints, ID ranges, every mapped event). Fast BPE tokenizers with a ByteLevel decoder.
  • Teacher transport: sparse top-K IPC (top-K logits, exact log-normalizer, forced realized labels) from Automodel and Megatron teachers, plus Megatron dense full-logit export. The default teacher_topk_ipc_k: 128 changes the objective (top-128 + REST instead of the full vocabulary); set 0 for dense.
  • Distributed execution: TP/CP-sharded loss without gathering the vocabulary; eligible Megatron students (PP=1, unpacked, static sum) evaluate KD owner-local on each CP rank. New primitives in nemo_rl/distributed/model_utils.py.
  • Data: durable sample_id on arrow_text, openai_format and ResponseDataset; CSDCrossTokenizerCollator is a thin subclass of the shared collator with feat(xtoken): cross-tokenizer collator options and pad text to longest row #4728's options on. Overlength text rows are clipped to a prefix that fits every model.
  • Lockstep sequence packing (optional, CSD only): the student and every teacher pack the same samples into the same bins (nemo_rl/data/packing/lockstep.py, docs/design-docs/csd-lockstep-sequence-packing.md).
  • Configs and docs: exemplars; three nightly recipes (Automodel TP4CP2 single- and multi-teacher, Megatron TP2CP2); docs/guides/xtoken-off-policy-distillation.md renamed to docs/guides/cross-tokenizer-distillation.md, with a new CSD section next to X-Token; README "Cross-Tokenizer Distillation" section covering X-Token and CSD.

Shared changes that also affect X-Token and other algorithms:

  • Megatron CP > 1 no longer requires sequence packing, and packed Megatron runs pass raw cu_seqlens_q/kv and a CP-local MoE router padding mask.
  • The arrow_text packed-cache schema version changes, so existing characters_per_sample caches are rebuilt on first use.
  • rebuild_teacher_full_logits_from_ipc returns (logits, fallback_count); the strict exact-token map selects every row whose top weight is 1.0.
  • Teacher table paths are accepted only under teachers[i].aligner.

Issues

Stacked on #4728: main ← #3286 ← #4721 ← #4728 ← this PR (base avenkateshha/xtoken-csd-shared-collator). Merge in stack order.

Usage

uv run python -m tools.csd.build_subtoks_table --source-tokenizer meta-llama/Llama-3.2-1B --destination-tokenizer Qwen/Qwen3-4B --output cross_tokenizer_data/csd/student_to_teacher.pt
uv run python -m tools.csd.build_subtoks_table --source-tokenizer Qwen/Qwen3-4B --destination-tokenizer meta-llama/Llama-3.2-1B --output cross_tokenizer_data/csd/teacher_to_student.pt
uv run python -m tools.csd.validate_subtoks_tables --student-tokenizer meta-llama/Llama-3.2-1B --teacher-tokenizer Qwen/Qwen3-4B \
  --student-to-teacher cross_tokenizer_data/csd/student_to_teacher.pt --teacher-to-student cross_tokenizer_data/csd/teacher_to_student.pt
uv run python examples/run_csd.py

See the CSD section of the cross-tokenizer guide for table format, loss options, transports and lockstep packing.

Before your PR is "Ready for review"

  • Read and followed the Contributor guidelines.
  • Added and updated necessary tests.
  • Added and updated documentation.
  • Completed GPU qualification.

Additional Information

Validation so far

Check Result
CPU unit suites (CSD, X-Token, data, distributed, tools) Pass; remaining failures are offline Hugging Face downloads, identical on the base
GPU unit tests, default markers 499 passed, 0 failed
GPU smoke, csd-qwen3-4b-to-llama3.2-1b-1n8g-dtensor-tp4cp2 8 steps + validation, finite loss
Megatron owner-local vs. replicated KD (TP2×CP2, K=0, 20 steps) Step 1: logits bitwise equal, loss/CE identical, KD rel. diff 9e-8; grad-norm rel. diff 2.2e-4. K=128: finite for 20 steps
Same-tokenizer KD and collator vs. pre-refactor CSD Bitwise identical (28 KD cases; 21 of 24 collator cases, the rest are intended behavior changes)

Open items

  • test_nightly_compute_stays_below_4947_hours fails: the CSD nightlies bring the nightly total to 4955 GPU-hours.
  • Megatron-marked unit tests need an image newer than nemo_rl_main_20260803 on the current base.
  • The multi-teacher recipe and lockstep packing have no GPU run on the current head; no convergence claims yet (results will be added to the guide).

Suggested review order: docs/guides/cross-tokenizer-distillation.md (CSD section) → tools/csd/ → nemo_rl/algorithms/loss/csd.py → nemo_rl/algorithms/x_token/loss_utils.py and nemo_rl/distributed/model_utils.py → nemo_rl/algorithms/xtoken_off_policy_distillation.py → nemo_rl/data/cross_tokenizer/csd_collate.py and nemo_rl/data/packing/lockstep.py → backend/worker changes under nemo_rl/models/ → tests.

🤖 Generated with Claude Code

@avenkateshha

Copy link
Copy Markdown
Contributor Author

/ok to test 22ebcf7

@copy-pr-bot

copy-pr-bot Bot commented Oct 12, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@github-actions github-actions Bot added the Documentation Improvements or additions to documentation label Oct 12, 2026
@avenkateshha
avenkateshha marked this pull request as ready for review October 12, 2026 18:33
@avenkateshha
avenkateshha marked this pull request as draft October 12, 2026 18:33
@avenkateshha
avenkateshha force-pushed the avenkateshha/xtoken-offset-alignment branch from 42fc8ce to 98d6069 Compare October 13, 2026 18:31
@avenkateshha
avenkateshha changed the base branch from avenkateshha/xtoken-offset-alignment to avenkateshha/xtoken-csd-shared-collator October 15, 2026 18:06
@avenkateshha
avenkateshha force-pushed the avenkateshha/xtoken-csd-shared-collator branch 4 times, most recently from 2d5df0d to 205dc30 Compare October 16, 2026 12:26
@avenkateshha
avenkateshha force-pushed the avenkateshha/xtoken-csd-shared-collator branch from a04b753 to e6fe651 Compare October 19, 2026 11:00
@avenkateshha
avenkateshha marked this pull request as ready for review October 19, 2026 16:41
avenkateshha and others added 12 commits October 19, 2026 10:11
Signed-off-by: Adithyakrishna Hanasoge <avenkateshha@nvidia.com>
CSDLossFn kept private copies of the same-tokenizer KD math
(_direct_topk_kl, _direct_full_vocab_kl, _same_vocab_masked_kl) that
X-Token now shares through same_tokenizer_topk_kl and
same_tokenizer_full_vocab_kl in nemo_rl/algorithms/x_token/loss_utils.py.
Delete the copies and call the shared helpers the way X-Token's loss
does, passing CSD's semantic KD mask (_student_kd_mask), its KD-token
count and the student tokenizer vocab size. The top-K vocabulary list
is now the shared per-vocab max over every teacher row, so with
vocab_topk below the vocab size, rows outside the KD mask take part in
choosing it. averaged_logits still runs one full-vocabulary KL over the
convex-averaged teacher logits, now cut to the student tokenizer vocab
(lm_head padding columns dropped).

The tests that compared against or stubbed the private methods now use
the shared helper directly or monkeypatch it at
nemo_rl.algorithms.loss.csd. The TP2xCP2 memory guards that reject a
full-vocabulary gather now patch vocab_parallel_full_log_softmax in
nemo_rl.algorithms.x_token.loss_utils, where CSD now reaches it.
test_csd_same_vocab_topk_ignores_masked_and_padding_rows keeps its
padding and masked-sample rows below the valid rows' column maxima, so
it still checks that those rows add nothing to the KD term.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
…ator

CSD carried its own copy of cross-tokenizer collation: text tokenization
with special tokens and overlength rejection, a multi-turn chat renderer
with its own assistant-chunk and end-of-turn rules, and a native-chat
wrapper. The shared CrossTokenizerCollator now offers the same text
behavior and whole-turn CE as options, so CSD uses it directly.

- Move the collator to nemo_rl/data/cross_tokenizer/csd_collate.py and
  reduce CSDCrossTokenizerCollator to a thin subclass. It adds only what
  is CSD-specific: a durable sample_id on every row, and the
  rollout-recorded MoE routes for router replay.
- The trainer turns on the shared options for CSD:
  reject_overlength_text, plus add_special_tokens_in_text in text mode
  and ce_covers_assistant_turn in ordinary chat mode. Native thinking
  alignment uses the shared native path unchanged. X-Token passes no
  options.
- Chat supervises only the final assistant turn, for both CE and KD;
  earlier messages are context. CE covers the whole final turn,
  including template markers, and KD covers its content and end-of-turn
  token. include_thinking_in_loss now also applies to CSD's ordinary
  chat KD mask. A template that appends a footer after the final turn
  is rejected with the shared prefix error. As for X-Token, a chat row
  is rejected when its token count exceeds a side's context, so a
  padded batch can be wider than a context that is not a multiple of
  the side's sequence divisor.
- Text-mode batches are unchanged: each tokenizer's special tokens,
  rows padded to the longest row rounded up to the sequence divisor,
  and overlength rows rejected instead of truncated, including for
  same-tokenizer teachers against their own context.
- Delete csd_data_processor; the CSD configs use kd_data_processor,
  which passes sample_id through, and the collator requires it.
- Update the pyrefly includes, the guide and the packing design doc,
  rewrite the collator tests for the shared path, and test the
  per-algorithm collator options in setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
- The three CSD recipes read their sub-token table paths (and the
  router-replay rollout path) from ${oc.env:CSD_*} with no default, so
  loading them without those variables fails, which breaks the config
  validation tests. Set the table paths to null and the rollout path to
  ??? (required) in the recipes, and pass them as Hydra overrides from
  the suite scripts, which already require the variables.
- Give the router-replay suite script the BEGIN/END CONFIG block, add
  STEPS_PER_RUN and NUM_RUNS to the other two, and make all three
  executable, as the launcher's dry run and the X-Token suite scripts
  expect.
- The TP/CP CSD shard test patched allgather_cp_contiguous_tensor on
  loss_functions, which does not import it; patch it on
  nemo_rl.algorithms.loss.csd, where the CSD loss resolves it.
- The CSD context-parallel layout test expected X-Token's error text;
  match the CSD message.
- The DTensor worker test's _shard_for_train stub now accepts the
  keyword arguments Policy.train passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Every Automodel worker now reads automodel_cfg.checkpoint when it builds
its checkpoint configuration (nemo_rl/models/automodel/checkpoint.py), and
X-Token's exemplars and recipes carry that block. CSD's did not, so the
student and every Automodel teacher stopped at worker start-up with
KeyError: 'checkpoint'. Add the block X-Token uses (safetensors, no
consolidation) to the CSD exemplars and to the teachers in the CSD
recipes; recipe students inherit it from their exemplar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
The two DTensor TP4xCP2 CSD recipes kept make_sequence_length_divisible_by
at 1 for the student and every CP=2 teacher. CSD batches are padded to
their longest row and never truncated, so a batch of short rows can have
an odd width, and context parallelism then rejects it: the student and
teacher logit windows must divide evenly across the CP ranks. Set the
divisor to 2 on every CP=2 side of both recipes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
…extras

These tests are selected only by --automodel-only / --mcore-only, and
they did not follow this PR's changes:

- The Automodel and Megatron loss post-processors pass
  global_valid_chunks_by_idx only to CSD losses, but
  test_forward_backward_multiple_microbatches and
  test_loss_post_processor_no_packing checked for it on a plain mock
  loss. Give the mock the CSDLossFn spec.
- Megatron setup reads the backend tokenizer from tokenizer.name. Add the
  tokenizer block to the FP8-recipe, FP8-buffer and optimizer-offload
  test configs, as the other _create_megatron_config tests already have.
- Megatron context parallelism no longer requires sequence packing.
  Replace the test that expected the old assertion with one that checks
  CP is applied with packing disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Megatron worker setup reads offload_optimizer_for_refit without a
fallback since #3984 removed its `.get(..., True)` default. The CSD
example teachers never set the key: YAML lists are not merged from the
defaults file, so the student inherits it from distillation_math.yaml
while each teacher entry is written out in full. A CSD example teacher
switched to Megatron therefore fails with a KeyError during worker
setup.

Add offload_policy_before_refit and offload_optimizer_for_refit to each
teacher in csd.yaml and csd_multiteacher.yaml, matching the teacher
entries of the X-Token example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Switch the student and the teacher of examples/configs/csd.yaml from
the Automodel backend to Megatron. Teacher logits stay dense.

Keep the example's learning-rate schedule on the new backend: the
Megatron scheduler now warms up linearly from 1e-6 to 5e-5 over 250
steps and then follows a cosine to 0 at step 5000, the same per-step
curve as the Automodel LinearLR + CosineAnnealingLR pair.

Offload the student after each optimizer step so the colocated teacher
forward runs without the student's weights and optimizer state on the
GPU.

Recipes that inherit csd.yaml keep their backends and schedules: the
two Automodel TP4/CP2 recipes enable Automodel and disable Megatron for
the student explicitly, and the Moonlight router-replay recipe pins its
constant Megatron learning rate. The Automodel recipes inherit the
student offload.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
The CSD example now runs its student and teacher on Megatron. Update the
guide's environment step to install the mcore extra instead of the
automodel extra, name Megatron as the example's backend, and note that
its checkpoints are saved in Megatron format and can be converted to
Hugging Face format with the command in the evaluation guide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
A language model's output at a position predicts the next token. With
kl_chunk_shift on, each chunk that does not start the sequence is scored
from the outputs one position earlier, which predict the chunk's own
tokens. The realized-token support, the noise filter, the token-chain
probabilities of mismatch chunks and the realized slot of sparse top-k
teacher logits all refer to those tokens.

With the shift off, the compared outputs predict the token after the
chunk while those terms still use the chunk's tokens, one position
apart. Default kl_chunk_shift to true in CSDLossConfig and in both CSD
example configs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Default csd_loss_fn to jsd in CSDLossConfig and in both CSD example
configs. JSD compares the teacher and student distributions with their
mixture, so it is symmetric and bounded by log 2 per event, while the
forward KL it replaces is unbounded when the student assigns little
probability to events the teacher favors. csd_jsd_beta stays 0.5, and
the last-position mismatch term still inherits csd_loss_fn when
csd_last_pos_loss_fn is unset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
avenkateshha and others added 17 commits October 19, 2026 10:12
Add csd_realized_only_mismatch, default false. When enabled, 1-to-many
and many-to-1 chunks are scored like many-to-many chunks: the student's
and the teacher's realized token-chain probabilities are compared
against a REST bucket instead of the prefix-support alternatives from
the sub-token tables. 1-to-1 chunks keep their common-token support.

The option works with dense and sparse teacher logits. With it enabled,
sparse teachers always retain the realized labels, as they do for
csd_pure_alm, and the prefix-support index is not built. It has no
position-0 term, so it is rejected together with csd_position_0_kl.

Tests check the realized-only loss and gradients against an
independent chain reference, that sparse rows holding only the
realized labels reproduce the dense result, that 1-to-1 chunks are
unchanged, and that sparse requests carry realized labels when the
option is set. Document the option in the guide and both CSD example
configs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
TokenAligner aligns the padded student and teacher sequences, so it
also pairs padding with padding. _drop_padding reset the padded
positions' chunk ids to -1 but left pair_valid True for every pair.
CSD's per-step chunk count, sum(pair_valid * sample_mask), therefore
counted padding pairs: on a padded text batch of ten documents it
counted 7515 pairs for 2330 chunks holding real tokens, which shrank
the KD term (and its logged value) by a batch-dependent factor.

When both attention masks are given, _drop_padding now marks a pair
invalid if it has tokens on both sides and all of its student or all
of its teacher tokens are padding. Pairs that are one-sided by
alignment, such as the student-only start-of-text pair, keep their
flag. The CSD chunk count then equals the number of chunks that hold
real tokens.

X-Token losses are unchanged: their chunk mask already requires real
tokens on both sides, so every pair this marks invalid was already
excluded. CSD's per-chunk terms are unchanged too; only its chunk
count, and so the KD scale, moves. With a single cross-tokenizer
teacher under dynamic_loss_scaling the training loss is unchanged up
to rounding; with fixed weights, or several teachers summed, the KD
term (or the teachers' relative weights) moves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
The CSD loss combines its KD and CE terms in one of two ways:

- dynamic_loss_scaling: true rescales KD to CE's size each step,
  loss = sg(CE / KD) * KD + CE, and ignores kl_loss_weight and
  ce_loss_scale.
- dynamic_loss_scaling: false uses
  loss = kl_loss_weight * KD + ce_loss_scale * CE.

Add unit tests for both modes. In fixed mode, the loss and gradient are
the weighted sums of the KD-only and CE-only parts. In dynamic mode, the
loss reads as 2 * CE, the gradient is (CE / KD) * grad(KD) + grad(CE),
changing the fixed weights has no effect, and a microbatch with no KD
positions keeps scale 1 so the loss is CE alone.

The CSD example configs keep dynamic scaling on and now note that
kl_loss_weight and ce_loss_scale apply only when it is off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
The Moonlight-16B-A3B router-replay recipe could only run with a
recorded student-vLLM rollout file and Moonlight-to-Qwen3 sub-token
tables supplied at launch, so it was a reproducer rather than a
self-contained suite. Remove the recipe, its suite script and its
disabled.txt entry. CSD's router-replay support is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
A mismatch (non-1-to-1) chunk could add an optional position-0 term: a
common-vocabulary partition loss at the chunk's first predictor. It was
off by default and in the example configs. Remove it, so the mismatch
loss is csd_mismatch_loss_beta times the last-position partition loss.

- Drop csd_position_0_kl, csd_mismatch_pos0_alpha and the older
  csd_mismatch_pos0_weight / csd_mismatch_loss_scale pair. A config that
  still sets one of them fails at load instead of being ignored.
- csd_mismatch_loss_beta is a plain float defaulting to 1.0, the previous
  effective weight. It can now be set on its own; before, setting it
  without the position-0 term also required csd_mismatch_pos0_alpha: 0.
- Drop the kl_partition_first_per_chunk and
  kl_mismatch_combined_per_chunk metrics.

With default settings, loss, gradient and mismatch metrics are bitwise
identical to before on the CSD loss test fixtures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Set the csd_mismatch_loss_beta default to 5.0, so each 1-to-many,
many-to-1 and many-to-many chunk loss counts five times a 1-to-1 chunk
loss in the CSD term. A mismatch chunk is scored over its realized
token chain, a few alternatives and one REST bucket, which yields a
smaller divergence than a 1-to-1 chunk scored over the common
vocabulary; the larger weight keeps these chunks from being drowned out.

The example configs set the value explicitly, and the guide lists the
option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
The CSD collator rejected any text row whose student or teacher
tokenization did not fit its context, so an unfiltered corpus stopped
training. In the default Formal-Logic corpus, 0.12% of rows exceed 2048
tokens and 3.1% exceed 1024 for Llama-3.2-1B / Qwen3-4B, enough to stop
both the example and the 1024-token recipes within a few steps.

- CSDCrossTokenizerCollator gains clip_overlength_text (text mode only):
  a row that does not fit every side is replaced by a prefix of its text
  that does, so the student and every teacher still cover the same text.
  Rows that fit are unchanged; the overlength check stays as a guard.
  CSD's trainer enables it in text mode; chat rows are still rejected.
- csd.yaml and csd_multiteacher.yaml load Formal-Logic by dataset id and
  subset, as the X-Token example does, so it resolves from the Hugging
  Face cache offline.
- The CSD suite scripts no longer require CSD_TRAIN_DATA and move from
  disabled.txt to nightly.txt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Add csd-qwen3-4b-to-llama3.2-1b-1n8g-megatron-tp2cp2: the CSD example on
Megatron with the student and the Qwen3-4B teacher at TP2xCP2, the same
batch, sequence length and step count as the Automodel recipe, a 5-step
warmup followed by a constant learning rate, and a sequence-length
multiple of 8 (2 x TP x CP). Its suite script builds both sub-token
tables and checks for finite training loss, and it runs nightly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
…op-k primitives

Add three primitives for evaluating a loss on context-parallel student
shards and for exporting sparse teacher rows, all without gathering the
vocabulary or sequence axes:

- DistributedSelectedLogprobs / distributed_selected_logprobs: exact
  log-probabilities of selected token ids at selected rows of a
  TP-sharded logit tensor. Forward reduces only per-row max/sum scalars
  and the selected values; backward recomputes the local softmax shard
  and scatters the gradient back into the selected rows. Columns past
  the real vocabulary size are excluded from log Z and the gradient.
- cp_load_balanced_segments: a rank's load-balanced CP rows as
  (local_start, global_start, length) intervals, matching
  _get_tokens_on_this_cp_rank.
- distributed_vocab_topk_logz_force: per-row natural top-k sorted by
  token id, exact temperature-scaled log Z, the score-ranked k-th token
  id, and a forced-label sidecar (raw logits plus natural top-k
  membership) resolved across TP. Ties are broken by the lowest global
  token id so the selection does not depend on the TP layout.

Tests cover values and gradients against dense references, padded
LM-head columns, segment/rank-token agreement, cutoff ties, sidecar
validation, and gloo TP1/TP2 runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Add a Megatron sender for sparse teacher logits that keeps rows in their
context-parallel shard:

- SparseTeacherLogitsPostProcessor computes, per teacher row and across
  TP only, the natural top-k sorted by token id, the exact
  temperature-scaled log Z over the real vocabulary, the score-ranked
  k-th token id, and a sidecar for up to F forced labels (raw logits and
  natural top-k membership). Forced ids come from the teacher data's
  force_include_token_ids [B, T_t, F] and are sharded with the same
  load-balanced CP layout as the logits. It is dispatched without
  sampling-temperature scaling.
- MegatronPolicyWorker.get_sparse_logits_ipc writes the seven fields
  into persistent [N_mb, B, T_local, width] slabs whose IPC handles are
  cached across calls, sized once for max_total_sequence_length.
  Only TP rank 0 on the last pipeline stage exports; each record lists
  the global intervals its rows cover in sequence_segments. Models that
  pack and context-shard inside their own forward are rejected at
  CP > 1. release_ipc_buffer also frees the sparse slabs.
- Policy.get_sparse_logits_ipc routes the call to Megatron workers and
  keeps one record per teacher CP shard and sample.
- ensure_teacher_ipc_buffer takes an optional capacity_seq_len.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Add readers that copy only the teacher rows a loss asks for, instead of
reassembling a teacher's full sequence on every GPU:

- SparseTeacherRowReader / gather_sparse_teacher_rows_from_ipc route
  each (sample, global position) to the teacher CP shard that holds it
  through the records' sequence_segments, open each backing slab once,
  and return one exact K-wide support per request: the natural top-k
  when the requested teacher label is in it, otherwise the natural
  top-(k-1) plus that label's forced logit (the recorded k-th id is
  evicted). Each request also gets the exact log Z and the label's
  natural top-k membership.
- DenseTeacherRowReader reads full-vocabulary rows from the existing
  dense handles (compact or rectangular), stitching the teacher's TP
  vocabulary shards; rows in a compact handle's omitted suffix read as
  zero. _rebuild_teacher_ipc_row now shares its row slicing with it.
- Both readers cache opened storage until close().
- _build_teacher_force_include_token_id_slots builds the
  [B, T_t, 2] forced labels the Megatron sender needs: every predictor
  row of every valid chunk keeps its realized teacher label, and a row
  shared by an unshifted chunk at the sequence start and the next
  shifted chunk keeps both labels.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Add an owner-local CSD evaluation for Megatron students, selected by the
runtime-injected CSDLossConfig.owner_local flag (set by setup in a
later commit). The student logits stay [B, S_pad/CP, V/TP] in their
load-balanced CP layout and are never gathered across CP:

- prepare_csd_owner_local_loss_input hands each cross-tokenizer teacher
  to the loss as a TeacherRowPayload (its sparse or dense IPC records)
  with global spans, full ids and the global position of every local
  logit row. A same-tokenizer teacher keeps today's contiguous-window
  reconstruction, built only when one is configured.
- _compute_csd_loss_owner_local classifies every chunk identically on
  all ranks; the rank holding a chunk's final student predictor row
  evaluates it, reading only that chunk's teacher rows through the
  sparse or dense row reader and the student values through
  distributed_selected_logprobs. A mismatch chunk's student prefix
  rows may sit on other CP ranks; each rank adds its share to one
  fixed-length vector summed across CP with a SUM backward. Dense
  teachers follow the replicated support rules; sparse teachers use
  each request's top-k with its realized label kept. Before the first
  collective, a MIN-reduced ok flag over TP then CP makes every rank
  raise together when one rank's setup fails.
- The loss is CP-partial: KD is the sum over owned chunks divided by
  the step-global valid-chunk count (required on this path), CE sums
  -log p over the rank's own rows divided by global_valid_toks, and the
  same-tokenizer KD is already a per-window partial. Dynamic loss
  scaling and the reported metrics use detached CP sums, so every rank
  applies the same ratio and reports complete values. Accuracy uses the
  rank's rows and scalar CP reductions.
- The Megatron worker builds LossPostProcessor with cp_normalize=False
  for such a loss (loss_returns_context_parallel_partial); the
  num_microbatches / cp_size schedule compensation stays. Megatron sums
  gradients over DP x CP, so the partials give gradient factor 1.
- owner_local_config_violations lists unsupported settings
  (kd_loss_mode other than sum, dynamic weights, pure ALM, BCE on
  non-binary partitions, sparse IPC without kept realized labels);
  CSDLossFn rejects them when owner_local is set.
- New knobs csd_selected_row_chunk_size (128) and
  csd_support_chunk_batch_size (16) bound memory only.

Gloo tests over TP/CP in {1, 2}^2 check that the CP-summed loss and the
reassembled gradient equal the replicated reference (dense) or the
TP1/CP1 owner-local result (sparse, itself checked against the
replicated sparse path), in both mismatch modes, with a noise filter,
fixed weights, an added same-tokenizer teacher, and a rank that owns no
chunk; that no CP gather runs without a same-tokenizer teacher; and
that a setup error on one rank raises on every rank.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Setup now decides, per CSD run, whether cross-tokenizer KD runs
owner-local and injects the result as loss_fn.owner_local:

- csd_owner_local_ineligibility lists why a run cannot: a student that
  is not Megatron, student PP > 1, sequence packing, dynamic batching,
  the loss settings from owner_local_config_violations, and, with
  sparse teacher IPC, cross-tokenizer teachers that are not Megatron or
  whose make_sequence_length_divisible_by is not a multiple of 2 x CP
  (and TP with sequence parallelism).
- A Megatron student with no reasons runs owner-local, with dense or
  sparse teacher rows. With reasons it keeps the replicated loss, which
  only dense IPC can feed, so teacher_topk_ipc_k > 0 is rejected with
  the reasons and a hint to set it to 0. An Automodel student keeps the
  replicated path and still requires Automodel sparse teachers.
- Owner-local runs export cross-tokenizer sparse teachers through
  get_sparse_logits_ipc with the two-slot forced labels; dense export
  is unchanged.
- Setup prints which evaluation the run uses and why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
On the replicated Megatron path at CP > 1, a same-tokenizer teacher's
KD term covers only this rank's contiguous window (a per-window partial
over the global KD-token count), but the loss wrapper then divides the
whole loss by the CP size. The term was therefore under-weighted by
1/CP, and every rank reported only its own window's share.

prepare_loss_input now marks the Megatron replicated path
(cp_normalized_by_caller, set when there is no model-owned CP sharder),
and CSDLossFn sums the same-tokenizer KD term across CP with a SUM
backward there: every rank holds the full term, and after the /CP
division the gradient factor is 1. The Automodel path and the
owner-local path, which do not divide by CP, are unchanged.

A gloo test checks that loss, metrics and reassembled gradient at CP2
match CP1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Set CSDLossConfig.teacher_topk_ipc_k to 128 and the CSD exemplars
(csd.yaml, csd_multiteacher.yaml) to match, and update the guide.

This changes the objective, not just the transport: a cross-tokenizer
teacher row now contributes its top-128 logits plus the exact REST mass
instead of the full vocabulary. Megatron students run it owner-local
with the Megatron sparse sender; Automodel students keep the replicated
sparse path. Set loss_fn.teacher_topk_ipc_k=0 for the previous dense
transport; CSD lockstep packing still requires it and says so at setup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Add a "Teacher transport and context parallelism" section to the
cross-tokenizer distillation guide: sparse and dense teacher transport,
how owner-local evaluation splits the loss across context-parallel
ranks, the student x teacher x teacher_topk_ipc_k matrix, the
owner-local requirements, and memory notes (no student gathers, row
reads, the two tiling knobs, and the persistent sparse buffers whose
reused IPC handles may not be released after an error or at shutdown).
The lockstep packing design no longer says Megatron sparse IPC is
missing.

Remove the unused CSDLossFn._rebuild_teacher_full_logits and
_rebuild_teacher_sparse_logits helpers and their test; the loss-input
preparation rebuilds or reads teacher logits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
The setup check that each sequence-length divisor is a multiple of the
context-parallel size read every entity's CP from automodel_cfg and
detected same-vocab teachers by a null projection path. In the shared
trainer a Megatron student or teacher keeps its CP in megatron_cfg and
pads its own inputs to lcm(divisor, 2 * CP), so a Megatron entity with
divisor 1 and CP=2 is valid, and CSD marks same-vocab teachers by a
null sub-token table path instead.

Skip Megatron entities, which pad themselves, and detect same-vocab
teachers with _teacher_is_cross_tokenizer, so the check covers both
algorithms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
avenkateshha and others added 4 commits October 19, 2026 13:34
In ordinary chat mode, CSD setup turned on ce_covers_assistant_turn, so CE
covered the whole final assistant turn, including the role header the chat
template writes before the content (for Llama-3.2:
<|start_header_id|> assistant <|end_header_id|> \n\n). Those tokens come
from the template at inference time; the model never generates them.
Supervising them adds no useful signal, and on a student whose
header-token embeddings are untrained they carry about 10 nats each.
That raised the step-0 chat CE by about 15% on Llama-3.2-1B and took
gradient-clipping budget away from content tokens and KD.

CSD chat now leaves the option off, so CE uses the same mask as KD: the
final assistant turn's content plus its end-of-turn token. No
kd_token_mask is emitted, and every KD consumer already falls back to
token_mask, so KD is unchanged. Text mode and native thinking alignment
are unchanged. The shared collator option itself stays available.

Update the setup option test, the CSD chat collator tests (CE mask,
router-replay positions, and a template footer that is now plain
context) and the CSD chat paragraph of the cross-tokenizer guide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Rename docs/guides/cross-tokenizer-distillation.md to
csd-cross-tokenizer-distillation.md and rewrite it as a CSD guide that
mirrors the X-Token guide: how CSD compares the models per chunk type,
backends and teacher transports, step-by-step sub-token table building
and validation, launch, loss knobs, multiple teachers and lockstep
packing.

Add a README "Cross-Tokenizer Distillation" section that introduces both
methods, keeps the X-Token subsection (and its anchor), and adds a CSD
subsection with table preparation and single- and multi-node launches.
Add CSD to the distillation recipe table, and give X-Token and CSD
separate cards in docs/index.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
Keep a single cross-tokenizer distillation guide at
docs/guides/xtoken-off-policy-distillation.md: a shared introduction
with the tokenizer-overlap and X-Token/CSD comparison tables, then an
X-Token section and a CSD section, and combined "Where files live" and
"Related" lists. Remove docs/guides/csd-cross-tokenizer-distillation.md
and point the README, docs index and CSD exemplar comments at the CSD
section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>
The guide now covers both X-Token and CSD, so rename
docs/guides/xtoken-off-policy-distillation.md to
docs/guides/cross-tokenizer-distillation.md. Point the README, docs
index, exemplar config comments and build_projection_matrix.sh at the
new path, and link the guide from the README's cross-tokenizer section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Adithya Hanasoge <avenkateshha@nvidia.com>

@avenkateshha avenkateshha left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Self-review of the CSD PR at c516f035b. It was run with an agent team: RL guidelines, three bug-finders, an upstream-API expert, design and tests. Three devil's-advocate passes then re-checked every finding at this head; findings below 80% confidence were dropped. Everything actionable is inline; this body holds context and follow-ups.

Fix in this PR (details inline):

  1. megatron/data.py:2032: TE infers pad_between_seqs=True for every Megatron packed run, so the grpo-cispo-…-megatron-cispo nightly fails. This affects non-CSD algorithms.
  2. megatron_policy_worker.py:1607: empty mb_losses on an all-padding DP rank → torch.stack([]) crash (Megatron SFT/DPO validation).
  3. csd.py:654: Automodel CP>1 dynamic loss scaling makes KD CP× too strong (both Automodel CP=2 nightlies).
  4. nightly.txt:324: nightly GPU-hours 4955 > 4947 cap (L0 unit failure).
  5. test_subtoks_tables.py:1: copyright header (CI red).
  6. test_train.py:335: the new Megatron test fails on the pinned MCore (a test bug).

PR description: remaining gaps (the latest rewrite fixed the rest):

  • "Shared changes" doesn't mention that X-Token now offloads the student optimizer after every step and every validation when policy.offload_optimizer_for_logprob: true (controller#L2348-L2356). Both X-Token exemplars set it; the nightly recipes override it to false.
  • Megatron-FSDP (use_megatron_fsdp / use_custom_fsdp: true) is now honored for every algorithm, with HF weights loaded directly (setup.py#L669-L677, #L983-L991); the base ignored the flag.
  • New shared policy keys aren't listed: freeze_parameter_patterns (Automodel-only, see inline), automodel_cfg.load_precision / shard_before_load, the Megatron optimizer dtype pass-throughs, and reduce_scatter_with_fp32_accumulation.
  • "CP > 1 no longer requires sequence packing" should note that logprob, top-k and full-payload export still need it (see setup.py:1344).
  • The two Megatron-marked failures attributed to the image are test-side (test_train.py:335, test_megatron_setup.py:205).
  • The validation section doesn't mention the red copyright-check.

Docs: docs/design-docs/csd-lockstep-sequence-packing.md L51-53, L602-604 and L608 still describe limitations this PR removed. For example, "Both teacher IPC entry points in Policy explicitly reject sequence packing" is no longer true: get_full_logits_ipc now takes a packing plan (lm_policy.py#L921-L1005). Reword those passages as history, or delete them.

Follow-ups (not blocking):

  • About 330 lines of multi-teacher aggregation in CSDLossFn are near-copies of X-Token's CrossTokenizerDistillationLossFn (similarity: _teacher_weight_score 1.00, _compute_dynamic_weights 0.99, _teacher_score_inputs 0.94, …). The select_teacher / CP crash (inline at controller L1557) already lives in both copies. The CSD copy also reduces over a per-call side-channel group (_dp_cp_group / _sum_window_kd_over_cp, csd.py#L331-L334, set at L557-L558). A shared helper that takes the group explicitly would fix both.
  • CSDSequencePackingLossWrapper._accumulate_metrics adds an 8th copy of the min/max metric sets (csd_packing.py#L455-L456), the duplication the TODO in wrapper.py#L29-L46 asks to remove. CSD emits no _min/_max metrics, so import _SEQ_METRIC_MIN/_MAX or drop the branches.

Worth keeping: the algorithm-free CP/TP primitives in distributed/model_utils.py; owner_local_config_violations / csd_owner_local_ineligibility as pure reason-list functions reused by __init__ and setup; and the CPU-testable CSD math with its gloo TP2/CP2 parity tests.

Generated by Claude Code

packed_seq_params = PackedSeqParams(
cu_seqlens_q=cu_seqlens_padded,
cu_seqlens_kv=cu_seqlens_padded,
cu_seqlens_q=cu_seqlens,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1 action item, please fix in this PR. nemo_rl/models/megatron/data.py:2032 · PR-introduced; affects every Megatron sequence-packed run, not only CSD.

TL;DR: TE 2.18 now infers pad_between_seqs=True for every packed Megatron forward, which disables FlashAttention 2/4; the grpo-cispo-…-megatron-cispo nightly (packing + attention_backend: flash) will fail with "No dot product attention backend is available".

PackedSeqParams carries raw and padded boundaries; TE uses them to pick the attention backend.

  1. The base passed the same cu_seqlens_padded object to all four fields (base L1832-L1836). This PR passes raw cu_seqlens for q/kv; with no padding cu_seqlens_padded is cu_seqlens.clone() (L2027), so it is always a distinct tensor.
  2. MCore forwards pad_between_seqs=None (transformer_engine.py#L2314-L2340), so TE infers it by object identity: if cu_seqlens_q_padded is cu_seqlens_q and cu_seqlens_kv_padded is cu_seqlens_kv: pad_between_seqs = False (TE dot_product_attention.py#L1549-L1561).
  3. With it True, TE disables FA2/FA4 and unfused attention for THD (TE utils.py#L976-L991).
  4. grpo-cispo-…-megatron-cispo.yaml (nightly.txt#L101) sets packing + attention_backend: flash, which turns fused/unfused off (MLM utils.py#L63-L82); the default flash_attention_version=2 turns FA3 off (setup.py#L1906-L1909). Nothing is left: ValueError: No dot product attention backend is available (TE#L1687-L1693) on the first packed forward. On sm<90 with attention_backend: auto, packed runs silently move from FA2 to cuDNN.

Probe (real function body + TE's rule): base → False; head → True, even for [0,5,12,16] vs [0,5,12,16].

The raw boundaries look intentional (FullLogitsPostProcessor and _packed_row_geometry read them), so a revert doesn't work, and neither does pad_between_seqs=not torch.equal(...): TP2+SP (divisor 2) pads odd rows, so it is still True for cispo.

Action: keep the raw PackedSeqParams for NeMo-RL consumers, but hand the model a copy whose q/kv boundaries are the padded tensors, at both call sites: model_forward (train.py#L262-L264) and the draft/Eagle call (train.py#L475-L480). One way to do it:

def _te_packed_seq_params(params: Optional[PackedSeqParams]) -> Optional[PackedSeqParams]:
    """Packed boundaries as TE expects them: q/kv point at the padded tensors."""
    if params is None:
        return None
    return dataclasses.replace(
        params,
        cu_seqlens_q=params.cu_seqlens_q_padded,
        cu_seqlens_kv=params.cu_seqlens_kv_padded,
    )

Add a model_forward test asserting kwargs["packed_seq_params"].cu_seqlens_q is kwargs["packed_seq_params"].cu_seqlens_q_padded; the existing pins at test_megatron_data.py L2912-2913 / L3053 stay valid. Not GPU-verified; GPU numbers taken at this head went through cuDNN.

logical_metrics if logical_metrics is not None else [x]
)
for metric_record in metric_records:
if metric_record["num_valid_samples"] <= 0:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1 action item, please fix in this PR. megatron_policy_worker.py:1607 · PR-introduced; hits every algorithm on Megatron.

The new skip of num_valid_samples <= 0 records can leave mb_losses == [] on a DP rank whose shard is all padding. all(...) over [] is True, so L1641-L1642 runs torch.stack([]) → RuntimeError: stack expects a non-empty TensorList. The base appended every record, and the Automodel worker already handles this case.

Breaking case (reproduced on CPU with the real maybe_pad_last_batch + shard_by_batch_size): Megatron SFT, DP=8, val_micro_batch_size=1, last validation batch of 3 samples (drop_last=False, sft.py#L574-L585) → padded to 8 rows with sample_mask=0 (utils.py#L558-L577) → ranks 3-7 hold only padding → every record skipped → crash. Same for DPO validation, and for GRPO when a DP shard is fully zeroed (grpo.py#L3697).

Action: at L1641 (outside the diff), so the else branch yields tensor(0.):

                if mb_losses and all(isinstance(loss, torch.Tensor) for loss in mb_losses):

plus a Megatron worker test with an all-sample_mask=0 DP shard.

Comment on lines +654 to +657
# Combine the aggregated KD term with the single student CE term.
loss, _, kl_scale = self._combine_kd_and_ce(
total_kd, ce_loss, total_kd.detach(), ce_loss.detach()
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

2 action items, please fix in this PR. csd.py:654-657 · PR-introduced.

TL;DR: on Automodel with CP>1 and dynamic_loss_scaling: true (the csd.yaml default, inherited by both Automodel CP=2 nightlies), the dynamic KD scale is built from a CP-partial KD, so the KD gradient is CP× too large relative to CE and kl_loss is logged at 1/CP.

  1. On Automodel CP the worker multiplies the chunk count by cp_size (automodel_policy_worker.py#L517-L525), so each rank's cross-tokenizer KD is KD_true / CP (same-tokenizer KD is a per-window share); cp_normalized_by_caller is False there (loss_input.py#L395).
  2. CE is forward-summed over CP, so every rank holds the full CE (csd.py#L1266-L1272).
  3. This call gives kl_scale = |CE| / |KD_rank| = CP·|CE| / |KD_true|; after the DP×CP gradient sum (automodel/train.py#L604-L614) the KD gradient is CP× the intended one.
  4. The owner-local path already uses the CP sum (_detached_cp_sum, L581-L582).

Probe (Automodel CP=2 vs a CP=1 reference): dynamic=True: ref scale=14.3198 | cp2 scale=28.6397 | max|grad diff|=5.065e-01. With only a same-tokenizer teacher the per-rank scales even diverge (8.76 / 12.20 vs 5.10). With the fix: same scale, grad diff ≤ 9e-8. Megatron is unchanged (_sum_window_kd_over_cp is True there). The nightlies only check isfinite, so they can't catch this.

AI-1

Use the CP-summed KD for the scale and the reported value (metric wiring in the next comment):

Suggested change
# Combine the aggregated KD term with the single student CE term.
loss, _, kl_scale = self._combine_kd_and_ce(
total_kd, ce_loss, total_kd.detach(), ce_loss.detach()
)
# Combine the aggregated KD term with the single student CE term.
# Without caller-side CP division (Automodel CP) each rank's KD is its
# CP share while CE is complete, so the dynamic scale and the reported
# KD use the CP sum, as on the owner-local path.
reported_kd = (
total_kd.detach()
if self._sum_window_kd_over_cp
else self._detached_cp_sum(total_kd, cp_group)
)
loss, reported_loss, kl_scale = self._combine_kd_and_ce(
total_kd, ce_loss, reported_kd, ce_loss.detach()
)

AI-2

Add a CP=2 gloo test with the Automodel contract (cp_normalized_by_caller=False) and dynamic scaling next to _run_same_vocab_megatron_cp in tests/unit/algorithms/csd/test_csd_loss_functions.py; the existing dynamic-scaling CP test only covers cp_normalized_by_caller=True.

Follow-up

Per-teacher kl_loss_t{i} / teacher_{i}/weighted_kl are also CP-partial on Automodel CP (logged at 1/CP); this fix doesn't cover them.

Comment on lines +677 to +678
loss=loss,
kd=total_kd,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Part of the csd.py:654 comment (same action). csd.py:677-678

Report the same CP-summed values the scale uses, so loss / kl_loss aren't logged at 1/CP on Automodel CP (only CP rank 0's metrics are kept, lm_policy.py#L1186-L1190):

Suggested change
loss=loss,
kd=total_kd,
loss=reported_loss,
kd=reported_kd,

# Multi-teacher cross-tokenizer off-policy distillation (xtoken): Qwen3-4B (cross-tok P-KL) + Llama-3.2-3B (same-vocab direct KL) -> Llama-3.2-1B, kd_loss_mode=sum, student TP4xCP2 <- teachers TP2xCP2, guards multi-teacher aggregation + per-teacher CP-buffer sharding.
tests/test_suites/llm/distillation-xtoken-off-policy-multiteacher-qwen3-4b-llama3.2-3b-to-llama3.2-1b-1n8g-automodel-tp4cp2.sh

# CSD cross-tokenizer distillation: Qwen3-4B -> Llama-3.2-1B on raw text, student TP4xCP2 <- teacher TP2xCP2, builds both sub-token tables and guards finite training.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1 action item, please fix in this PR (L0 unit failure). tests/test_suites/nightly.txt:324 · PR-introduced.

The three CSD suites add 8 GPU-hours (2 + 2 + 4 under tools/launch's integer formula), taking the nightly total to 4955, while test_nightly_compute_stays_below_4947_hours asserts <= 4947. DRYRUN=1 ./tools/launch tests/test_suites/nightly.txt gives 4955 at head and 4947 for the base list, so the base passes exactly at the cap and this PR tips it over.

Action: feature PRs that add nightlies bump the cap in the same PR (#4458: 4830→4947; also #4526, #4341). Once the 100-step GPU runs (see the suite comment) fix NUM_MINUTES, update the constant, test name and message in tests/unit/test_recipes_and_test_suites.py, or keep one CSD suite out of nightly.txt until then.

Comment thread examples/configs/csd.yaml
csd_mismatch_loss_beta: 5.0
csd_noise_filter_topk: 0
csd_realized_only_mismatch: false
teacher_topk_ipc_k: 128 # 0 sends dense teacher logits

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1 action item (low priority). examples/configs/csd.yaml:28 · PR-introduced.

With teacher_topk_ipc_keep_realized: true (the exemplar default), sparse KD adds each chunk's realized label to its support and lowers the mismatch minimum support from 2 to 1 (csd.py#L2618-L2623). So K=128 and K=0 optimize different losses even when K covers the whole vocabulary. Probe at K=V: dense 0.2297 vs sparse 0.3085, max |Δgrad| 0.037; with keep_realized: false they agree to 1e-9. The guide says the two "optimize different objectives" (guide#L543) but blames only the top-K cut, and this comment still reads as a pure transport choice:

Suggested change
teacher_topk_ipc_k: 128 # 0 sends dense teacher logits
teacher_topk_ipc_k: 128 # 0 sends dense teacher logits (a different KD objective; see the guide)

Do the same for csd_multiteacher.yaml L28. In the guide at L543, consider adding: "With teacher_topk_ipc_keep_realized: true the sparse KD also adds each chunk's realized label to its support, so the two differ even when K covers the vocabulary."

Comment on lines +1444 to +1450
raise ValueError(
f"teachers[{teacher_idx}].aligner.projection_matrix_path is null, "
"but safe "
"student-token reuse requires identical tokenizer mapping, backend, "
"special-token configuration, chat template, and template kwargs; "
f"differing fields: {differing_fields}."
)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1 action item. xtoken_off_policy_distillation.py:1444-1450 · PR-introduced.

The only caller is the CSD-only validate_csd_tokenizer_reuse (L1466). In CSD, a same-tokenizer teacher is one without a table pair; the projection path is null for CSD cross-tokenizer teachers too (L821-L825), so this message points users at the wrong key. The version below keeps the safe student-token reuse substring the test matches (test_distillation.py#L1872):

Suggested change
raise ValueError(
f"teachers[{teacher_idx}].aligner.projection_matrix_path is null, "
"but safe "
"student-token reuse requires identical tokenizer mapping, backend, "
"special-token configuration, chat template, and template kwargs; "
f"differing fields: {differing_fields}."
)
raise ValueError(
f"teachers[{teacher_idx}].aligner has no CSD table pair, so it is "
"treated as a same-tokenizer teacher, but safe "
"student-token reuse requires identical tokenizer mapping, backend, "
"special-token configuration, chat template, and template kwargs; "
f"differing fields: {differing_fields}."
)

Comment on lines +401 to +404
# Per-teacher metadata: parallel lists (one entry per ``teachers[i]``),
# injected by ``xtoken_off_policy_distillation.setup``. Every teacher ships
# full-vocab logits (the loss derives the top-k subset student-side), so
# there is no per-teacher ``send_full_logits`` flag.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1 action item. csd.py:401-404 · PR-introduced. "Every teacher ships full-vocab logits" contradicts the teacher_topk_ipc_k: 128 default:

Suggested change
# Per-teacher metadata: parallel lists (one entry per ``teachers[i]``),
# injected by ``xtoken_off_policy_distillation.setup``. Every teacher ships
# full-vocab logits (the loss derives the top-k subset student-side), so
# there is no per-teacher ``send_full_logits`` flag.
# Per-teacher metadata: parallel lists (one entry per ``teachers[i]``),
# injected by ``xtoken_off_policy_distillation.setup``.

NUM_RUNS=$(( (MAX_STEPS + STEPS_PER_RUN - 1) / STEPS_PER_RUN )) # Round up
NUM_MINUTES=30
STUDENT_MODEL=meta-llama/Llama-3.2-1B
TEACHER0_MODEL=Qwen/Qwen3-4B # cross-tokenizer (P-KL via projection)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1 action item. …-dtensor-tp4cp2.sh:12 · PR-introduced. This teacher uses CSD sub-token tables, not P-KL:

Suggested change
TEACHER0_MODEL=Qwen/Qwen3-4B # cross-tokenizer (P-KL via projection)
TEACHER0_MODEL=Qwen/Qwen3-4B # cross-tokenizer (CSD sub-token tables)

Comment on lines +506 to +508
student_to_teacher_table_path: Path to this teacher's forward pseudo-target table
(student-to-teacher sub-token chains). ``None`` for a same-tokenizer
teacher.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

1 action item. xtoken_off_policy_distillation.py:506 · PR-introduced.

TeacherAlignerConfig.projection_matrix_path's docstring (L500-L502, just above the diff) still says None marks a same-tokenizer teacher. For algorithm: csd, a null projection with a table pair is a cross-tokenizer teacher (L821-L825). Suggested wording: "X-Token: None marks a same-tokenizer teacher. CSD: the table pair below selects cross-tokenizer." The validate_csd_tokenizer_reuse docstring (L1458, "every null-projection teacher") needs the same fix.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant