Skip to content

feat(benchmarks): GPU benchmark for Beta-VAE train step + encoder inference#253

Merged
zachtheyek merged 7 commits into
masterfrom
feature/gpu-benchmarks
Jul 24, 2026
Merged

feat(benchmarks): GPU benchmark for Beta-VAE train step + encoder inference#253
zachtheyek merged 7 commits into
masterfrom
feature/gpu-benchmarks

Conversation

@zachtheyek

Copy link
Copy Markdown
Owner

Closes #252

Adds benchmarks/bench_gpu.py — the first GPU benchmark in the suite (the existing scripts time
only hot CPU kernels). It builds the real Beta-VAE (create_beta_vae_model) on a single GPU and
drives synthetic batches of the true pipeline shapes to measure per-GPU throughput and peak VRAM,
sweeping the per-replica batch to find the largest that fits.

What it measures

  • --mode train — a full training step: compute_total_loss (reconstruction + KL + both
    clustering heads) → tape.gradient → global-norm clip (1.0, matching _apply_gradients) → Adam
    apply. A training example is a cadence (B, 6, 16, 512), so one step runs 18*B encoder + 6*B
    decoder passes — exactly like _distributed_train_step.
  • --mode encode — encoder forward on observations (B, 16, 512, 1), i.e. the inference path.
  • --find-max doubles the per-replica batch until OOM and reports the largest power of two that
    fits; --batch-sizes a,b,c measures explicit sizes.

Peak VRAM is per GPU (each replica holds a full model copy → combined ≈ ×replicas). Single-GPU +
memory growth (mirrors main.py) so the VRAM figures track real usage; model built inside a
OneDeviceStrategy scope per the repo's strategy-scope rule.

Key finding

The Beta-VAE is compute-bound, not VRAM-bound. On blpc3 (RTX PRO 6000, 96 GB) training
throughput peaks at per-replica batch ~128 (2.81 GB) and does not improve toward the 76 GB VRAM
ceiling; inference peaks near batch ~1024 using <2 GB. The defaults (train 128, inference 2048) are
already near-optimal — a large-VRAM card's spare memory can't be converted into throughput for this
compact model. Full baseline tables are in benchmarks/README.md.

Notes / caveats

  • GPU-only, runs in the NGC container — like the gpu/cluster integration tests, it is not
    part of the CI CPU surface, and (consistent with the deliberately framework-free benchmarks/
    suite) it is not pytest-collected; it prints a table and writes a JSON result.
  • --find-max is capped at --max-batch 4096: an encoder forward whose conv feature maps exceed
    2^31 elements (batch ≳ 8192) trips an uncatchable TF int32 launch-config abort rather than a
    clean OOM. 4096 covers the training VRAM ceiling and a generous inference range.

Verification

  • ruff check / ruff format clean; pre-commit clean.
  • Ran end-to-end on blpc3 in the container: --mode train --find-max (max batch 4096 @ 76.5 GB) and
    --mode encode --batch-sizes 256..4096; numbers are the tables in the README.

🤖 Generated with Claude Code

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Claude finished @zachtheyek's task in 3m 43s —— View job


Claude Code review — PR #253

Nice self-contained addition. The training step faithfully mirrors _distributed_train_step (compute_total_losstape.gradient → global-norm clip at 1.0 → Adam apply — verified against src/aetherscan/train.py:2009-2101), the shapes are correct (target shape matches the batch_spec in prepare_distributed_train_dataset, src/aetherscan/train.py:766), and the model is built inside strategy.scope() per the hard rule. Single-GPU + memory-growth mirrors main.py so the peak VRAM figure is meaningful. README additions are clear and the "compute-bound, not VRAM-bound" takeaway is well-supported by the numbers.

The comments I left are minor — nothing that changes correctness of the reported numbers.

Inline comments posted

  • bench_gpu.py:161-164--find-max is declared but never checked; behaviour is gated entirely by presence/absence of --batch-sizes. Either wire the flag in or drop it (mutually-exclusive group would give a clean error on --find-max --batch-sizes ...).
  • bench_gpu.py:219params["obs_shape"] in the JSON always logs _OBS_SHAPE even in --mode train, so a train-run JSON records the encode-mode shape. Suggest logging the actual driving shape (matches the example_shape= you already print on stdout).
  • bench_gpu.py:112-121--warmup 0 blows up with NameError: name 'out' is not defined before the timing loop starts; small guard would help.
  • bench_gpu.py:105-110_rand re-seeds default_rng(0) each call, so main == true == false == target byte-for-byte. Fine for timing; worth a one-liner comment so a future debugger doesn't blame the loss.
  • bench_gpu.py:73/1e9 is SI GB, not GiB; README says "GB". A one-word clarification prevents someone reconciling against nvidia-smi (which uses MiB) from second-guessing the numbers.
  • bench_gpu.py:140-149 — Small argparse hardening: bad --batch-sizes tokens raise a raw ValueError, and --start-batch 0 / --max-batch 0 produces an infinite loop.

Things I checked and liked

  • create_beta_vae_model is called inside strategy.scope() — model + beta_vae.compile(Adam(...)) both happen inside the scope, satisfying the hard rule.
  • Config singletons used correctly: init_config() then get_config() (no direct instantiation).
  • Peak-VRAM reset (_reset_vram_peak) happens after the warmup sync and before the timed loop — the reported peak reflects steady-state only.
  • OOM caught via tf.errors.ResourceExhaustedError (the right exception) and the sweep terminates rather than continuing to allocate.
  • _CLIP_NORM = 1.0 is duplicated from _apply_gradients with an explicit comment pointing at the source — sensible given no shared constant exists today.
  • Correctly not pytest-collected (consistent with the framework-free benchmarks/ convention documented in benchmarks/README.md).
  • The --max-batch 4096 cap is justified in the header (int32 launch-config abort at batch ≳ 8192) — the README also carries the same rationale.

Not blocking, just noting

  • Between batch sizes the model + Adam slot variables persist, so at larger sizes the optimizer sees non-random weights. Doesn't affect throughput (compute is shape-dependent, not value-dependent), but if you ever want to guarantee identical starting conditions per size you'd need to re-create_beta_vae_model() inside the loop — worth a comment either way.
  • The header phrases "18B encoder + 6B decoder passes" where "pass" means per-observation forwards (there are actually 3 encoder invocations per step, each processing 6*B observations). The count is right; a reader unfamiliar with the pipeline might briefly parse "pass" as "graph invocation". Not worth changing unless you're editing that block anyway.

--- · Branch feature/gpu-benchmarks

@github-actions github-actions Bot added the enhancement New feature or request label Jul 23, 2026
Comment thread benchmarks/bench_gpu.py
Comment on lines +161 to +164
"--find-max",
action="store_true",
help="Double the batch size until the GPU OOMs (the default when --batch-sizes is unset).",
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--find-max is decorative — it's declared but never consulted.

The sweep is actually gated by whether --batch-sizes is set (see _batch_schedule, benchmarks/bench_gpu.py:140-149). args.find_max is never read, so --find-max alone does nothing that omitting --batch-sizes doesn't already do, and --find-max --batch-sizes 128,256 silently ignores the flag rather than sweeping past 256.

Two consistent options:

  • Drop the flag and let --batch-sizes be the sole switch (README already says the sweep is the default), OR
  • Actually use it: if args.batch_sizes and not args.find_max: (or make the two mutually exclusive via add_mutually_exclusive_group) so --find-max is meaningful and mis-combinations surface as an error rather than a silent no-op.

Comment thread benchmarks/bench_gpu.py Outdated
f"bench_gpu_{args.mode}",
{
"mode": args.mode,
"obs_shape": list(_OBS_SHAPE),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

obs_shape in the JSON params is misleading in --mode train.

You always write _OBS_SHAPE (the encode-mode shape) even when the driving input is a cadence (6, 16, 512). A downstream reader looking at the JSON for a train run will see obs_shape=[16, 512, 1] and misinterpret what was actually measured.

Suggest logging the shape you actually drove — either

"example_shape": list(_CADENCE_SHAPE if args.mode == "train" else _OBS_SHAPE),

or record both (cadence_shape + obs_shape) unconditionally. Bonus: it'd match the example_shape= label already printed on stdout at line 189.

Comment thread benchmarks/bench_gpu.py
Comment on lines +112 to +121
try:
for _ in range(warmup):
out = step_fn(*inputs)
out.numpy() # sync after warmup (also absorbs tf.function tracing) before timing
_reset_vram_peak()
start = time.perf_counter()
for _ in range(steps):
out = step_fn(*inputs)
out.numpy() # force the queued steps to finish before stopping the clock
elapsed = time.perf_counter() - start

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

out.numpy() explodes on --warmup 0.

If a user runs with --warmup 0, the first out.numpy() at line 115 raises NameError: name 'out' is not defined — the timing loop never executes. Not a common invocation, but the arg accepts 0 without complaint. Cheapest guard is either

parser.add_argument("--warmup", type=int, default=3, ...)  # already there
# then:
if args.warmup < 1:
    parser.error("--warmup must be >= 1")

or initialise out = None and skip the sync when it's None.

Comment thread benchmarks/bench_gpu.py Outdated
Comment on lines +105 to +110
if mode == "train":
# main, true, false, target — each a batch of cadences (B, 6, 16, 512).
inputs = tuple(_rand((batch, *_CADENCE_SHAPE)) for _ in range(4))
else:
# encode: a batch of single observations (B, 16, 512, 1).
inputs = (_rand((batch, *_OBS_SHAPE)),)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_rand re-seeds default_rng(0) on every call, so all four training inputs are byte-identical tensors (main == true == false == target). Fine for GPU timing (compute cost doesn't depend on values), but it's worth an explicit one-liner in the docstring — a future reader inspecting the JSON or debugging weird loss values will assume they're independent draws. Alternatively, hoist the rng to a module-level singleton so each call advances state.

Comment thread benchmarks/bench_gpu.py Outdated


def _peak_vram_gb() -> float:
return tf.config.experimental.get_memory_info(_DEVICE)["peak"] / 1e9

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unit nit: /1e9 is SI GB (10⁹ bytes), not GiB (2³⁰). Nvidia tooling (nvidia-smi, driver docs) mixes the two — a 96 GB Blackwell is ~89.4 GiB — so a reader cross-checking the README's "76.52 GB peak" at batch 4096 against nvidia-smi's "MiB" may not know which convention you're using. Either add a comment noting SI GB here, or divide by 2**30 and label as GiB in the tables. Not a bug; just prevents confusion the first time somebody tries to reconcile the numbers.

Comment thread benchmarks/bench_gpu.py
Comment on lines +140 to +149
def _batch_schedule(args) -> list[int]:
if args.batch_sizes:
return [int(x) for x in args.batch_sizes.split(",") if x.strip()]
# --find-max (default): powers of two from --start-batch up to the cap; the sweep stops at
# the first OOM, so the last success is the largest power of two that fits.
sizes, b = [], args.start_batch
while b <= args.max_batch:
sizes.append(b)
b *= 2
return sizes

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor input hardening: --batch-sizes " 128, foo" raises ValueError: invalid literal for int() from int(x) rather than a friendly argparse error, and --start-batch 0 / --max-batch 0 produces an infinite loop (since b=0, b*=2 stays 0 and is <= any positive cap). Neither is a real-world path, but a cheap fix is parser.error(...) in main() when the parsed values fall outside the sane range.

@zachtheyek

Copy link
Copy Markdown
Owner Author

This branch evolved substantially since the first pass, and is now validated on-cluster (bla0, 6× A4000). Current state (0a8ea75..3d5c7b3):

  • Multi-GPU bench_gpu.py (--num-gpus, MirroredStrategy) — resolves all 6 first-pass nits (mutually-exclusive --find-max/--batch-sizes, example_shape in JSON, --warmup guard, module-level RNG, SI-GB label, argparse hardening).
  • --accumulation-steps — models _train_epoch's accumulate-then-apply cadence.
  • bench_rf.py — the previously-unmeasured CPU RF stage (framework-free).
  • A real multi-GPU bug, found and fixed by the on-cluster smoke. The first accumulation implementation applied pre-reduced grads in cross-replica context — which core-dumps under MirroredStrategy (Adam slot on GPU:0 accessed from GPU:1; apply_gradients with a single reduced grad doesn't broadcast to the replicas' mirrored slots). Single-GPU masked it. Rewrote to the canonical per-replica accumulator pattern: accumulate into mirrored accumulator variables inside strategy.run, then apply once inside strategy.run so the all-reduce happens on apply. _make_step_fn is now encode-only.
  • bla0 A4000 baselines added to benchmarks/README.md (single/multi-GPU + accumulation).

On-cluster validation (bla0, all paths clean): train single 795 / 6-GPU 3,087 (~3.9×) / accumulation K=4 4,272 cad/s @ 9.91 GB; encode single 53k / 6-GPU 214k obs/s (~4.1×). The multi-GPU + accumulation paths (the ones that couldn't be tested locally — no GPU, TF deadlocks on the dev box) are what the smoke exercised and fixed.

Fresh eyes welcome on the accumulator pattern in _measure in particular. @claude

Comment thread benchmarks/bench_gpu.py
Comment on lines +165 to +180
def _apply_and_reset():
grads = [acc / accumulation_steps for acc in accumulators]
clipped, _ = tf.clip_by_global_norm(grads, _CLIP_NORM)
model.optimizer.apply_gradients(zip(clipped, model.trainable_variables, strict=False))
for acc in accumulators:
acc.assign(tf.zeros_like(acc))

@tf.function
def run(dist_inputs):
# Accumulate grads over accumulation_steps micro-batches into the per-replica
# accumulators, then apply once. The apply runs INSIDE strategy.run, so MirroredStrategy
# all-reduces on apply — the correct multi-GPU accumulate-then-apply cadence, and it
# sidesteps the cross-device error from applying pre-reduced grads in cross-replica context.
for _ in range(accumulation_steps):
strategy.run(_accumulate, args=(dist_inputs,))
strategy.run(_apply_and_reset)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct pattern, and the "apply inside strategy.run" comment nails the right justification. One subtle semantic drift from production worth surfacing (does not affect the throughput numbers, so this is a note, not a change request):

Production _apply_gradients (src/aetherscan/train.py:2093-2098) does reduce → clip → apply in cross-replica context: strategy.reduce(MEAN) in _distributed_train_step runs first, then clip_by_global_norm(1.0) is computed once on the mean-across-replicas gradient, then optimizer.apply_gradients broadcasts to mirrors.

_apply_and_reset here does per-replica clip → apply-with-implicit-reduce: each replica clips its own acc / K independently against 1.0, and the all-reduce (mean) happens inside optimizer.apply_gradients on the clipped per-replica tensors. So the effective grad is mean_r(clip(acc_r / K)) rather than clip(mean_r(acc_r) / K) — not identical when at least one replica's global norm exceeds the threshold. Compute cost is essentially the same either way (one tree-reduce over params), so throughput/VRAM are faithful, but the header claim "exactly as _train_epoch" is a shade strong — the cadence (K micro-batches per apply) is exact; the reduce/clip ordering differs.

If you want production-exact clip semantics without giving up the per-replica accumulator (needed for the multi-GPU fix), you can do the merge explicitly before clipping — something like:

def _apply_and_reset():
    ctx = tf.distribute.get_replica_context()
    reduced = ctx.all_reduce(tf.distribute.ReduceOp.MEAN, [acc / accumulation_steps for acc in accumulators])
    clipped, _ = tf.clip_by_global_norm(reduced, _CLIP_NORM)
    model.optimizer.apply_gradients(zip(clipped, model.trainable_variables, strict=False))
    for acc in accumulators:
        acc.assign(tf.zeros_like(acc))

That gets the same reduced-then-clipped-once semantics as _apply_gradients while keeping the per-replica accumulator + apply-inside-strategy.run structure that fixes the cross-device crash. But given the stated purpose is throughput + VRAM, leaving this as-is with a one-line comment noting the divergence is also fine.

Comment thread benchmarks/bench_gpu.py
Comment on lines +150 to +155
if mode == "train":
# Per-replica gradient accumulators (created in scope so they mirror across replicas).
with strategy.scope():
accumulators = [
tf.Variable(tf.zeros_like(v), trainable=False) for v in model.trainable_variables
]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Second-order, doesn't affect the reported numbers: at --accumulation-steps 1 you still walk the accumulator path (one strategy.run(_accumulate) + one strategy.run(_apply_and_reset) per step, plus the assign_add + zero-out on every parameter). The whole thing is inside one @tf.function, so TF can fuse most of it — the bla0 numbers (795 → 3,087 cad/s for 1→6 GPUs, ~3.9×) suggest overhead is small — but a fused K=1 fast-path (single tape → clip → apply, no accumulator I/O) would be a slightly cleaner baseline for the "plain apply-every-step" row. Not a blocker, and probably not worth the extra code path unless you see K=1 numbers you don't trust.

Comment thread benchmarks/bench_gpu.py
Comment on lines +184 to +191
def sync(out) -> None:
# Force queued device work to finish before the clock is read. Train applies mutate the
# model variables, so reading one is a sufficient barrier; encode syncs on its output.
if mode == "train":
model.trainable_variables[0].numpy()
elif out is not None:
strategy.experimental_local_results(out)[0].numpy()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The model.trainable_variables[0].numpy() barrier is correct but the reason is non-obvious enough to be worth spelling out in the comment: it works because every apply ends in a collective all-reduce, so any replica whose stream has drained past step N implies all replicas completed the all-reduce for step N. Without that guarantee, reading one MirroredVariable would only sync the primary replica. A future reader debugging weird timing numbers might add a "properly" cross-replica sync assuming this one is sloppy, so a one-liner locks in the reasoning.

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Claude Code review — PR #253 (second pass, focused on the accumulator pattern)

Fresh read of the accumulator rewrite in _measure (bench_gpu.py:150-180), the multi-GPU plumbing, and the new bench_rf.py. The pattern is correct — it's the canonical MirroredStrategy gradient-accumulation shape (per-replica mirrored accumulators created in strategy.scope(); per-replica assign_add inside strategy.run; apply inside strategy.run so the all-reduce happens on optimizer.apply_gradients). The "sidesteps the cross-device error from applying pre-reduced grads in cross-replica context" comment is exactly right: applying pre-reduced grads while the Adam slot MirroredVariables live on multiple replicas is the classic cause of the Adam slot on GPU:0 accessed from GPU:1 crash, and moving the apply inside strategy.run fixes it because the optimizer then triggers the merge itself.

The on-cluster validation (bla0, 6× A4000, 1→6-GPU 3.9× scaling on train, 4.1× on encode) is the right kind of evidence — the crash was masked by single-GPU, and the fix is only meaningfully verifiable under MirroredStrategy.

Three notes inline — none blocking, none change the numbers:

  1. bench_gpu.py:165-180 — Reduce/clip ordering differs from production _apply_gradients: benchmark clips per-replica then all-reduces (in apply_gradients); production strategy.reduce(MEAN) first then clips the reduced grad. Compute cost near-identical; header's "exactly as _train_epoch" describes the cadence faithfully but not the reduce/clip order. Snippet inline for a production-exact variant if you want it.
  2. bench_gpu.py:150-155 — At --accumulation-steps 1 the accumulator path is still walked (extra assign_add + zero-out per param). Overhead looks small from the numbers; noting for completeness.
  3. bench_gpu.py:184-191 — Primary-replica-only sync is correct given the collective barrier on every apply, but the reasoning is non-obvious enough to be worth a one-line comment so a future debugger doesn't "fix" it.

Things I checked and liked

  • Accumulator creation inside strategy.scope() (bench_gpu.py:152-154) — required for mirrored allocation; without it, acc.assign_add(g) inside strategy.run would trip the same cross-device error the rewrite fixes.
  • Model + Adam both compiled inside strategy.scope()create_beta_vae_model does beta_vae.compile(Adam(...)) (src/aetherscan/models/vae.py:777-779), and main() wraps the whole call in strategy.scope() (bench_gpu.py:289-290). Adam's slot variables are therefore created as MirroredVariables on first apply.
  • strategy.experimental_distribute_values_from_function for inputs — right primitive; keeps tensors on-device and avoids per-step host→device copies. Reusing dist_inputs across all K micro-batches and all timed steps is fine for throughput (compute cost is shape-, not value-, dependent).
  • for _ in range(accumulation_steps) unrolls at trace time into K static strategy.run(_accumulate) invocations — bloats the graph proportionally to K but avoids tf.while_loop overhead; fine for the realistic K range.
  • Peak-VRAM reset (bench_gpu.py:198-199) fires after warmup + sync, so the reported figure excludes tracing allocations and includes the persistent accumulator — README's "peak VRAM then includes the persistent accumulator" is accurate.
  • _positive_int, mutually-exclusive --find-max/--batch-sizes, _batch_list argparse type, example_shape in JSON, module-level RNG, SI-GB label — all six first-pass nits are resolved.
  • --accumulation-steps argparse-guarded to positive int and ignored for --mode encode in the reported JSON (accumulation_steps: args.accumulation_steps if args.mode == \"train\" else 1) — clean.

bench_rf.py

  • Inlined _prepare_latent_features is a byte-for-byte match with src/aetherscan/models/random_forest.py:21-52 (same explicit per-cadence loop, same shape and divisibility check) — good, timing reflects the production implementation. If the production version ever moves to a vectorized reshape, the docstring's "the same explicit per-cadence loop" note flags where to update.
  • Hardcoded defaults match src/aetherscan/config.py: n_estimators=1000, bootstrap=True, max_features=\"sqrt\", n_jobs=-1, rf.seed=11, latent_dim=8, num_observations=6, num_samples_rf=99840, train_val_split=0.8. All ✓.
  • Random-label upper-bound framing is correct — trees grow until pure, so fit time is a conservative upper bound; separable latents fit faster. README makes this explicit.
  • Fit measured once (low-variance / expensive), prep + predict best-of-repeats — right choice given the ~235s fit cost on M3.
  • Framework-free (numpy + sklearn only) so it runs anywhere the other CPU benchmarks do — consistent with the framework-free benchmarks/ convention.

README additions

  • Table with A4000 baselines is clear; the scaling / VRAM / accumulation trade-off is well-summarized ("compute-bound, throughput flat as batch grows toward the 16 GB ceiling").
  • The K=4 accumulation row (4,272 vs 3,087 cad/s @ 9.91 vs 3.21 GB/GPU) is exactly the kind of number you want on record for future capacity planning.
  • One-line clarification opportunity: "one apply per K micro-batches" implicitly claims fewer apply/clip/all-reduce ops per cadence-processed — worth calling out as the mechanism for the throughput bump so a reader doesn't have to reason it out.

Nothing blocking. The multi-GPU + accumulation paths are the ones that couldn't be tested locally, and they're the ones that got the deepest read here — the pattern is sound and matches how canonical TF MirroredStrategy accumulation is meant to look.

--- · Branch `feature/gpu-benchmarks`

zachtheyek added a commit that referenced this pull request Jul 23, 2026
…cumulation mechanism

Addresses the #253 second-pass review notes (all comment-only; no logic change, so the on-cluster
baselines still stand):
- Soften the module docstring's "exactly as _train_epoch" to cadence-exact, and note in
  `_apply_and_reset` that production reduces-then-clips while the benchmark clips-per-replica-then-
  all-reduces-on-apply — mean_r(clip(acc_r/K)) vs clip(mean_r(acc_r)/K), which differ only when a
  replica's global norm exceeds the clip threshold and are compute-identical, so throughput/VRAM
  stay faithful.
- Spell out why reading one mutated MirroredVariable is a sufficient *cross-replica* sync barrier
  (every apply ends in a collective all-reduce).
- README: name the mechanism behind the --accumulation-steps throughput bump (one apply + clip +
  all-reduce per K micro-batches amortizes the fixed per-apply overhead).

Refs #252
@zachtheyek

Copy link
Copy Markdown
Owner Author

Thanks — all three notes addressed in 63b54ec (comment-only; no logic change, so the on-cluster bla0 baselines still stand as measured).

1. Reduce/clip ordering (bench_gpu.py:180). Correct catch, and I've made the claim honest rather than changing the path. Softened the module docstring from "exactly as _train_epoch" to cadence-exact, and added the precise divergence note in _apply_and_reset: production reduces-then-clips (clip(mean_r(acc_r)/K)), this clips-per-replica-then-all-reduces-on-apply (mean_r(clip(acc_r/K))), differing only when a replica's global norm exceeds _CLIP_NORM, compute-identical either way.

I deliberately did not adopt the explicit all_reduce(MEAN)-then-clip variant, for three reasons: (a) it changes the validated apply path, so I'd have to re-run the multi-GPU + accumulation smoke on bla0 to re-confirm no cross-device crash and that the numbers hold — churn for a path whose output this benchmark doesn't measure; (b) the benchmark reports throughput + peak VRAM, both of which are order-invariant here; (c) it keeps the accumulate-then-apply structure minimal. The one-line note now documents the divergence exactly as you suggested, so a reader porting this to a correctness harness knows where to switch.

2. K=1 walks the accumulator path (bench_gpu.py:155). Acknowledged — leaving as-is intentionally. A fused K=1 fast-path would be a second code path that no longer exercises the accumulate→apply structure the multi-GPU fix lives in, so the "plain apply-every-step" row would stop sharing the path it's meant to baseline. The measured overhead is in the noise (1→6 GPU is 795→3,087, 3.9×; a per-param assign_add + zero-out fuses into the one @tf.function), so the extra branch would cost more in divergence risk than it saves. If a future K=1 number looks off, that's the first place I'd add the fast-path.

3. Primary-replica sync barrier (bench_gpu.py:191). Agreed the reasoning was non-obvious — expanded the sync comment to state it: every train apply ends in a collective all-reduce, so a stream drained past step N on any replica implies all replicas finished that step's all-reduce, making a single mutated-MirroredVariable read a sufficient cross-replica barrier (not merely a primary-replica one). Locks it against a future "fix."

README. Named the mechanism behind the --accumulation-steps bump — one apply + clip + all-reduce per K micro-batches amortizes the fixed per-apply overhead over 4× the cadences — so it doesn't have to be reasoned out.

bench_rf parity, the six first-pass nits, and the accumulator-pattern correctness all as you confirmed. If this reads clean I'll stop here and leave it for maintainer merge.

@claude

@claude

claude Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Claude Code review — PR #253 (third pass, closing)

Re-read 63b54ec in full and independently spot-checked the ordering claim against production. All three notes are addressed; nothing left blocking.

Reduce/clip ordering (bench_gpu.py:168-174). The comment's math is exact — production _apply_gradients (src/aetherscan/train.py:2093) clips after the strategy.reduce(MEAN, ...) at src/aetherscan/train.py:2039-2043, so production really is clip(mean_r(acc_r)/K) and the benchmark is mean_r(clip(acc_r/K)). Rebuttal for keeping the current path is sound: benchmark measures throughput + peak VRAM, both order-invariant (one tree-reduce + one clip either way), so re-validating a code-path change on bla0 buys nothing. Softening the module docstring to cadence-exact + the inline NOTE is the right trade.

K=1 accumulator path (bench_gpu.py:150-155). Agreed — a fused K=1 fast-path would break the invariant that the K=1 row baselines the same code as K>1. The 1→6-GPU 3.9× scaling number from the previous pass says the overhead is within noise. Comment noted, no change needed.

Cross-replica sync (bench_gpu.py:194-198). The expanded comment names the mechanism explicitly — every apply_gradients on MirroredVariables triggers a synchronous all-reduce via merge_call, so a drained stream on one replica implies all replicas completed the step's collective, making the single-variable read a genuine cross-replica barrier. That's exactly the reasoning to lock against a future well-meaning "fix" that would try to sync each replica's variables separately.

README amortization sentence. "One apply + clip + one cross-replica all-reduce per K micro-batches ... amortizing that fixed per-apply overhead over 4× as many cadences" — matches the mechanism (each apply's fixed cost is the collectives + the params-scale clip tree-reduce; K=4 pays it 4× less often). Makes the 3,087 → 4,272 cad/s bump self-explanatory.

Since 63b54ec is comment-only, the on-cluster bla0 baselines stand as measured — no re-run needed.

LGTM — leaving for maintainer merge, and per the loop rule not re-tagging.

--- · Branch `feature/gpu-benchmarks` @ `63b54ec`

… inference

The benchmarks/ suite covered only hot CPU kernels; the Beta-VAE's GPU work
(training step + encoder inference) was unmeasured. Add bench_gpu.py: builds
the real model on one GPU, drives synthetic batches of the true pipeline shapes
(train: cadence (B,6,16,512) = 18B enc + 6B dec passes/step; encode: obs
(B,16,512,1)), reports throughput + peak VRAM, and --find-max sweeps the
per-replica batch to the largest that fits. Documents blpc3 (RTX PRO 6000)
baselines in benchmarks/README.md.

GPU-only (runs in the NGC container), consistent with the framework-free suite.
--find-max is capped at 4096 to avoid a TF int32 launch-config abort at batch
>= 8192.

Closes #252
Add --num-gpus: build the Beta-VAE under a MirroredStrategy over the first N
GPUs and report aggregate throughput + per-GPU peak VRAM, covering the
cross-replica gradient all-reduce and multi-GPU scaling (the single-GPU
version measured neither).

Also resolves the first-pass review nits:
- --find-max / --batch-sizes are now a mutually-exclusive group (was decorative)
- JSON records example_shape (was mislabeled obs_shape in train mode)
- --warmup validated >= 1 and the sync guarded (was a NameError at --warmup 0)
- module-level RNG so main/true/false/target inputs aren't byte-identical
- VRAM label clarified to SI GB (1e9 B) vs nvidia-smi's MiB/GiB
- argparse hardening (_positive_int / _batch_list reject bad tokens; no
  infinite --find-max loop at start/max 0)

Refs #252
The train step applied grads every micro-batch, but the real _train_epoch
accumulates the all-reduced grads over accumulation_steps micro-batches and
applies once with the global-norm clip. Split the per-replica step into
grad-compute only, and move the reduce/accumulate/clip/apply into one optimizer
step in _measure — mirroring _distributed_train_step + _apply_gradients.

--accumulation-steps K (default 1 = apply every step) now measures the
once-per-K apply cadence and the persistent-accumulator VRAM; throughput is
reported over all micro-batches. Encode mode is unchanged.

Refs #252
The second-stage RF (sklearn, CPU) was unmeasured. bench_rf.py times
prepare_latent_features (the per-cadence latent reshape), RandomForestClassifier.fit,
and predict_proba on synthetic (n_cadences x 48) binary data at production
hyperparameters (1000 trees, max_features=sqrt, n_jobs=-1). Framework-free
(numpy + sklearn), so it runs alongside the other CPU benchmarks and needs no GPU.

Also documents --num-gpus / --accumulation-steps in benchmarks/README.md and adds a
MacBook bench_rf baseline (fit is a random-label upper bound). Multi-GPU + bla0 GPU
baselines and cluster RF numbers land with the System-Requirements update (#183).

Refs #252
…pattern)

The accumulation refactor applied pre-reduced grads in cross-replica context, which core-dumps under
MirroredStrategy (an Adam slot on GPU:0 accessed from GPU:1 — apply_gradients with a single reduced
grad doesn't broadcast to the replicas' mirrored slots). Single-GPU masked it.

Switch to the canonical per-replica accumulator pattern: accumulate grads into mirrored accumulator
variables inside strategy.run over accumulation_steps micro-batches, then apply once INSIDE
strategy.run so MirroredStrategy all-reduces on apply. _make_step_fn is now encode-only (the train
path builds its accumulating step in _measure).

Validated on bla0 (6x A4000): train single 795 / multi 3087 / accumulation K=4 4272 cad/s;
encode single 53k / multi 214k obs/s — all num-gpus / accumulation / encode paths run clean.

Refs #252
… accumulation

Captures the bla0 (6× RTX A4000, the release-training host) single-GPU train + encode baselines, the
multi-GPU MirroredStrategy scaling (~3.9–4.1× over 6 replicas), and the --accumulation-steps
throughput/VRAM signature. Replaces the pending-baselines placeholder.

Refs #252
…cumulation mechanism

Addresses the #253 second-pass review notes (all comment-only; no logic change, so the on-cluster
baselines still stand):
- Soften the module docstring's "exactly as _train_epoch" to cadence-exact, and note in
  `_apply_and_reset` that production reduces-then-clips while the benchmark clips-per-replica-then-
  all-reduces-on-apply — mean_r(clip(acc_r/K)) vs clip(mean_r(acc_r)/K), which differ only when a
  replica's global norm exceeds the clip threshold and are compute-identical, so throughput/VRAM
  stay faithful.
- Spell out why reading one mutated MirroredVariable is a sufficient *cross-replica* sync barrier
  (every apply ends in a collective all-reduce).
- README: name the mechanism behind the --accumulation-steps throughput bump (one apply + clip +
  all-reduce per K micro-batches amortizes the fixed per-apply overhead).

Refs #252
@zachtheyek
zachtheyek force-pushed the feature/gpu-benchmarks branch from 63b54ec to 836bd17 Compare July 24, 2026 04:37
@zachtheyek
zachtheyek merged commit cf8b127 into master Jul 24, 2026
8 checks passed
@zachtheyek
zachtheyek deleted the feature/gpu-benchmarks branch July 24, 2026 05:47
@claude

claude Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Release note draft — New

Two new benchmarks let you profile the deep-learning stages on your own hardware: bench_gpu.py measures Beta-VAE training-step and encoder-inference throughput and peak VRAM per GPU (with a batch-size sweep and multi-GPU / gradient-accumulation modes), and bench_rf.py times the Random Forest fit and prediction path. Baseline numbers for the RTX PRO 6000 Blackwell (blpc3) and the 6×A4000 release host (bla0) ship in benchmarks/README.md, along with the practical takeaway that the Beta-VAE is compute-bound rather than VRAM-bound — so the existing training (128) and inference (2048) batch defaults are already near-optimal even on large-VRAM cards. GPU-only, runs inside the NGC container on a cluster.

Draft for the changelog; edit or drop as needed.

zachtheyek added a commit that referenced this pull request Jul 24, 2026
Completes PR #267's punted SKILL.md edit (the CI bot can't write under .claude/;
applied here as the local CODEOWNER). Updates the benchmarks/ Project-Structure
caption to reflect the CPU micro-benchmarks + the container-only GPU benchmark
added in #253.

Refs #266
zachtheyek added a commit that referenced this pull request Jul 24, 2026
docs(benchmarking): reflect bench_gpu.py + bench_rf.py added in PR #253
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add GPU benchmarks for the Beta-VAE (training step + encoder inference)

1 participant