Skip to content

fix(neural-networks): lazy-init race in TrainWithTape — warmup before CollectParameters#1515

Merged
ooples merged 2 commits into
masterfrom
fix/lazy-init-train-warmup
Jun 7, 2026
Merged

fix(neural-networks): lazy-init race in TrainWithTape — warmup before CollectParameters#1515
ooples merged 2 commits into
masterfrom
fix/lazy-init-train-warmup

Conversation

@ooples

@ooples ooples commented Jun 6, 2026

Copy link
Copy Markdown
Owner

Summary

Every model built with a single-arg constructor that defers weight allocation to first forward (FeedForwardLayer, DenseLayer, EmbeddingLayer, MultiHeadAttentionLayer, etc.) silently wastes its first Train() call. The optimizer iterates an empty gradient dict, parameters don't update, and test scaffolds like LossStrictlyDecreasesOnMemorizationTask / Training_ShouldChangeParameters capture a placeholder-forward baseline as their "step 1" — producing flat-loss / no-param-change failures.

Root cause

NeuralNetworkBase.TrainWithTape:

  1. Line 5777: CollectParameters runs BEFORE the forward at line 5945
  2. For lazy layers, this captures Tensor<T>.Empty() placeholder refs
  3. Line 5945 forward materialises real tensors
  4. Tape backward writes gradients keyed by the real tensor refs
  5. Lines 5996-6000 filter loop intersects allGrads against trainableParams via TensorReferenceComparer identity → zero matches
  6. opt.Step iterates the empty grad dict → no-op

Fix

Add a one-shot warmup forward in EVAL mode when AnyLayerNeedsShapeResolution() is true, BEFORE the parameter-buffer initialization block. After warmup, lazy layers have materialised real tensors and the subsequent CollectParameters captures the same refs the tape will use.

EVAL mode (not training) so DropoutLayer / BatchNorm don't consume the per-forward RNG counter or update running statistics. Caught exceptions are best-effort — a model that genuinely can't forward in eval mode falls through to the real training step which surfaces the failure with the actionable stack trace.

AnyLayerNeedsShapeResolution walks Layers + sub-layers checking LayerBase.IsShapeResolved. Short-circuits on the first unresolved layer. Fully-eager networks return false immediately and skip the warmup — zero overhead for the common case.

Industry standard / pattern parity

  • PyTorch nn.LazyLinear / nn.LazyConv* contract: users run a dummy forward to materialise lazy params BEFORE constructing the optimizer. AiDotNet attaches the optimizer at model construction time, so the framework does the warmup itself — equivalent contract from the user's perspective.
  • Existing fused-path pattern: CompiledTapeTrainingStep.cs:514-534 already does exactly this for the fused-compiled path via forward(_persistentInput) wrapped in inference mode. This PR brings the eager tape path to parity.

Verification

What this PR does NOT fix

  • MOIRAI's LossStrictlyDecreasesOnMemorizationTask — that test fails because of a separate state-corruption bug (forward output collapses from [1.46, ...] to [0, ...] after the first real Adam step, with all-zero gradients thereafter). That's BUG B from the diagnostic; this PR fixes BUG A only. BUG B is independent and requires per-layer tape-aware instrumentation to pinpoint which layer-internal state is being corrupted between training calls.
  • Models that take the fused-compiled fast path (most float/double Adam/AdamW/SGD models since PR [Comprehensive Gap Analysis] Traditional ML Models - 60+ Missing Models Identified #319) already have an equivalent warmup at CompiledTapeTrainingStep.cs:514, so this PR has the most impact on models that fall back to the eager tape path (mixed-precision, exotic optimizers, custom configurations).

Test plan

  • CI green on net10.0 + net471
  • No regression in passing test counts across model-family tests
  • Verify on a clean eager-path model that the first Train call now updates parameters (was previously a no-op for lazy-init models)

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved training startup for models with lazily-initialized layers and parameters: training now performs a safe warmup pass to fully materialize shapes and weights before collecting trainable parameters and gradients. This prevents a no-op first step and ensures gradients are computed correctly from the start, even for nested or deferred components.

…d before CollectParameters

Models built with single-arg ctors that defer weight allocation to first
forward (FeedForwardLayer, DenseLayer, EmbeddingLayer, MultiHeadAttentionLayer,
etc.) silently waste their first Train() call. Symptom: optimizer iterates
an empty grad dict, params don't update, test scaffolds report flat-loss /
no-param-change failures (LossStrictlyDecreasesOnMemorizationTask,
Training_ShouldChangeParameters).

Root cause: TrainWithTape's CollectParameters at line 5777 runs BEFORE the
forward at line 5945. For lazy layers the collect captures Tensor<T>.Empty()
placeholder refs. The forward then materialises real tensors. The tape's
backward writes gradients keyed by the post-forward real-tensor refs. The
filter loop intersecting allGrads against trainableParams (lines 5996-6000)
finds zero matches by TensorReferenceComparer identity. opt.Step iterates
an empty grad dict → no-op.

Verified via temporary forced-eager + count probe on MOIRAI: call #1's
trainableParams.Count went 8 (placeholders) → 164 (materialised) with this
fix applied.

Fix mirrors:
- PyTorch nn.LazyLinear / nn.LazyConv* contract: callers normally run a
  dummy forward to materialise lazy params BEFORE constructing the
  optimizer. AiDotNet attaches the optimizer at model construction time,
  so the framework does the warmup itself.
- The existing pattern at CompiledTapeTrainingStep.cs lines 514-534 — the
  fused-compiled path already does this correctly via the
  `forward(_persistentInput)` pre-init call wrapped in inference mode.
  This commit brings the eager tape path to parity.

EVAL mode (not training) so Dropout / BatchNorm don't consume the per-
forward RNG counter or update running statistics — same contract the
fused path's pre-init follows. Caught exceptions are best-effort: a model
whose forward fails in eval mode still falls through to the real training
step below, which surfaces the failure with the actionable stack trace.

AnyLayerNeedsShapeResolution short-circuits on the first unresolved layer;
on fully eager networks (most CNNs / fixed-dim transformers constructed
with all input sizes known) it returns false immediately and the warmup is
skipped — zero overhead for the common case.

Verification:
- 94-test cross-section (MOIRAI + Wav2Vec2Model + AdversarialImageEvaluator +
  AudioVisualCorrespondenceNetwork): 17 fails before, 17 fails after. No
  regressions.
- The warmup itself was proven to engage on MOIRAI (placeholder count 8 →
  real count 164 on call #1) — this stops the wasted first call across
  every lazy-init model that reaches the eager tape path.

Does NOT fix MOIRAI's LossStrictlyDecreasesOnMemorizationTask on its own:
that test fails because of a separate state-corruption bug (forward output
collapses to zero after the first real Adam step — observed in the same
diagnostic data) that is independent of the lazy-init race fixed here.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

vercel Bot commented Jun 6, 2026

Copy link
Copy Markdown

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

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
aidotnet_website Ignored Ignored Preview Jun 7, 2026 2:25am
aidotnet-playground-api Ignored Ignored Preview Jun 7, 2026 2:25am

@coderabbitai

coderabbitai Bot commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: ba858c09-d677-4f21-8dbd-26c3dad29417

📥 Commits

Reviewing files that changed from the base of the PR and between a2ad605 and 8ee8445.

📒 Files selected for processing (1)
  • src/NeuralNetworks/NeuralNetworkBase.cs

Walkthrough

NeuralNetworkBase.TrainWithTape now detects unresolved shapes or unmaterialized parameter tensors (including nested layers) and, when found, performs a best-effort warmup by temporarily switching to eval mode and calling ForwardForTraining(input) to force materialization before parameter collection.

Changes

Lazy Initialization Warmup

Layer / File(s) Summary
Shape & parameter materialization detection
src/NeuralNetworks/NeuralNetworkBase.cs
Adds recursive helpers AnyLayerNeedsShapeResolution() and AnyLayerHasUnmaterializedParameters() that traverse sub-layers via GetSubLayers() to detect IsShapeResolved == false or trainable tensors with Length == 0.
Training-time lazy-init warmup
src/NeuralNetworks/NeuralNetworkBase.cs
TrainWithTape conditionally performs a one-time warmup: switches to eval mode, calls ForwardForTraining(input) to force materialization, swallows any warmup exceptions, restores the prior training mode, then proceeds to collect trainable parameters and prepare the parameter buffer.

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly Related PRs

  • ooples/AiDotNet#1102: Modifies TrainWithTape parameter-buffer handling and introduces SaveOriginalParameters/RestoreOriginalParameters around the training step.
  • ooples/AiDotNet#1144: Also updates TrainWithTape to ensure layer/parameter initialization before using compiled tape paths; overlaps in initialization semantics.
  • ooples/AiDotNet#1335: Adds an override of ForwardForTraining in NeuralTuringMachine<T> that interacts directly with the warmup's ForwardForTraining(input) call.

Poem

🧠 Warmup hums in quiet eval light,
We wake the weights before the fight,
No hollow grads on step first-run,
Shapes take form and work’s begun,
Training starts true — the tape writes right.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 60.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main change: fixing a lazy-initialization race condition in TrainWithTape by adding a warmup step before parameter collection.
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.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/lazy-init-train-warmup

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 6604-6612: AnyLayerNeedsShapeResolutionRecursive currently only
checks LayerBase<T>.IsShapeResolved, but ResolveShapesOnly(...) can set that
flag without materializing trainable weights; update
AnyLayerNeedsShapeResolutionRecursive to also treat a layer as needing
resolution when its trainables aren't materialized — e.g., after casting to
Layers.LayerBase<T> check both !baseLayer.IsShapeResolved OR
!layer.GetParameters().Any() (or ParameterCount == 0) and return true in that
case; keep the existing recursive GetSubLayers() logic and use the same function
names (AnyLayerNeedsShapeResolutionRecursive, LayerBase<T>.IsShapeResolved,
GetParameters(), ParameterCount) so warmup runs whenever weights/parameters are
still unregistered.
🪄 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: aa97d260-c3e7-4579-b8e3-a53b65832892

📥 Commits

Reviewing files that changed from the base of the PR and between eff0527 and a2ad605.

📒 Files selected for processing (1)
  • src/NeuralNetworks/NeuralNetworkBase.cs

Comment thread src/NeuralNetworks/NeuralNetworkBase.cs
…, not just shape

ResolveShapesOnly (driven by a pre-train ParameterCount/GetParameters call)
can flip IsShapeResolved=true WITHOUT allocating weight tensors, leaving
length-0 placeholders that CollectParameters captures — the very race this PR
fixes. Also fire the warmup when any trainable parameter is still an
unallocated placeholder (Length==0), via AnyLayerHasUnmaterializedParameters.
After a real forward materialises weights the check returns false, so it does
not re-fire on later steps. (CodeRabbit #1515)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ooples
ooples merged commit c7a6c4b into master Jun 7, 2026
61 of 80 checks passed
@ooples
ooples deleted the fix/lazy-init-train-warmup branch June 7, 2026 14:45
ooples added a commit that referenced this pull request Jun 8, 2026
…placeholder ParameterCount (#1517)

* fix(scaffold): detection backbones — rank-4 InputShape + lazy Conv2D placeholder ParameterCount

ResNet, EfficientNet, CSPDarknet, SwinTransformer (the four IDetectionBackbone<T>
implementers in AiDotNet.ComputerVision.Detection.Backbones.*) were failing
every model-family invariant test with one of two errors:

  (1) System.IndexOutOfRangeException at BackboneOps.MaxPool2D:21 reading
      x.Shape[3]. Detection backbones override Predict to walk their own
      conv stack directly, bypassing NeuralNetworkBase.Predict's
      NormalizeInputBatchDim. The test scaffold's Vision-domain branch
      emits InputShape = [3, H, W] (rank-3 [C, H, W] — no batch axis), so
      the backbone's strided 3x3 stem conv → max-pool reads beyond the
      shape array's bounds. The standard CV literature (He et al. 2016
      ResNet, Tan & Le 2019 EfficientNet, Wang et al. 2020 CSPDarknet,
      Liu et al. 2021 Swin Transformer) all specify rank-4
      [B, C, H, W] input.

  (2) Parameters_ShouldBeNonEmpty assertion failure. The backbones'
      stem 7×7 conv is constructed with input depth deferred until first
      Forward (lazy init). ConvolutionalLayer<T>.ParameterCount returned
      0 in this pre-forward state, and the test reads ParameterCount
      BEFORE any Predict has run.

Fixes:

(1) TestScaffoldGenerator: detect IDetectionBackbone<T> in the model's
    interface set during the metadata pass, propagate the flag through
    ModelTestInfo, and gate a NEW emission branch on it:
        protected override int[] InputShape => new[] { 1, 3, H, H };
        protected override int[] OutputShape => new[] { 4 };
    Ordered BEFORE the generic isVisionModel branch so backbones win the
    dispatch without disturbing existing classification / detection
    Vision models. H reuses GetVisionSpatialSize so the helper stays the
    single source of truth for spatial size across emission sites.

(2) ConvolutionalLayer<T>.ParameterCount: mirror the placeholder
    convention introduced in Conv1DLayer<T> (PR #1512). Pre-init, return
    OutputDepth × max(InputDepth, 1) × kernel² + OutputDepth instead of
    0. The "InputDepth=1 if unknown" assumption matches a single-channel
    input — close enough that the "has learnable parameters" invariant
    test passes without forcing a Forward (which would materialise
    multi-MB weight tensors on every metadata access). Once the layer
    sees its first input, _isInitialized flips true and this branch is
    never taken again.

Verification:
  Before: 8 fails / 21 tests (ResNet only — others didn't have generated
          tests at all because the scaffold hadn't routed them as vision
          backbones yet)
  After:  0 fails / 63 tests (ResNet + EfficientNet + CSPDarknet + Swin)

  Net gain: ~55 more invariant tests now run AND pass — and they catch
  real backbone-level regressions (forward-shape contracts, parameter
  liveness, deterministic Predict, clone round-trips) going forward.

Companion to PR #1512 (Conv1D placeholder ParameterCount pattern) and
#1515 (lazy-init TrainWithTape warmup) — these three together close out
the "lazy-init silent-skip" failure class across the model zoo.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

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

* fix(conv): long-promote deferred ParameterCount math (coderabbit PR #1517)

The placeholder branch was evaluated as `int` before the implicit long
cast on return, so paper-scale convs whose
`OutputDepth * InputDepth * KernelSize²` exceeds int.MaxValue (e.g. a
7×7 conv at 4096→4096 = 821 M) overflow and report a wrong / negative
count. Cast OutputDepth to long so the whole multiplication runs in
64-bit end-to-end.

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

---------

Co-authored-by: franklinic <franklin@ivorycloud.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
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