(fsdp2 dev.) bench: FSDP1 vs FSDP2 backend benchmark harness (step-level and end-to-end) - #926
Closed
yushengsu-thu wants to merge 5 commits into
Closed
yushengsu-thu wants to merge 5 commits into
yushengsu-thu wants to merge 5 commits into
Conversation
benchmarks/fsdp_backend drives the real TrainerCore / strategy / backend seam with production-shaped EAGLE3, DFlash2 and DSpark drafts (random weights, synthetic batches) under torchrun and records step time, peak memory, host synchronizations and NCCL collective counts per rank; summarize.py renders the FSDP1/FSDP2 comparison as markdown.
…summary Runs can be labelled (--label) and request the sibling PRs' BackendOptions (--compile-blocks, --fp8-linear, --shard-frozen-tables) when the checkout defines them; --measure-checkpoint times the full-state gather and the synchronous/asynchronous write; summarize.py renders one column per label with deltas against the fsdp2 baseline.
e2e_offline.py drives build_offline_runtime -> Trainer.fit() on synthetic feature files (offline reader, FeatureDataLoader workers, controller acks, logging, final checkpoint) and reports steady-state throughput from the controller's log callbacks plus total wall time; summarize_e2e.py renders it. summarize.py now takes profiler and checkpoint fields from rank 0.
yushengsu-thu
force-pushed
the
bench/fsdp-backend-compare
branch
from
October 4, 2026 06:09
07fff12 to
76f8bf7
Compare
This was referenced Oct 4, 2026
Draft
Closed
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Conclusion (what the harness measured on 4 × B300, torch 2.13.0+cu130)
benchmarks/fsdp_backend/(bench_fsdp_backends.py564,e2e_offline.py293,summarize.py148,summarize_e2e.py65); no changes tospecforge/, no tests, no docs.| change | speed (offline e2e, steady state) | memory (peak allocated per rank) |
|---|---|---|
| FSDP1 → FSDP2 ([verifying] Add optional FSDP2 training backend #915) | EAGLE3 faster −132 ms/step (796 → 664 ms); DFlash2 / DSpark no change (+17 / −14 ms) | EAGLE3 smaller −1782 MB; DFlash2 / DSpark no change |
| FSDP2 +
compile_blocks((fsdp2 dev. perf) Add training.compile_blocks: per-block torch.compile before fully_shard #922), fixed-length inputs | faster −182 / −88 / −95 ms (DFlash2 / DSpark / EAGLE3) | smaller −3252 / −2730 / −784 MB || FSDP1 +
static_shapes((fsdp2 dev. perf) Add training.compile_blocks: per-block torch.compile before fully_shard #922's option on FSDP1), online third devbox | DFlash2 faster, −128 ms per step (1083 → 955 ms compute per step, −11.9%); DSpark faster, −54 ms per step (721 → 667 ms compute per step, −7.5%) | DFlash2 larger, small: +2838 MB (54812 → 57650 MB nvidia-smi max); DSpark no change (41340 → 42594 MB nvidia-smi max, +1254 MB, within the granularity of reserved memory) || FSDP2 +
static_shapes((fsdp2 dev. perf) Add training.compile_blocks: per-block torch.compile before fully_shard #922), variable masks | DFlash2 faster, −738 ms/step (1750 → 1012 ms, −42.2%); DSpark faster, −89 ms/step (823 → 734 ms, −10.8%) | DFlash2 no change (37652 → 37444 MB, −208 MB); DSpark no change (25156 → 25058 MB, −99 MB) || FSDP2 +
static_shapes+compile_blocks((fsdp2 dev. perf) Add training.compile_blocks: per-block torch.compile before fully_shard #922), variable masks | DFlash2 faster, −912 ms/step (1750 → 838 ms, −52.1%); DSpark faster, −182 ms/step (823 → 641 ms, −22.1%) | DFlash2 smaller, large: −3549 MB (37652 → 34103 MB); DSpark smaller, large: −2828 MB (25156 → 22329 MB) || FSDP2 +
compile_blocks+fp8_linear((fsdp2 dev. perf) Add training.fp8_linear: torchao Float8Linear with float8 all-gather #923), fixed-length inputs | faster −225 / −88 / −72 ms vs fsdp2 (vs compile alone: −43 / ±0 / +23 ms) | smaller, large −8869 / −4262 / −1256 MB || FSDP2 +
static_shapes+compile_blocks+fp8_linear((fsdp2 dev. perf) Add training.fp8_linear: torchao Float8Linear with float8 all-gather #923), variable masks | DFlash2 faster, −955 ms/step (1750 → 795 ms, −54.6%); DSpark faster, −176 ms/step (823 → 648 ms, −21.3%) | DFlash2 smaller, large: −8868 MB (37652 → 28783 MB); DSpark smaller, large: −4359 MB (25156 → 20797 MB) || FSDP2 +
fp8_linearwithout compile | slower +135 / +64 / +104 ms per micro-step | small (−961 / −921 / −415 MB) || FSDP2 +
shard_frozen_tables((fsdp2 dev.) Add training.shard_frozen_tables: frozen target tables in the root FSDP group #924) | no change (−1 / +3 ms) | slightly larger peak (+595 / +593 MB), at rest −1782 MB || FSDP2 +
checkpoint_async((fsdp2 dev. perf) Add training.checkpoint_async: background checkpoint writes #925) | faster per save: −5.4 / −5.2 / −1.6 s stall per checkpoint | no change |Motivation
A repeatable way to answer "what does a backend change buy at production shapes" without a dataset or model download, at two levels: the trainer step in isolation and the full training loop. The numbers in #915's benchmark comments and in #922–#925 come from this harness.
What it does
benchmarks/fsdp_backend/bench_fsdp_backends.py(torchrun) builds the real composite training models at production shapes with random weights — EAGLE3 (configs/qwen3-8b-eagle3.json), DFlash2 (configs/qwen3-8b-dflash.json+ DFlash2 conv/selector keys), DSpark (configs/qwen3-8b-dspark.json) — feeds syntheticTrainBatches through the realTrainerCore/ strategy /create_training_backendseam (EAGLE3 through the offline reader/normalizer/collator) and records per rank: optimizer-step and micro-step time, peak allocated/reserved memory, memory after wrap, host synchronizations and NCCL collective counts for one profiled step, loss and grad norm for parity, and optionally checkpoint save timings (--measure-checkpoint).--labelnames a variant;--compile-blocks,--fp8-linear,--shard-frozen-tablesrequest the option PRs'BackendOptionswhen the checkout defines them.benchmarks/fsdp_backend/e2e_offline.py(torchrun) is the end-to-end counterpart:specforge.launch.build_offline_runtime→Trainer.fit()on synthetic feature files on disk (offline reader,FeatureDataLoaderworkers,TrainerControlleracks/logging, interval and final checkpoints), reporting steady-state throughput from the controller's own log callbacks plus total wall time — so a kernel-level win that is eaten elsewhere in the loop shows up.e2e_offline.py --variable-maskzeroes a random 20–80% prefix of every synthetic sample so the valid-anchor count changes per micro-batch, as it does with real conversations;--variable-lengthgives every sample a random length (25–100% of--seq-len) so pad-to-longest batches change shape;--static-shapesturns on (fsdp2 dev. perf) Add training.compile_blocks: per-block torch.compile before fully_shard #922'straining.static_shapes,--shape-buckets 512,1024,1536(fsdp2 dev.) - follow-up : Add training.static_shape_buckets: a few static lengths for compiled blocks #929's buckets;--compile-dynamicrequests dynamic-shape compilation when the checkout supports it. These reproduce offline what the online runs showed for per-block compilation (see "What the online runs taught" below).summarize.py/summarize_e2e.pyrender one column per label with absolute values and deltas againstfsdp2;run_matrix.shruns the FSDP1/FSDP2 matrix on one node. The online (disaggregated) runs below used the productionspecforge trainmanaged-local path with real prompts; their driver scripts are not part of this PR.Features are random tensors in the offline runs: losses are a parity check between backends, not a training signal.
End-to-end comparison — offline (
Trainer.fit)Offline e2e =
build_offline_runtime→Trainer.fit(): offline reader,FeatureDataLoaderwith 4 workers,TrainerControlleracks and logging, final checkpoint; 256 synthetic samples × 6 epochs = 48 optimizer steps, seq 2048, batch 2 × accum 4, DP=4 on 4 × B300 (torch 2.13.0+cu130); steady state = last 24 steps from the controller's log callbacks. Step-level = the realTrainerCorestep on a resident batch, batch 2 × accum 8, seq 4096. Production-shaped Qwen3-8B drafts with random weights and synthetic features; deltas are per rank.With
save_interval: 8(#925): sync → async step time 2286 → 1614 ms (DFlash2), 1980 → 1333 ms (DSpark), 1149 → 947 ms (EAGLE3); GPU peak unchanged.End-to-end comparison — online (disaggregated)
Online e2e = the disaggregated path exactly as
specforge trainruns it (managed-local): one SGLang 0.5.18 capture server with the spec-capture patch on GPU 0 (Qwen3-8B,mem_fraction_static 0.5), Mooncake store, producers feeding real ShareGPT prompts (6000 conversations,max_length 2048, qwen chat template), and a DP=3 trainer on GPUs 1–3; batch 2 × accum 4, 40 optimizer steps,num_anchors 512; steady state = the controller'sperf/*counters (averaged over the ranks that logged them) over the last 24 optimizer steps.samples/s= 24 samples per optimizer step (batch 2 × accum 4 × 3 trainer GPUs) divided by the mean step time of that window;train computeanddata waitare per optimizer step. GPU memory is the nvidia-smi maximum over the run for the trainer GPUs.DFlash2
fsdp2fsdp2fsdp(FSDP1)fsdp2fsdp2+ shard_frozen_tablesfsdp2, save_interval 8fsdp2, save_interval 8, checkpoint_asyncDSpark
fsdp2fsdp2fsdp(FSDP1)fsdp2fsdp2+ shard_frozen_tablesfsdp2, save_interval 8fsdp2, save_interval 8, checkpoint_asyncThe first online matrix of this series ran with the options silently ignored: the disaggregated launch path did not forward them (fixed in #922–#925); the tables above are from the runs after the fix. Online memory is nvidia-smi
usedon the trainer GPUs, i.e. what the caching allocator reserved, a coarse measure; the precise peak-allocated numbers are in the offline table.Second devbox, with
static_shapes(#922 / #923):End-to-end comparison — online (disaggregated)
Online e2e = the disaggregated path exactly as
specforge trainruns it (managed-local): one SGLang 0.5.18 capture server with the spec-capture patch on GPU 0 (Qwen3-8B,mem_fraction_static 0.5), Mooncake store, producers feeding real ShareGPT prompts (6000 conversations,max_length 2048, qwen chat template), and a DP=3 trainer on GPUs 1–3; batch 2 × accum 4, 40 optimizer steps,num_anchors 512; steady state = the controller'sperf/*counters (averaged over the ranks that logged them) over the last 24 optimizer steps.samples/s= 24 samples per optimizer step (batch 2 × accum 4 × 3 trainer GPUs) divided by the mean step time of that window;train computeanddata waitare per optimizer step. GPU memory is the nvidia-smi maximum over the run for the trainer GPUs.DFlash2
fsdp2fsdp2fsdp2fsdp2+ static_shapesfsdp2+ static_shapes + compilefsdp2+ static_shapes + compile + fp8DSpark
fsdp2fsdp2fsdp2fsdp2+ static_shapesfsdp2+ static_shapes + compilefsdp2+ static_shapes + compile + fp8Measured on a second 4 × B300 devbox (same type as the first) after the fixes described above; the
fsdp2baseline was re-run there. Online memory is nvidia-smiusedon the trainer GPUs, i.e. what the caching allocator reserved, a coarse measure; the precise peak-allocated numbers are in the offline tables.What the online runs taught
fsdp2for every option. The reason was not noise:specforge/training/disaggregated.pybuilds the offline-disaggregated and online trainers through its own call sites, which never forwardedbackend_options/checkpoint_async(only the single-process path viaassembly._common_launch_kwargsdid). Each option PR now forwards them at both call sites with tests; the online tables above are from runs after that fix. The harness itself was not affected (it callsbuild_offline_runtimedirectly), which is exactly why the discrepancy was visible.compile_blocksruns crashed on torch 2.13 at the first micro-batch whose padded length differed:InductorError: CantSplit: 8*s50 + 65536 not divisible by s50 + 8192(online batches are padded to the longest sample, so the context length that the block'sflex_attentionsees became symbolic after Dynamo's first recompile; 8192 = 512 anchors × 16 draft tokens, 8 = KV heads). The fixed-length offline e2e never hit it; with fixed length but variable masks the graph compiles and the step time stays at −0.1% (dynamic-shape kernels, no speed-up). (fsdp2 dev. perf) Add training.compile_blocks: per-block torch.compile before fully_shard #922 therefore addstraining.static_shapes(every micro-batch padded todata.max_length, exactlynum_anchorsanchor slots per sample); the variable-mask table below shows what that buys: thefsdp2baseline itself gets much faster (no per-shape rebuild of the flex block mask and the anchor tensors) and the compile gain comes back on top.RuntimeError: Expected self.size(1) to be divisible by 16, but got self.size(1)=3858, a block's K/V projection over the context positions), and its float8 all-gather breaks on FSDP2's padded uneven shards (setStorage … out of boundswith DP=3 for 4096 rows). (fsdp2 dev. perf) Add training.fp8_linear: torchao Float8Linear with float8 all-gather #923 warns withoutstatic_shapes, enforcesdata.max_length % 16 == 0with it, and falls back to a bf16 all-gather when the DP size does not divide the weight rows.End-to-end comparison — offline with variable loss masks (
Trainer.fit)Offline e2e with variable loss masks = the same
Trainer.fit()run as above, but every synthetic sample has a random 20–80% prefix masked out of the loss, so the number of valid anchors differs between micro-batches as it does with real conversations (the harness's--variable-mask); 256 samples × 6 epochs, seq 2048, batch 2 × accum 4, DP=4 on 4 × B300, steady state = last 24 steps.DFlash2
fsdp2fsdp2fsdp2+static_shapesfsdp2+static_shapes+compile_blocksfsdp2+static_shapes+compile_blocks+fp8_linearDSpark
fsdp2fsdp2fsdp2+static_shapesfsdp2+static_shapes+compile_blocksfsdp2+static_shapes+compile_blocks+fp8_linearAt step level (not e2e) FSDP2 also removes the 6
cudaEventSynchronizeper optimizer step of FSDP1'slimit_all_gathersrate limiter on the per-block path and builds/wraps in half the time (≈25 s vs 41–54 s).