Skip to content

Commit 01c6e73

Browse files
franklinicclaude
andcommitted
feat(#1662): opt-in single-pass fast approximate grad-clip (§5c)
Adds FastApproxGradClip (default OFF). When on + clipping active, the streaming clipped path runs as a SINGLE backward pass: the clip scale comes from an EMA of the previous step's global grad-norm (NFNet-style adaptive clipping), and this step's exact norm is accumulated in the same pass to update the EMA. First step seeds the EMA without clipping. NOT bit-identical (documented approximation) — this is the clipped path that beats PyTorch on backward-pass count (torch's apply_optimizer_in_backward cannot clip at all). Branch restructured into unclipped single-pass / fast-clip single-pass / exact two-pass; persistent tape + ReleaseStreamingActivations toggle now gated on the genuine two-pass only. Convergence test (loss decreases, stays finite) PASSES; 14/14 fused+streaming tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 59fdb3b commit 01c6e73

2 files changed

Lines changed: 107 additions & 10 deletions

File tree

src/NeuralNetworks/NeuralNetworkBase.cs

Lines changed: 72 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6141,6 +6141,22 @@ private TrainSentinel AcquireTrainSentinel()
61416141
/// </summary>
61426142
public StreamingTrainingMode StreamingTraining { get; set; } = StreamingTrainingMode.Auto;
61436143

6144+
/// <summary>
6145+
/// #1662 lever #1 (§5c) — opt-in single-pass approximate gradient clipping for the streaming
6146+
/// (optimizer-in-backward) path. When <c>true</c> and clipping is active, the clip scale is
6147+
/// computed from an EMA of the PREVIOUS step's global grad-norm instead of the exact current
6148+
/// norm, so even clipped training runs in ONE backward pass (no 2× norm pass). This is an
6149+
/// approximation (NFNet-style adaptive clipping, Brock et al. 2021): it is NOT bit-identical
6150+
/// to exact <c>clip_grad_norm_</c> and changes the trajectory. Default: <c>false</c> (exact
6151+
/// two-pass). Only meaningful when streaming is engaged (e.g. <see cref="StreamingTrainingMode.ForceOn"/>).
6152+
/// </summary>
6153+
public bool FastApproxGradClip { get; set; } = false;
6154+
6155+
// EMA of the global grad-norm for FastApproxGradClip; < 0 until seeded on the first clipped
6156+
// fast-clip step. Persists across steps so the estimate tracks the run.
6157+
private double _fastClipEmaNorm = -1.0;
6158+
private const double FastClipEmaBeta = 0.9;
6159+
61446160
/// <summary>Learning rate used by the default streaming 8-bit Adam epilogue. Defaults
61456161
/// to 1e-4 — the conservative rate large-transformer/foundation-model
61466162
/// training uses, which stays stable under 8-bit moment quantization. When
@@ -6267,17 +6283,21 @@ protected void TrainWithTapeStreaming(
62676283
// clipping is off, one streaming pass suffices and the tape is non-persistent.
62686284
double maxGradNorm = MaxGradNormValue;
62696285
bool clip = maxGradNorm > 0.0;
6286+
// #1662 lever #1 (§5c): opt-in fast-clip turns the clipped case into a SINGLE streaming
6287+
// pass by clipping with an EMA of the previous step's global grad-norm (NFNet-style
6288+
// adaptive clipping) instead of the exact current-step norm. Only the exact path needs
6289+
// the two-pass-over-a-persistent-tape dance; fast-clip and unclipped are single-pass.
6290+
bool twoPass = clip && !FastApproxGradClip;
62706291

62716292
using var tape = new GradientTape<T>(
6272-
clip ? new GradientTapeOptions { Persistent = true } : null);
6273-
// #1662 lever #1: the clipped two-pass path reuses the persistent tape across the norm
6274-
// pass and the apply pass. ComputeGradientsStreaming releases activations by default
6275-
// (the process-global GradientTape<T>.ReleaseStreamingActivations), which would make the
6276-
// second pass throw "activations released" — so for the clipped path keep them resident,
6277-
// saving/restoring the flag so the setting never leaks to other tapes/steps. (The
6278-
// unclipped single-pass path keeps the default release, freeing each grad as produced.)
6293+
twoPass ? new GradientTapeOptions { Persistent = true } : null);
6294+
// The exact-clip two-pass path reuses the persistent tape across the norm pass and the
6295+
// apply pass. ComputeGradientsStreaming releases activations by default (the process-
6296+
// global GradientTape<T>.ReleaseStreamingActivations), which would make the second pass
6297+
// throw "activations released" — so for that path keep them resident, saving/restoring
6298+
// the flag so the setting never leaks. (Single-pass paths keep the default release.)
62796299
bool savedStreamingRelease = GradientTape<T>.ReleaseStreamingActivations;
6280-
if (clip) GradientTape<T>.ReleaseStreamingActivations = false;
6300+
if (twoPass) GradientTape<T>.ReleaseStreamingActivations = false;
62816301
try
62826302
{
62836303
var output = ForwardForTraining(input);
@@ -6341,7 +6361,7 @@ protected void TrainWithTapeStreaming(
63416361
streamingOptimizer.Apply(source, grad);
63426362
});
63436363
}
6344-
else
6364+
else if (twoPass)
63456365
{
63466366
// Clip set = layer-owned trainable params only (NOT the extras), matching
63476367
// the eager ApplyGradientClipping call site exactly so the global norm and
@@ -6391,6 +6411,48 @@ protected void TrainWithTapeStreaming(
63916411
streamingOptimizer.Apply(source, grad);
63926412
});
63936413
}
6414+
else
6415+
{
6416+
// #1662 lever #1 (§5c) FAST-CLIP — opt-in, NOT bit-identical. Clip with an EMA of the
6417+
// PREVIOUS step's global grad-norm so the whole step is a SINGLE streaming pass even
6418+
// when clipping (no 2× backward). The exact current-step norm is accumulated in the
6419+
// same pass and folded into the EMA for next step. First step (no EMA): don't clip,
6420+
// just seed. NFNet-style adaptive clipping (Brock et al. 2021); documented approximation.
6421+
var clipSet = new HashSet<Tensor<T>>(
6422+
trainableParams, Helpers.TensorReferenceComparer<Tensor<T>>.Instance);
6423+
6424+
double emaNorm = _fastClipEmaNorm; // < 0 until seeded
6425+
bool haveEma = emaNorm >= 0.0;
6426+
bool scaleDown = haveEma && emaNorm > maxGradNorm;
6427+
T scale = scaleDown
6428+
? NumOps.FromDouble(maxGradNorm / (emaNorm + 1e-6))
6429+
: NumOps.One;
6430+
6431+
double curNormSq = 0.0;
6432+
tape.ComputeGradientsStreaming(lossTensor, sources,
6433+
(source, grad) =>
6434+
{
6435+
if (grad is null || grad.Length == 0) return;
6436+
if (clipSet.Contains(source))
6437+
{
6438+
var span = grad.Data.Span;
6439+
int len = grad.Length;
6440+
for (int i = 0; i < len; i++)
6441+
{
6442+
double v = NumOps.ToDouble(span[i]); // unscaled — feeds the true-norm EMA
6443+
curNormSq += v * v;
6444+
if (scaleDown) span[i] = NumOps.Multiply(span[i], scale);
6445+
}
6446+
}
6447+
streamingOptimizer.Apply(source, grad);
6448+
});
6449+
6450+
// Fold this step's exact norm into the EMA for the next step's clip estimate.
6451+
double curNorm = Math.Sqrt(curNormSq);
6452+
_fastClipEmaNorm = haveEma
6453+
? FastClipEmaBeta * emaNorm + (1.0 - FastClipEmaBeta) * curNorm
6454+
: curNorm;
6455+
}
63946456

63956457
// Post-step hook: first-order optimizers already updated each parameter in place during
63966458
// Apply (no-op here); a full-gradient optimizer like streaming L-BFGS buffered the
@@ -6409,7 +6471,7 @@ protected void TrainWithTapeStreaming(
64096471
}
64106472
finally
64116473
{
6412-
if (clip) GradientTape<T>.ReleaseStreamingActivations = savedStreamingRelease;
6474+
if (twoPass) GradientTape<T>.ReleaseStreamingActivations = savedStreamingRelease;
64136475
}
64146476
}
64156477

tests/AiDotNet.Tests/IntegrationTests/NeuralNetworks/FusedOptimizerIntegrationTests.cs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,41 @@ public async Task FusedInBackward_Clipped_MatchesClassicAdam_ToFloatPrecision()
194194
"clipped two-pass fused optimizer-in-backward diverged from classic clipped Adam beyond float precision");
195195
}
196196

197+
/// <summary>
198+
/// #1662 lever #1 (§5c) fast-clip convergence: the opt-in single-pass approximate clip
199+
/// (EMA of the prior step's grad-norm) must train stably — loss decreases and stays finite —
200+
/// even though it is NOT bit-identical to exact clipping. This is the path that lets clipped
201+
/// training run in one backward pass (a capability PyTorch's apply_optimizer_in_backward lacks
202+
/// entirely). It need not match the exact-clip trajectory, only converge.
203+
/// </summary>
204+
[Fact(Timeout = 120000)]
205+
public async Task FastClip_SinglePass_ReducesLoss_AndStaysFinite()
206+
{
207+
await Task.CompletedTask;
208+
209+
var input = CreateRandomTensor(new[] { 16, 4 }, seed: 42);
210+
var target = CreateRandomTensor(new[] { 16, 2 }, seed: 43);
211+
212+
var net = BuildMlp();
213+
net.Predict(CreateRandomTensor(new[] { 1, 4 }, seed: 99));
214+
net.SetMaxGradNormForTest(1.0); // clipping ON
215+
net.FastApproxGradClip = true; // single-pass approximate
216+
net.StreamingTraining = StreamingTrainingMode.ForceOn;
217+
218+
var optimizer = BuildAdam(net, learningRate: 0.01);
219+
220+
double first = double.NaN, last = 0.0;
221+
for (int step = 0; step < 30; step++)
222+
{
223+
net.TrainPublic(input, target, optimizer);
224+
last = net.LastLossPublic;
225+
Assert.False(double.IsNaN(last) || double.IsInfinity(last),
226+
$"fast-clip produced non-finite loss at step {step}");
227+
if (step == 0) first = last;
228+
}
229+
Assert.True(last < first, $"fast-clip did not reduce loss: {first} -> {last}");
230+
}
231+
197232
/// <summary>
198233
/// End-to-end validation of the FP16-activation training path for a NON-Adam fused
199234
/// optimizer (Tensors #574 + AiDotNet #1543). With <c>AIDOTNET_FP16_ACTIVATIONS=1</c>,

0 commit comments

Comments
 (0)