ezpz.examples.fsdp_tpโ๏ธ
ezpz/examples/fsdp_tp.py
2D tensor/sequence parallel + FSDP training demo on a Llama-style model.
Sam Foreman 2025-09-08
Modified from: https://pytorch.org/tutorials/intermediate/TP_tutorial.html
This is the script to test 2D Parallel which combines Tensor/Sequence parallel with Fully Sharded Data Parallel (TP/SP + FSDP) on a example Llama2 model. We show an E2E working flow from forward, backward and optimization.
We enabled Fully Sharded Data Parallel + Tensor Parallel in separate parallel dimensions: Data Parallel ("dp") across hosts Tensor Parallel ("tp") within each host
The data-parallel dim can itself be split for HSDP via --dp-replicate /
--dp-shard: weights are replicated across dp_replicate groups and
sharded within each dp_shard group (dp = dp_replicate * dp_shard). The
defaults (dp_replicate=1, dp_shard=-1) give a single flat sharded dp dim.
We use a simple diagram to illustrate below:
+-----.-----+-----+-----+ | 0 | 1 | 2 | 3 | | | | | | +-----+-----+-----+-----+ | 4 | 5 | 6 | 7 | | | | | | +-----+-----+-----+-----+ | 8 | 9 | 10 | 11 | | | | | | +-----+-----+-----+-----+
+----------+ +------------+ +----------+ +------------+ | Host 1 | | Host 2 | | | | Host N | | 8 GPUs | | 8 GPUs | | | | 8 GPUs | | | | | | ... | | | | (TP) | | (TP) | | | | (TP) | |[0,1,..,7]| | [8,9..,15] | | | | [8N-8,8N-7 | | | | | | | | .., 8N-1] | | | | | | | | | +----------+ +------------+ +----------+ +------------+
- FSDP:
[0, 8, ..., 8N-8], [1, 9, ..., 8N-7], ..., [7, 15, ..., 8N-1]
Launch with:
ezpz launch -m ezpz.examples.fsdp_tp --tp 2 --batch-size 8
Help output (python3 -m ezpz.examples.fsdp_tp --help):
usage: fsdp_tp.py [-h] [--dim DIM] [--n-layers N_LAYERS] [--n-heads N_HEADS]
[--n-kv-heads N_KV_HEADS] [--multiple-of MULTIPLE_OF]
[--ffn-dim-multiplier FFN_DIM_MULTIPLIER]
[--norm-eps NORM_EPS] [--vocab-size VOCAB_SIZE]
[--lr LR] [--epochs EPOCHS]
[--batch-size BATCH_SIZE]
[--test-batch-size TEST_BATCH_SIZE]
[--num-workers NUM_WORKERS] [--seed SEED] [--tp TP]
[--sharding-strategy SHARDING_STRATEGY]
[--max-grad-norm MAX_GRAD_NORM] [--outdir OUTDIR]
[--dataset DATASET] [--tokenizer_name TOKENIZER_NAME]
[--hf-split HF_SPLIT] [--hf-text-column HF_TEXT_COLUMN]
[--hf-limit HF_LIMIT] [--seq-len SEQ_LEN]
[--max-seq-len MAX_SEQ_LEN]
[--fp32]
2D Parallel Training
options:
-h, --help show this help message and exit
--dim DIM
--n-layers N_LAYERS
--n-heads N_HEADS
--n-kv-heads N_KV_HEADS
--multiple-of MULTIPLE_OF
--ffn-dim-multiplier FFN_DIM_MULTIPLIER
--norm-eps NORM_EPS
--vocab-size VOCAB_SIZE
--lr LR
--epochs EPOCHS
--batch-size BATCH_SIZE
--test-batch-size TEST_BATCH_SIZE
--num-workers NUM_WORKERS
--seed SEED
--tp TP
--sharding-strategy SHARDING_STRATEGY
--max-grad-norm MAX_GRAD_NORM
--outdir OUTDIR
--dataset DATASET
--tokenizer_name TOKENIZER_NAME
--hf-split HF_SPLIT, --hf_split HF_SPLIT
Dataset split to load.
--hf-text-column HF_TEXT_COLUMN, --hf_text_column HF_TEXT_COLUMN
Column containing raw text in the dataset.
--hf-limit HF_LIMIT, --hf_limit HF_LIMIT
Max rows from the HF dataset. 0 (default) = no
limit. Pass e.g. `--hf-limit 512` to subsample
for smoke tests.
--seq-len SEQ_LEN
--max-seq-len MAX_SEQ_LEN
--fp32 Disable mixed precision (use fp32) for debugging NaNs.
The remaining comments outline the parallel layout used to combine TP/SP with FSDP.
main(args)
โ๏ธ
Entrypoint to set up distributed context and dispatch training.
Source code in src/ezpz/examples/fsdp_tp.py
@ezpz.timeitlogit(rank=ezpz.get_rank())
def main(args: argparse.Namespace) -> int:
"""Entrypoint to set up distributed context and dispatch training."""
ezpz.silence_noisy_loggers()
t0 = time.perf_counter()
rank = ezpz.distributed.setup_torch(tensor_parallel_size=args.tp, seed=args.seed)
t_setup = time.perf_counter()
base_dir = args.outdir if args.outdir else None
# Collective (broadcasts the shared timestamp) โ every rank must call it.
outdir = get_example_outdir(WBPROJ_NAME, base_dir=base_dir)
# Create the W&B run HERE, so it exists before the slow startup path.
# History (inside train()) is constructed only after tokenization,
# model build, FLOP counting, FSDP wrapping and torch.compile โ on a
# 4-node agpt-2b run that is ~60s, and at 20b it spans the OOM-prone
# build/compile phase. Creating the run at History time meant a job
# that died before the first step uploaded nothing at all.
#
# This does NOT move ownership away from History: History still builds
# the tracker, and its WandbBackend still calls setup_wandb. It simply
# *adopts* this run rather than creating one โ setup_wandb passes
# reinit=None and wandb.init() returns the existing run object when one
# is live (verified on wandb 0.24.0 and 0.28.1), so config updates from
# both sites merge onto a single run.
#
# Gated on the same backend resolution History uses, so
# EZPZ_TRACKER_BACKENDS=none (or a csv/mlflow-only selection) still
# keeps W&B entirely out of the run. WANDB_MODE=disabled/offline
# remains the other opt-out, and setup_wandb additionally no-ops when
# verify_wandb() fails or rank != 0 โ it performs no collectives, so
# calling it early cannot deadlock or diverge ranks.
if rank == 0 and "wandb" in ezpz.tracker.resolve_backend_names():
ezpz.setup_wandb(project_name=WBPROJ_NAME, dir=outdir)
# Dumped *after* wandb.init so the resolved config lands in the
# run's captured console log too (wandb does not capture stdout
# retroactively).
jstr = json.dumps(vars(args), indent=2, sort_keys=True, default=str)
logger.info(f"config:\n{jstr}")
logger.info("Outputs will be saved to %s", outdir)
# W&B run created above; History (inside train()) adopts it and adds
# the CSV/JSONL backends.
train_start = time.perf_counter()
# nullcontext (prof=None) unless --profile / --pyinstrument-profiler set.
with profiling_context_from_args(args, outdir) as prof:
# Pass main()'s t0 (captured before setup_torch) so
# train/restart_seconds covers the full cold path incl. distributed
# init โ the dominant cost of a real --auto-retry failover.
history = train(
args=args, outdir=outdir, profiler=prof, process_start=t0
)
train_end = time.perf_counter()
timings = {
"main/setup_torch": t_setup - t0,
"main/train": train_end - train_start,
"main/total": train_end - t0,
"timings/training_start": train_start - t0,
"timings/train_duration": train_end - train_start,
"timings/end-to-end": train_end - t0,
}
logger.info("Timings: %s", timings)
history.tracker.log(
{
(f"timings/{k}" if not k.startswith("timings/") else k): v
for k, v in timings.items()
}
)
if ezpz.get_rank() == 0:
dataset = history.finalize(
outdir=outdir,
run_name=WBPROJ_NAME,
dataset_fname="train",
)
del dataset # logged by finalize()
return 0
parallelize(model, device_mesh, mixed_precision, reshard_after_forward='always', activation_checkpoint='none', loss_parallel=False, meta_init=False, device=None)
โ๏ธ
Apply tensor parallelism + FSDP2 (fully_shard) to the model.
FSDP2 shards each module group independently (embedding, every
TransformerBlock, then [norm, output], then the root). This per-module
sharding keeps the backward-pass gradient/activation memory bounded โ
in particular for the large 256K-vocab embedding and output projection
โ where FSDP1's single flat-parameter wrap would OOM at long sequence
length. Activation checkpointing (when requested) is applied to each
block BEFORE fully_shard so the checkpoint envelope sits inside the
sharded unit (torchtitan's ordering).
meta_init: when True the model was built on the meta device (no
storage). Pre-shard init_weights is skipped; after fully_shard the
sharded params are materialized on device via to_empty and then
init_weights(buffer_device=device) fills them โ so the full dense model
is never placed on one device (avoids the large-model build OOM). Requires
device.
Source code in src/ezpz/examples/fsdp_tp.py
def parallelize(
model: nn.Module,
device_mesh: DeviceMesh,
mixed_precision: Optional[MixedPrecisionPolicy],
reshard_after_forward: str = "always",
activation_checkpoint: str = "none",
loss_parallel: bool = False,
meta_init: bool = False,
device: Optional["torch.device"] = None,
) -> nn.Module:
"""Apply tensor parallelism + FSDP2 (``fully_shard``) to the model.
FSDP2 shards each module group independently (embedding, every
TransformerBlock, then [norm, output], then the root). This per-module
sharding keeps the backward-pass gradient/activation memory bounded โ
in particular for the large 256K-vocab embedding and output projection
โ where FSDP1's single flat-parameter wrap would OOM at long sequence
length. Activation checkpointing (when requested) is applied to each
block BEFORE ``fully_shard`` so the checkpoint envelope sits inside the
sharded unit (torchtitan's ordering).
``meta_init``: when True the model was built on the ``meta`` device (no
storage). Pre-shard ``init_weights`` is skipped; after ``fully_shard`` the
sharded params are materialized on ``device`` via ``to_empty`` and then
``init_weights(buffer_device=device)`` fills them โ so the full dense model
is never placed on one device (avoids the large-model build OOM). Requires
``device``.
"""
tp_mesh = device_mesh["tp"]
# Choose the mesh fully_shard shards over:
# dp_replicate > 1 -> 2D (dp_replicate, dp_shard) submesh; FSDP2 reads a
# 2D DP mesh as HSDP (replicate across the outer dim,
# shard within the inner) automatically.
# dp_replicate == 1 -> 1D dp_shard mesh; plain FSDP sharding, identical
# to the pre-HSDP behavior (avoids a needless
# size-1 replicate wrap).
if device_mesh["dp_replicate"].size() > 1:
fsdp_dp_mesh = device_mesh["dp_replicate", "dp_shard"]
else:
fsdp_dp_mesh = device_mesh["dp_shard"]
reshard = _reshard_arg(reshard_after_forward)
# Dense path: init the real params now. Meta path: skip โ the params are on
# `meta` (no storage), so init happens after fully_shard via to_empty +
# init_weights(buffer_device=device) below.
if not meta_init:
model.init_weights() # type: ignore
# Only apply tensor/sequence parallelism when the tp mesh dim is > 1.
# At tp=1 (FSDP-only) the TP plan is pure overhead: SequenceParallel
# still wraps norms as DTensors sharded over a size-1 tp dim, which
# produces a `_NormPartial` placement that must be all-reduced โ and
# combined with FSDP2's dp sharding triggers the "2 sequential
# all_reduce ... suboptimal" warning every step, for zero benefit
# (there's nothing to shard across a 1-rank tp group). torchtitan
# guards the same way (`if parallel_dims.tp_enabled`).
if tp_mesh.size() > 1:
model = parallelize_module(
model,
tp_mesh,
{
"tok_embeddings": RowwiseParallel(
input_layouts=Replicate(),
output_layouts=Shard(1),
),
"norm": SequenceParallel(),
# With loss_parallel, keep logits vocab-sharded (Shard(-1))
# and return the LOCAL [N, vocab/tp] tensor so the loss can run
# vocab-parallel CE (no full-vocab all-gather). Otherwise gather
# to Replicate() so the loss sees full-vocab logits (default).
"output": ColwiseParallel(
input_layouts=Shard(1),
output_layouts=Shard(-1) if loss_parallel else Replicate(),
use_local_output=bool(loss_parallel),
),
},
)
assert isinstance(model.layers, Iterable)
for _, transformer_block in enumerate(model.layers):
layer_tp_plan = {
"attention_norm": SequenceParallel(),
"attention": PrepareModuleInput(
input_layouts=(Shard(1), None), # type:ignore
desired_input_layouts=(Replicate(), None), # type:ignore
),
"attention.wq": ColwiseParallel(),
"attention.wk": ColwiseParallel(),
"attention.wv": ColwiseParallel(),
"attention.wo": RowwiseParallel(output_layouts=Shard(1)),
"ffn_norm": SequenceParallel(),
"feed_forward": PrepareModuleInput(
input_layouts=(Shard(1),),
desired_input_layouts=(Replicate(),),
),
"feed_forward.w1": ColwiseParallel(),
"feed_forward.w2": RowwiseParallel(output_layouts=Shard(1)),
"feed_forward.w3": ColwiseParallel(),
}
attn_layer = transformer_block.attention # type: ignore
attn_layer.n_heads = attn_layer.n_heads // tp_mesh.size()
attn_layer.n_kv_heads = attn_layer.n_kv_heads // tp_mesh.size()
parallelize_module(
module=transformer_block, # type: ignore
device_mesh=tp_mesh,
parallelize_plan=layer_tp_plan,
)
# Activation checkpointing must wrap each block BEFORE fully_shard so the
# checkpoint envelope lives inside the FSDP2 unit (torchtitan ordering;
# the reverse โ AC after sharding โ is the FSDP1 order and is wrong for
# FSDP2). _apply_activation_checkpointing replaces each block in
# `model.layers` in-place with a compile-aware CheckpointWrapper.
if activation_checkpoint != "none":
_apply_activation_checkpointing(model, activation_checkpoint)
# FSDP2: shard each module group on the dp sub-mesh. Per-module sharding
# (vs FSDP1's one flat param) is what keeps backward memory bounded.
fsdp_kwargs = {"mesh": fsdp_dp_mesh, "reshard_after_forward": reshard}
if mixed_precision is not None:
fsdp_kwargs["mp_policy"] = mixed_precision
# Embedding first (largest single param: vocab*dim).
if getattr(model, "tok_embeddings", None) is not None:
fully_shard(model.tok_embeddings, **fsdp_kwargs)
# Each transformer block (or its CheckpointWrapper) as its own unit.
assert isinstance(model.layers, Iterable)
for block in model.layers:
fully_shard(block, **fsdp_kwargs)
# norm + output together (output is the other vocab*dim-sized param).
if (
getattr(model, "norm", None) is not None
and getattr(model, "output", None) is not None
):
fully_shard([model.norm, model.output], **fsdp_kwargs)
# Root last.
fully_shard(model, **fsdp_kwargs)
# Meta path: params are now sharded DTensors still on `meta`. Materialize
# ONLY this rank's shard on the real device (to_empty โ no full-model copy),
# then init_weights fills the sharded params and recomputes the freqs_cis
# buffer on `device` (to_empty leaves buffer data uninitialized). On resume,
# dcp.load later overwrites the params + persistent buffers.
if meta_init:
assert device is not None, "meta_init=True requires a device"
model.to_empty(device=device)
model.init_weights(buffer_device=device) # type: ignore
_configure_fsdp_gradient_division(model)
logger.info(f"Model after parallelization (FSDP2):\n{model=}\n")
return model
parse_args(argv=None)
โ๏ธ
CLI parser for 2D parallel (TP/SP + FSDP) training.
Source code in src/ezpz/examples/fsdp_tp.py
def parse_args(argv: Optional[list[str]] = None):
"""CLI parser for 2D parallel (TP/SP + FSDP) training."""
if argv is None:
argv = sys.argv[1:]
parser = argparse.ArgumentParser(
description="2D Parallel Training",
formatter_class=DefaultsFormatter,
)
parser.add_argument(
"--dim",
type=int,
default=256,
help=(
"Model hidden / embedding dimension (a.k.a. d_model). Overridden "
"when --model selects a preset."
),
)
parser.add_argument(
"--n-layers",
type=int,
default=32,
help=(
"Number of TransformerBlocks stacked in the model. Overridden "
"when --model selects a preset."
),
)
parser.add_argument(
"--n-heads",
type=int,
default=32,
help=(
"Number of attention heads per layer. Must divide --dim. "
"Overridden when --model selects a preset."
),
)
parser.add_argument(
"--n-kv-heads",
type=int,
default=4,
help=(
"Number of key/value heads for grouped-query attention (GQA). "
"Must divide --n-heads. Set equal to --n-heads for standard MHA. "
"Overridden when --model selects a preset."
),
)
parser.add_argument(
"--multiple-of",
type=int,
default=360,
help=(
"Round the SwiGLU FFN hidden dim up to a multiple of this value "
"(for hardware-friendly shapes). Ignored when --hidden-dim is "
"set explicitly."
),
)
parser.add_argument(
"--ffn-dim-multiplier",
type=float,
default=None,
help=(
"Scale factor applied to the SwiGLU FFN hidden dim before the "
"--multiple-of rounding step. None (default) means no extra "
"scaling; Llama2-style models use 1.3. Ignored when "
"--hidden-dim is set explicitly."
),
)
parser.add_argument(
"--hidden-dim",
type=int,
default=None,
help=(
"Override SwiGLU FFN hidden dim. When None (default), TransformerBlock "
"derives it as `4 * dim` and FeedForward applies the 2/3 + "
"ffn_dim_multiplier + multiple_of pipeline. Set this to a concrete "
"value (e.g. 11008 for agpt-2b, 14336 for agpt-20b) to bypass the "
"formula and hit a published architecture exactly."
),
)
parser.add_argument(
"--rope-theta",
type=float,
default=10000.0,
help=(
"Base frequency for RoPE positional embeddings. Llama1/2 used "
"10000 (the default); Llama3 uses 500000; agpt-2b uses 50000."
),
)
parser.add_argument(
"--norm-eps",
type=float,
default=1e-5,
help="Epsilon added to RMSNorm denominators for numerical stability.",
)
parser.add_argument(
"--vocab-size",
type=int,
default=32_000,
help=(
"Tokenizer vocabulary size. Sets the embedding table and output "
"projection sizes; must match the tokenizer used for the dataset."
),
)
parser.add_argument(
"--lr",
type=float,
default=3e-3,
help="Peak learning rate for the AdamW optimizer.",
)
parser.add_argument(
"--epochs",
type=int,
default=5,
help="Number of passes over the training dataset.",
)
parser.add_argument(
"--batch-size",
type=int,
default=1,
help=(
"Per-DP-rank training batch size (a.k.a. micro-batch). "
"Global batch = --batch-size * (world_size / --tp)."
),
)
parser.add_argument(
"--model",
type=str,
default=None,
# No `choices=` โ accepts both preset names (validated in
# apply_model_preset) AND free-form HF repo IDs like
# `meta-llama/Llama-3.2-1B`. Disambiguation is by the `/` character:
# presence of `/` => HF repo ID; absence => preset/alias lookup.
help=(
"Model size preset (overrides dim/layer defaults). "
"Presets: debug/small/medium/large/xl/xxl/xxxl/agpt-2b/agpt-20b. "
"xl/xxl/xxxl accept long-form aliases (`xlarge`/`extra-large`, etc). "
"agpt presets accept `agpt2b`/`agpt_2b` etc. "
"Pass a HuggingFace repo id with a `/` (e.g. "
"`meta-llama/Llama-3.2-1B`) to load HF weights instead โ that "
"path forces --tp 1 (FSDP-only)."
),
)
parser.add_argument(
"--test-batch-size",
type=int,
default=1000,
help=(
"Per-DP-rank batch size for the eval/test loader. Only "
"consumed by the MNIST data path; ignored for random and HF "
"datasets."
),
)
parser.add_argument(
"--num-workers",
type=int,
default=0,
help=(
"Subprocess workers for the DataLoader. 0 (default) loads "
"in-process โ fine for tokenized HF datasets; bump for "
"image pipelines or heavy on-the-fly preprocessing."
),
)
parser.add_argument(
"--seed",
type=int,
default=None,
help=(
"Seed for torch/numpy/python RNGs (forwarded to "
"ezpz.setup_torch). None (default) leaves the RNGs unseeded "
"for non-deterministic runs."
),
)
parser.add_argument(
"--tp",
type=int,
default=2,
help=(
"Tensor-parallel degree (a.k.a. TP / Megatron-style sharding). "
"Must divide WORLD_SIZE. The remaining dimension "
"(WORLD_SIZE / --tp) is used for FSDP data parallelism. "
"Set to 1 for FSDP-only. Forced to 1 when --model is a HF "
"repo id."
),
)
parser.add_argument(
"--dp-replicate",
type=int,
default=1,
help=(
"Data-parallel REPLICATE degree (HSDP outer dim). Weights are "
"replicated across this many groups; within each group they are "
"sharded across --dp-shard ranks. Default 1 = no replication "
"(pure FSDP sharding, i.e. today's behavior). Set >1 for HSDP "
"(e.g. shard within a node, replicate across nodes). Mirrors "
"torchtitan's data_parallel_replicate_degree. Constraint: "
"dp_replicate * dp_shard * tp == WORLD_SIZE."
),
)
parser.add_argument(
"--dp-shard",
type=int,
default=-1,
help=(
"Data-parallel SHARD degree (FSDP inner dim). Weights are "
"sharded across this many ranks within each replicate group. "
"Default -1 = 'use all remaining ranks' = "
"WORLD_SIZE / (dp_replicate * tp), which reproduces today's "
"flat data-parallel behavior. Mirrors torchtitan's "
"data_parallel_shard_degree."
),
)
parser.add_argument(
"--reshard-after-forward",
dest="reshard_after_forward",
nargs="?",
const="always",
default="always",
choices=list(RESHARD_POLICIES),
help=(
"FSDP2 reshard_after_forward policy (memory vs. comm tradeoff). "
"`always` (default, ZeRO-3): reshard params after forward โ "
"lowest memory, re-all-gathers params in backward. `never` "
"(ZeRO-2): keep params gathered after forward โ more memory, "
"skips the backward all-gather. Bare `--reshard-after-forward` "
"== `always`; `--no-reshard-after-forward` == `never`. For HSDP "
"(replicate + shard) use --dp-replicate / --dp-shard."
),
)
parser.add_argument(
"--no-reshard-after-forward",
dest="reshard_after_forward",
action="store_const",
const="never",
help="Alias for --reshard-after-forward never (ZeRO-2).",
)
# Deprecated legacy alias, hidden from --help. Resolved post-parse by
# _resolve_reshard_after_forward: full_shard->always, shard_grad_op/
# no_shard->never (with a deprecation warning), hybrid_shard*->hard error.
parser.add_argument(
"--sharding-strategy",
dest="sharding_strategy",
type=str,
default=None,
help=argparse.SUPPRESS,
)
parser.add_argument(
"--activation-checkpoint",
"--ac",
type=str,
default="none",
# `full` is an alias for `block` for compatibility with
# torchtitan's CLI surface (their `activation_checkpoint_mode`
# uses `full` for what we call `block` โ every transformer
# block wrapped). Resolved in _apply_activation_checkpointing.
choices=["none", "block", "full", "selective"],
help=(
"Activation checkpointing strategy. "
"`none` (default) keeps all forward activations in memory. "
"`block` (alias: `full`) wraps each TransformerBlock โ typical "
"30-40 pct activation memory reduction, ~20 pct throughput hit "
"(matches torchtitan's default for agpt-2b/agpt-20b). "
"`selective` checkpoints only the attention computation inside "
"each block โ ~15-20 pct memory reduction, ~10 pct throughput "
"hit. Trade activation memory for recomputation cost โ useful "
"when OOM-ing during training (NOT during init; for init-time "
"OOM consider increasing --tp or reducing --seq-len). "
"NOTE: cannot be combined with --compile (upstream AOTAutograd "
"DeviceMesh-in-saved-tensors bug โ see the --compile warning). "
"With FSDP2 you usually don't need --ac anyway; it was a "
"workaround for the FSDP1 backward-memory OOM that FSDP2 fixes."
),
)
parser.add_argument(
"--meta-init",
type=str,
default="auto",
choices=["auto", "on", "off"],
help=(
"Build the native Transformer on the `meta` device, then "
"materialize only each rank's shard after FSDP2 sharding "
"(torchtitan pattern). Avoids the OOM from moving the full dense "
"model onto one device before sharding, which otherwise caps model "
"size at what fits whole on a single GPU (~2-8B) regardless of "
"node count. `auto` (default) enables it for large native models "
"(>= ~6B params) and keeps small models on the exact dense init "
"path (bit-for-bit reproducible); `on` forces it for any native "
"model; `off` forces the legacy dense path. Ignored for HF "
"`from_pretrained` models (they load real pretrained weights). "
"Override the auto threshold with EZPZ_META_INIT_MIN_PARAMS."
),
)
parser.add_argument(
"--max-grad-norm",
type=float,
default=1.0,
help=(
"Clip gradients to this L2 norm before the optimizer step. "
"Set to 0 (or negative) to disable gradient clipping."
),
)
parser.add_argument(
"--outdir",
type=str,
default=None,
help=(
"Base directory for metrics logs + the History report. None "
"(default) writes under the current working directory. (Model "
"checkpoints go to --ckpt-dir, not here.)"
),
)
parser.add_argument(
"--ckpt-dir",
"--ckpt_dir",
type=str,
default=None,
dest="ckpt_dir",
help=(
"Directory for DCP (sharded) checkpoints. When set, enables "
"checkpoint save (see --save-interval) AND auto-resume: on "
"startup the latest complete checkpoint here is loaded and "
"training continues from it (unless --no-resume). This is what "
"makes `ezpz launch --auto-retry` resume across attempts."
),
)
parser.add_argument(
"--save-interval",
"--save_interval",
type=int,
default=0,
dest="save_interval",
help=(
"Save a checkpoint every N optimizer steps (requires "
"--ckpt-dir). 0 (default) disables saving."
),
)
parser.add_argument(
"--train-iters",
"--train_iters",
type=int,
default=0,
dest="train_iters",
help=(
"Stop after N optimizer steps, regardless of --epochs. 0 "
"(default) runs the full --epochs pass. Step-based cap for "
"fixed-length runs / restart-time experiments."
),
)
parser.add_argument(
"--no-resume",
action="store_true",
help=(
"Ignore any existing checkpoint in --ckpt-dir and start fresh "
"(step 0). Default behavior auto-resumes from the latest."
),
)
parser.add_argument(
"--async-ckpt",
"--async_ckpt",
action="store_true",
dest="async_ckpt",
help=(
"Save checkpoints asynchronously: stage to fast node-local "
"--ckpt-stage-dir (background thread, overlaps training), then "
"fan out to the durable --ckpt-dir on shared FS. Requires "
"--ckpt-dir. Resume is unchanged (always from --ckpt-dir)."
),
)
parser.add_argument(
"--ckpt-stage-dir",
"--ckpt_stage_dir",
type=str,
default=None,
dest="ckpt_stage_dir",
help=(
"Node-local staging dir for --async-ckpt (default "
"/tmp/ezpz-ckpt-<jobid>). Transient โ NOT resumable on its own; "
"only the fanned-out --ckpt-dir copy is durable."
),
)
# parser.add_argument('--dataset', type=str, default='random')
parser.add_argument(
"--dataset",
type=str,
default="eliplutchok/fineweb-small-sample",
help=(
"Training dataset. Special values: `mnist` (image debug "
"dataset) and `random` (synthetic tokens, no IO). Anything "
"else is treated as a HuggingFace dataset repo id."
),
)
parser.add_argument(
"--tokenizer_name",
type=str,
default="meta-llama/llama-2-7b-hf",
help=(
"HuggingFace tokenizer repo id used to tokenize the HF "
"dataset. Auto-overridden to --model when --model is a HF "
"repo id and --tokenizer_name wasn't passed explicitly."
),
)
parser.add_argument(
"--hf-split",
"--hf_split",
type=str,
default="train",
help="Dataset split to load.",
)
parser.add_argument(
"--hf-text-column",
"--hf_text_column",
type=str,
default="text",
help="Column containing raw text in the dataset.",
)
parser.add_argument(
"--hf-limit",
"--hf_limit",
type=int,
default=0,
help=(
"Maximum number of rows to sample from the HF dataset. "
"0 (default) = no limit (use the full dataset). Pass a "
"positive value (e.g. `--hf-limit 512`) to subsample for "
"smoke tests. Subsampling is deterministic given "
"$EZPZ_HF_SAMPLE_SEED."
),
)
# parser.add_argument('--max_batch_size', type=int, default=None)
parser.add_argument(
"--seq-len",
type=int,
default=int(os.environ.get("SEQ_LEN", 1024)),
help=(
"Training sequence length (tokens per sample). Defaults to "
"$SEQ_LEN if set, otherwise 1024. Must be <= --max-seq-len."
),
)
parser.add_argument(
"--max-seq-len",
type=int,
default=32768,
help=(
"Maximum sequence length the model is built to support โ "
"sets the RoPE frequency table size and the attention "
"scratch budget. Increase if you raise --seq-len."
),
)
parser.add_argument(
"--fp32",
action="store_true",
help="Disable mixed precision (use fp32) for debugging NaNs.",
)
parser.add_argument(
"--compile",
action="store_true",
help=(
"Compile each TransformerBlock with torch.compile after "
"FSDP/TP wrap (matches torchtitan's apply_compile pattern). "
"Per-block compile dodges the Dynamo + DTensor _MaskPartial "
"graph break that whole-model compile hits on TP-wrapped "
"tok_embeddings, and amortizes compile cost across N layers."
),
)
parser.add_argument(
"--compile-mode",
type=str,
default="default",
choices=["default", "reduce-overhead", "max-autotune"],
help=(
"torch.compile mode (only used when --compile is set). "
"`default` is safest. `reduce-overhead` enables cudagraphs "
"for small models / large batches. `max-autotune` does "
"extensive kernel search โ slow startup, fastest steady state."
),
)
parser.add_argument(
"--act-mem-budget",
type=float,
default=1.0,
help=(
"Activation-memory budget for the inductor min-cut partitioner "
"(sets torch._functorch.config.activation_memory_budget). Only "
"takes effect with --compile. 1.0 (default) saves ALL "
"activations (no recompute); lower values let the compiler "
"recompute activations in backward to cut peak memory โ e.g. "
"0.5 keeps ~half. This is how torchtitan fits larger batches "
"for the same model (its MemoryBudgetAC sets 0.5). Try 0.5 if "
"you OOM in backward at a batch size that should fit."
),
)
parser.add_argument(
"--loss-impl",
type=str,
default="eager",
choices=[
"eager",
"chunked",
"chunked-backward",
"compiled",
"loss-parallel",
"fused-linear",
],
help=(
"Cross-entropy implementation. The large-vocab output path is the "
"memory bottleneck: a full (B*T, vocab) fp32 logits tensor + its "
"grad (agpt-2b 256K vocab, seq=8192, bs=2: ~16.8 GiB EACH) can OOM "
"a GPU tile (UR_RESULT_ERROR_OUT_OF_RESOURCES) even when the model "
"fits. Pick by what you need (numbers = measured agpt-2b tp=1):\n"
" โข `eager` (default): plain F.cross_entropy on full logits. "
"Simplest; OOMs at agpt-2b bs2/seq8192. Use for small vocab/seq.\n"
" โข `chunked`: chunks only the FORWARD (--loss-chunk-size). Does "
"NOT bound backward; still OOMs at large vocab. Rarely useful.\n"
" โข `chunked-backward`: custom autograd Function that also bounds "
"the backward graph (recomputes each chunk's grad), saving ~one "
"full logits buffer vs eager. General + model-agnostic (works for "
"HF models, no torch.compile needed) โ good at MODERATE vocab/seq "
"or when compile is unavailable. Still holds two logit-sized "
"buffers, so it does NOT fix the very-large-vocab OOM (use "
"fused-linear/compiled there).\n"
" โข `compiled`: torch.compile fuses log_softmax+NLL+backward so "
"the full transient is never materialized (torchtitan's approach). "
"Fits (~45 GB) and is the FASTEST that fits (~28%% MFU). Needs "
"working torch.compile. Best default when it fits.\n"
" โข `fused-linear` (Liger/Cut-CE): runs the output projection "
"per row-chunk so the full (B*T,vocab) logits/grad are NEVER built "
"โ bounds BOTH row and vocab dims. LOWEST memory (~32 GB, below "
"compiled) at ~24%% MFU; trades a little speed for headroom (bigger "
"batch/seq). ezpz Transformer + tp=1 only (HF / tp>1 fall back to "
"compiled).\n"
" โข `loss-parallel`: vocab-parallel CE sharding the vocab across "
"TP ranks (each holds vocab/tp) via TP all-reduces. Bounds the "
"VOCAB dim; only helps at tp>1 (at tp=1 falls back to eager). At "
"tp>1 it is also the only correct path (plain CE hits a "
"Tensor/DTensor mismatch on Replicate logits). ~23 GB/rank, "
"~34%% MFU at tp=2.\n"
"NOTE: `--compile` only compiles the transformer blocks, NOT the "
"loss, so it does NOT by itself fix the loss transient โ use "
"--loss-impl for that."
),
)
parser.add_argument(
"--loss-chunk-size",
type=int,
default=1024,
help=(
"Row-chunk size (number of (B*T) token rows per cross-entropy "
"chunk) for --loss-impl=chunked, chunked-backward, and "
"fused-linear. Smaller = lower peak memory, more kernel launches. "
"Ignored for eager/compiled/loss-parallel."
),
)
# max_batch_size: int = 32
# max_seq_len: int = 32768
# Shared profiler flags (--profile / --pyinstrument-profiler / etc.),
# consumed by profiling_context_from_args around the training loop.
add_profiling_args(parser)
args = parser.parse_args(argv)
apply_model_preset(args, argv)
# Fold the deprecated --sharding-strategy alias into reshard_after_forward
# (and hard-error the removed hybrid_shard* values).
_resolve_reshard_after_forward(args)
_maybe_enable_cpu_backend_for_async_ckpt(args)
return args
train(args, outdir, profiler=None, process_start=None)
โ๏ธ
Run TP/SP + FSDP training and optionally log metrics.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
args
|
Namespace
|
Parsed CLI namespace. |
required |
outdir
|
Path | str | PathLike
|
Output directory for metrics / reports. |
required |
profiler
|
Optional[Any]
|
Optional active profiler ( |
None
|
Source code in src/ezpz/examples/fsdp_tp.py
@ezpz.timeitlogit(rank=ezpz.get_rank())
def train(
args: argparse.Namespace,
outdir: Path | str | os.PathLike,
profiler: Optional[Any] = None,
process_start: Optional[float] = None,
) -> int:
"""Run TP/SP + FSDP training and optionally log metrics.
Args:
args: Parsed CLI namespace.
outdir: Output directory for metrics / reports.
profiler: Optional active profiler (``torch.profiler.profile`` or
``None``) from :func:`ezpz.profile.profiling_context_from_args`.
When non-None, ``profiler.step()`` is called once per training
step so the schedule (wait/warmup/active/repeat) advances.
"""
# Timestamp for the restart-time metric. Prefer the caller's
# process-start (main() captures it BEFORE setup_torch, so
# train/restart_seconds includes distributed init โ the dominant cost of a
# real cold failover). Fall back to now() when train() is called directly.
_train_t0 = process_start if process_start is not None else perf_counter()
_restart_logged = False
_optim_dtype_logged = False # log AdamW state dtype once, after step 1
_precision_in_jsonl = False # fold precision summary into JSONL once
world_size = ezpz.distributed.get_world_size()
assert world_size % args.tp == 0, "WORLD_SIZE must be divisible by TP"
# Resolve the data-parallel topology: dp_replicate (HSDP outer) x
# dp_shard (FSDP inner). Defaults (replicate=1, shard=-1) reproduce the
# flat FSDP dp dim exactly.
dp_replicate, dp_shard = _resolve_dp_degrees(
world_size=world_size,
tp=args.tp,
dp_replicate=args.dp_replicate,
dp_shard=args.dp_shard,
)
dpsize = dp_replicate * dp_shard # == flattened "dp" size
# Global batch size = per-DP-rank micro-batch x data-parallel degree.
# Data parallelism is dp_replicate * dp_shard (== dpsize): BOTH the HSDP
# replicate dim and the FSDP shard dim get a distinct data slice (the
# DistributedSampler shards over num_replicas=dpsize). TP is NOT data
# parallel โ tp ranks process the SAME batch (sharded across the hidden
# dim), so tp does not enter here. Backfilled onto args so it lands in the
# wandb run config (History logs vars(args); we also push it to
# wandb.config below). Computed here, not in parse_args, because the
# default --dp-shard -1 ("use all remaining ranks") only resolves to a
# concrete degree once WORLD_SIZE is known.
#
# This is the general Megatron/NeMo formula
# gbs = micro_batch * world_size * grad_accum
# / (tensor_parallel * pipeline_parallel)
# specialized to this example: there is no pipeline parallelism
# (pipeline_parallel = 1) and no gradient accumulation (grad_accum = 1 โ
# zero_grad/backward/step run every iteration), and world_size / tp ==
# dp_replicate * dp_shard == dpsize. If either is ever added, this must
# gain the corresponding factor (* grad_accum, / pipeline_parallel).
args.global_batch_size = args.batch_size * dpsize
if ezpz.get_rank() == 0:
logger.info(
"global_batch_size=%d (batch_size=%d x dp_replicate=%d x "
"dp_shard=%d; tp=%d does not scale the batch)",
args.global_batch_size,
args.batch_size,
dp_replicate,
dp_shard,
args.tp,
)
# fused-linear (Liger/Cut-CE): needs the model's hidden states + output
# weight, so only the ezpz Transformer (not HF models) and โ for now โ
# only tp=1 (tp>1 needs vocab-shard composition, gated to a follow-up).
# Resolved against the actual model after it's built (see below).
want_fused_linear = getattr(args, "loss_impl", "eager") == "fused-linear"
# loss-parallel (vocab-sharded CE) only does anything at tp>1; at tp=1 the
# local vocab IS the full vocab. NOTE: `use_loss_parallel` is resolved
# LATER (after the HF branch may force args.tp=1), so an HF model launched
# with --tp>1 --loss-impl=loss-parallel doesn't enter vocab-parallel CE
# with a stale TP group. See the resolution just before parallelize().
# 3D mesh: (dp_replicate, dp_shard, tp). We then flatten the two DP dims
# into a single named "dp" dim so all existing dp consumers
# (DistributedSampler num_replicas/rank, loss dp_group) keep working with
# correct rank arithmetic. fully_shard picks the DP submesh explicitly
# (2D -> HSDP when dp_replicate > 1, else 1D dp_shard == today's FSDP).
device_mesh = ezpz.init_device_mesh_safe(
str(ezpz.get_torch_device()),
(dp_replicate, dp_shard, args.tp),
mesh_dim_names=("dp_replicate", "dp_shard", "tp"),
)
# Flatten the two DP dims into a single "dp" dim. Must go through
# flatten_device_mesh_safe: `_flatten` builds the flattened PG via
# split_group when the default PG is device-bound, which xccl (XPU)
# doesn't support โ the same workaround init_device_mesh_safe applies.
ezpz.distributed.flatten_device_mesh_safe(
device_mesh[("dp_replicate", "dp_shard")], "dp"
)
logger.info(f"Device mesh created:\n{device_mesh=}")
hf_dataset = None
hf_tokenizer = None
if args.dataset.lower() not in {"mnist", "random"}:
from ezpz.data.hf import get_hf_text_dataset
seed = int(os.environ.get("EZPZ_HF_SAMPLE_SEED", "1337"))
hf_dataset, hf_tokenizer = get_hf_text_dataset(
dataset_name=args.dataset,
split=args.hf_split,
text_column=args.hf_text_column,
tokenizer_name=args.tokenizer_name,
seq_len=args.seq_len,
limit=args.hf_limit,
seed=seed,
)
if hf_tokenizer.vocab_size != args.vocab_size:
logger.warning(
"Overriding vocab_size from %s to tokenizer vocab_size=%s",
args.vocab_size,
hf_tokenizer.vocab_size,
)
args.vocab_size = hf_tokenizer.vocab_size
# HF repo IDs are forced to the HF code path; the ezpz Transformer
# construction below would silently produce a randomly-initialized model
# with the wrong architecture for the requested repo.
is_hf_model = bool(args.model and "/" in args.model)
config = ModelArgs(
dim=args.dim,
n_layers=args.n_layers,
n_heads=args.n_heads,
n_kv_heads=args.n_kv_heads,
batch_size=args.batch_size,
vocab_size=args.vocab_size,
multiple_of=args.multiple_of,
hidden_dim=args.hidden_dim,
rope_theta=args.rope_theta,
ffn_dim_multiplier=args.ffn_dim_multiplier,
norm_eps=args.norm_eps,
max_seq_len=args.max_seq_len,
)
logger.info(f"config:\n{config}")
metrics_every = int(os.environ.get("EZPZ_METRICS_EVERY", "1"))
track_logits = os.environ.get("EZPZ_TRACK_LOGITS", "0") == "1"
track_hist = os.environ.get("EZPZ_TRACK_HIST", "0") == "1"
track_act_hist = os.environ.get("EZPZ_TRACK_ACT_HIST", "1") == "1"
hist_bins = int(os.environ.get("EZPZ_HIST_BINS", "64"))
hist_samples = int(os.environ.get("EZPZ_HIST_SAMPLES", "20000"))
dataset_tag = args.dataset.lower().replace("/", "_")
# Update wandb config with model args (run already initialised in main).
# NOTE: `config` (ModelArgs) is not mutated later, so it's safe to push
# here. The resolved CLI `args`, however, ARE still mutated below (the HF
# branch forces args.tp=1; args.loss_impl can be normalized to
# "compiled"), so vars(args) is pushed just before the training loop
# instead (see below) โ logging it here would record the requested
# settings rather than the effective ones actually used.
if (
ezpz.get_rank() == 0
and wandb is not None
and getattr(wandb, "run", None) is not None
):
from dataclasses import asdict
wandb.config.update(asdict(config)) # type:ignore
device_type = ezpz.distributed.get_torch_device_type()
device = (
torch.device("cpu")
if device_type == "cpu"
else torch.device(f"{device_type}:{ezpz.get_local_rank()}")
)
# Decide meta-device init (native large models only) before building, so the
# native build below can go on `meta` and never place the full dense model
# on one device. HF models always resolve to False (they load real weights).
meta_init = _resolve_meta_init(args, config, is_hf_model)
if is_hf_model:
# HF path: pull arch + weights from the hub. The ezpz Transformer
# above is skipped entirely. Note we still built `config` above so
# downstream logging / wandb.config.update(asdict(config)) doesn't
# crash, but it does NOT reflect the real HF architecture โ that's
# in `model.config` after the load below.
from transformers import AutoModelForCausalLM
if args.tp > 1:
logger.warning(
"HF model %s requested with --tp=%d; ezpz's TP plan is "
"hardcoded to its own Transformer module names and won't "
"match HF's LlamaDecoderLayer / GemmaDecoderLayer / ... "
"Forcing --tp 1 (FSDP-only).",
args.model,
args.tp,
)
args.tp = 1
hf_dtype = torch.float32 if args.fp32 else torch.bfloat16
hf_token = os.environ.get("HF_TOKEN") or os.environ.get(
"HUGGING_FACE_HUB_TOKEN"
)
logger.info(
"Loading HF model %s (dtype=%s)%s",
args.model,
hf_dtype,
" with HF_TOKEN" if hf_token else "",
)
model = AutoModelForCausalLM.from_pretrained(
args.model,
torch_dtype=hf_dtype,
token=hf_token,
)
else:
if meta_init:
# Build on `meta`: no storage allocated, so even a 20B model costs
# nothing here. parallelize() shards it, then to_empty materializes
# only this rank's shard on the real device.
with torch.device("meta"):
model = Transformer.from_model_args(config)
else:
model = Transformer.from_model_args(config)
mstr = summarize_model(
model,
verbose=False,
depth=2,
)
logger.info(f"\n{mstr}")
# Meta models are materialized (sharded) inside parallelize() via to_empty;
# moving a meta model with .to(device) would NOT allocate real storage, so
# skip it here. Dense + HF paths place the real model on-device now.
if not meta_init:
model.to(device)
# FLOPs estimation: try the exact fake-tensor path first, fall back
# to the linear-scaling probe if it fails.
#
# FAKE-TENSOR PATH (preferred): runs the forward+backward at the
# real (batch, seq) shape under FakeTensorMode (shape-only tensors,
# no allocations โ no OOM) with sdpa_kernel(MATH) forced so SDPA
# decomposes into bmms that FlopCounterMode can see. Exact count,
# attention included.
#
# LINEAR-SCALING PROBE (fallback): runs at (1, 128) with real
# tensors and scales by token ratio. Exact for O(seqยทdim) MLP/proj
# ops, but UNDER-COUNTS attention because the O(seqยฒยทdim) QยทKแต and
# attnยทV matmuls don't scale linearly. Worse, on CPU and on fused
# SDPA backends (flash / efficient / cuDNN), FlopCounterMode often
# reports zero for the SDPA op entirely โ so both probe and actual
# silently drop attention from the count. Reported MFU is then a
# lower bound: real utilization is at least the printed number,
# often significantly higher on long-seq runs.
_model_flops = try_estimate_fake(
model, (args.batch_size, args.seq_len)
)
if _model_flops > 0:
logger.info(
"FLOPs counted exactly via FakeTensorMode at shape "
"(batch=%d, seq=%d): %.3e (includes attention).",
args.batch_size,
args.seq_len,
_model_flops,
)
elif meta_init:
# The real-tensor probe below would run an actual forward, which a
# meta model (no storage) cannot do. Skip it โ MFU/TFLOPS just stay 0
# for meta-init runs when the fake-tensor count is unavailable.
if ezpz.get_rank() == 0:
logger.warning(
"Fake-tensor FLOP estimate returned 0 and model is on `meta` "
"(--meta-init); skipping the real-tensor probe. train/tflops "
"and train/mfu will be 0 for this run."
)
else:
_flops_probe_batch = 1
_flops_probe_seq = min(128, args.seq_len)
_flops_probe = try_estimate(
model, (_flops_probe_batch, _flops_probe_seq)
)
_actual_tokens = args.batch_size * args.seq_len
_probe_tokens = _flops_probe_batch * _flops_probe_seq
_model_flops = int(
_flops_probe * _actual_tokens / max(_probe_tokens, 1)
)
if args.seq_len > _flops_probe_seq:
logger.warning(
"Fake-tensor FLOP estimate failed; falling back to "
"linear-scaling probe (probe seq=%d -> actual seq=%d). "
"This under-counts O(seq^2) attention by ~%dx; reported "
"MFU is a lower bound (real utilization is at least this "
"high).",
_flops_probe_seq,
args.seq_len,
args.seq_len // _flops_probe_seq,
)
# FSDP2 mixed-precision policy (param in bf16, reduce in fp32). None when
# --fp32 is set (pure fp32 for NaN debugging).
mp_config: Optional[MixedPrecisionPolicy] = None
_reduce_dtype = torch.float32 # default (also the effective dtype under --fp32)
if not args.fp32:
# reduce_dtype: fp32 gradient reduce-scatter is more accurate, but for
# a large-vocab output projection (e.g. agpt's 256K) the single
# reduce-scatter tensor can exceed CCL's ~2GB-per-message MPI limit
# (256K*2048*4B = 2.1GB) โ `atl_mpi !req.is_completed`. Set
# EZPZ_REDUCE_DTYPE=bf16 to halve the collective size (1.05GB) and
# stay under the limit. Validated against an explicit set: a typo
# silently falling back to fp32 would re-trigger the very CCL
# failure this escape hatch exists to avoid, so raise instead.
_reduce_dtype_env = os.environ.get("EZPZ_REDUCE_DTYPE", "fp32")
_reduce_dtype_key = _reduce_dtype_env.lower()
if _reduce_dtype_key == "fp32":
_reduce_dtype = torch.float32
elif _reduce_dtype_key in ("bf16", "bfloat16"):
_reduce_dtype = torch.bfloat16
else:
raise ValueError(
f"Invalid EZPZ_REDUCE_DTYPE={_reduce_dtype_env!r}. Expected "
"one of: 'fp32', 'bf16', 'bfloat16' (case-insensitive)."
)
mp_config = MixedPrecisionPolicy(
param_dtype=torch.bfloat16,
reduce_dtype=_reduce_dtype,
)
# Resolve loss-parallel NOW that args.tp is final (the HF branch above may
# have forced tp=1). loss-parallel only does anything at tp>1; at tp=1 the
# local vocab IS the full vocab, so normalize to compiled CE rather than
# leaving loss_impl='loss-parallel' to hit an unhandled impl at the call
# site. This also covers the HF + --tp>1 --loss-impl=loss-parallel case:
# tp is now 1, so we fall back instead of entering vocab-parallel CE with a
# stale TP group.
use_loss_parallel = (
getattr(args, "loss_impl", "eager") == "loss-parallel" and args.tp > 1
)
if getattr(args, "loss_impl", "eager") == "loss-parallel" and not use_loss_parallel:
logger.warning(
"--loss-impl=loss-parallel requires tp>1 (got tp=%d); falling back "
"to compiled CE.",
args.tp,
)
args.loss_impl = "compiled"
if is_hf_model:
# HF path: FSDP2-only wrap (no TP โ the TP plan is ezpz-specific).
# Apply activation checkpointing first (HF models use their own
# gradient_checkpointing_enable inside _apply_activation_checkpointing),
# then fully_shard each decoder block + the root.
if args.activation_checkpoint != "none":
_apply_activation_checkpointing(model, args.activation_checkpoint)
# Find the decoder block stack: the SINGLE deepest non-empty
# ModuleList (e.g. `model.model.layers`). Collecting every ModuleList
# would over-shard MoE/multimodal models; the deepest one is reliably
# the decoder stack (matches _find_block_list / HF's _no_split_modules).
deepest_modlist: Optional[torch.nn.ModuleList] = None
deepest_depth = -1
deepest_len = -1
for name, module in model.named_modules():
if (
isinstance(module, torch.nn.ModuleList)
and len(module) > 0
):
depth = name.count(".")
if depth > deepest_depth or (
depth == deepest_depth and len(module) > deepest_len
):
deepest_depth = depth
deepest_len = len(module)
deepest_modlist = module
# Same HSDP mesh selection as parallelize(): 2D DP submesh when
# replicating, else the 1D shard mesh (identical to pre-HSDP).
if device_mesh["dp_replicate"].size() > 1:
hf_dp_mesh = device_mesh["dp_replicate", "dp_shard"]
else:
hf_dp_mesh = device_mesh["dp_shard"]
hf_fsdp_kwargs = {
"mesh": hf_dp_mesh,
"reshard_after_forward": _reshard_arg(args.reshard_after_forward),
}
if mp_config is not None:
hf_fsdp_kwargs["mp_policy"] = mp_config
if deepest_modlist is not None:
for block in deepest_modlist:
fully_shard(block, **hf_fsdp_kwargs)
else:
logger.warning(
"HF model: no decoder ModuleList found; sharding only the "
"root module (per-layer memory savings will be reduced)."
)
fully_shard(model, **hf_fsdp_kwargs)
_configure_fsdp_gradient_division(model)
else:
# TP + FSDP2. parallelize() applies activation checkpointing per
# block BEFORE fully_shard (correct FSDP2 ordering), so we do NOT
# re-apply it afterwards.
# loss-parallel needs vocab-sharded (local) logits out of the output
# projection; only meaningful at tp>1 (at tp=1 there's nothing to
# shard, so it falls back to eager โ see the loss call site).
model = parallelize(
model,
device_mesh,
mp_config,
reshard_after_forward=args.reshard_after_forward,
activation_checkpoint=args.activation_checkpoint,
loss_parallel=use_loss_parallel,
meta_init=meta_init,
device=device,
)
if args.compile:
# Activation-memory budget for the inductor min-cut partitioner.
# Default 1.0 = save every activation (no recompute); < 1.0 lets the
# compiler recompute a fraction of activations in backward to cut
# peak memory. This is the knob torchtitan's MemoryBudgetAC sets
# (0.5) โ it's why TT fits a larger batch than this example for the
# same model. Global config, applies to every compiled block below.
if args.act_mem_budget != 1.0:
import torch._functorch.config as _functorch_config
_functorch_config.activation_memory_budget = args.act_mem_budget
logger.info(
"Set activation_memory_budget=%.3f (inductor will recompute "
"activations in backward to cut peak memory).",
args.act_mem_budget,
)
if args.activation_checkpoint != "none":
# --ac + --compile together trip an upstream AOTAutograd bug:
# AssertionError: expected all tensors_saved_with_vc_check to
# be Tensors, got [... DeviceMesh]
# The non-reentrant checkpoint_wrapper saves a DeviceMesh into
# the autograd graph, which the compiled-backward saved-tensors
# check rejects. Under FSDP2 every sharded module carries a
# DeviceMesh, so this fires even at --tp 1 (with FSDP1 it
# required --tp > 1). Repro + triage:
# torchtitan/.../docs/upstream-issues/repro_devicemesh_in_saved_tensors.py
# Not fixable here โ drop one of --ac / --compile. (With FSDP2
# you typically no longer need --ac for memory; it was a
# workaround for the FSDP1 OOM that FSDP2 already resolves.)
logger.warning(
"--compile + --activation-checkpoint=%s will likely crash "
"with an AOTAutograd 'tensors_saved_with_vc_check ... "
"DeviceMesh' assertion (upstream bug; fires under FSDP2 even "
"at --tp 1). Drop one of --ac / --compile. Note FSDP2 usually "
"removes the need for --ac (it fixed the FSDP1 OOM).",
args.activation_checkpoint,
)
# Compile each TransformerBlock individually rather than the whole
# model. This is what torchtitan does (apply_compile in
# torchtitan/models/.../infra/parallelize.py) and it dodges the
# Dynamo + DTensor _MaskPartial graph break that whole-model
# compile hits on TP-wrapped tok_embeddings:
#
# RuntimeError when making fake tensor call: call_method
# redistribute(...) on DTensor(_MaskPartial(...))
#
# The embedding's RowwiseParallel output_fn does a redistribute
# from _MaskPartial โ Shard(1), which Dynamo can't trace under
# fake tensors. Excluding the embedding from compile (and only
# compiling the blocks) keeps the speedup where it matters
# (attention + MLP, the repeated structure) without exposing
# Dynamo to the TP output transform. Bonus: compile cost is paid
# once for one block and reused across N layers, not N times.
#
# Find the block list: ezpz Transformer has `.layers`, HF
# decoder-only models nest it as `.model.layers`.
block_container = None
if hasattr(model, "layers"):
block_container = model.layers
elif hasattr(model, "model") and hasattr(model.model, "layers"):
block_container = model.model.layers
if block_container is None:
logger.warning(
"Could not find a TransformerBlock list (model.layers or "
"model.model.layers) โ falling back to whole-model "
"torch.compile, which may hit DTensor graph breaks."
)
model = torch.compile(model, mode=args.compile_mode)
else:
logger.info(
"Compiling each TransformerBlock with torch.compile"
"(mode=%s, fullgraph=True) โ %d blocks.",
args.compile_mode,
len(block_container),
)
for layer_id, block in block_container.named_children():
compiled = torch.compile(
block, mode=args.compile_mode, fullgraph=True
)
block_container.register_module(layer_id, compiled)
base_model = model
if not hasattr(base_model, "layers"):
base_model = getattr(model, "_fsdp_wrapped_module", model)
# Resolve fused-linear eligibility now that the model exists. Requires the
# ezpz Transformer (has `.output` weight + return_hidden) and tp=1 for now.
use_fused_linear = False
if want_fused_linear:
has_output = hasattr(base_model, "output") and hasattr(
base_model.output, "weight"
)
if is_hf_model or not has_output:
logger.warning(
"--loss-impl=fused-linear needs the ezpz Transformer "
"(hidden states + output weight); falling back to compiled "
"for this model."
)
# Normalize so the `_compute_loss` call site actually runs compiled
# CE โ leaving loss_impl='fused-linear' would fall through to an
# unhandled impl (now an error; previously silent eager + OOM).
args.loss_impl = "compiled"
elif args.tp > 1:
logger.warning(
"--loss-impl=fused-linear with tp>1 is not yet supported "
"(needs vocab-shard composition); falling back to compiled."
)
args.loss_impl = "compiled"
else:
use_fused_linear = True
act_activations: dict[str, torch.Tensor] = {}
act_handles: list[torch.utils.hooks.RemovableHandle] = []
if track_hist and track_act_hist and ezpz.get_rank() == 0 and not is_hf_model:
# `_register_activation_hooks` indexes into `model.layers[i]`, which
# is ezpz-Transformer specific. HF models nest blocks under
# `model.model.layers` (Llama/Mistral) or `model.gpt_neox.layers`
# (GPT-NeoX) etc., so the hook registration would key-error. Skip
# the hooks for HF runs; the rest of the metrics still work.
hist_layers_spec = os.environ.get(
"EZPZ_HIST_LAYERS", f"0,{config.n_layers - 1}"
)
layer_ids = _parse_hist_layers(hist_layers_spec, config.n_layers)
act_activations, act_handles = _register_activation_hooks(
base_model, layer_ids
)
logger.info(f"Creating optimizer=AdamW with lr={args.lr}")
# Prefer the fused AdamW kernel (single kernel for the whole param
# update) โ it's what torchtitan uses and it's measurably faster than
# `foreach` on XPU. Fall back to foreach if fused isn't supported for
# this build/device (older torch, CPU, etc.).
try:
optimizer = torch.optim.AdamW(
model.parameters(),
lr=args.lr,
betas=(0.9, 0.95),
eps=1e-8,
weight_decay=0.1,
fused=True,
)
except (RuntimeError, ValueError) as exc:
logger.warning(
"Fused AdamW unavailable (%s); falling back to foreach=True.", exc
)
optimizer = torch.optim.AdamW(
model.parameters(),
lr=args.lr,
betas=(0.9, 0.95),
eps=1e-8,
weight_decay=0.1,
foreach=True,
)
# Log the ACTUAL precision of each component (introspects the live model +
# MP policy, not the intended config), push it to the W&B run config, and
# keep the dict to (a) fold into the first metrics row and (b) amend with
# the optimizer-state dtype after the first step (on a FRESH run the state
# dict is empty until then; a RESUMED run has it populated by
# load_checkpoint, so reading it post-step-1 is correct either way).
_precision_summary = _log_precision_summary(model, mp_config, _reduce_dtype, args)
# --- Resume from checkpoint (auto-detect latest) --------------------------
# Both model AND optimizer exist and are fully sharded here, so this is the
# correct point to restore sharded DCP state into them. Auto-resume (no
# flag) is what lets `ezpz launch --auto-retry` โ which relaunches the
# IDENTICAL command each attempt โ pick up where a failed attempt left off.
# Async checkpointing needs a durable target to fan out to; a node-local
# stage dir alone is not resumable after a failure (see _checkpoint.py).
if args.async_ckpt and not args.ckpt_dir:
raise ValueError("--async-ckpt requires --ckpt-dir (the durable target)")
ckpt_stage_dir = args.ckpt_stage_dir
if args.async_ckpt and not ckpt_stage_dir:
jobid = (
os.environ.get("PBS_JOBID")
or os.environ.get("SLURM_JOB_ID")
or str(os.getpid())
).split(".")[0]
ckpt_stage_dir = f"/tmp/ezpz-ckpt-{jobid}"
# In-flight async checkpoint handle (drained before the next save + at exit).
pending_ckpt = None
resume_meta: "dict[str, object] | None" = None
if args.ckpt_dir and not args.no_resume:
from ezpz.examples._checkpoint import load_checkpoint
resume_meta = load_checkpoint(args.ckpt_dir, model, optimizer)
if resume_meta is not None and ezpz.get_rank() == 0:
logger.info(
"RESUMED from step=%s (epoch=%s, batch_offset=%s, "
"tokens_seen=%s)",
resume_meta.get("step"),
resume_meta.get("epoch"),
resume_meta.get("batch_offset"),
resume_meta.get("tokens_seen"),
)
# reuse device for input placement
tp_group = device_mesh.get_group("tp")
if args.dataset.lower() == "mnist":
data_prefix = Path(os.getcwd()).joinpath(
".cache", "ezpz", "data", f"{args.dataset.lower()}"
)
from ezpz.data.vision import get_mnist
from ezpz.data.distributed import TPBroadcastDataLoader
data = get_mnist(
outdir=Path(data_prefix),
train_batch_size=args.batch_size,
test_batch_size=args.test_batch_size,
num_replicas=dpsize,
rank=device_mesh.get_local_rank("dp"),
pin_memory=True,
num_workers=args.num_workers,
)
dataset = data["dataset"]
sampler = data["sampler"]
dataloader = data["dataloader"]
if args.tp > 1:
dataloader = TPBroadcastDataLoader(dataloader, tp_group)
elif args.dataset.lower() == "random":
from ezpz.data.distributed import get_random_dataset_fsdp_tp
data = get_random_dataset_fsdp_tp(
batch_size=args.batch_size,
vocab_size=args.vocab_size,
seq_length=args.seq_len,
dp_group=device_mesh.get_group("dp"),
tp_group=tp_group,
broadcast_within_tp=True,
drop_last=True,
)
dataset = data["dataset"]
sampler = data["sampler"]
dataloader = data["dataloader"]
# if args.dataset.lower() != "random":
else:
from ezpz.data.distributed import TPBroadcastDataLoader
assert hf_dataset is not None
dataset = hf_dataset
# drop_last=True (both sampler + loader) so every batch has a static
# batch dim โ a ragged tail triggers a torch.compile recompile at the
# epoch boundary that OOMs the compiled CE. See _build_hf_dataloader.
sampler, dataloader = _build_hf_dataloader(
dataset,
batch_size=args.batch_size,
dpsize=dpsize,
dp_rank=device_mesh.get_local_rank("dp"),
world_size=ezpz.get_world_size(),
)
if args.tp > 1:
dataloader = TPBroadcastDataLoader(dataloader, tp_group)
# ezpz.breakpoint(0)
logger.info("Starting 2D training...")
model.train()
# outdir = Path(args.outdir).joinpath(ezpz.utils.get_timestamp())
metrics_path = Path(outdir).joinpath(
f"metrics-{ezpz.distributed.get_rank()}.jsonl"
)
Path(outdir).mkdir(parents=True, exist_ok=True)
history = ezpz.history.History(
project_name=WBPROJ_NAME,
config={"args": vars(args), **ezpz.get_dist_info()},
outdir=outdir,
report_dir=outdir,
report_enabled=True,
jsonl_path=metrics_path,
jsonl_overwrite=True,
# Disable cross-rank history aggregation while profiling (either
# profiler) โ the all-gather of per-rank metrics perturbs the very
# step times the profiler is measuring.
distributed_history=(
1 < world_size <= 384
and not getattr(args, "pytorch_profiler", False)
and not getattr(args, "pyinstrument_profiler", False)
),
)
# Re-push the precision summary now that History has created the W&B run.
# The initial push (at optimizer-build time, above) runs BEFORE this and so
# no-ops on `wandb.run is None`; without this second push a run that dies
# before its first optimizer step would leave W&B with no precision
# diagnostics at all. Cheap + idempotent (config update, allow_val_change).
_push_precision_to_wandb(_precision_summary)
# For TP, input needs to be the same across all TP ranks.
# while for SP, input can be different across all ranks
# We will use dp_rank for setting the random seed
# to mimic the behavior of the dataloader
# x = torch.tensor((args.batch_size, args.seq_len))
x = torch.tensor(0)
global_step = 0
# Cumulative count of training tokens consumed across the whole run
# (summed global tokens/step). Logged as train/tokens_seen โ the standard
# x-axis for loss-vs-tokens curves. See the metrics block: global
# tokens/step = batch * full_seq_len * dpsize (full pre-shard seq length,
# not the SP-local shard, so it's exact and rank-invariant).
tokens_seen = 0
# Checkpoint timings from the PREVIOUS step, folded into the next step's
# metrics dict so they reach JSONL + W&B:
# - train/ckpt_save_seconds : sync save's blocking write (all 23 GB to
# the durable dir on the training thread).
# - train/ckpt_stage_seconds : async save's CPU-stage stall only (the
# cheap part โ copy state to host, kick off the background write).
# - train/ckpt_drain_seconds : async fan-out (/tmp -> shared FS) blocking
# time at the START of the next step. This is the EXPENSIVE half of an
# async save and was previously untimed โ it lands between steps, so it
# is captured by neither ckpt_stage_seconds nor train/dt. The honest
# per-save stall for async is stage + drain, NOT stage alone.
pending_stage_seconds: "float | None" = None
pending_save_seconds: "float | None" = None
pending_drain_seconds: "float | None" = None
# Resume bookkeeping. When resuming, seed the counters from the checkpoint
# and reconstruct (start_epoch, resume_offset) so the loop skips
# already-consumed batches. drop_last=True on both sampler and loader makes
# batches-per-epoch deterministic, so global_step -> (epoch, offset) is
# exact. batches_per_epoch may be 0 for iterable/unsized loaders โ guard it.
start_epoch = 0
resume_offset = 0
if resume_meta is not None:
global_step = int(resume_meta.get("step", 0) or 0)
tokens_seen = int(resume_meta.get("tokens_seen", 0) or 0)
try:
batches_per_epoch = len(dataloader)
except TypeError:
batches_per_epoch = 0
if batches_per_epoch > 0:
start_epoch = global_step // batches_per_epoch
resume_offset = global_step % batches_per_epoch
else:
# Unsized loader: fall back to the saved epoch/offset if present.
start_epoch = int(resume_meta.get("epoch", 0) or 0)
resume_offset = int(resume_meta.get("batch_offset", 0) or 0)
# Time-to-first-post-resume-step: measured against the training entry
# timestamp captured at the top of train() (see _train_t0), logged once
# on the first completed step below as train/restart_seconds.
_resumed = True
else:
_resumed = False
# Push the RESOLVED CLI args to the wandb run config now โ after every
# args mutation (HF tp-force, loss_impl normalization) and after the
# global_batch_size backfill โ so the logged config reflects the settings
# actually used for the rest of training, not the requested ones.
if (
ezpz.get_rank() == 0
and wandb is not None
and getattr(wandb, "run", None) is not None
):
# allow_val_change since some keys may already be present from main().
wandb.config.update(vars(args), allow_val_change=True) # type:ignore
for epoch in range(start_epoch, args.epochs):
if sampler is not None:
sampler.set_epoch(epoch)
for idx, batch in enumerate(dataloader):
# Skip already-consumed batches in the resumed epoch only.
if _resumed and epoch == start_epoch and idx < resume_offset:
continue
# Step-based stop (independent of --epochs).
if args.train_iters and global_step >= args.train_iters:
break
# Finalize the backgrounded async fan-out AS SOON AS every rank's
# copy is done โ not deferred to the next save boundary. Each step
# this cheaply votes (a torch all-reduce on the main thread, in
# lockstep across ranks โ the same footing as the barriers already
# here, no cross-thread hazard) whether all ranks finished copying;
# when they have, it stamps the durable .complete marker. This keeps
# the saved-but-not-yet-durable window to ~copy-duration (~seconds)
# instead of a full save interval, so a crash falls back at most ~1
# interval like a sync save. The guard is rank-uniform (pending_ckpt
# is set/cleared identically on every rank), so all ranks enter the
# collective together. None-safe / no-op until ready.
if args.async_ckpt and pending_ckpt is not None:
from ezpz.examples._checkpoint import try_finalize_if_ready
_fin_t0 = perf_counter()
if try_finalize_if_ready(pending_ckpt) is not None:
pending_drain_seconds = perf_counter() - _fin_t0
pending_ckpt = None
ezpz.distributed.synchronize()
t0 = perf_counter()
attn_mask = None
if isinstance(batch, dict) and "input_ids" in batch:
x = batch["input_ids"]
attn_mask = batch.get("attention_mask")
else:
x = batch
assert isinstance(x, torch.Tensor)
x = x.to(device)
x = x.to(torch.long)
if args.dataset == "random":
inp = x[:, :-1]
labels = x[:, 1:]
else:
inp = x[:, :-1]
labels = x[:, 1:]
inp = inp.to(device)
labels = labels.to(device)
if attn_mask is not None:
attn_mask = attn_mask.to(device)
# fused-linear gets hidden states (B,T,dim) instead of logits, so
# the loss can form logits in chunks and never materialize the full
# (B,T,vocab) tensor. Otherwise the model returns logits as usual.
if use_fused_linear:
pred = model(inp, return_hidden=True)
else:
pred = model(inp)
# HF causal-LM models return a CausalLMOutput dataclass with a
# `.logits` tensor; ezpz's Transformer returns logits directly.
if hasattr(pred, "logits"):
pred = pred.logits
# pred is (B,T,vocab) logits, or (B,T,dim) hidden under
# fused-linear; either way dim-1 is the (SP-local) seq length.
local_seq_len = pred.shape[1]
if labels.shape[1] != local_seq_len:
labels = _slice_for_sequence_parallel(labels, local_seq_len)
if attn_mask is not None:
if attn_mask.shape[1] > 1:
attn_labels = attn_mask[:, 1:]
else:
attn_labels = attn_mask
if attn_labels.shape[1] != local_seq_len:
attn_labels = _slice_for_sequence_parallel(
attn_labels, local_seq_len
)
# Build a single ignore-mask (attention-pad OR tokenizer-pad) and
# apply it with ONE masked_fill, instead of two .clone()s + two
# boolean index-assigns. Each .clone() + labels[mask]=-100 was a
# separate aten::copy_ per step; this collapses them to one copy.
# -100 can't collide with a valid pad_id (>=0), so mask order is
# irrelevant. (Profiling: agpt-2b aten::copy_ was ~8.8% of step.)
pad_id = getattr(dataset, "pad_id", None)
ignore_mask = None
if attn_mask is not None:
ignore_mask = attn_labels == 0
if pad_id is not None:
pad_mask = labels == int(pad_id)
ignore_mask = (
pad_mask if ignore_mask is None else (ignore_mask | pad_mask)
)
if ignore_mask is not None:
labels = labels.masked_fill(ignore_mask, -100)
ezpz.distributed.synchronize()
t1 = perf_counter()
tp_mod = getattr(ezpz, "tp", None)
tp_rank = (
getattr(tp_mod, "get_tensor_parallel_rank", lambda: 0)()
if tp_mod is not None
else 0
)
# First-step finite/max debug stats. Gated behind
# EZPZ_TRACK_LOGITS because `torch.isfinite(pred)` allocates a
# full `(B, T, vocab)`-shaped bool tensor on the un-reduced
# logits โ at agpt's 256K vocab and long seq that's multiple GB
# materialized *before* the loss, which can OOM a run that would
# otherwise fit (the loss itself may be chunked/compiled to stay
# bounded, but this debug probe is not). Off by default.
# Skip the logits probe under fused-linear: `pred` is hidden
# states there, not logits, so finite/max-abs of it is meaningless
# (and the full logits are never materialized by design).
if track_logits and not use_fused_linear and epoch == 0 and idx == 0:
pred_finite = torch.isfinite(pred)
pred_nonfinite = int((~pred_finite).sum().item())
pred_max = float(pred.abs().max().item())
logger.info(
"pred_stats rank=%s tp=%s shape=%s nonfinite=%s max_abs=%s",
ezpz.get_rank(),
tp_rank,
tuple(pred.shape),
pred_nonfinite,
f"{pred_max:.6f}",
)
if use_fused_linear:
# `pred` is hidden states (B,T,dim); the fused loss runs the
# output projection MODULE per row-chunk (so FSDP unshards the
# weight + routes its grad) and never materializes the full
# (B,T,vocab) logits or its grad.
loss = _cross_entropy_fused_linear(
pred,
base_model.output,
labels,
ignore_index=-100,
chunk_size=args.loss_chunk_size,
)
elif use_loss_parallel:
# `pred` is this rank's local [B, T, vocab/tp] shard
# (output projection has use_local_output=True under
# loss-parallel). Vocab-parallel CE reduces across the TP
# group; global vocab is needed to compute shard bounds.
loss = _cross_entropy_vocab_parallel(
pred,
labels,
ignore_index=-100,
global_vocab_size=args.vocab_size,
tp_group=tp_group,
)
else:
# tp>1 non-loss-parallel: `pred` is a REPLICATED DTensor
# (output ColwiseParallel output_layouts=Replicate,
# use_local_output=False) but `labels` is plain, so plain CE
# would raise "mixed torch.Tensor and DTensor". Localize to a
# plain tensor first (no-op at tp=1/HF). See
# _localize_logits_for_loss for the full rationale + guard.
loss = _compute_loss(
_localize_logits_for_loss(pred),
labels,
impl=args.loss_impl,
ignore_index=-100,
chunk_size=args.loss_chunk_size,
)
if epoch == 0 and idx == 0:
valid_labels = int((labels != -100).sum().item())
logger.info(
"loss_inputs rank=%s tp=%s local_seq_len=%s labels=%s valid_labels=%s",
ezpz.get_rank(),
tp_rank,
local_seq_len,
tuple(labels.shape),
valid_labels,
)
# loss = F.cross_entropy(
# pred.flatten(0, 1),
# labels.flatten(0, 1),
# )
# loss = output.loss
optimizer.zero_grad(set_to_none=True)
loss.backward()
grad_norm_preclip = None
if args.max_grad_norm > 0:
grad_norm_preclip = torch.nn.utils.clip_grad_norm_(
model.parameters(), args.max_grad_norm
)
optimizer.step()
# AdamW state (exp_avg/exp_avg_sq) is allocated by the first step on
# a fresh run (a resumed run already has it from load_checkpoint);
# log its dtype once, now that it exists (also amends the summary +
# W&B config with the optimizer-state dtype).
if not _optim_dtype_logged:
_log_optimizer_state_dtype(optimizer, _precision_summary)
_optim_dtype_logged = True
ezpz.distributed.synchronize()
t2 = perf_counter()
global_step += 1
# Advance the torch.profiler schedule once per optimizer step.
# No-op when not profiling (profiler is None).
if profiler is not None:
profiler.step()
metrics: dict[str, object] = {
"train/iter": global_step,
"train/epoch": epoch,
"train/bidx": idx,
"train/loss": loss.item(),
"train/dt": t2 - t0,
"train/dtf": t1 - t0,
"train/dtb": t2 - t1,
}
if grad_norm_preclip is not None:
metrics["grad/norm_preclip"] = float(grad_norm_preclip)
if global_step % max(metrics_every, 1) == 0:
metrics.update(_collect_param_grad_stats(model, device))
metrics["opt/iter"] = (global_step,)
metrics["opt/lr"] = float(optimizer.param_groups[0]["lr"])
metrics["input/iter"] = (global_step,)
metrics["input/max"] = float(x.max().item())
metrics["input/min"] = float(x.min().item())
metrics["labels/valid"] = float((labels != -100).sum().item())
if track_logits:
pred_finite = torch.isfinite(pred)
metrics["logits/nonfinite"] = float(
(~pred_finite).sum().item()
)
metrics["logits/max_abs"] = float(pred.abs().max().item())
if track_hist and ezpz.get_rank() == 0:
logits_sample = _sample_tensor_values(pred, hist_samples)
if logits_sample is not None:
logits_hist = _histogram_dict(logits_sample, hist_bins)
if logits_hist is not None:
metrics[f"hist/{dataset_tag}/logits"] = logits_hist
layer_grad_norms = _collect_layer_grad_norms(base_model)
if layer_grad_norms:
layer_grad_hist = _histogram_dict(
torch.tensor(layer_grad_norms), hist_bins
)
if layer_grad_hist is not None:
metrics[
f"hist/{dataset_tag}/grad_norm_per_layer"
] = layer_grad_hist
if track_act_hist and act_activations:
for act_key, act_tensor in act_activations.items():
act_sample = _sample_tensor_values(
act_tensor, hist_samples
)
act_hist = _histogram_dict(act_sample, hist_bins)
if act_hist is not None:
metrics[
f"hist/{dataset_tag}/activations/{act_key}"
] = act_hist
if history.tracker.get_backend("wandb") is not None:
_wandb_log_histograms(
metrics, step=global_step, enabled=track_hist
)
# Reuse the train/dt we already computed above so the MFU
# denominator can never silently drift from the reported
# step time.
dt_step = float(metrics["train/dt"]) # type: ignore[arg-type]
if _model_flops > 0 and dt_step > 0:
# Per-DEVICE TFLOPS / MFU. `_model_flops` is counted on the
# FULL, un-sharded model (estimated before parallelize() applies
# TP), so it is the work of ONE data-parallel group โ done
# COLLECTIVELY by the group's `tp` GPUs. Divide by args.tp so
# each metric reflects a single GPU's share; otherwise per-GPU
# TFLOPS/MFU over-count by exactly `tp` (2x at tp=2, 4x at tp=4;
# at tp=1 this is a no-op). Divide by tp, NOT world_size: unlike
# the GLOBAL token count (tps_per_gpu รท world_size), _model_flops
# is already per-DP-group, so only the tp ranks sharing that one
# model must be divided out โ the dpsize groups each do this
# full-model work independently.
flops_per_gpu = _model_flops / args.tp
metrics["train/tflops"] = flops_per_gpu / dt_step / 1e12
metrics["train/mfu"] = compute_mfu(flops_per_gpu, dt_step)
# Throughput.
# - train/tps : global tokens/sec across all GPUs
# - train/tps_per_gpu : per-GPU tokens/sec (torchtitan's `tgs`)
#
# Global tokens/step = batch * FULL pre-shard seq len (inp.shape[1])
# * dpsize. `inp` is Replicate() across the tp group (the TP plan
# shards only the embedding OUTPUT, never the input), so inp.shape[1]
# is the full sequence, identical on every rank โ exact and
# rank-invariant even though only rank 0 logs. tp does NOT enter the
# GLOBAL count: the tp ranks hold the SAME sequence (full-length
# logits under the default Replicate() output, or Shard(1) slices
# summing to inp.shape[1] under fused-linear / loss-parallel), never
# distinct sequences. On the HF path (tp forced to 1, no SP) the
# tp-dim ranks see DUPLICATE samples, so multiplying by dpsize (not
# world_size) counts each distinct token once.
# Global tokens processed THIS step across all distinct-data ranks.
tokens_this_step = args.batch_size * inp.shape[1] * dpsize
tokens_seen += tokens_this_step
# Cumulative consumed training tokens โ the standard x-axis for
# loss curves. Accumulated every step (this block runs each step),
# so it's exact regardless of the metrics-logging interval.
metrics["train/tokens"] = tokens_this_step
metrics["train/tokens_seen"] = tokens_seen
if dt_step > 0:
# Per-GPU throughput = global tokens / (actual GPU count) / dt.
# Divide by world_size, NOT dpsize: under TP the `tp` GPUs in a
# data-parallel group process that group's tokens TOGETHER, so
# the per-GPU rate is tpร lower. (tokens_per_rank / dt would be
# per-DP-group, over-counting per-GPU by `tp`; at tp=1
# world_size==dpsize so this is unchanged.)
metrics["train/tps_per_gpu"] = tokens_this_step / world_size / dt_step
metrics["train/tps"] = tokens_this_step / dt_step
# Async-ckpt staging time from the previous step's save, carried
# here so it lands in the JSONL + W&B alongside the other train/*
# metrics (set once, then cleared).
if pending_stage_seconds is not None:
metrics["train/ckpt_stage_seconds"] = pending_stage_seconds
pending_stage_seconds = None
if pending_save_seconds is not None:
metrics["train/ckpt_save_seconds"] = pending_save_seconds
pending_save_seconds = None
if pending_drain_seconds is not None:
metrics["train/ckpt_drain_seconds"] = pending_drain_seconds
pending_drain_seconds = None
# Restart time: on the FIRST completed step after a resume, log how
# long from train() entry (process init + dist setup + model build
# + dcp.load + this step) to a productive step. This is the full
# cold path a real --auto-retry failover pays. Logged once.
if _resumed and not _restart_logged:
metrics["train/restart_seconds"] = perf_counter() - _train_t0
_restart_logged = True
# Device memory: empty on CPU/MPS, 4 keys on CUDA/XPU.
metrics |= ezpz.get_memory_metrics(prefix="train/")
history.update(metrics, summarize=False)
# Write the precision summary as its OWN JSONL record, once.
# Deliberately NOT merged into `metrics`: History is a NUMERIC
# store (it coerces values with float() and builds an xarray
# Dataset), so string dtype names there break get_dataset() with
# "ValueError: too many dimensions 'str'" โ taking the end-of-run
# report/plots down with it. _write_jsonl_entry is string-safe and
# independent of the numeric store. Emitted after step 1 so
# optimizer_states is populated.
if not _precision_in_jsonl:
try:
history._write_jsonl_entry(
{f"precision/{k}": v for k, v in _precision_summary.items()}
)
except Exception: # noqa: BLE001 โ logging must not break training
logger.debug("could not write precision JSONL entry", exc_info=True)
_precision_in_jsonl = True
history.log_metrics(
metrics,
logger=logger,
debug_prefixes=("hist/", "grad/", "input/", "labels/", "param/"),
include_summary=True,
rank0_only_summary=True,
)
if epoch == 0 and idx == 0:
logger.info(f"{x.shape}")
# Save a checkpoint every --save-interval optimizer steps.
if (
args.ckpt_dir
and args.save_interval
and global_step % args.save_interval == 0
):
_ckpt_meta = {
"tokens_seen": tokens_seen,
"epoch": epoch,
# Next batch to run on resume within `epoch`.
"batch_offset": idx + 1,
}
if args.async_ckpt:
from ezpz.examples._checkpoint import (
finalize_fanout,
save_checkpoint_async,
start_fanout,
)
# Only one async save may be in flight (DCP constraint), so
# ensure the previous one is finalized before starting a new
# one. Normally the per-step try_finalize_if_ready already
# finalized it (pending_ckpt is None here); this is the
# fallback for when the save interval is SHORTER than the
# copy โ then finalize_fanout blocks on the still-running
# copy. Only record the drain time when we actually finalized
# here (else keep the per-step poll's measurement). None-safe.
if pending_ckpt is not None:
_drain_t0 = perf_counter()
finalize_fanout(pending_ckpt)
pending_drain_seconds = perf_counter() - _drain_t0
pending_ckpt = None
_t_stage = perf_counter()
pending_ckpt = save_checkpoint_async(
args.ckpt_dir,
ckpt_stage_dir,
global_step,
model,
optimizer,
meta=_ckpt_meta,
)
# Caller-thread stall = staging time only. Stash it so the
# NEXT step's metrics dict carries train/ckpt_stage_seconds
# through history.update -> JSONL + W&B (this save block runs
# after the current step's metrics were already written).
_stage_s = perf_counter() - _t_stage
pending_stage_seconds = _stage_s
# Kick the /tmp -> shared-FS fan-out onto a background
# thread; it overlaps the next save interval of training and
# is finalized at the next save boundary (above).
start_fanout(pending_ckpt)
if ezpz.get_rank() == 0:
logger.info(
"train/ckpt_stage_seconds=%.4f (async stage @ "
"step %d)",
_stage_s,
global_step,
)
else:
from ezpz.examples._checkpoint import save_checkpoint
# Synchronous save BLOCKS the training loop for the full
# write. Time it (train/ckpt_save_seconds) so the sync-vs-
# async trade-off is measurable โ this is the stall async
# removes. Folded into the next step's metrics dict.
_t_save = perf_counter()
save_checkpoint(
args.ckpt_dir,
global_step,
model,
optimizer,
meta=_ckpt_meta,
)
pending_stage_seconds = None # (async-only; keep clear)
_save_s = perf_counter() - _t_save
pending_save_seconds = _save_s
if ezpz.get_rank() == 0:
logger.info(
"train/ckpt_save_seconds=%.4f (sync save @ step %d)",
_save_s,
global_step,
)
# Step-based stop breaks the inner loop; also break the epoch loop.
if args.train_iters and global_step >= args.train_iters:
break
# Finish any in-flight async checkpoint so a run that ends right after a
# save doesn't lose it (the background fan-out may still be running), then
# tear down the fan-out worker. drain() joins the background copy if one is
# in flight, else runs it inline; either way it does the barrier + marker.
if args.async_ckpt:
from ezpz.examples._checkpoint import drain, shutdown_fanout_pool
drain(pending_ckpt)
shutdown_fanout_pool()
if act_handles:
for handle in act_handles:
handle.remove()
ezpz.distributed.barrier()
logger.info("Finished 2D training")
return history