Skip to content

fix(training): reclaim fused-optimizer activations per step (#1624, #1640) - #1641

Merged
ooples merged 1 commit into
masterfrom
fix/1624-fused-optimizer-arena
Jun 18, 2026
Merged

ooples merged 1 commit into
masterfrom
fix/1624-fused-optimizer-arena

Conversation

@ooples

@ooples ooples commented Jun 17, 2026 •

Copy link
Copy Markdown
Owner

Closes #1624
Closes #1640

Root cause

The default training route for a float model with a plain Adam/SGD optimizer is the fused-optimizer path (NeuralNetworkBase.TryTrainWithFusedOptimizer). Unlike the eager tape (TrainWithTape) and streaming paths — which each open their own per-step TensorArena — it opened none, so every step ran under whatever arena the caller had open. When a caller wraps a whole multi-step training loop in one un-Reset outer TensorArena — which the shared ModelFamilyTests base does, and which any cross-step buffer pooling does — each step's forward/backward activations piled into that outer arena's ring and accumulated. The cursor only advances, so the resident set grew by one step's working set per step → linear growth → OOM.

This is not the streaming path and not an autotuner misfire: ShouldUseStreamingTraining() correctly keeps these models on the eager/fused path (their weights+grads+Adam-moments footprint is tiny vs. available RAM). The leak was purely the missing per-step scope on the fused route.

Fix — the way PyTorch bounds training memory

PyTorch reaches a steady state because its caching allocator returns each iteration's freed blocks to a reuse pool (memory never grows with step count), while optimizer state (exp_avg/exp_avg_sq in optimizer.state) is created once and persists outside the per-iteration churn.

This change applies that exact model: open a per-step using var arena = TensorArena.Create() around the fused step. The arena's cross-arena _persistent pool is the block cache — Dispose returns the step's transient activations/gradients to it for the next step to reuse. It's unconditional (every step, including the first), so there's no one-time leak.

Safe because the compiled plan's persistent state — Adam m/v moments + persistent input/target — is held by the plan's own managed references on the GC heap, not arena-ring allocated, so the per-step scope recycles only transients and leaves optimizer state intact (mirroring optimizer.state persisting across iterations). Verified by fused Adam step-for-step parity + a convergence assertion.

Validation

#1640 — the named victim, VisionTransformer (ViT-Base, ~86M params, [1,3,224,224]), one outer arena, 40 steps, Release, 40 GB cap — clean A/B:

step WITHOUT fix WITH fix
1 2412 MB 1861 MB
10 7855 MB 2696 MB
20 11856 MB 2581 MB
30 15885 MB 2602 MB
40 19898 MB (+403 MB/step, still climbing) 2596 MB (flat from step 1)

Without the fix it climbs linearly ~403 MB/step, which extrapolates to #1640's observed 100 GB at ~step 245 — matching its 300 s multi-step window. So #1640's blow-up is the same cross-step arena accumulation, not a within-step activation peak; this one fix resolves both issues.

#1624 — SimCSE under DOTNET_GCHeapHardLimit=0x400000000 (16 GB): live heap plateaus flat across 10 / 20 / 30 steps (was 2.73× and climbing → OOM).

Correctness / no regression: fused Adam-convergence parity + LR-schedule mapping + compiled-step suite 16/16, broader fused/transformer-training suite 29/29 green; ~789 ms/step (no perf regression). The added regression test FusedTrainingArenaBoundednessTests asserts both the post-warmup heap plateau and that the memorization loss keeps falling (so a fix that bounded memory by corrupting Adam's moments would fail it).

Builds against master's pinned AiDotNet.Tensors (0.86.1) — no package bump required.

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jun 17, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
aidotnet_website Ready Ready Preview, Comment Jun 17, 2026 10:40pm
aidotnet-playground-api Ready Ready Preview, Comment Jun 17, 2026 10:40pm

@coderabbitai

coderabbitai Bot commented Jun 17, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@ooples, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 2 minutes and 9 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based credits.

🚦 How do rate limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan refill rate.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, the refill rate gradually slows as usage increases. The highest same-day bursts are limited more strictly.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 36cfcf09-a2c0-4205-bef3-24cd986c9227

📥 Commits

Reviewing files that changed from the base of the PR and between 3935e9b and 259b88c.

📒 Files selected for processing (2)
  • src/NeuralNetworks/NeuralNetworkBase.cs
  • tests/AiDotNet.Tests/UnitTests/NeuralNetworks/FusedTrainingArenaBoundednessTests.cs

Walkthrough

NeuralNetworkBase<T>.TryTrainWithFusedOptimizer now allocates a nullable per-step TensorArena only when GetFusedStepCount() > 0, preventing fused-path transients from accumulating in the caller's outer arena. A new regression test runs SimCSE<float> training in a single non-reset outer arena and asserts post-warmup heap growth stays below 4 MB/step.

Changes

Fused Optimizer Arena Boundedness Fix

Layer / File(s) Summary
Conditional per-step arena allocation
src/NeuralNetworks/NeuralNetworkBase.cs
TryTrainWithFusedOptimizer conditionally wraps the fused execution in a per-step TensorArena allocated only after the fused plan is configured (GetFusedStepCount() > 0); otherwise stepArena stays null so configure/fallback attempts don't accumulate transients in the caller's arena.
Regression test: heap plateau contract
tests/AiDotNet.Tests/UnitTests/NeuralNetworks/FusedTrainingArenaBoundednessTests.cs
FusedTrainingArenaBoundednessTests constructs SimCSE<float> with StreamingTraining = ForceOff, runs warmup steps inside a single non-reset outer TensorArena, samples full-GC live heap at steps 10, 11, and 26 via FullGcLiveBytes(), and asserts average per-step growth between steps 11 and 26 is below 4 MB. Includes a deterministic Rand helper for stable inputs across runs.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Poem

🧠 The arena once grew without bound,
Each fused step left transients around.
A guard was placed—
When count had graced
Past zero, new scope could be found! 🎯

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title directly references the core fix: reclaiming fused-optimizer activations per step, with issue numbers #1624 and #1640 clearly linked.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/1624-fused-optimizer-arena

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/NeuralNetworks/NeuralNetworkBase.cs`:
- Around line 7590-7593: Replace the thread-static GetFusedStepCount() check
with the per-instance _fusedTrainingCommitted flag to gate the TensorArena
creation, since GetFusedStepCount() is shared across threads and can cause
incorrect arena allocation for multi-model scenarios. Additionally, restructure
the arena allocation from a using statement spanning the entire method body to
one that creates the arena conditionally based on _fusedTrainingCommitted and
disposes it immediately after TryStepWithFusedOptimizer completes in a finally
block, ensuring the transient arena is released before any fallback code paths
like TrainWithTapeStreaming can execute.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a4d41dc2-0a0e-4472-87e6-657f83b603eb

📥 Commits

Reviewing files that changed from the base of the PR and between 146bc63 and 3935e9b.

📒 Files selected for processing (2)
  • src/NeuralNetworks/NeuralNetworkBase.cs
  • tests/AiDotNet.Tests/UnitTests/NeuralNetworks/FusedTrainingArenaBoundednessTests.cs

Comment thread src/NeuralNetworks/NeuralNetworkBase.cs Outdated
@ooples
ooples force-pushed the fix/1624-fused-optimizer-arena branch from 3935e9b to 915b642 Compare June 17, 2026 22:30
@vercel

vercel Bot commented Jun 17, 2026

Copy link
Copy Markdown

Deployment failed with the following error:

Resource is limited - try again in 24 hours (more than 100, code: "api-deployments-free-per-day").

Learn More: https://vercel.com/franklins-projects-02a0b5a0?upgradeToPro=build-rate-limit

@ooples ooples changed the title fix(training): bound fused-optimizer per-step transients under an outer arena fix(training): reclaim fused-optimizer activations per step (#1624, #1640) Jun 17, 2026
…1624/#1640)

The default training route for a float model with plain Adam/SGD is the
fused-optimizer path (TryTrainWithFusedOptimizer). Unlike the eager tape and
streaming paths it opened no per-step TensorArena, so every step ran under
whatever arena the caller had open. When a caller wraps the whole loop in ONE
un-Reset outer arena (the shared ModelFamilyTests base, or any cross-step buffer
pooling), each step's forward/backward activations piled into that outer arena's
ring and accumulated -> linear growth -> OOM. On ViT-Base this was +403 MB/step
(#1640's 100 GB over a long run); on SimCSE ~290 MB/step (#1624).

Fix: open a per-step arena around the fused step, matching how the eager and
streaming paths already scope -- and how PyTorch bounds training memory. PyTorch's
caching allocator returns each iteration's freed blocks to a reuse pool so the
process reaches a steady state, while optimizer state persists outside the
per-iteration churn. The arena's _persistent pool is that block cache; Dispose
returns the step's transients for the next step to reuse. The compiled plan's
Adam m/v moments + persistent input/target are GC-heap-held by the plan's own
references (not arena-ring allocated), so the scope recycles only transients and
leaves optimizer state intact -- exactly like PyTorch's optimizer.state.
Unconditional (every step, including the first), so there is no one-time leak.

The arena is disposed in a finally immediately after the fused step returns, so
it never spans the result handling or the OOM -> TrainWithTapeStreaming fallback
in the else-if (which degrades to the memory-bounded path precisely because we
are out of memory -- holding the failed step's transient ring across that switch
would waste memory under pressure and nest streaming inside a transient scope).

Verified: ViT-Base (86M) under one outer arena, 40 steps, 40 GB cap -- WITHOUT:
+403 MB/step climb (2412->19898 MB, would hit 100 GB at ~step 245); WITH: flat
~2.6 GB from step 1. SimCSE under a 16 GB cap plateaus flat. Fused Adam
step-for-step parity + convergence and broader fused suite 29/29 green;
~789 ms/step, no perf regression. The regression test asserts BOTH the
post-warmup plateau AND that the memorization loss keeps falling (the persistent
Adam moments survive the per-step scope).

Closes #1624
Closes #1640

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

This branch was successfully deployed

2 active (1 outdated) deployments
Preview – aidotnet-playground-api — 259b88c3 Deployed Jun 17, 2026 by vercel[bot]
Preview – aidotnet_website — 3935e9b6 Deployed Jun 17, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant