Download docs/impl/sft.md from teawhite/maliang: direct link, hf CLI and curl.
- Browser
- Download file 28.1 kB
-
https://huggingface.co/teawhite/maliang/resolve/main/docs/impl/sft.md
- Command line
-
hf download hf://teawhite/maliang/docs/impl/sft.md
-
curl -L -o sft.md https://huggingface.co/teawhite/maliang/resolve/main/docs/impl/sft.md
sft — bidirectional gc25d teacher on v10 (SPEC §6–§7)
Status 2026-10-22: code done, CPU-tested (30 tests) and smoke-tested with real weights on data/gc25d_v10_mini (4×H200, GPUs 2–5).
A review-fix pass then made resume exact (data position and lineage) and added data-wait timing. See "Review fixes" at the end.
The full run has not been started. It needs data/gc25d_v10 (full prep), and its command is at the end.
What
| file | role |
|---|---|
code/gc25d/dataset.py |
GcLatentDataset (latents + parts ids + text table + the three dropouts + world-anchored UNKNOWN masking) and gc_collate |
code/gc25d/train.py |
fork of ActionRoPE actionrope/train.py (same section order): GcTrainModule, sem_param_groups, warmup_lambda, run_validation (cond / uncond / null-text), export through gcdistill.gc_dit.save_gc + check_gc_ckpt, rotating save_state, resume / warm start |
scripts/train_sft.sh |
launcher: env vars → accelerate yaml (8×GA2 and 8×GA1 use the existing files, other combinations are generated as configs/accelerate_zero2_<N>gpu_ga<G>.yaml), tee -a $OUT/train.log, refuses a second job on the same OUT |
code/gc25d/sample.py |
offline eval: TI2V sampling (infer.sample + model.gc_forward) on val clips → mp4 GT | generated | cond (parts coloured) + per-frame PSNR/SSIM json + metrics.json |
code/gc25d/tests/test_dataset.py |
15 tests: contract, split/drop/limit, val never drops, ids vocab, dropout rates/independence/determinism, masking (world-anchored, characters untouched, coverage, NaN ⇒ masked, speed), collate, real mini clip |
code/gc25d/tests/test_train.py |
15 tests: tiny WanModel forward/backward through GcTrainModule, manual loss + frame-0 exclusion, sem_rms grows from 0, param groups + warmup, CLI defaults = SPEC §7, val variants, export keys/metadata, lineage metadata, epoch_steps = ceil, resume_position (exact / epoch end / geometry changed / legacy), init_ckpt_of_state, CSV header rotation, and real accelerate on CPU: the seedable-sampler permutation depends only on (seed, epoch), skip_first_batches continues the uninterrupted order, and the sync pattern is [F,T,F,T,T] |
Nothing under code/arope/ was touched. model.py, capsules.py and gcdistill/gc_dit.py are unchanged; they are only imported.
How (conventions that matter)
Dataset (dataset.py)
- Sample =
input_latentsbf16 [48,21,30,52],contextbf16 [21,512,4096],context_idsint64 [21],sem_idsuint8 [81,480,832],clip_id,dropped = {"text": bool, "cond": bool, "mask": float}. Heremaskis the fraction of all 81×480×832 pixels that the mask set to 255. key_to_id = {k: i for i, k in enumerate(sorted(text_table))}. This equalsinfer._KEY_TO_ID(tested).- Rows come from
index/clips.jsonlfiltered bysplit. Rows withdrop=trueare excluded unlessinclude_dropped=True.limitshuffles withRandom(seed), keeps the firstlimitrows and sorts them, asAropeLatentDatasetdoes. The val split forces all three dropout probabilities to 0. vocab="ids"⇒model.collapse_partsafter masking.- RNG: each sample draws from
random.Random(f"gc25d:{seed}:{epoch}:{index}"). It always draws three uniforms (text, cond, mask) in that order, then the mask parameters. Results are identical for a given (seed, epoch, index), whatever the worker count or rank. The trainer callstrain_ds.set_epoch(epoch)itself before every epoch. With the seedable sampler, accelerate'sDataLoaderShard.set_epochonly reaches the sampler and never the dataset.persistent_workers=Falsebecause persistent workers would keep a stale epoch. - Dropouts, as in SPEC §7: text p=0.1 replaces all 21 cells with
NULL_PROMPT. cond p=0.05 sets the ids to all 255 (the npz is not even read), independently of text. mask p=0.5 applies only when cond was not dropped. - World-anchored UNKNOWN mask (SPEC §7,
apply_unknown_mask):- 1 half-plane with p .7, 2 with p .3. θ ~ U[0,2π) in world XY and q ~ U(0.05, 0.40).
- The ground plane is Z =
meta.ground_z[0]. If that value is NaN the code falls back to the nanmedian, and if there is none it skips the mask. - Ground points come from
capsules.ground_pointson the 4×4 pooled grid, then nearest-upsampled ×4.ground_pointsonly accepts integer dst pixels and the geometric cell centre (4j+1.5, 4i+1.5) falls between pixels, so cell (i, j) uses dst pixel (4j+2, 4i+2). That is half a dst pixel, under 1 source pixel. - τ = the (1−q) quantile of the frame-0 static pixels' projections onto θ, weighted per pixel. Every frame then sets static pixels (raw ≤ 6) to 255 when their ground point is beyond any plane or is NaN. Characters (10..21) are never touched.
- Cost is ≈0.27–0.37 s per masked sample (81 frames × 25k rays). An unmasked sample takes 0.09 s. Training uses 4 workers per rank.
Trainer (train.py)
The section order follows upstream (args / model / export+log / validation / main), so diff -u ref/arope_git/ActionRoPE/actionrope/train.py code/gc25d/train.py is readable. These parts are kept verbatim:
Accelerator(gradient_accumulation_steps)andset_seed(device_specific=True).- The DS overrides:
--grad_clipis written intodeepspeed_config.gradient_clipping, and a GA mismatch with the yaml ⇒ SystemExit. LambdaLRis stepped manually, and only onsync_gradients.- The per-rank
cpu_gentimesteptid ~ U{0..999}(seed·1000 + rank + start_step). - The
accumulateblock,dit.freqsmoved to the GPU,config.json/done.json, and the CSV + tensorboard logs.
Changes from upstream:
GcTrainModule.forward:x_t = add_noise, then frame 0 is replaced by clean x0, thenmodel.gc_forward(..., first_frame_cond, context_ids, grad ckpt when training, aux). Frame 0 is dropped from the loss, andloss = MSE × training_weight(t). The returned stats are{mse, timestep, sem_rms, x_rms}.Optimizer:
AdamW(sem_param_groups(module, lr, cond_lr, wd))= [all DiT params at--lr,dit.gc_sem_cond.*at--cond_lr]. The betas stay at the torch defaults (0.9, 0.999). The warmup factor multiplies both groups.sem_param_groupstakes aprefix(defaultdit.gc_sem_cond.) so the distill runners can reuse it.Validation: fixed
val_nval clips (limit+ seed) ×--val_timesteps× fixed noise. Each (clip, t) runs three forwards with the same noise:- cond: true ids and true text;
- uncond: ids all 255;
- nulltext:
NULL_PROMPT× 21.
val_log.csvholdsstep, timestep, mse, mse_uncond, mse_nulltext, gap_uncond, gap_nulltext, n, and tensorboard getsval/*_t{t}andval/*_mean.Export:
gcdistill.gc_dit.save_gcwrites 831 BF16 keys with thedit.prefix stripped, through.tmp+ rename and chmod 644. It is followed bycheck_gc_ckptand a vocab check. Metadata =GC_META(arm=gc25d, format=gc25d, sem_cond=gc_sem_cond, mask_channel=False) plusvocab, data=v10, data_root, step, stage=sft_bidirectional, cond_type=ids, cond_key=ids, text_dropout, cond_dropout, mask_p, lr, cond_lr, weight_decay, warmup_steps, first_frame_cond, seed, init_ckpt, model_dir, trainer, all as strings.save_state=distill.runlib.save_state_rotating: two slots plus thestatesymlink.trainer_state.json={step, arm, vocab, init_ckpt, epoch, epoch_micro, num_processes, grad_accum, batch_size, n_micro, n_train, seed}. Hereepoch_microis the number of micro-batches this rank has consumed inepoch, andn_microislen(dataloader)per rank.Data order:
Accelerator(dataloader_config=DataLoaderConfiguration(use_seedable_sampler=True, data_seed=--seed)). accelerate replaces the RandomSampler withSeedableRandomSampler, so each epoch's permutation is a pure function of (seed, epoch) and is the same on every rank before sharding. An explicitdata_seedis needed becauseset_seed(device_specific=True)gives each rank a different torch seed. Upstream instead uses the sampler generator's running state, whichload_statedoes not save (accelerate only saves sampler state for IterableDatasets), so upstream cannot resume mid-epoch.Steps per epoch =
epoch_steps(len(dataloader), GA)= ceil. accelerate forces a sync at the end of the dataloader, so a tail shorter than GA is still one step (mini 4 GPUs: 25 micro ⇒ 13 steps; tested against real accelerate:[F,T,F,T,T]for 5 micro at GA 2).Resume:
--resume <dir>:load_state, fast-forward the LR schedule and reseedcpu_gen(seed·1000 + rank + start_step, as upstream). A vocab mismatch ⇒ SystemExit. The data position continues exactly (resume_position):- If the stored geometry (
num_processes, grad_accum, batch_size, n_micro, n_train, seed) matches, the trainer resumes in the storedepochand skips its firstepoch_micromicro-batches withaccelerator.skip_first_batches. The skip happens in the batch sampler, so skipped clips are never loaded. Every later (clip, text/cond/mask draw) is then the same as in an uninterrupted run.epoch_microis a multiple of GA (or the whole epoch), so the accumulation boundaries do not move. If the save fell on the epoch's last micro-batch, the run starts the next epoch. - If the geometry changed, the rest of the current epoch is abandoned and the run starts the next epoch from its beginning. That gives a new permutation and new draws, so no sample is replayed with identical draws.
- A legacy
trainer_state.jsonwith only{step, arm, vocab}derivesepoch = step // ceil-steps-per-epochand skips the rest of the way into it. - What is still not bit-exact:
t(cpu_genis reseeded, as upstream) and DataLoader worker seeds. The noise ε comes from the CUDA RNG, whichload_staterestores.
- If the stored geometry (
--resume X.safetensors: warm start throughmodel.load_gc_weights. A gc25d export must have the same vocab. A plain Wan export with nogc_sem_cond.*keys gets a zero-initialised cond.- Lineage metadata:
init_ckptis the warm-start.safetensors, or""for base Wan with a zero-initialised cond. A dir resume never changes it: the value is carried intrainer_state.json, and a legacy state falls back to the run'sconfig.json. The resume is recorded separately asresumed_from(realpath of the slot) andresumed_step.
train_log.csvholdsstep, loss, mse, timestep, lr, cond_lr, grad_norm, sem_rms, x_rms, text_drop, cond_drop, mask_frac, step_time, data_time, mem_gb, epoch, clips:step_timeis the wall time of the whole optimizer step, including waiting for data.data_timeis the part spent waiting for the DataLoader, which includes the worker re-fork at each epoch start. Validation and save time are excluded.clipslists rank 0's clip_ids for the step's micro-batches, joined with;.done.jsonhass_per_step_mean(wall time),data_s_per_step_meananddata_pos.- If an existing CSV in OUT has a different header (code upgrade plus resume), it is renamed to
*.old<N>.csvfirst, so rows never land under the wrong header. - A non-finite reduced loss ⇒ SystemExit.
Default
--num_workersis 4 (upstream 2) because of the mask cost.--save_stateand--val_unconddefault on (SPEC §7).A train/val leak check (shared clip or shared run) ⇒ SystemExit.
Launcher (scripts/train_sft.sh)
Environment variables:
OUT(required, must resolve under$M/outputs/),NGPU(8),GPUS(default 0..NGPU−1; NGPU must match),GA(2),MAX_STEPS(20000),RESUME,VOCAB(parts),DATA($M/data/gc25d_v10),PORT(default: a free port),EXTRA, andALLOW_EXISTING=1to start from 0 in an OUT that already has checkpoints.Exports
DIFFSYNTH_SKIP_DOWNLOAD,PYTHONPATH,HF_HOME, and putsTMPDIR,TORCHINDUCTOR_CACHE_DIR,TRITON_CACHE_DIR,TORCH_EXTENSIONS_DIR,TORCH_HOMEandXDG_CACHE_HOMEall under$M/.tmp.Refuses to start (exit 3) in three cases:
- the
flockon$OUT/.train_sft.lockis taken; - a
train.py … --output $OUTprocess exists; - OUT already holds
step-*.safetensorsorstatebut noRESUMEwas given.
It also refuses when
$DATA/index/clips.jsonlis missing, and only warns whenREADYis missing.- the
Generated configs:
configs/accelerate_zero2_4gpu_ga2.yaml, made during the smoke fromaccelerate_zero2_ga2.yamlby changingnum_processesandgradient_accumulation_stepsonly.
sample.py
- Supports
--ckpt X|--base [--vocab],--data_root,--raw_root,--clip_ids a,b | @file|--val_n N(val, drop=false, round-robin over runs),--steps 50 --shift 5 --cfg 1 --sem_cfg 1 --sem_mode cond|unknown --seed 0 --out_dir,--no_ssimand--no_recon_ceiling. - It does not use
infer.load_models, which installs the defaultidsvocab first, so a parts checkpoint would raise. Instead it callsGM.load_dit+GM.load_gc_weights(the vocab comes from the checkpoint). - GT = raw tar jpgs
<raw_root>/<subset>/<scene>/<run>read withinfer.read_raw_frames, thengeometry.resample_rgb. - The ids panel shows what the model received. Static classes use
geometry.PALETTE; player parts are a magenta/violet family with a cyan weapon, boss parts a red/orange family with a blue weapon; 255 is hatched.
Tests (CPU)
cd /opt/dlami/nvme/zhiyangdeng/maliang
DIFFSYNTH_SKIP_DOWNLOAD=True PYTHONPATH=$PWD/code/gc25d:$PWD/code/arope TMPDIR=$PWD/.tmp OMP_NUM_THREADS=8 \
.venv/bin/python -m pytest -q code/gc25d/tests/test_dataset.py code/gc25d/tests/test_train.py # 30 passed, ~31 s
What the masking tests check:
- World anchoring. Synthetic UE-convention cameras (orthographic and reversed-Z perspective) translate over 81 frames. World ground points more than 60 cm from a plane boundary are projected into every 5th frame. At static pixels, the mask equals the world half-plane test exactly. More than 500 points are seen in at least 3 frames.
- Characters. Pixels 10..21 never change, and only static pixels become 255.
- Coverage. With one plane, the frame-0 static fraction is within 0.01 of q. With two planes it lies in [max q, Σq]. Over 40 seeds, 2-plane draws stay within the expected count.
- NaN. Sky rays from a shallow perspective camera are masked.
Smoke (real weights, outputs/_smoke/sft/)
Setup: 4 procs on GPUs 2–5, GA 2 ⇒ global batch 8. I chose GA 2 over GA 4 (batch 16) so that one optimizer step does the same per-GPU
work (2 micro-batches) as the full 8-GPU GA2 run, which makes the measured s/step the full-run s/step. Data: data/gc25d_v10_mini
(100 train / 100 val clips).
OUT=outputs/_smoke/sft NGPU=4 GPUS=2,3,4,5 GA=2 MAX_STEPS=10 DATA=data/gc25d_v10_mini \
EXTRA="--save_every 5 --val_every 5 --val_n 4 --warmup_steps 2" bash scripts/train_sft.sh
OUT=outputs/_smoke/sft NGPU=4 GPUS=2,3,4,5 GA=2 MAX_STEPS=13 RESUME=outputs/_smoke/sft/state DATA=data/gc25d_v10_mini \
EXTRA="--save_every 5 --val_every 5 --val_n 4 --warmup_steps 2" bash scripts/train_sft.sh
| check | result |
|---|---|
| loss finite | steps 1–13: 0.05–0.42 (× training_weight). grad_norm 0.16–0.78, with one 6.07 spike in a repeat run that did not reappear across 3 more epoch boundaries |
sem_rms grows from 0 |
step 1: 0.0, step 2: 3.8e-4, step 3: 2.3e-3, step 10: 3.9e-3 (x_rms ≈ 0.35, so ≈1 %). cond lr warmup 5e-5 → 1e-4 |
| val rows | val_log.csv: steps 5 and 10 × t ∈ {201.3, 500.6, 799.7}, n=4. At step 10: mse 0.377/0.176/0.149, gap_uncond +0.0013/+0.0005/−0.0045, gap_nulltext +0.0022/+0.0016/+0.0010. As expected after 10 steps, the cond gap is ~0 |
| export | step-5, step-10 and step-13.safetensors: each 831 tensors, all BF16, 10.02 GB, check_gc_ckpt OK, ckpt_vocab = parts, metadata as above |
| save_state | state_A (step 5) and state_B (step 10), 66 GB each on 4 GPUs. The state symlink rotated correctly |
| resume (pre-fix code) | --resume …/state (→ state_B, step 10) continued at 11–13 with the LR at its plateau and sem_rms continuous (3.3e-3). It exported step-13 and rotated state → state_A. Review found that steps 11–13 replayed the exact samples of steps 1–3 (same clips and the same mask_frac 0.04015…/text drop/0.08591…). This is fixed; see "Review fixes" |
| second launch on the same OUT | refused: 已有训练在跑(锁 … 被占用), exit 3 |
single process, no DS (python train.py --limit 8 --max_steps 2) |
OK: 831-key export, peak 46.9 GB (plain bf16 AdamW, numbers not representative) |
| s/step | 2.40 s of compute (40-step run with no val/saves, GPUs uncontended) ≈ 1.2 s per micro-batch. This matches ActionRoPE's 1.20 s/step at GA 1. The first step takes 7.1 s. Each epoch ends with one 1-micro step (~1.35 s): per-rank epoch length 25 is odd, and accelerate syncs at the end of the dataloader. Since the fix, step_time is wall time including data wait: 2.41–2.45 s steady with 0.018 s data wait, and 2.51 s mean over steps 3–16 including the epoch-boundary steps (see the fix smoke) |
| peak memory | 35.2 GB (main) / 35.3 GB (max) per GPU on 4 GPUs. On 8 GPUs ZeRO-2 shards the optimizer 8-way: expect ≈ 25–28 GB (ActionRoPE measured 32.8 GB on 4 and 22.7 GB on 8) |
The first smoke ran while another project's (PCE) job held ~39 GB at 100 % utilisation on all 8 GPUs. The step time was the same once it had left.
UNKNOWN mask statistics on mini train with default probabilities (53/100 samples masked):
| measure | mean | median | p90 |
|---|---|---|---|
| frame-0 static fraction masked | 0.31 | ||
| whole-clip pixel fraction | 0.39 | 0.80 | |
| frame-80 static fraction | 0.47 | 1.00 | |
| share of frames > 90 % masked | 0.126 | 0 | 0.54 |
sample.py on 1 val clip (02_Autumn_Woodland_Shrine__P9_golden_s2909__002400), 8 steps:
| model | PSNR frames 1–80 | SSIM | frame-0 PSNR | VAE ceiling | sampling | total |
|---|---|---|---|---|---|---|
--ckpt outputs/_smoke/sft/step-10.safetensors |
15.46 dB | 0.571 | 36.86 | 31.54 | 3.6 s | 28 s |
--base |
15.01 dB | 0.540 | 36.86 | 31.54 | 3.4 s | 24 s |
Outputs are in outputs/_smoke/sft/sample/{step-10_s8,base_s8}/. I removed the timing run's and the 1-proc run's checkpoints, and the stale state_B.
outputs/_smoke/sft/ keeps step-{5,10,13}.safetensors (real-weight 831-key parts checkpoints; the distill smokes can use them) and state_A.
Full run
cd /opt/dlami/nvme/zhiyangdeng/maliang && OUT=/opt/dlami/nvme/zhiyangdeng/maliang/outputs/sft_v10 NGPU=8 GA=2 MAX_STEPS=20000 VOCAB=parts \
DATA=/opt/dlami/nvme/zhiyangdeng/maliang/data/gc25d_v10 nohup bash scripts/train_sft.sh \
> /opt/dlami/nvme/zhiyangdeng/maliang/outputs/sft_v10.launcher.out 2>&1 &
# crash → same command plus RESUME=/opt/dlami/nvme/zhiyangdeng/maliang/outputs/sft_v10/state
This uses the SPEC §7 defaults throughout:
- lr 1e-5, cond_lr 1e-4, wd 0.01, warmup 500, clip 1.0;
- 8 × micro 1 × GA 2 = 16;
- save_every 1000 plus rotating state, val_every 500 with val_n 16 at t 200/500/800 in all three variants;
- dropouts 0.1 / 0.05 / 0.5, grad ckpt on.
Estimate: 20,000 × 2.43 s wall ≈ 13.5 h of training. Data wait is ~0.02 s/step, plus a ~1.1 s worker re-fork once per 747-step epoch, which is negligible. Add 20 exports (10 GB, <30 s each), 20 state saves (~66 GB per slot, <1 min each) and 40
validations (16 clips × 3 t × 3 variants over 8 ranks, ~10 s each), for ≈ 14 h in total. Disk: 20 × 10 GB step files + 2 × ~66 GB state ≈ 330 GB on NVMe.
Under the SPEC §5 split (10 val runs = 500 clips), computed from the raw per-run clips_81.json files and the buried list:
- 12,800 clips = 12,300 train + 500 val. 345 of the 348 buried clips are in train runs and 3 in val runs.
- That leaves 11,955 train clips: 11,955 // 8 = 1,494 micro-batches per rank (even), 747 steps per epoch, ≈ 26.8 epochs in 20k steps.
- An earlier version of this doc used the investigator's 20-run split, which gives 11,452 train, 715 steps per epoch and 28 epochs. That
split is not the SPEC split. Re-check against
data/gc25d_v10/index/clips.jsonlonce prep finishes:config.json→dataset.geometry/steps_per_epochprints the real values.
Watch:
train/sem_rmsandtrain/sem_over_xshould keep rising.val/gap_uncond_meanshould become clearly positive, which shows the condition is used.gap_nulltextmeasures the text.
Open issues / notes
LEAD DECISION NEEDED BEFORE THE FULL RUN: mask coverage grows over the clip. Both reviewers flagged this, and it is not a code defect: the code follows SPEC §7 to the letter, with τ calibrated on frame 0 only. The half-planes are fixed in the world and the camera travels several metres in 81 frames, so the mask grows. Evidence on mini:
- My run (53/100 masked): frame 80 averages 47 % masked and 12.6 % of frames are > 90 % masked.
- Reviewer A (100 train clips, mask forced on), last-cell static coverage: ortho mean 0.35, p90 0.96; perspective mean 0.47, p90 1.00. 18–20 % of masked clips end more than 90 % masked.
- Reviewer B (50 perspective + 50 ortho clips × 3 seeds), whole-clip static coverage: mean 0.40 (perspective) / 0.37 (ortho), p90 0.86 / 0.82. Frame 80 has p90 = 1.0. 14.0 % / 11.7 % of masked frames are > 90 % masked, and 12–16 % are < 1 % masked because the mask slid off screen.
- Overall about 6 % of all training frames have no static condition, on top of the 5 % cond dropout.
At deploy, canvases are 8–40 % unseen, and the auto-demo keeps black ≤ 10 % in almost every frame (
ref/gc25d-live-demo/README.md:36, :102–103). Training is therefore much heavier than deploy in the tail.Options, all keeping the half-planes world-anchored and so cell-local / causal-safe:
- (a) calibrate q on the mean frame or the last frame instead of frame 0;
- (b) cap per-frame coverage by clamping τ per frame, e.g. τ_f = max(τ, the (1−0.4) quantile of frame f's static projections);
- (c) keep the current behaviour and record the decision in SPEC §7.
This fix pass did not change the behaviour because SPEC §7 is authoritative. Options (a) and (b) are each a few lines in
dataset.sample_planes/planes_maskand need a matching update to the coverage test.A half-weight tail step (one micro-batch) happens only when the per-rank epoch length is odd. That is true on mini with 4 GPUs (25), but not for the full run under the SPEC split (1,494 per rank, even). When it does happen, accelerate passes the boundary to DeepSpeed (
set_gradient_accumulation_boundary) and the step count uses the ceiling (see Trainer), so the bookkeeping is consistent.The val subset is upstream's
limit(seeded shuffle), so it is not run-balanced. sample.py's--val_nis run-balanced.Warm start from an
ids-vocab checkpoint into apartsrun is not supported (load_gc_weightsrefuses the vocab mismatch). It would need a proj_in channel expansion.sample.pyloads the DiT straight onto the GPU. Load takes 6–8 s and an 8-step clip with VAE decode, recon ceiling and SSIM takes ~25 s.
Review fixes (2026-10-22, fixer pass)
Two independent reviewers produced 9 findings, which reduce to 5 distinct issues. Each was verified against the code, the logs and SPEC before any change.
Fixed
- Resume replayed identical samples, and the epoch counter used floor (
train.py). This was the only finding with code consequences.- Verified: in the smoke, steps 11/12/13 reproduced steps 1/2/3 exactly, and the timing run had 13 steps per epoch while the log printed 12.
- Root cause: accelerate does not save the sampler generator for map-style datasets, the epoch was derived as
start_step // floor, and the dropout RNG is keyed on (seed, epoch, idx). - Fix:
SeedableRandomSamplerwithdata_seed, the data position stored intrainer_state.json,skip_first_batcheson resume,epoch_steps= ceil, and a geometry check that falls back to the next epoch. Details are under Trainer → Resume.
init_ckptlineage after a dir resume. Verified: step-13 recordedinit_ckpt=…/sft/state, a rotating symlink. Nowinit_ckptis only ever a warm-start.safetensorsor"". It is carried throughtrainer_state.json(legacy states fall back toconfig.json), and the resume is recorded separately asresumed_from(slot realpath) andresumed_step.step_timeexcluded data wait. Verified: the timing run had 106.6 s of wall time against Σstep_time = 100.9 s.step_timeis now the wall time from the end of the previous step, with validation and save time excluded.- The new
data_timecolumn records the data-wait part, anddone.jsonhasdata_s_per_step_mean. clips(rank-0 clip_ids) was added so data continuity can be checked from the log.- Because the columns changed, an old-header CSV in a resumed OUT is renamed first.
- Full-run numbers used the 20-run split (this doc). Verified: SPEC §5 gives 11,955 train clips, 1,494 per rank, 747 steps per epoch and ≈26.8 epochs. The half-weight-step note is now scoped to odd per-rank epochs, which the full run does not have.
Not changed (lead decision) 5. Mask coverage grows over the clip (both reviewers, "design risk, not a code defect"). The code implements SPEC §7 literally, and SPEC is authoritative, so I kept the behaviour. The evidence and options (a)/(b)/(c) are under Open issues and need a decision before the full run.
Fix smoke (4 procs on GPUs 2–5, GA 2, mini, outputs/_smoke/sft/fix_A, fix_B; comparison in fix_tools/compare_resume.{py,out}):
# A: warm start from step-13.safetensors, 16 steps uninterrupted (crosses the 13-step epoch boundary), save/val at 8 and 16
OUT=outputs/_smoke/sft/fix_A RESUME=outputs/_smoke/sft/step-13.safetensors NGPU=4 GPUS=2,3,4,5 GA=2 MAX_STEPS=16 DATA=data/gc25d_v10_mini \
EXTRA="--save_every 8 --val_every 8 --val_n 4 --warmup_steps 2" bash scripts/train_sft.sh
# B: resume from A's step-8 slot into a separate OUT, run to 16
OUT=outputs/_smoke/sft/fix_B RESUME=outputs/_smoke/sft/fix_A/state_A … (same) bash scripts/train_sft.sh
| check | result |
|---|---|
| warm start | step-13.safetensors loaded (831 tensors, has_sem_cond). Step 1 has sem_rms 2.95e-3, not 0 |
| ceil steps/epoch | log: 25 个 micro batch = 13 步. Step 13 is the 1-micro tail (one clip in clips), and epoch 1 starts at step 14 |
| state position | A's state_A holds {step 8, epoch 0, epoch_micro 16, …} and state_B holds {step 16, epoch 1, epoch_micro 6} |
| exact resume | B: 数据从 epoch 0 的第 16 个 micro batch 接上(精确). Steps 9–16 of B equal A row by row in clips, epoch, text_drop, cond_drop and mask_frac. No (clip, epoch) pair from steps 1–8 reappears. Loss differs because t is reseeded, as upstream does |
| lineage | A's step-8 and step-16 have init_ckpt=…/sft/step-13.safetensors, resumed_from="". B's step-16 has the same init_ckpt, resumed_from=…/fix_A/state_A, resumed_step=8 |
| exports | 831 BF16, check_gc_ckpt OK, vocab parts (A: step 8 and 16; B: step 16) |
| timing (A) | steady 2.41–2.45 s/step wall, data 0.018 s; tail step 13: 1.95 s (data 0.30); epoch start 14: 3.93 s (data 1.11, worker re-fork); mean over steps 3–16 2.51 s (data 0.12). Peak memory 35.2 / 35.3 GB |
| timing (B) | 6.4 s/step: another job's 8 processes (~9.5 GB, up to 100 % utilisation on all 8 GPUs) started during the smoke. I did not touch them. The numbers above are run A's |
After checking, I deleted the fix smoke's state slots and exports (≈225 GB) and kept the logs, configs and comparison output. The older
outputs/_smoke/sft/step-{5,10,13}.safetensors and state_A (legacy trainer_state = {step, arm, vocab}) are untouched. Resuming that legacy
state uses the "derived from step" path (step 13 ⇒ epoch 1, skip 0).