fix(sft): drop rows with phantom media tokens; fail fast on fatal rank errors - #3198
fix(sft): drop rows with phantom media tokens; fail fast on fatal rank errors#3198hubert-marek wants to merge 1 commit into
Conversation
…k errors Two fixes for one production failure mode, found root-causing a deterministic "NCCL hang" at the same training step across five 64-GPU runs: 1. A row whose text contains a literal media marker (here: "<image>" inside a tool-call's file-write arguments) tokenizes to the model's image-placeholder id with no pixel data behind it. The packed batch then fails the scatter-time token/feature check (image_tokens = N+1 vs features = N) on whichever rank draws the row. mm_token_type_ids marks only renderer-emitted placeholder runs, so any placeholder id at a type-0 position — or anywhere in a row that produced no multimodal data — is phantom text. SFTDataset._process now drops such rows with a warning naming the example. 2. When a rank did raise, clean_exit's finally block called dist.destroy_process_group() while the other 62 ranks were still blocked in a collective the dying rank would never join. The graceful shutdown hangs in ProcessGroup.shutdown(), the healthy ranks spin at 100% GPU inside NCCL until the collective timeout, and the watchdog then blames a victim collective on a healthy rank — the actual exception is invisible unless the failing rank happens to pass the launcher's rank filter. The fatal path now hard-exits (os._exit) after logging and wandb.finish, so the launcher's failure propagation (torchrun / srun --kill-on-bad-exit) tears the world down in seconds instead of dist_timeout. The clean path still destroys the process group gracefully. Verified against the offending corpus row: the guard drops it (and keeps healthy text-only, single-image, and multi-image rows); before the guard, the row reproducibly wedged an 8-node run at the same step under a fixed data seed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Follow-up on root cause: the guard in this PR is a detector, and the class is better closed one layer up, in the renderer's text encoder.
So this is data being parsed as control — message content can synthesize a reserved token. Every Encoding message text with Not proposing that here, since it touches nine renderers and changes tokenization for any existing corpus containing marker-like text — that needs a maintainer's call on default-on vs opt-in, and on whether checkpoints trained under current behavior matter. Raising it as a draft against the renderers repo separately; this PR stands on its own as defence-in-depth plus the fail-fast fix. For scale: a corrected all-fields sweep of our 13.1M-row corpus found 24 rows carrying literal markers, all in |
The failure this fixes
Five consecutive 64-GPU SFT runs (Nemotron-VL graft, 8×H200, cp=2 ulysses, seq 131k) wedged at the same training step under a fixed data seed: 62 ranks spinning at 100% GPU inside NCCL, two ranks at 0% in
futex_wait, no error in the job log, and afterdist_timeouta watchdog report blaming an FSDP all-gather on a healthy rank. It looked like fabric. It wasn't.The two silent ranks (one CP pair) had hit, inside
model.forward:Their pack contained a text-only SWE trace in which an assistant tool-call writes a file whose docs contain the literal string
<image>({type<image>|bold}, a template-language example). The tokenizer maps that substring to the image-placeholder id — one phantom placeholder with no pixel features behind it, so the packed batch counts N+1 image tokens against N features.clean_exitthen caught the exception and, in itsfinally, calleddist.destroy_process_group()— while the 62 peers were still blocked in a collective the dying pair would never join. The graceful shutdown hangs inProcessGroup.shutdown(), the peers spin to the collective timeout, and the flight-recorder dump describes only victims (the two culprit ranks never reach a collective, so they write no trace). With--local-ranks-filter=0the actual traceback existed only in the per-rank torchrun log of local ranks 2–3.Fix 1 — drop rows with phantom media tokens (
sft/data.py)mm_token_type_idsis built from renderer-emittedPlaceholderRanges only, so a placeholder id at a type-0 position — or anywhere in a row that produced no multimodal data at all — is phantom text, never legitimate.SFTDataset._processnow counts those and drops the row with a warning naming the example:The check is renderer-agnostic (uses the
mm_token_type_id_mapprotocol attribute, inert for text-only renderers) and O(seq_len) per row against a 1–2 element set.Fix 2 — fail fast on fatal rank errors (
utils.py)On the fatal path,
clean_exitno longer attempts a gracefuldestroy_process_group(); it logs, flushes wandb, andos._exit(1). The launcher's failure propagation (torchrun /srun --kill-on-bad-exit) then tears the world down in seconds instead ofdist_timeoutminutes, and the surviving logs point at the rank that actually raised rather than at a victim collective. The clean path is unchanged:wandb.finish()then graceful destroy.This applies to both the sync and async wrappers (the async comment about
sys.exitbeing swallowed by the event loop applied toraise, andos._exitis immune to both).Verification
ruff check/ruff format --checkclean on both files.Corpus census
A full sweep of the 46-subset corpus (13.1M rows) for literal media markers in any message field found 24 flagged rows, all in
tool_callsarguments — the field the previous content-only sanitizer never walked. Rendering all 24 through the guard: 7 are real phantoms (2–11 phantom tokens each, all in the SWE-agent family; the guard drops every one) and 17 are benign (the marker never survives into the token stream on that render path). Every other subset is clean. So the guard costs 7 rows out of 13.1M while removing the entire crash class.