maliang / docs /impl /sft.md
teawhite's picture
Add files using upload-large-folder tool
51ac7cc verified
|
Raw History Blame Contribute Delete
28.1 kB

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_latents bf16 [48,21,30,52], context bf16 [21,512,4096], context_ids int64 [21], sem_ids uint8 [81,480,832], clip_id, dropped = {"text": bool, "cond": bool, "mask": float}. Here mask is 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 equals infer._KEY_TO_ID (tested).
  • Rows come from index/clips.jsonl filtered by split. Rows with drop=true are excluded unless include_dropped=True. limit shuffles with Random(seed), keeps the first limit rows and sorts them, as AropeLatentDataset does. The val split forces all three dropout probabilities to 0.
  • vocab="ids" ⇒ model.collapse_parts after 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 calls train_ds.set_epoch(epoch) itself before every epoch. With the seedable sampler, accelerate's DataLoaderShard.set_epoch only reaches the sampler and never the dataset. persistent_workers=False because 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_points on the 4×4 pooled grid, then nearest-upsampled ×4. ground_points only 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) and set_seed(device_specific=True).
  • The DS overrides: --grad_clip is written into deepspeed_config.gradient_clipping, and a GA mismatch with the yaml ⇒ SystemExit.
  • LambdaLR is stepped manually, and only on sync_gradients.
  • The per-rank cpu_gen timestep tid ~ U{0..999} (seed·1000 + rank + start_step).
  • The accumulate block, dit.freqs moved 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, then model.gc_forward(..., first_frame_cond, context_ids, grad ckpt when training, aux). Frame 0 is dropped from the loss, and loss = 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_groups takes a prefix (default dit.gc_sem_cond.) so the distill runners can reuse it.

  • Validation: fixed val_n val 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.csv holds step, timestep, mse, mse_uncond, mse_nulltext, gap_uncond, gap_nulltext, n, and tensorboard gets val/*_t{t} and val/*_mean.

  • Export: gcdistill.gc_dit.save_gc writes 831 BF16 keys with the dit. prefix stripped, through .tmp + rename and chmod 644. It is followed by check_gc_ckpt and a vocab check. Metadata = GC_META (arm=gc25d, format=gc25d, sem_cond=gc_sem_cond, mask_channel=False) plus vocab, 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 the state symlink. trainer_state.json = {step, arm, vocab, init_ckpt, epoch, epoch_micro, num_processes, grad_accum, batch_size, n_micro, n_train, seed}. Here epoch_micro is the number of micro-batches this rank has consumed in epoch, and n_micro is len(dataloader) per rank.

  • Data order: Accelerator(dataloader_config=DataLoaderConfiguration(use_seedable_sampler=True, data_seed=--seed)). accelerate replaces the RandomSampler with SeedableRandomSampler, so each epoch's permutation is a pure function of (seed, epoch) and is the same on every rank before sharding. An explicit data_seed is needed because set_seed(device_specific=True) gives each rank a different torch seed. Upstream instead uses the sampler generator's running state, which load_state does 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 reseed cpu_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 stored epoch and skips its first epoch_micro micro-batches with accelerator.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_micro is 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.json with only {step, arm, vocab} derives epoch = step // ceil-steps-per-epoch and skips the rest of the way into it.
      • What is still not bit-exact: t (cpu_gen is reseeded, as upstream) and DataLoader worker seeds. The noise ε comes from the CUDA RNG, which load_state restores.
    • --resume X.safetensors: warm start through model.load_gc_weights. A gc25d export must have the same vocab. A plain Wan export with no gc_sem_cond.* keys gets a zero-initialised cond.
    • Lineage metadata: init_ckpt is the warm-start .safetensors, or "" for base Wan with a zero-initialised cond. A dir resume never changes it: the value is carried in trainer_state.json, and a legacy state falls back to the run's config.json. The resume is recorded separately as resumed_from (realpath of the slot) and resumed_step.
  • train_log.csv holds step, 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_time is the wall time of the whole optimizer step, including waiting for data. data_time is the part spent waiting for the DataLoader, which includes the worker re-fork at each epoch start. Validation and save time are excluded.
    • clips lists rank 0's clip_ids for the step's micro-batches, joined with ;.
    • done.json has s_per_step_mean (wall time), data_s_per_step_mean and data_pos.
    • If an existing CSV in OUT has a different header (code upgrade plus resume), it is renamed to *.old<N>.csv first, so rows never land under the wrong header.
    • A non-finite reduced loss ⇒ SystemExit.
  • Default --num_workers is 4 (upstream 2) because of the mask cost. --save_state and --val_uncond default 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, and ALLOW_EXISTING=1 to start from 0 in an OUT that already has checkpoints.

  • Exports DIFFSYNTH_SKIP_DOWNLOAD, PYTHONPATH, HF_HOME, and puts TMPDIR, TORCHINDUCTOR_CACHE_DIR, TRITON_CACHE_DIR, TORCH_EXTENSIONS_DIR, TORCH_HOME and XDG_CACHE_HOME all under $M/.tmp.

  • Refuses to start (exit 3) in three cases:

    • the flock on $OUT/.train_sft.lock is taken;
    • a train.py … --output $OUT process exists;
    • OUT already holds step-*.safetensors or state but no RESUME was given.

    It also refuses when $DATA/index/clips.jsonl is missing, and only warns when READY is missing.

  • Generated configs: configs/accelerate_zero2_4gpu_ga2.yaml, made during the smoke from accelerate_zero2_ga2.yaml by changing num_processes and gradient_accumulation_steps only.

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_ssim and --no_recon_ceiling.
  • It does not use infer.load_models, which installs the default ids vocab first, so a parts checkpoint would raise. Instead it calls GM.load_dit + GM.load_gc_weights (the vocab comes from the checkpoint).
  • GT = raw tar jpgs <raw_root>/<subset>/<scene>/<run> read with infer.read_raw_frames, then geometry.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.jsonl once prep finishes: config.json → dataset.geometry / steps_per_epoch prints the real values.

Watch:

  • train/sem_rms and train/sem_over_x should keep rising.
  • val/gap_uncond_mean should become clearly positive, which shows the condition is used. gap_nulltext measures 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_mask and 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_n is run-balanced.

  • Warm start from an ids-vocab checkpoint into a parts run is not supported (load_gc_weights refuses the vocab mismatch). It would need a proj_in channel expansion.

  • sample.py loads 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

  1. 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: SeedableRandomSampler with data_seed, the data position stored in trainer_state.json, skip_first_batches on resume, epoch_steps = ceil, and a geometry check that falls back to the next epoch. Details are under Trainer → Resume.
  2. init_ckpt lineage after a dir resume. Verified: step-13 recorded init_ckpt=…/sft/state, a rotating symlink. Now init_ckpt is only ever a warm-start .safetensors or "". It is carried through trainer_state.json (legacy states fall back to config.json), and the resume is recorded separately as resumed_from (slot realpath) and resumed_step.
  3. step_time excluded data wait. Verified: the timing run had 106.6 s of wall time against Σstep_time = 100.9 s.
    • step_time is now the wall time from the end of the previous step, with validation and save time excluded.
    • The new data_time column records the data-wait part, and done.json has data_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.
  4. 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).