jburtoft's picture
training_scripts/README: correct root cause description (masked_scatter->sort in XLA lowering, not audio encoder)
cbbd993 verified
|
Raw
History Blame Contribute Delete
12.8 kB

Training scripts

Full reproduction pipeline for training the 4-layer distilled Voxtral draft in this repository. See the main model card for the end-to-end recipe. This document describes each script individually.

All scripts are self-contained and driven by command-line arguments. Defaults assume /mnt/data/ for working data and /mnt/drafts/ for output checkpoints, but every path is overridable via a flag.

Two training paths

The scripts support two Trainium software stacks:

  1. torch-neuronx XLA (SDK 2.31 DLAMI) -- the default. Uses torch_xla with xm.mark_step(). Runs in the standard SDK 2.31 DLAMI's pytorch_2_9 venv. Faster steady-state per-step, produced the weights in this repo.
  2. PyTorch Native Beta 4 (SageMaker Training DLC) -- eager mode + torch.compile(backend='neuron'). Runs in a separate venv created by beta35-eval's deploy-beta35.sh --variant beta4 script. Same convergence, slightly slower per-step, simpler port from vanilla PyTorch training code, and the direction AWS is investing in for future Trainium training.

Both paths produce the same trained weights (within run-to-run seed variance). The _beta4 variants of the sanity and training scripts are the Beta 4 ports.

Pipeline diagram

LibriSpeech .flac -----> precompute_pseudo_labels.py ---->  {clip}.pt per clip
                                          |                   (input_ids,
   audio_encoder (CPU) + Voxtral greedy --+                    audio_embeds,
                                                               teacher_token_ids)
                                                                      |
Trainium training loop:                                               v
    for step in range(total_steps):                                   |
        batch = load({clip}.pt files, pad to max_seq_len)  <----------+
        inputs_embeds = splice audio_embeds into text embeds
        logits = student(inputs_embeds)
        loss = CE(logits, teacher_token_ids)  # teacher forcing
        backward + step
                                                                      |
                                                                      v
                                       /mnt/drafts/best  <---- best checkpoint by
                                                              val top-1 agreement
                                                              on held-out audio

Scripts

Script Purpose Stack
sanity_voxtral_xla.py Verify Voxtral loads on trn2 via torch_xla. Expected to fail on the audio path (documents the trn2 sort op limitation). XLA
sanity_voxtral_xla_v2.py Verified working XLA path: audio encoder on CPU, LLM decoder on trn2 via torch_xla. XLA
sanity_voxtral_beta4.py Beta 4 equivalent of sanity_voxtral_xla_v2.py. Uses torch.device("neuron:0") eager mode + torch.compile(backend='neuron'). Prints wall time for both eager and compiled variants. Beta 4
build_draft.py Take a Voxtral checkpoint and prune to N decoder layers. Saves to --out-dir. Used at training-start to create the initial student. Any
build_librispeech_manifest.py Build a CSV manifest from a single LibriSpeech split (dev-clean etc.). Any
build_librispeech_combined_manifest.py Build a manifest combining multiple LibriSpeech splits, with de-duplication against a previously-precomputed pseudo-label directory. Any
precompute_pseudo_labels.py For each audio clip in a manifest: run Voxtral greedy on CPU, save (input_ids, audio_embeds, teacher_token_ids) as <stem>.pt. Any (CPU only)
train_distill.py (v1) Simple training loop, no validation. Kept for reference; use v2 instead. XLA
train_distill_v2.py (v2) Training with periodic validation on a held-out set + best-checkpoint tracking. This is the script that produced this repo's weights. XLA
train_distill_v2_beta4.py Beta 4 port of v2. Identical arguments + adds --compile flag to wrap the LLM decoder in torch.compile(backend='neuron'). Beta 4
validate_draft.py Standalone quality check for a trained draft. Runs greedy generation with the draft alone, then computes teacher-forced per-position top-1 agreement between draft and target. Any (CPU)

Key design choices

Layer selection: [0, 10, 20, 29]

  • Includes layer 0 (initial embedding processing) and layer 29 (final projection) for stability.
  • Even spacing in between (10, 20) samples different depths of the target's representation.
  • 4 layers is ~13% of the target's decoder FLOPs (4/30).
  • Not systematically swept. Alternatives like [0, 7, 15, 22, 29] (5 layers) may give a better acceptance/cost tradeoff and are trivial to try by changing --keep-layers.

Pseudo-labels via greedy teacher

  • Runs Voxtral end-to-end on each audio clip with greedy decoding.
  • Saves target's transcript token IDs; student learns to reproduce them exactly under teacher forcing (cross-entropy loss).
  • Alternatives considered:
    • KL on soft logits: potentially better quality but requires storing full 131072-vocab distributions per position (~256 MB per 128-token clip, 500+ GB total). Not run.
    • Ground-truth transcripts (LibriSpeech gold): risks the student learning phrasings different from Voxtral's, hurting downstream spec-decode acceptance.
    • Chose hard-label distillation (teacher's argmax) as the simplest reasonable option.

Frozen audio path

  • Audio encoder + projector + embed_tokens + final norm + lm_head are byte-identical between student and target -- no reason to train them.
  • Only the 4 decoder layers get gradients (~428M trainable of the ~3B target total).

Batch size 2

  • Chose conservatively for a first run. Larger batches (4, 8) are likely feasible and would give more stable gradients. Untested; open for future work.
  • Batch size 1 also works but shows higher loss variance.

Cosine LR schedule

  • Standard for short distillation runs.
  • 100-step linear warmup + cosine decay to 10% of peak LR over remaining steps.
  • Peak LR: 1e-4. Reasonable for LLM fine-tuning; not swept.

Trainium-specific workaround (XLA path only)

Root cause -- XLA path only. On the torch-neuronx XLA path, Voxtral's end-to-end forward compile fails with [NCC_EVRF029] Operation sort is not supported on trn2. The op being sorted is s32[1176576] -- a 1D int32 index tensor of size 1 * 383 * 3072 -- which is the flattened form of Voxtral's inputs_embeds [1, 383, 3072]. The sort comes from torch_xla's lowering of masked_scatter in VoxtralModel.forward, specifically the splicing of audio embeddings into inputs_embeds at the audio-token positions. It is not the audio encoder itself. The audio encoder (audio_tower + multi_modal_projector) compiles cleanly on Neuron in both XLA and Beta 4 eager modes.

Verified 2026-07-31:

  • XLA model.model.audio_tower(input_features) -> compiles + returns [1, 1500, 1280] correctly.
  • XLA inputs_embeds.masked_scatter(mask, audio_embeds) on [1, 383, 3072] -> fails with sort error.
  • Beta 4 eager inputs_embeds.masked_scatter(...) on the same tensor -> works.
  • Beta 4 eager model(**inputs) end-to-end on neuron:0 -> works, ~1150 ms per forward.

What the workaround does (used by every script here): precompute audio embeddings on CPU once per training clip, splice them into inputs_embeds also on CPU in the DataLoader collate function, and only send the audio-conditioned inputs_embeds to Neuron. The audio encoder + projector never runs on Neuron in the training loop.

  1. precompute_pseudo_labels.py runs model.model.get_audio_features(input_features) on CPU. This produces audio_embeds of shape [375, 3072] per clip.
  2. train_distill_v2.py (and its Beta 4 variant) splices audio_embeds into inputs_embeds at the audio-token positions in the collate function (on CPU, per batch).
  3. The Trainium graph only sees inputs_embeds (already audio-conditioned), not input_features. No audio encoder involved.

Since the audio path is frozen (never trained), this precomputation only runs once per training clip -- it's cheaper than re-running the frozen encoder every training step regardless of whether it would compile.

Beta 4 note: you could rewrite train_distill_v2_beta4.py to compute audio_embeds on-device inside the training loop and skip the precompute step. It works (see the sanity test), but it's slower per step (1150 ms including audio encoder) than the precompute approach (200-590 ms per step, audio-encoder cost paid once offline). The precompute approach is retained in train_distill_v2_beta4.py for parity with the XLA script and for training efficiency.

Observations from the reference run (XLA path, 3000 steps)

Final training summary (2000 LibriSpeech clips, batch=2):

val step  200: top-1 agreement = 12.82%
val step  400: top-1 agreement = 19.23%
val step  600: top-1 agreement = 25.05%
val step  800: top-1 agreement = 31.36%
val step 1000: top-1 agreement = 36.59%
val step 1200: top-1 agreement = 41.62%
val step 1400: top-1 agreement = 49.01%
val step 1600: top-1 agreement = 51.78%
val step 1800: top-1 agreement = 53.75%
val step 2000: top-1 agreement = 57.40%
val step 2200: top-1 agreement = 62.43%
val step 2400: top-1 agreement = 64.40%
val step 2600: top-1 agreement = 61.93%
val step 2800: top-1 agreement = 66.67%  <-- best
val step 3000: top-1 agreement = 65.78%
  • Curve was still climbing at step 2400; plateaued at 62-67% for the last 600 steps. Training data appears to be the bottleneck at this scale.
  • Data-scaling law observed in earlier runs: 4x more training data (500 -> 2000 clips) produced 1.7x higher final val top-1 (39% -> 67%). Scaling to 10k+ LibriSpeech clips (e.g., train-clean-100 = ~28k clips) should push val top-1 into the 75-85% range.
  • Compile bursts happen periodically as new sequence shapes are encountered by the XLA HLO compiler. After the first ~60 s compile the training loop runs at ~130 ms/step steady-state. Save-checkpoint triggers a flush every N steps but is quick.

Beta 4 comparison (500 steps, --compile)

Same hyperparameters, same data, same seed. 500-step run:

Step XLA v2 Beta 4 compiled
100 8.28% 6.61%
200 9.17% 9.76%
300 10.06% 10.16%
400 14.40% 15.88%
500 18.64% 18.34%

Convergence is essentially identical. The two paths produce interchangeable weights.

Per-step wall clock (steady state, batch=2, max_seq_len=512):

Path Compile time Steady-state step
torch-neuronx XLA ~60 s ~130 ms
Beta 4 --compile ~49 s ~200-590 ms (some recompiles on val batches)
Beta 4 eager (no --compile) ~107 s ~3700 ms (28x slower)

XLA is currently the fastest path for this scale. Beta 4 with --compile is 1.5-2x slower total wall time. Beta 4 eager is not competitive.

Recommendation: use train_distill_v2.py (XLA) unless your environment mandates the Beta 4 stack (some SageMaker training clusters, TorchTitan integration, or if you want to try torch.compile fusion on more advanced setups). The Beta 4 script is a drop-in replacement with the same arguments plus --compile.

Known gotchas

  1. First iteration compile is slow (both paths): 60-150 s per new tensor shape encountered. Subsequent iterations hit the cache. Keep max_seq_len constant across all batches for best cache hit rate.
  2. Checkpoint save is expensive: ~30-40 s per save (writes 3.6 GB safetensors + re-loads the full target for state-dict merging via load_state_dict(..., strict=False)). Use --save-every 500 or larger to minimize interruptions.
  3. CPU pseudo-labeling is slow but embarrassingly parallel: ~5-10 s per clip on a single CPU. 2000 clips takes ~2 hours on one instance. If precompute is the bottleneck, run it on a large-core CPU instance (c7i.16xlarge is much faster than trn2's ~48 CPUs).
  4. Voxtral's audio path is byte-identical between student and target, so the .pt files produced by precompute_pseudo_labels.py are reusable across draft architectures. If you train a 6-layer variant later, the precompute step doesn't need to re-run.
  5. Beta 4-specific: the first log-print says step time is very high (e.g., "step 1 | step time 24768ms"). That's the cumulative-average step time carrying the compile cost. Look at the deltas between successive elapsed timestamps to see the true per-step wall time.
  6. Beta 4-specific: torch.compile(dynamic=False) locks shapes. If your batch shapes vary (e.g., variable-length audio), you'll trigger recompiles and lose the fusion advantage. Pad every batch to a single fixed shape.