Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 18 additions & 11 deletions src/Helpers/LayerHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1808,9 +1808,16 @@ ILayer<T> Wire(ILayer<T> layer)
}

// Add dropout layer after embedding
// Closes #1383: Wire() the DropoutLayer so its RandomSeed is set
// from architecture.RandomSeed — DropoutLayer's Forward derives
// its per-call dropout-mask seed from RandomSeed + an internal
// counter, giving reproducible masks across consecutive trainings
// at the same architecture seed. Without Wire(), RandomSeed stays
// null and the dropout mask falls through to ThreadSafeRandom whose
// state advances cumulatively across trainings.
if (dropoutRate > 0)
{
yield return new DropoutLayer<T>(dropoutRate);
yield return Wire(new DropoutLayer<T>(dropoutRate));
}

// Add encoder layers
Expand All @@ -1823,10 +1830,10 @@ ILayer<T> Wire(ILayer<T> layer)
// Add normalization
yield return Wire(new LayerNormalizationLayer<T>());

// Add dropout if specified
// Add dropout if specified (Wire'd — see #1383 comment above).
if (dropoutRate > 0)
{
yield return new DropoutLayer<T>(dropoutRate);
yield return Wire(new DropoutLayer<T>(dropoutRate));
}

// Feed-forward network
Expand All @@ -1836,10 +1843,10 @@ ILayer<T> Wire(ILayer<T> layer)
// Add normalization
yield return Wire(new LayerNormalizationLayer<T>());

// Add dropout if specified
// Add dropout if specified (Wire'd — see #1383 comment above).
if (dropoutRate > 0)
{
yield return new DropoutLayer<T>(dropoutRate);
yield return Wire(new DropoutLayer<T>(dropoutRate));
}
}

Expand All @@ -1855,10 +1862,10 @@ ILayer<T> Wire(ILayer<T> layer)
// Add normalization
yield return Wire(new LayerNormalizationLayer<T>());

// Add dropout if specified
// Add dropout if specified (Wire'd — see #1383 comment above).
if (dropoutRate > 0)
{
yield return new DropoutLayer<T>(dropoutRate);
yield return Wire(new DropoutLayer<T>(dropoutRate));
}

// Cross-attention block
Expand All @@ -1868,10 +1875,10 @@ ILayer<T> Wire(ILayer<T> layer)
// Add normalization
yield return Wire(new LayerNormalizationLayer<T>());

// Add dropout if specified
// Add dropout if specified (Wire'd — see #1383 comment above).
if (dropoutRate > 0)
{
yield return new DropoutLayer<T>(dropoutRate);
yield return Wire(new DropoutLayer<T>(dropoutRate));
}

// Feed-forward network
Expand All @@ -1881,10 +1888,10 @@ ILayer<T> Wire(ILayer<T> layer)
// Add normalization
yield return Wire(new LayerNormalizationLayer<T>());

// Add dropout if specified
// Add dropout if specified (Wire'd — see #1383 comment above).
if (dropoutRate > 0)
{
yield return new DropoutLayer<T>(dropoutRate);
yield return Wire(new DropoutLayer<T>(dropoutRate));
}
}
}
Expand Down
100 changes: 98 additions & 2 deletions src/NeuralNetworks/Layers/DropoutLayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -267,9 +267,30 @@ public override Tensor<T> Forward(Tensor<T> input)
if (!IsTrainingMode)
return input;

// Closes #1383: derive a deterministic seed from the layer's
// RandomSeed + a forward-call counter so consecutive trainings in
// the same process produce bit-identical dropout masks at the same
// seed. Without this, TensorDropoutMask falls through to
// RandomHelper.ThreadSafeRandom whose per-thread state advances
// cumulatively across trainings — the canonical "two trainings at
// the same seed give different weights" bug. When RandomSeed is
// null (user opted out of reproducibility) we leave seed=null and
// let the engine pick its default non-reproducible source.
// Atomic increment via the shared AdvanceSeedCounter helper —
// two concurrent Forward calls on the same layer instance
// (multi-stream eval, parallel inference) never observe the
// same counter value and defeat the determinism contract. The
// helper centralizes the Interlocked.Increment + Unsafe.As
// reinterpret so the GPU forward path below shares the exact
// same atomic increment semantics.
ulong counter = AdvanceSeedCounter();
int? perCallSeed = RandomSeed.HasValue
? DeriveSeed32(RandomSeed.Value, counter)
: (int?)null;

// === Vectorized: Use TensorDropoutMask for optimized dropout mask generation (Phase C: New IEngine methods) ===
// TensorDropoutMask generates the mask with proper scaling in a single GPU/SIMD-accelerated call
_dropoutMask = Engine.TensorDropoutMask<T>(input._shape, _dropoutRate, _scale);
_dropoutMask = Engine.TensorDropoutMask<T>(input._shape, _dropoutRate, _scale, perCallSeed);

// Apply mask using Engine for GPU/CPU accelerated element-wise multiplication
return Engine.TensorMultiply(input, _dropoutMask);
Expand Down Expand Up @@ -410,7 +431,21 @@ public override Tensor<T> ForwardGpu(params Tensor<T>[] inputs)

float rate = (float)NumOps.ToDouble(_dropoutRate);
float scale = (float)NumOps.ToDouble(_scale);
ulong seed = _seedCounter++ ^ (uint)Environment.TickCount;
// Closes #1383: deterministic seed derivation. Shared helpers with
// the CPU Forward path above (DeriveSeed32 / DeriveSeed64 +
// AdvanceSeedCounter) ensure CPU and GPU produce bit-identical
// dropout masks at the same RandomSeed — prerequisite for the
// cross-engine determinism check in GpuTransformerDeterminismTests.
// Was previously XORing with Environment.TickCount, which is
// process-uptime — different on every process invocation — so
// same-seed trainings produced different GPU dropout masks across
// runs. The AdvanceSeedCounter helper performs the atomic
// Interlocked.Increment so concurrent ForwardGpu calls on the same
// layer instance never observe the same _seedCounter value.
ulong counter = AdvanceSeedCounter();
ulong seed = RandomSeed.HasValue
? DeriveSeed64(RandomSeed.Value, counter)
: counter ^ (uint)Environment.TickCount;

// Generate uniform random mask [0, 1) on GPU
var randoms = gpuEngine.RandomUniformGpu<T>(input._shape, 0f, 1f, seed);
Expand Down Expand Up @@ -455,5 +490,66 @@ public override void ResetState()
// Clean up GPU resources
_gpuDropoutMask?.Dispose();
_gpuDropoutMask = null;

// Reset the dropout-mask seed counter. Closes #1383: ResetState
// is the canonical "fresh training session" boundary; without
// this, a second training on the same layer instance would
// resume the counter where the previous left off, producing
// different masks than a fresh layer instance at the same
// RandomSeed would.
//
// Use Interlocked.Exchange (via Unsafe.As reinterpret to long)
// so this write has the same memory-ordering semantics as the
// Interlocked.Increment in AdvanceSeedCounter — mixing plain
// assignment with Interlocked operations on the same field can
// produce torn or stale reads on 32-bit runtimes.
System.Threading.Interlocked.Exchange(
ref System.Runtime.CompilerServices.Unsafe.As<ulong, long>(ref _seedCounter), 0L);
}

/// <summary>
/// Advances the internal forward-call counter atomically and returns
/// the PRE-increment value (i.e., the same semantics as a post-fix
/// <c>_seedCounter++</c>). Used by both the CPU and GPU Forward paths
/// to read+increment the counter under a single
/// <see cref="System.Threading.Interlocked"/>-ordered operation so
/// the field's memory model is consistent with the
/// <see cref="System.Threading.Interlocked.Exchange(ref long, long)"/>
/// reset in <see cref="ResetState"/>.
/// </summary>
private ulong AdvanceSeedCounter()
{
long incremented = System.Threading.Interlocked.Increment(
ref System.Runtime.CompilerServices.Unsafe.As<ulong, long>(ref _seedCounter));
// Interlocked.Increment returns the POST-increment value; subtract
// 1 to recover the pre-increment value the callers expect.
return unchecked((ulong)(incremented - 1));
}

/// <summary>
/// Shared seed-derivation helper for the CPU Forward path. Mixes the
/// layer's <see cref="LayerBase{T}.RandomSeed"/> with the per-call
/// counter via a Knuth-multiplicative hash. The 32-bit width matches
/// <see cref="Tensors.Helpers.RandomHelper.CreateSeededRandom(int)"/>'s
/// seed parameter type that <c>TensorDropoutMask</c> consumes on CPU.
/// </summary>
private static int DeriveSeed32(int randomSeed, ulong counter)
{
return unchecked((int)(((uint)randomSeed * 2654435761u) ^ (uint)counter));
}

/// <summary>
/// Shared seed-derivation helper for the GPU Forward path. The 64-bit
/// width matches <see cref="Tensors.Engines.Gpu.DirectGpuTensorEngine.RandomUniformGpu{T}"/>'s
/// <c>ulong seed</c> parameter. The 32-bit projection
/// <c>(uint)randomSeed * 2654435761ul ^ counter</c> in the low 64
/// bits is bit-identical to <see cref="DeriveSeed32"/>'s output once
/// reinterpreted as int — so CPU and GPU forward paths derive the
/// same dropout-mask seed from the same (RandomSeed, counter) pair,
/// which is the prerequisite for cross-engine determinism checks.
/// </summary>
private static ulong DeriveSeed64(int randomSeed, ulong counter)
{
return unchecked(((ulong)(uint)randomSeed * 2654435761ul) ^ counter);
}
}
10 changes: 9 additions & 1 deletion src/NeuralNetworks/Layers/FeedForwardLayer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -311,7 +311,15 @@ protected override void EnsureInitialized()
// expansion). Routing through AllocateLazyWeight lets the
// pool pre-evict before the new GC byte[] lands.
_weights = AllocateLazyWeight([_inputSize, _outputSize]);
var rng = new SimdRandom();
// Closes #1383: honor the layer-level RandomSeed so consecutive
// FeedForwardLayer constructions at the same architecture seed
// produce bit-identical weight init. Previously this used the
// parameterless `new SimdRandom()` which consumes the
// process-static counter and gives a different seed on each
// construction within the same process.
var rng = RandomSeed.HasValue
? new SimdRandom(RandomSeed.Value)
: new SimdRandom();
var wSpan = _weights.Data.Span;
int total = wSpan.Length;

Expand Down
61 changes: 59 additions & 2 deletions src/NeuralNetworks/Layers/LayerBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3983,10 +3983,25 @@ public virtual WeightLoadResult LoadWeights(
/// <param name="fanIn">Number of input units.</param>
/// <param name="fanOut">Number of output units.</param>
/// <param name="strategy">Optional strategy. If null, uses Xavier uniform (industry standard default).</param>
// Cached default strategy (Xavier/Glorot — industry standard for sigmoid/tanh)
// Cached default strategy ONLY for the non-reproducible path (no
// RandomSeed set). It backs onto RandomHelper.ThreadSafeRandom whose
// state advances cumulatively across calls — fine when the caller
// explicitly opted out of reproducibility by leaving RandomSeed null,
// BUT NOT FINE when RandomSeed is set (closes #1383: consecutive
// trainings at the same seed must produce bit-identical weights).
// The seeded path below builds a fresh EagerInitializationStrategy
// per call using a layer-RandomSeed-derived seeded Random, so each
// initialization is reproducible.
private static readonly Lazy<IInitializationStrategy<T>> DefaultStrategy =
new(() => new Initialization.EagerInitializationStrategy<T>());

// Per-layer counter so multiple InitializeLayerWeights calls on the
// same layer instance produce different (but still deterministic)
// weight tensors. Without this, e.g. the four Q/K/V/O weight matrices
// in an MHA layer would all be initialized from the same RNG draw and
// end up bit-identical to each other.
private int _initWeightsCallCounter;

/// <summary>
/// Initializes weights using this layer's <see cref="InitializationStrategy"/>,
/// falling back to Xavier/Glorot normal if none was set.
Expand All @@ -3995,7 +4010,49 @@ public virtual WeightLoadResult LoadWeights(
/// </summary>
protected void InitializeLayerWeights(Tensor<T> tensor, int fanIn, int fanOut)
{
(InitializationStrategy ?? DefaultStrategy.Value).InitializeWeights(tensor, fanIn, fanOut);
if (InitializationStrategy is not null)
{
InitializationStrategy.InitializeWeights(tensor, fanIn, fanOut);
return;
}

// Closes #1383: when this layer has a RandomSeed pinned (set by
// LayerHelper from architecture.RandomSeed), build a FRESH seeded
// strategy per call rather than the process-shared
// DefaultStrategy singleton. The singleton wraps
// RandomHelper.ThreadSafeRandom whose per-thread Random state
// advances cumulatively across consecutive trainings — so two
// back-to-back trainings at the same architecture seed produce
// different initial weights without this branch.
if (RandomSeed.HasValue)
{
// Mix the tensor shape (fanIn, fanOut) into the derivation
// alongside the layer's RandomSeed and the per-call counter.
// This defends the seeded path against the case where two
// different layer instances share the same RandomSeed value
// (uncommon — LayerHelper.CreateDefaultTransformerLayers
// assigns each layer a unique seed via seedRng.Next() — but
// possible if a consumer manually sets RandomSeed). Without
// shape-mixing, two layers at the same RandomSeed + same
// _initWeightsCallCounter index initializing weight tensors
// of the same fanIn × fanOut would land on bit-identical
// weights, breaking the symmetry the network architecture
// relies on. Different-shape tensors already differ via
// (fanIn, fanOut); same-shape tensors at the same call
// index across distinct layer instances now also differ
// via the counter, which is per-instance.
int derived = unchecked((int)(
((uint)RandomSeed.Value * 2654435761u)
^ ((uint)fanIn * 40503u)
^ ((uint)fanOut * 2654435789u)
^ (uint)System.Threading.Interlocked.Increment(ref _initWeightsCallCounter)));
var seeded = new Initialization.EagerInitializationStrategy<T>(
AiDotNet.Tensors.Helpers.RandomHelper.CreateSeededRandom(derived));
seeded.InitializeWeights(tensor, fanIn, fanOut);
return;
}
Comment thread
ooples marked this conversation as resolved.

DefaultStrategy.Value.InitializeWeights(tensor, fanIn, fanOut);
}

/// <summary>
Expand Down
Loading
Loading