Skip to content

Phase 3: real FSDP, jit+NamedSharding, measurement, mid-epoch resume - #15

Closed
AshishKumar4 wants to merge 5 commits into
revamp/phase-4-flow-matchingfrom
revamp/phase-3-parallelism
Closed

Phase 3: real FSDP, jit+NamedSharding, measurement, mid-epoch resume#15
AshishKumar4 wants to merge 5 commits into
revamp/phase-4-flow-matchingfrom
revamp/phase-3-parallelism

Conversation

@AshishKumar4

Copy link
Copy Markdown
Owner

Sixth PR of the revamp series — stacked on #14. Merges cleanly onto it (verified).

FSDP is real now

Before this PR the mesh was a single 'data' axis and the train step passed P() for the train state — parameters, EMA and both Adam moments were replicated on every device. I verified that empirically before and after; same inspection, opposite result:

fsdp_size params ≥256 elems sharded optimizer-state leaves sharded
1 (default) 0 / 13 — PartitionSpec() 0 / 26
4 13 / 13PartitionSpec('fsdp',) 26 / 26

The mesh is now ('data','fsdp'), and the parameter spec tree is derived once in generate_states from jax.eval_shape plus a size heuristic (--fsdp_min_param_size); optimizer state and EMA inherit the param specs through tx.init. Zero model-file changes — exactly as designed. fsdp_size=1 reproduces the old replicated behavior.

shard_map is gone

grep -rn shard_map flaxdiff/ returns 0. The train step is jax.jit with in_shardings/out_shardings and donation; GSPMD derives the collectives, so the hand-written pmean, the local_device_index argument and the fold_in device-index RNG hack are all deleted (jax_threefry_partitionable already defaults True on jax 0.9).

Two parity gates guard the migration, both runnable on CPU with simulated devices: single-device vs multi-device loss trajectories agree, and FSDP vs replicated agree.

Measurement, which did not exist at all before

train/step_time_ms, train/samples_per_sec and an MFU estimate now go to wandb; --profile_steps writes a jax.profiler trace; --compilation_cache_dir enables the persistent XLA cache (the biggest single win for restart-heavy TPU workflows).

Async dispatch unblocked

The old loop did if not jnp.isfinite(loss) every step — a Python branch on a device array, forcing a host sync that defeated JAX's async dispatch. is_finite is now an output of the jitted step and the consecutive-bad-step counter stays on device (jnp.where), checked on host only at the logging cadence. Batch H2D moved into a prefetch iterator so transfer overlaps compute, and donate_argnums now donates the state (the large buffer) rather than the batch, which required breaking the best_state aliasing first.

Checkpointing caught up with the sharding

Sharded orbax save/restore with restore_args built from the target sharding tree — replacing a full host gather (np.array on every leaf) that immediately called wait_until_finished(), defeating its own AsyncCheckpointer. Restore onto a different mesh is tested. Grain iterator state is checkpointed too, so resume is finally mid-epoch-correct — previously the data iterator restarted from the top of the epoch on every resume.

Capacity

--remat rematerializes transformer blocks (dots_with_no_batch_dims_saveable) — this sets your maximum trainable model size. --grad_accum_steps adds optax.MultiSteps, tested to hold params still until the accumulation boundary and to survive the sharding heuristic.

Verification

113 passed (from 73), re-run by me independently. tests/test_parallelism.py adds 19 tests covering the sharding heuristic, mesh construction, prefetch ordering/error propagation, both parity gates, FSDP sharding proof, checkpoint roundtrip, cross-mesh restore, gradient accumulation and mid-epoch resume.

Not done: the unified precision policy (deliberately scoped last and left out; the ad-hoc dtype= threading remains).

🤖 Generated with Claude Code

AshishKumar4 and others added 5 commits July 27, 2026 01:47
The training step ran under shard_map with in_specs=(P(), P(), P('data'),
P('data')) - arg 0 fully replicated, so parameters and optimizer state sat on
every device. That was pure DDP; there was no FSDP to speak of, and model size
was capped by one device's memory.

Replace it with jax.jit + NamedSharding over a two-axis ('data','fsdp') mesh
and let GSPMD derive the collectives:

- Parameter layout comes from a shape-based heuristic applied to every leaf of
  the abstract state, so optimizer moments and the EMA copy inherit their
  params' spec through tx.init. No model file declares partitioning.
- The hand-written pmean over gradients is gone. The loss is already a mean
  over the batch-sharded axis, so its gradient carries the all-reduce.
- The per-device fold_in RNG hack is gone with the device-index argument;
  threefry is partitionable, so one key per step is correct.
- donate_argnums now donates the train state instead of the batch, which
  required best_state to stop aliasing a live state - it is a host-side numpy
  tree now, which is what get_best_state() already handed out.

Batches are assembled with jax.make_array_from_process_local_data instead of
the manual split/device_put in form_global_array, and the host-to-device
transfer moved into a two-deep prefetch thread so it overlaps compute. The
dead DataLoaderWithMesh prefetch path is deleted rather than left as a second
way to do the same thing.

Checkpoints now save and restore sharded arrays in place via orbax
restore_args, instead of gathering the whole state onto the host with
get_np_tree and then immediately blocking on wait_until_finished. The on-disk
layout is unchanged, so existing pretrained checkpoints and the inference
loader keep working.

Numerical parity is the gate: single-device, 8-device and 2-way-FSDP runs are
asserted to agree over 20 steps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Activation memory is what caps trainable model size, so make the DiT-family
blocks rematerializable. ModulatedBlock and MMDiTBlock are wrapped through one
helper, with dots_with_no_batch_dims_saveable so the big matmuls' outputs
survive and the recompute stays cheap.

Two things the tests forced out:

- `train` picks a Python branch inside the blocks, so remat traced it and blew
  up on a bool conversion. It has to be static, which means the block call
  sites pass their arguments positionally now.
- The S5 mixer carries complex64 residuals, and saving a residual goes through
  jax.lax.reduce_precision, which rejects complex dtypes. Those blocks get
  policy=None (recompute everything) rather than a silently degraded policy.

Also adds --grad_accum_steps, which wraps the optimizer in optax.MultiSteps,
and --fsdp_size / --fsdp_min_param_size / --profile_steps /
--compilation_cache_dir / --log_every to reach the trainer's new knobs.

Remat is asserted to leave the parameter tree, forward values and gradients
unchanged, so turning it on cannot invalidate a checkpoint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The performance work is only evaluable if the instrumentation is trustworthy,
so it gets tested like the training maths: FLOPs come back positive from the
compiled step, samples/sec and step_time_ms are internally consistent, MFU
scales the right way with time and device count and is skipped rather than
guessed at on hardware with no published peak, the profiler actually writes a
trace, and the compilation cache directory is really configured.

Divergence detection is covered both ways: a run whose loss is always NaN
stops with the consecutive-step count in the message, and a healthy run runs
to completion without tripping it. That check now accumulates on device and is
read at the logging cadence, so it costs no per-step host sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…igration orphaned

Resuming rebuilt the template from self.dataset_state, which is always None on
a fresh trainer, so the iterator position was written to the checkpoint and
then never read back - every resume still restarted the epoch. The template is
built from the checkpoint's own structure now, which also keeps checkpoints
written before iterator tracking (and those from iterators that cannot report
a position) restorable.

Grain reports its position as JSON bytes and tensorstore has no dtype for
that, so the bytes ride along as a uint8 array and are decoded on restore.

Saving is async now, so tests that read a checkpoint back in the same process
have to pass through wait_for_checkpoints(); one of them was racing.

Also removes what the shard_map migration orphaned: shutil in simple_trainer
(its only user was the deleted move_contents_to_subdir), the unused
distributed_training local in the train step, the device-count locals in both
validation loops, and jnp in dataloaders.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@AshishKumar4

Copy link
Copy Markdown
Owner Author

Landed on main as part of the full revamp stack (main fast-forwarded to bc9d06b). GitHub reports no new commits between this head and main, so every commit here is merged; closing as superseded by the stack merge rather than leaving it open.

@AshishKumar4
AshishKumar4 deleted the revamp/phase-3-parallelism branch July 27, 2026 16:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant