# 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_gpu_ga.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 `: `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.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 `///` 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).