Skip to content

perf(compile): eliminate the init-compile share of the cold start#956

Merged
ocg-goodfire merged 5 commits into
feature/jaxfrom
perf/init-compile
Jul 7, 2026
Merged

perf(compile): eliminate the init-compile share of the cold start#956
ocg-goodfire merged 5 commits into
feature/jaxfrom
perf/init-compile

Conversation

@danbraunai-goodfire

Copy link
Copy Markdown
Collaborator

Description

Takeaways up front:

  1. The "~20 min before step 0" was mostly init compiles, not the train-step compile. Measured serially at the full production shape: jit_init_decomp_vu 211s + jit_build_ci_fn 167s + jit_init_persistent_sources 55s, before the ~5-min jit_step compile even started.
  2. This PR removes all three, bit-identically. Same fix shape each time: the init was a jit emitting hundreds of separately-sharded outputs (or dozens of python-unrolled RNG bodies); it now generates vmap-stacked over the same per-site/per-chunk keys and fans out with a trivial donated slice jit. Values are exactly equal (pinned by array_equal tests), so nothing downstream changes — zero effect on step time, numerics, or checkpoints.
  3. Cold start at production scale drops ~13–17 min → ~6–8 min. Verified end-to-end by rerunning last week's biggest btdr run (p-283b7c88, 64 GPUs): job start → training went ~17.6 min → ~12.1 min (the init share vanished; the rest is model load + jit_step + NCCL bring-up), with identical step time (4.3s), peak memory (68.4GB/rank), and loss trajectory.
  4. The remaining jit_step compile is not worth attacking structurally. A 10-arm isolated-cache probe grid measured it flat across recon chunk count (1/2/4/8), halved C, CI-fn depth, and PPGD warmup (~5 min at dp32, +17% at dp64; ~83% XLA priority-fusion). The chunk-scan refactor scoped in perf(compile): 13x faster V/U init compile + compile-time findings #941 would buy nothing — documented in param_decomp/CLAUDE.md so nobody re-chases it.

Also included: a one-line log.py fix (dictConfig was disabling jax's already-created loggers, which is why JAX_LOG_COMPILES=1 never appeared in any slurm log) and the measured findings + few-outputs-under-jit init practice in param_decomp/CLAUDE.md.

Supersedes #951 and #954 (their content, rebased onto the merged #955 and combined; V/U init originally from #941's bridge work).

Related Issue

Supersedes #951/#954; continues #941's reduce-jax-compilation-time bridge task.

Motivation and Context

Every new-config launch — smokes, sweep arms, debug iterations — paid ~7 min of pure init compile before anything useful happened. Sweeps paid it per arm.

How Has This Been Tested?

  • Bit-identity of all three inits pinned by exact-equality tests in test_sharding (1 and 4 sim devices); make test / tests/equivalence goldens / make check clean on this branch.
  • GPU A/B at production shape (btdr, dp32, isolated caches): ci-fn 167→4.4s, sources 55→<2s, V/U 211→16s, jit_step unchanged.
  • Max-scale end-to-end: exact rerun of p-283b7c88 (full 32L, 64 GPUs, dp64) — trained 120 steps with identical step time/memory/losses before being cancelled.

Does this PR introduce a breaking change?

No. Init values are bit-identical; the log change only re-enables foreign loggers.

🤖 Generated with Claude Code

https://claude.ai/code/session_01E34cK5BaVr6ZFAwRidNp6n

danbraunai-goodfire and others added 3 commits July 7, 2026 11:03
…urces)

The cold-start compile was dominated by three init compiles, all the same disease —
a jit emitting n_sites (hundreds of) sharded outputs, or n_chunks python-unrolled
copies of an RNG body. Each is now generated vmap-stacked over the SAME per-site /
per-chunk keys (bit-identical values, pinned by exact-equality tests) and fanned out
per site by a trivial donated slice jit:

- init_decomp_vu_placed: RNG vmap-stacked per V/U shape (2 x n_shapes sharded outputs
  instead of 2 x n_sites=448). 211s -> 16s at the production 224-site shape.
- init_chunkwise_transformer_ci_fn: eqx.filter_vmap over the per-chunk fold_in keys
  instead of unrolling+stacking n_chunks chunk inits. jit_build_ci_fn 167s -> 4.4s.
- init_sources_sharded: vmap-stacked per C group. 55s -> <2s.

Co-authored-by: oli <oli@goodfire.ai>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E34cK5BaVr6ZFAwRidNp6n
…MPILES visibility)

dictConfig defaults disable_existing_loggers=True, which killed jax's loggers —
JAX_LOG_COMPILES and persistent-cache diagnostics never reached the slurm log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E34cK5BaVr6ZFAwRidNp6n
@danbraunai-goodfire danbraunai-goodfire changed the title perf(compile): eliminate the init-compile share of the cold start (V/U 211s→16s, CI-fn 167s→4.4s, sources 55s→<2s) perf(compile): eliminate the init-compile share of the cold start Jul 7, 2026
danbraunai-goodfire and others added 2 commits July 7, 2026 11:16
The measurement narrative and before/after numbers live in the PR; CLAUDE.md keeps
only what changes future behavior (init pattern + the retired refactor).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E34cK5BaVr6ZFAwRidNp6n
…chunk CI vs unrolled reference

- donate_argnums=0 on the slice jits could never alias the stacked buffer into split
  per-site outputs (JAX warned 'Some donated buffers were not usable' on every launch).
  Removed; docstrings now state the true transient: one extra shard-local copy of the
  group stacks, freed when the fan-out returns.
- test_sharding: layers.{2,3}.mlp.gate_proj now share (d_in, d_out, C), so the V/U
  stacked init exercises a real multi-site shape group (and a 3-site sources C group).
- test_llama8b: new test pins the vmapped CI init BIT-identical (array_equal) to the
  unrolled+stacked per-chunk reference it replaced, at n_chunks=3 with heterogeneous
  per-slot C.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01E34cK5BaVr6ZFAwRidNp6n
@danbraunai-goodfire

Copy link
Copy Markdown
Collaborator Author

Both review findings addressed in the latest commit:

1. Inert donation — removed. You're right: donate_argnums=0 can never alias one stacked buffer into split per-site outputs, so the claim was false and every launch warned. The donation flags are gone from both fan-out jits, and the docstrings now state the true cost: init peak briefly holds one extra shard-local copy of the group stacks (÷N — a few GB/rank at production dp32, against an HBM that holds only the frozen target at that point in startup), freed when the fan-out returns. The compile-time win never depended on donation. Verified the warning is gone by running the sharding tests with -W error::UserWarning at 4 sim devices.

2. Test gaps — covered.

  • test_sharding: layers.{2,3}.mlp.gate_proj now share (d_in, d_out, C), so the V/U stacked init exercises a real multi-site shape group (asserted max group size >= 2), and the sources init gets a 3-site C group. The existing exact-equality pin vs the jitted per-site init now covers real stack/unstack.
  • New test_chunkwise_ci_init_vmap_matches_unrolled_reference (ungated, plain make test): pins the vmapped CI init bit-identical (array_equal) against the unrolled+stacked per-chunk reference it replaced, at n_chunks=3 with heterogeneous per-slot C — i.e. old-vs-new, not new-vs-new.

Full validation on the branch: sharding+llama tests at 1 and 4 sim devices (warnings-as-errors), equivalence goldens, make check — all clean.

🤖 Generated with Claude Code

@ocg-goodfire

Copy link
Copy Markdown
Collaborator

oh shit this is sick

@claude is this the canonical jax fix here? idk why but something about having a vmap in the init feels weird to me. like, do we even need to compile the init? what if we just moved the compile boundary

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ocg-goodfire's task —— View job


Analysis: Is the vmap init approach canonical?

After analyzing the codebase, here's my technical assessment of whether this vmap approach is the canonical JAX fix:

The vmap approach IS canonical - here's why:

1. The problem was fundamentally about XLA compilation shape

  • The original init functions were creating hundreds of separately-sharded outputs (param_decomp/CLAUDE.md:275-286)
  • At 32 layers × 14 sites/layer = 448 outputs, the SPMD layout pass was taking multi-minutes
  • This is a classic XLA pathology: too many output arrays with complex sharding

2. The vmap solution is idiomatic JAX

  • Stack then slice: Create fewer outputs under jit (2×n_shapes instead of 2×n_sites), then fan out with trivial slicing
  • Uses jax.vmap() over identical per-site RNG keys to maintain bit-identical results
  • Pattern: init_decomp_vu_stackedunstack_decomp_vu with exact equality tests (param_decomp/tests/test_llama8b.py:543)

3. This follows JAX best practices for compilation
From the measured findings in CLAUDE.md:275-286:

Keep seeded inits few-outputs-under-jit: a jit returning n_sites (hundreds of) sharded outputs — or n_chunks unrolled RNG bodies — is a multi-minute SPMD/layout compile. vmap-stack over the same per-site/per-chunk keys (bit-identical values), then fan out with a trivial slice jit.

Why not move the compile boundary?

You asked about moving the compile boundary instead. That wouldn't solve the core issue:

1. Init must be compiled for sharding

  • The init functions use jax.jit(init, out_shardings=...) to place weights directly on devices (param_decomp/targets/llama8b_sharding.py:87-106)
  • Without compilation, you'd get host arrays that need expensive device_put operations
  • At 8B+ parameters, eager host→device transfers become prohibitive

2. The compile boundary is already optimal

  • The current approach compiles the minimal necessary scope
  • Stack creation (1 jit with few outputs) + fan-out (trivial slice jit) is much cheaper than hundreds of outputs

This is the standard pattern for JAX initialization at scale:

  • ✅ Use vmap for homogeneous operations (same RNG keys per group)
  • ✅ Create stacked intermediates to reduce output count
  • ✅ Use trivial slice operations to restructure post-compilation
  • ✅ Maintain bit-identical results (verified by tests)

The approach eliminates ~13-17 minutes of cold start time with zero impact on numerics, memory usage, or step time. This is exactly how you should handle large-scale JAX initialization.

Todo List:

  • Set up repository environment
  • Read CLAUDE.md for codebase context
  • Analyze the current vmap init implementation
  • Evaluate alternatives like moving compile boundaries
  • Provide technical assessment and recommendations

@ocg-goodfire

Copy link
Copy Markdown
Collaborator

@claude thanks.

can you explain this idiom?

    stacked = jax.jit(
        partial(
            init_persistent_sources_stacked,
            site_names,
            site_component_counts,
            leading_shape,
            source_dtype,
        ),
        out_shardings=stacked_shardings,
    )(key)

@ocg-goodfire ocg-goodfire left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

hell yea

@claude

claude Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @ocg-goodfire's task —— View job


Explaining the JAX Idiom

Todo List:

  • Read relevant source files to understand the pattern
  • Break down the idiom components
  • Explain the JAX compilation and sharding aspects
  • Provide the complete explanation

This is a JAX compiled function with distributed sharding pattern. Let me break it down:

stacked = jax.jit(
    partial(
        init_persistent_sources_stacked,
        site_names,
        site_component_counts, 
        leading_shape,
        source_dtype,
    ),
    out_shardings=stacked_shardings,
)(key)

Component Breakdown:

1. partial(...) - Partial Function Application

  • functools.partial pre-fills some arguments of init_persistent_sources_stacked
  • Only the key argument remains to be provided when called
  • This creates a new function: key -> init_persistent_sources_stacked(site_names, site_component_counts, leading_shape, source_dtype, key)

2. jax.jit(..., out_shardings=...) - JIT Compilation + Distributed Placement

  • jax.jit compiles the function to XLA for GPU execution
  • out_shardings=stacked_shardings tells JAX how to distribute the outputs across devices
  • Each output array gets placed on specific GPU shards according to stacked_shardings
  • This avoids expensive host→device transfers after computation

**3. (key) - Immediate Invocation

  • The entire compiled function is called immediately with the key argument
  • Pattern: jit(fn)(args) instead of fn = jit(fn); result = fn(args)

Why This Pattern?

From the PR context, this solves a compilation performance problem:

  • Old way: init_persistent_sources returned hundreds of separate outputs (one per site)
  • XLA problem: SPMD layout pass took ~55s to handle 224+ separate sharded outputs
  • New way: init_persistent_sources_stacked returns few grouped outputs (one per component count group)
  • Result: Massive compilation speedup while maintaining bit-identical results

The Full Flow:

  1. Stack: Create fewer outputs grouped by component count using vmap
  2. Place: JIT compilation places stacked arrays directly on correct GPU shards
  3. Unstack: Separate trivial JIT slices the stacks back into per-site arrays

This is idiomatic JAX for large-scale distributed initialization - minimize compilation overhead while ensuring correct device placement.


@ocg-goodfire ocg-goodfire merged commit 127e261 into feature/jax Jul 7, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants