Repository navigation
feat(csd): add cross-tokenizer distillation and sub-token preparation - #4680
avenkateshha wants to merge 33 commits into
Conversation
|
/ok to test 22ebcf7 |
42fc8ce to
98d6069
Compare
22ebcf7 to
6e4222c
Compare
2d5df0d to
205dc30
Compare
b11f902 to
378aff8
Compare
a04b753 to
e6fe651
Compare
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>
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>
1bb5090 to
a281128
Compare
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
left a comment
There was a problem hiding this comment.
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):
megatron/data.py:2032: TE inferspad_between_seqs=Truefor every Megatron packed run, so thegrpo-cispo-…-megatron-cisponightly fails. This affects non-CSD algorithms.megatron_policy_worker.py:1607: emptymb_losseson an all-padding DP rank →torch.stack([])crash (Megatron SFT/DPO validation).csd.py:654: Automodel CP>1 dynamic loss scaling makes KD CP× too strong (both Automodel CP=2 nightlies).nightly.txt:324: nightly GPU-hours 4955 > 4947 cap (L0 unit failure).test_subtoks_tables.py:1: copyright header (CI red).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, andreduce_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
CSDLossFnare near-copies of X-Token'sCrossTokenizerDistillationLossFn(similarity:_teacher_weight_score1.00,_compute_dynamic_weights0.99,_teacher_score_inputs0.94, …). Theselect_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_metricsadds 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/_maxmetrics, so import_SEQ_METRIC_MIN/_MAXor 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, |
There was a problem hiding this comment.
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.
- The base passed the same
cu_seqlens_paddedobject to all four fields (base L1832-L1836). This PR passes rawcu_seqlensfor q/kv; with no paddingcu_seqlens_paddediscu_seqlens.clone()(L2027), so it is always a distinct tensor. - 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). - With it
True, TE disables FA2/FA4 and unfused attention for THD (TE utils.py#L976-L991). grpo-cispo-…-megatron-cispo.yaml(nightly.txt#L101) sets packing +attention_backend: flash, which turns fused/unfused off (MLM utils.py#L63-L82); the defaultflash_attention_version=2turns 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 withattention_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: |
There was a problem hiding this comment.
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.
| # 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() | ||
| ) |
There was a problem hiding this comment.
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.
- 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 isKD_true / CP(same-tokenizer KD is a per-window share);cp_normalized_by_calleris False there (loss_input.py#L395). - CE is forward-summed over CP, so every rank holds the full CE (csd.py#L1266-L1272).
- 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. - 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):
| # 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.
| loss=loss, | ||
| kd=total_kd, |
There was a problem hiding this comment.
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):
| 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. |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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:
| 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."
| 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}." | ||
| ) |
There was a problem hiding this comment.
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):
| 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}." | |
| ) |
| # 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. |
There was a problem hiding this comment.
1 action item. csd.py:401-404 · PR-introduced. "Every teacher ships full-vocab logits" contradicts the teacher_topk_ipc_k: 128 default:
| # 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) |
There was a problem hiding this comment.
1 action item. …-dtensor-tp4cp2.sh:12 · PR-introduced. This teacher uses CSD sub-token tables, not P-KL:
| TEACHER0_MODEL=Qwen/Qwen3-4B # cross-tokenizer (P-KL via projection) | |
| TEACHER0_MODEL=Qwen/Qwen3-4B # cross-tokenizer (CSD sub-token tables) |
| 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. |
There was a problem hiding this comment.
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.
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.
algorithm: xtoken(default)algorithm: csdtools/x_token/)tools/csd/)run_xtoken_off_policy_distillation.pyrun_csd.py,csd.yaml,csd_multiteacher.yamlK=128) or dense CUDA IPCMain changes
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 withcsd_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-teachersum/averaged_logits/select_teacher.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_topk_ipc_k: 128changes the objective (top-128 + REST instead of the full vocabulary); set0for dense.sum) evaluate KD owner-local on each CP rank. New primitives innemo_rl/distributed/model_utils.py.sample_idonarrow_text,openai_formatandResponseDataset;CSDCrossTokenizerCollatoris 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.nemo_rl/data/packing/lockstep.py,docs/design-docs/csd-lockstep-sequence-packing.md).docs/guides/xtoken-off-policy-distillation.mdrenamed todocs/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:
cu_seqlens_q/kvand a CP-local MoE router padding mask.arrow_textpacked-cache schema version changes, so existingcharacters_per_samplecaches are rebuilt on first use.rebuild_teacher_full_logits_from_ipcreturns(logits, fallback_count); the strict exact-token map selects every row whose top weight is 1.0.teachers[i].aligner.Issues
Stacked on #4728:
main← #3286 ← #4721 ← #4728 ← this PR (baseavenkateshha/xtoken-csd-shared-collator). Merge in stack order.Usage
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"
Additional Information
Validation so far
csd-qwen3-4b-to-llama3.2-1b-1n8g-dtensor-tp4cp2Open items
test_nightly_compute_stays_below_4947_hoursfails: the CSD nightlies bring the nightly total to 4955 GPU-hours.nemo_rl_main_20260803on the current base.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.pyandnemo_rl/distributed/model_utils.py→nemo_rl/algorithms/xtoken_off_policy_distillation.py→nemo_rl/data/cross_tokenizer/csd_collate.pyandnemo_rl/data/packing/lockstep.py→ backend/worker changes undernemo_rl/models/→ tests.🤖 Generated with Claude Code