Warrior_EA/DirectML/WarriorCPU.cpp
AnimateDread 4f28165cd3 fix: remove broken DFA optimizer, use plain gradient descent
The DFA (Direct Feedback Alignment) option was never a correct implementation:
it deterministically flipped the sign of half of all gradients based on
connection index parity, causing permanent gradient ascent for those weights
and guaranteed divergence. The backward pass was also incompatible with the
OpenCL/DirectML neuron model (layer.Total() == 1). This change removes all DFA
logic, including the enum value and `DfaFeedbackSignal` method, and replaces it
with plain gradient descent in all momentum update kernels. The `optimizer`
kernel argument is retained for binary compatibility but is no longer used.
2026-07-29 00:03:54 -04:00

1420 lines
61 KiB
C++

//+------------------------------------------------------------------+
//| Warrior_EA |
//| CPU thread-pool compute kernels, semantics mirror |
//| AI\Network.cl / WarriorDML.cpp exactly (see those files |
//| for the reference derivation of each kernel). |
//+------------------------------------------------------------------+
#include "WarriorCPU.h"
#define NOMINMAX
#include <windows.h>
#include <vector>
#include <queue>
#include <thread>
#include <mutex>
#include <condition_variable>
#include <functional>
#include <atomic>
#include <cmath>
#include <algorithm>
#include <future>
#include <chrono>
namespace
{
// Fault isolation boundary: a hardware exception (bad pointer, misaligned
// SIMD access, etc.) or a C++ exception raised while running a kernel must
// never be allowed to unwind past this DLL, because MQL5/#import has no
// concept of an exception - it would fall straight through to the OS
// unhandled-exception path and kill the whole terminal process (this is
// exactly how the 0xc0000409 overnight crash happened). Every kernel
// dispatch and the pool-startup call are routed through these two guards
// instead, so a fault degrades to "this call failed, CPU_GetLastError()
// explains why" - the caller (CNet on the MQL5 side) can retry/reinit
// instead of the whole terminal dying.
//
// Split into their own functions because MSVC forbids mixing __try/__except
// with C++ try/catch in the same function (C2712/C2713) - the C++ exception
// guard and the SEH guard each need their own stack frame.
bool CallFnNoThrow(const std::function<void(int, int)> &fn, int begin, int end)
{
try
{
fn(begin, end);
return true;
}
catch(...)
{
return false;
}
}
bool SehCallFn(const std::function<void(int, int)> &fn, int begin, int end)
{
__try
{
return CallFnNoThrow(fn, begin, end);
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
return false;
}
}
bool SehStartPool(class ThreadPool &pool, int threads);
//+------------------------------------------------------------------+
//| Minimal persistent thread pool with a parallel_for helper. Work |
//| is split into contiguous chunks (one per worker) so cache lines |
//| stay reasonably local; a countdown latch signals completion. |
//| Owned exclusively by one CpuContext - never shared across |
//| contexts, so no cross-instance coordination (grow/refcount) is |
//| needed here at all, just plain start-once/stop-once lifecycle. |
//+------------------------------------------------------------------+
class ThreadPool
{
public:
void Start(int threads)
{
Stop();
if(threads <= 0)
threads = (int)std::thread::hardware_concurrency();
if(threads <= 0)
threads = 1;
{
std::lock_guard<std::mutex> lock(m_mutex);
m_stop = false;
}
for(int i = 0; i < threads; i++)
m_workers.emplace_back([this]{ WorkerLoop(); });
m_workerCount.store(threads);
}
void Stop()
{
if(m_workers.empty())
return;
{
std::lock_guard<std::mutex> lock(m_mutex);
m_stop = true;
}
m_cv.notify_all();
// Bounded wait per worker instead of a plain join(): std::thread has no native timed join, so
// each worker's join() runs on a throwaway "joiner" thread and this thread waits on it via a
// future with a timeout instead of blocking directly. CPU_Shutdown() calls Stop() while holding
// this context's mutex (see its declaration comment) - if MQL5's DLL-call watchdog force-kills
// the calling thread while it's stuck in an unbounded join(), that mutex would stay locked
// FOREVER, poisoning every future call against THIS context. Because each context is private to
// one CDirectMLMy instance (no global/shared state - see CpuContext), that poisoning can no
// longer take out every other chart's calls into this DLL the way a process-wide singleton
// would; it only strands the one instance that hit it. Ownership of each worker's std::thread is
// moved into the joiner's closure, so if we give up and detach the joiner on timeout, the
// now-empty (moved-from, non-joinable) entry left behind in m_workers is safe to destroy - the
// joiner keeps running in the background and joins the real worker whenever it actually exits.
const auto timeout = std::chrono::seconds(10);
for(auto &t : m_workers)
{
if(!t.joinable())
continue;
std::promise<void> done;
std::future<void> fut = done.get_future();
std::thread joiner([owned = std::move(t), prom = std::move(done)]() mutable
{
owned.join();
prom.set_value();
});
if(fut.wait_for(timeout) == std::future_status::timeout)
joiner.detach();
else
joiner.join();
}
m_workers.clear();
m_workerCount.store(0);
{
std::lock_guard<std::mutex> lock(m_mutex);
std::queue<std::function<void()>> empty;
std::swap(m_tasks, empty);
m_stop = false;
}
}
int ThreadCount() const { return m_workerCount.load(); }
~ThreadPool() { Stop(); }
// Runs fn(begin,end) once per chunk, chunks covering [0,count) contiguously,
// one chunk per worker thread; blocks until every chunk has completed.
// Returns false if any chunk faulted (see SehCallFn) - the caller's buffer
// is then left partially/not updated by that chunk, so the whole call must
// be treated as failed rather than silently reporting success.
//
// Takes fn BY VALUE and heap-allocates every piece of state the enqueued
// tasks touch (via shared_ptr), rather than referencing this function's own
// stack locals. Reason: CpuContext's declaration comment already documents
// that MQL5's terminal can forcibly kill the DLL-calling thread mid-call if
// it decides the call is hung. When that happens here, the ThreadPool's
// worker threads are NOT killed with it - they are independent OS threads
// that keep running and, previously, kept writing into `remaining`/`allOk`/
// `doneMutex`/`doneCv` (and calling through `fn`) after the stack frame that
// owned them had already been torn down. That is a use-after-free into a
// dead stack - undefined behavior that reliably corrupts the process rather
// than just failing this one call, which is exactly the class of crash this
// was supposed to be immune to (see SehCallFn). Every kernel dispatch site
// below also passes its lambda as [=] rather than [&] for the same reason -
// a by-reference capture of that call's own local pointers/ints would be
// just as dangling once its frame is gone.
bool ParallelFor(int count, std::function<void(int, int)> fn)
{
if(count <= 0)
return true;
// Below this, the heap allocs + mutex/condvar handoff below cost more than just running the
// whole range inline on the calling thread - this network's layers are small (tens to low
// hundreds of neurons), so nearly every dispatch was paying full thread-pool overhead for a
// sliver of work per thread. Threshold is deliberately conservative (only skips clearly-not-
// worth-it cases); raise it further if profiling shows larger layers still dominated by overhead.
const int kInlineThreshold = 512;
int workers = std::max(1, m_workerCount.load());
int chunks = std::min(workers, count);
if(chunks <= 1 || count < kInlineThreshold)
return SehCallFn(fn, 0, count);
struct State
{
std::atomic<int> remaining;
std::atomic<bool> allOk{true};
std::mutex doneMutex;
std::condition_variable doneCv;
explicit State(int n) : remaining(n) {}
};
auto state = std::make_shared<State>(chunks);
auto fnPtr = std::make_shared<std::function<void(int, int)>>(std::move(fn));
int base = count / chunks;
int extra = count % chunks;
int start = 0;
for(int c = 0; c < chunks; c++)
{
int len = base + (c < extra ? 1 : 0);
int begin = start;
int end = start + len;
start = end;
Enqueue([state, fnPtr, begin, end]
{
// SehCallFn swallows both hardware faults and C++ exceptions raised
// inside the kernel; either would otherwise escape this std::thread
// with no handler and kill the whole terminal process. `remaining`
// must still be decremented on failure or every other in-flight
// ParallelFor caller waiting on doneCv would deadlock forever.
if(!SehCallFn(*fnPtr, begin, end))
state->allOk.store(false);
if(state->remaining.fetch_sub(1) == 1)
{
std::lock_guard<std::mutex> lock(state->doneMutex);
state->doneCv.notify_one();
}
});
}
std::unique_lock<std::mutex> lock(state->doneMutex);
state->doneCv.wait(lock, [state]{ return state->remaining.load() == 0; });
return state->allOk.load();
}
private:
void Enqueue(std::function<void()> job)
{
{
std::lock_guard<std::mutex> lock(m_mutex);
m_tasks.push(std::move(job));
}
m_cv.notify_one();
}
void WorkerLoop()
{
for(;;)
{
std::function<void()> job;
{
std::unique_lock<std::mutex> lock(m_mutex);
m_cv.wait(lock, [this]{ return m_stop || !m_tasks.empty(); });
if(m_stop && m_tasks.empty())
return;
job = std::move(m_tasks.front());
m_tasks.pop();
}
job();
}
}
std::vector<std::thread> m_workers;
std::queue<std::function<void()>> m_tasks;
std::mutex m_mutex;
std::condition_variable m_cv;
bool m_stop = false;
std::atomic<int> m_workerCount{0};
};
bool SehStartPool(ThreadPool &pool, int threads)
{
__try
{
pool.Start(threads);
return true;
}
__except(EXCEPTION_EXECUTE_HANDLER)
{
return false;
}
}
struct Buffer
{
std::vector<double> data;
bool inUse = false;
};
//+------------------------------------------------------------------+
//| Everything one CDirectMLMy/CNet instance needs, heap-allocated by |
//| CPU_Init() and handed back as an opaque CpuHandle. No part of |
//| this DLL keeps any global or static mutable state - every export |
//| below (besides CPU_Init/CPU_GetHardwareConcurrency) operates only |
//| on the CpuContext its caller passes in, so a fault or a wedged |
//| call (e.g. the mutex-poisoning scenario in ThreadPool::Stop's |
//| comment) can only ever strand its own context, never another |
//| instance's, and the DLL can be loaded/used by any number of |
//| instances or threads in parallel with zero cross-talk. |
//+------------------------------------------------------------------+
struct CpuContext
{
// Guards buffers/lastError bookkeeping ONLY - never the parallel dispatch/wait itself. Every
// kernel below locks this mutex just long enough to validate handles and extract raw buffer
// pointers, then releases it BEFORE calling pool.ParallelFor(), which blocks (potentially for a
// while, especially under load) waiting for every worker chunk to finish. MQL5's terminal
// enforces a hang-watchdog on EA execution: if a #import DLL call ever runs long enough to trip
// it, the terminal can forcibly terminate the calling thread mid-call. If that termination
// happened while this thread held `mutex` across a ParallelFor wait, the lock_guard's destructor
// would never run and `mutex` would stay locked FOREVER - but since it belongs to this context
// alone, that only poisons future calls against this one instance, not any other chart's.
std::mutex mutex;
ThreadPool pool;
int lastError = 0;
std::vector<Buffer> buffers;
};
int AllocBufferSlot(CpuContext *ctx)
{
for(size_t i = 0; i < ctx->buffers.size(); i++)
if(!ctx->buffers[i].inUse)
return (int)i;
ctx->buffers.push_back(Buffer());
return (int)ctx->buffers.size() - 1;
}
bool ValidHandle(CpuContext *ctx, int h)
{
return h >= 0 && (size_t)h < ctx->buffers.size() && ctx->buffers[h].inUse;
}
inline size_t Size(CpuContext *ctx, int h) { return ctx->buffers[h].data.size(); }
// A caller-supplied count/shape argument mismatched against the actual buffer
// size would index past the end of a std::vector - undefined behavior that
// (unlike an overrun into a GPU UAV buffer) reliably crashes the whole host
// process. Every kernel below validates the exact index range it is about to
// touch against the real buffer sizes first and bails out to an MQL5-visible
// "false" return instead.
#define REQUIRE(ctx, cond) do { if(!(cond)) { (ctx)->lastError = 100; return 0; } } while(0)
// Mirrors AI\Network.cl's MAX_WEIGHT clamp, applied to every weight-update kernel below. Without it a
// gradient spike (e.g. from class-balance oversampling replaying the same rare-class bar several times
// in a row) can drive a weight to +-Infinity; the next Adam step then divides Infinity by Infinity
// (mt/vt both Inf), producing NaN, which propagates through every FeedForward sum that touches it and
// never recovers (Adam(NaN) is always NaN). That silently freezes the whole network at NaN output -
// manifesting as every bar classifying to whatever the "can't decide" default is, since NaN comparisons
// are always false.
// Tightened from 1.0e6 - that ceiling was so loose it never actually engaged before training had
// already gone unstable (real collapses were happening at weight magnitudes several orders of
// magnitude below it). 100.0 matches the equivalent clamp in Dmitriy Gizlyk's reference NeuroNet.mqh
// engine (references\MQL5\Experts\NeuroNet_DNG\NeuroNet.mqh).
const double MAX_WEIGHT = 100.0;
inline double ClampWeight(double w) { return std::max(-MAX_WEIGHT, std::min(MAX_WEIGHT, w)); }
// Floor on the magnitude of a saturated tanh/sigmoid unit's activation derivative. Without this, a
// neuron pinned near its activation extremes (output near -1/0/1) produces a near-zero derivative,
// zeroing that neuron's entire backprop gradient contribution regardless of how wrong its output is -
// it can then never receive a corrective signal to unstick it. 1e-4 matches the equivalent floor in
// Dmitriy Gizlyk's reference NeuroNet.mqh/NeuroNet.cl engine.
const double MIN_ACTIVATION_DERIVATIVE = 1.0e-4;
// Decoupled (AdamW-style) weight decay applied inside every Adam kernel below, on top of the
// MAX_WEIGHT clamp above. MAX_WEIGHT only stops outright +-Infinity/NaN blowups; it does nothing to
// stop weights slowly, unboundedly growing over hundreds of training eras on a fixed, heavily
// class-balance-oversampled dataset (the same rare-class bars replayed up to 5x every single era,
// forever, with no shuffling - see Train()'s reps loop). That slow growth is what was producing the
// multi-hour climb-to-90%+-then-collapse-to-single-digits cycles: OOS accuracy would improve for
// dozens of eras as weights fit the data, then the accumulated growth would push the network into an
// unstable regime (large IS error spike alongside the OOS collapse) before Adam re-settled and the
// climb started over. Every Adam step pulls each weight a small fraction of the way back toward
// zero, capping how large it can drift regardless of how many eras keep pushing it the same
// direction. 0.001, NOT the 0.01 Loshchilov & Hutter default - see AI\Network.mqh's WEIGHT_DECAY
// comment for the full derivation: decay is applied per SAMPLE here (~20k+ online steps/era), and
// 0.01 was observed to grind discriminative weights below the calibration-capped class-prior
// offsets (per-bar logit spread decaying monotonically until argmax degenerated to constant-
// Neutral), while 0.001 still bounds long-run growth. Keep in sync across all four backends.
// UPDATE: weight decay alone did not fully stop the cycles - it targets slow unbounded drift, but the
// actual collapses turned out to be sudden, violent overshoot events (OOS accuracy falling BELOW the
// 33% random-guess floor for 3-class within ~20 eras, IS error spiking to 0.85+ - the model becoming
// confidently wrong, not just uncalibrated). That's a single/few-step Adam blowup, most likely
// triggered by the class-balance oversampling replaying the same rare-class bar up to 5x back-to-back
// every era (see Train()'s reps loop) - 5 identical consecutive gradients build artificially strong,
// correlated momentum (mt) that can overshoot badly once the next, different bar arrives. MAX_WEIGHT_DELTA
// below clips the actual per-step update, not just the resulting weight, which directly bounds how much
// damage any single overshoot step (whatever triggers it) can do.
const double WEIGHT_DECAY = 0.001;
// Per-step update clip - see the UPDATE note above. A healthy Adam step is normally O(lt) (~1e-4 to
// 1e-3 here); 0.1 is generous headroom for legitimate fast learning while hard-stopping the
// multi-order-of-magnitude spikes that were whipping OOS accuracy from 90%+ down to single digits in
// a couple dozen eras. Applied to `delta` BEFORE it's added to the weight, unlike MAX_WEIGHT (which
// only clamps the post-update weight value and is far too loose - 1e6 - to prevent this).
const double MAX_WEIGHT_DELTA = 0.1;
inline double ClampDelta(double d) { return std::max(-MAX_WEIGHT_DELTA, std::min(MAX_WEIGHT_DELTA, d)); }
inline double Activation(double sum, int activation)
{
if(activation == 0)
return tanh(sum);
if(activation == 1)
return 1.0 / (1.0 + exp(-std::max(-50.0, std::min(50.0, sum))));
if(activation == 2 && sum < 0)
return sum * 0.01;
return sum;
}
} // namespace
//+------------------------------------------------------------------+
//| Lifecycle |
//+------------------------------------------------------------------+
WARRIORCPU_API CpuHandle __stdcall CPU_Init(int threads)
{
CpuContext *ctx = new CpuContext();
if(!SehStartPool(ctx->pool, threads))
{
delete ctx; // hardware fault during pool startup - see SehStartPool
return 0;
}
return reinterpret_cast<CpuHandle>(ctx);
}
// Stateless query of the machine's true hardware concurrency - touches no
// context at all, so callers can size a CPU_Init() request correctly without
// a probe-then-discard Init/GetThreadCount/Shutdown roundtrip.
WARRIORCPU_API int __stdcall CPU_GetHardwareConcurrency()
{
int n = (int)std::thread::hardware_concurrency();
return n > 0 ? n : 1;
}
WARRIORCPU_API void __stdcall CPU_Shutdown(CpuHandle handle)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return;
ctx->pool.Stop();
delete ctx;
}
WARRIORCPU_API int __stdcall CPU_GetLastError(CpuHandle handle)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
std::lock_guard<std::mutex> lock(ctx->mutex);
return ctx->lastError;
}
WARRIORCPU_API int __stdcall CPU_GetThreadCount(CpuHandle handle)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
return ctx->pool.ThreadCount();
}
//+------------------------------------------------------------------+
//| Buffers |
//+------------------------------------------------------------------+
WARRIORCPU_API int __stdcall CPU_BufferCreate(CpuHandle handle, int elementCount)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx || elementCount <= 0)
return -1;
std::lock_guard<std::mutex> lock(ctx->mutex);
int slot = AllocBufferSlot(ctx);
ctx->buffers[slot].data.assign((size_t)elementCount, 0.0);
ctx->buffers[slot].inUse = true;
return slot;
}
WARRIORCPU_API void __stdcall CPU_BufferFree(CpuHandle handle, int bufHandle)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return;
std::lock_guard<std::mutex> lock(ctx->mutex);
if(bufHandle < 0 || (size_t)bufHandle >= ctx->buffers.size())
return;
ctx->buffers[bufHandle] = Buffer();
}
WARRIORCPU_API int __stdcall CPU_BufferWrite(CpuHandle handle, int bufHandle, const double *data, int count)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, bufHandle) || (size_t)count > ctx->buffers[bufHandle].data.size())
return 0;
// A NaN/Inf slipping into a weight or gradient buffer doesn't crash anything -
// it silently poisons every value it touches from then on (NaN propagates
// through every arithmetic op), so training runs for hours producing garbage
// with no error ever reported. Reject it at the boundary instead.
for(int i = 0; i < count; i++)
REQUIRE(ctx, std::isfinite(data[i]));
std::copy(data, data + count, ctx->buffers[bufHandle].data.begin());
return 1;
}
WARRIORCPU_API int __stdcall CPU_BufferRead(CpuHandle handle, int bufHandle, double *data, int count)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, bufHandle) || (size_t)count > ctx->buffers[bufHandle].data.size())
return 0;
std::copy(ctx->buffers[bufHandle].data.begin(), ctx->buffers[bufHandle].data.begin() + count, data);
return 1;
}
//+------------------------------------------------------------------+
//| Dense feed-forward / gradient / weight-update kernels - mirror |
//| AI\Network.cl's FeedForward/CaclOutputGradient/CaclHiddenGradient/|
//| UpdateWeightsMomentum/UpdateWeightsAdam 1:1, parallelized over |
//| the neuron (outer) dimension. |
//+------------------------------------------------------------------+
WARRIORCPU_API int __stdcall CPU_FeedForward(CpuHandle handle, int wHandle, int iHandle, int oHandle, int inputs, int activation)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
int neurons;
double *w, *in, *out;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, wHandle) || !ValidHandle(ctx, iHandle) || !ValidHandle(ctx, oHandle))
return 0;
neurons = (int)Size(ctx, oHandle);
REQUIRE(ctx, inputs >= 0 && neurons >= 0);
REQUIRE(ctx, Size(ctx, iHandle) >= (size_t)inputs);
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)neurons * (inputs + 1));
w = ctx->buffers[wHandle].data.data();
in = ctx->buffers[iHandle].data.data();
out = ctx->buffers[oHandle].data.data();
}
if(!ctx->pool.ParallelFor(neurons, [=](int begin, int end)
{
for(int i = begin; i < end; i++)
{
int shift = (inputs + 1) * i;
double sum = 0.0;
for(int k = 0; k < inputs; k++)
sum += in[k] * w[shift + k];
sum += w[shift + inputs];
out[i] = Activation(sum, activation);
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
WARRIORCPU_API int __stdcall CPU_CalcOutputGradient(CpuHandle handle, int tHandle, int oHandle, int igHandle, int activation, int count)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
double *t, *o, *ig;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, tHandle) || !ValidHandle(ctx, oHandle) || !ValidHandle(ctx, igHandle))
return 0;
REQUIRE(ctx, count >= 0 && Size(ctx, tHandle) >= (size_t)count && Size(ctx, oHandle) >= (size_t)count && Size(ctx, igHandle) >= (size_t)count);
t = ctx->buffers[tHandle].data.data();
o = ctx->buffers[oHandle].data.data();
ig = ctx->buffers[igHandle].data.data();
}
if(!ctx->pool.ParallelFor(count, [=](int begin, int end)
{
for(int i = begin; i < end; i++)
{
double out_v = o[i];
double temp = 0.0;
if(activation == 0)
{
// Deliberately NOT multiplied by the tanh derivative (1-out^2):
// that factor vanishes as out_v approaches +-1, which is exactly
// where a TANH output neuron needs to converge for a +-1 target
// (e.g. the buy/sell extremes of a single-neuron regression
// head), stalling training right when it matters most. Using the
// raw (target-out) error is the same fix as pairing sigmoid with
// cross-entropy - the saturating derivative doesn't re-damp the
// already-correct error signal.
temp = std::max(-1.0, std::min(1.0, t[i])) - out_v;
}
else if(activation == 1)
{
// Also deliberately NOT multiplied by the sigmoid derivative out_v*(1-out_v) -
// same reasoning as activation==0 above. This is the classification output layer
// (3 neurons, one-hot 0/1 targets); (target-out) unscaled is exactly the
// sigmoid+binary-cross-entropy gradient (the loss derivative and the sigmoid
// derivative algebraically cancel), whereas re-multiplying by out_v*(1-out_v) is
// the MSE-with-sigmoid formula, which damps toward zero as out_v approaches 0 or 1
// - exactly where a 0/1-target neuron needs to converge. With this damping (plus
// the 0.00000001 underflow guard at the saturated extremes), the classification
// output layer's effective learning rate was throttled to a small fraction of the
// regression path's, observed in practice as all 3 output neurons converging to
// (and never escaping) an identical value - softmax of three equal logits is
// always exactly 1/3 each, with ties resolved to index 0 ("Buy" wins by default).
temp = std::max(0.0, std::min(1.0, t[i])) - out_v;
}
else
{
// NONE (raw logits - the 3-class softmax classification output layer) and PRELU (2,
// never actually used as an OUTPUT activation in this codebase) both fall here.
// Identity/NONE has derivative 1 everywhere, so the plain (target-out) error passes
// through unscaled - matching CNeuron::calcOutputGradients()'s unconditional formula
// on the MQL5-side un-accelerated fallback. Leaving this an unhandled else left temp
// at 0.0, i.e. every softmax-classification output neuron got a permanently zero
// gradient on this DLL - and since CPU_CalcHiddenGradient propagates backward FROM
// the output layer's gradient, that silently froze the whole network (not just the
// output layer) at its random initial weights whenever the CPU tier was active.
temp = t[i] - out_v;
}
ig[i] = temp;
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
WARRIORCPU_API int __stdcall CPU_CalcHiddenGradient(CpuHandle handle, int wHandle, int gHandle, int oHandle, int igHandle,
int outputs, int activation, int count)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
size_t wSize, oSize, gSize;
double *w, *g, *o, *ig;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, wHandle) || !ValidHandle(ctx, gHandle) || !ValidHandle(ctx, oHandle) || !ValidHandle(ctx, igHandle))
return 0;
REQUIRE(ctx, count >= 0 && outputs >= 0);
REQUIRE(ctx, Size(ctx, igHandle) >= (size_t)count);
// count is deliberately Neurons()+1 (an extra row for the bias gradient
// slot - see Gradient's BufferInit(numNeurons+1,...)), but Output and
// Weights only have Neurons() rows (no matching bias row), so the last
// row's index legitimately runs past the end of both buffers. That's a
// harmless OOB on a GPU UAV read (the result is never used downstream -
// biases don't get a backprop weight); on CPU every read here is
// bounds-checked and treated as 0 instead of rejecting the whole call.
wSize = Size(ctx, wHandle);
oSize = Size(ctx, oHandle);
gSize = Size(ctx, gHandle);
w = ctx->buffers[wHandle].data.data();
g = ctx->buffers[gHandle].data.data();
o = ctx->buffers[oHandle].data.data();
ig = ctx->buffers[igHandle].data.data();
}
if(!ctx->pool.ParallelFor(count, [=](int begin, int end)
{
for(int i = begin; i < end; i++)
{
int shift = (outputs + 1) * i;
double out_v = ((size_t)i < oSize) ? o[i] : 0.0;
double sum = 0.0;
for(int k = 0; k < outputs; k++)
{
size_t wi = (size_t)shift + k;
if(wi < wSize && (size_t)k < gSize)
sum += g[k] * w[wi];
}
if(activation == 0)
{
sum = std::max(-1.0, std::min(1.0, sum + out_v)) - out_v;
sum *= std::max(MIN_ACTIVATION_DERIVATIVE, 1.0 - out_v * out_v);
}
else if(activation == 1)
{
sum = std::max(0.0, std::min(1.0, sum + out_v)) - out_v;
sum *= std::max(MIN_ACTIVATION_DERIVATIVE, out_v * (1.0 - out_v));
}
else if(activation == 2 && out_v < 0.0)
{
// PReLU, param=0.01 - unbounded/non-saturating, no clamp/implied-target
// reformulation needed (matches CPU_CalcHiddenGradientConv's activation==2 branch a
// few functions below, and AI\Network.cl's CaclHiddenGradient case 2). This branch was
// missing entirely, so every hidden PReLU layer (the default hidden-layer activation -
// see ExpertSignalAIBase.mqh's HiddenLayerActivation()) had its backprop gradient left
// completely unscaled on this backend whenever out_v was negative - a 100x overstated
// gradient (1.0 instead of the correct 0.01) into that neuron's input weights, active
// whenever the CPU DLL tier is in use (i.e. no OpenCL GPU available).
sum *= 0.01;
}
ig[i] = sum;
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
// Bias weight (j == inputs) is intentionally never touched here, matching
// the OpenCL/DirectML UpdateWeightsMomentum dispatch range (0..inputs-1).
WARRIORCPU_API int __stdcall CPU_UpdateWeightsMomentum(CpuHandle handle, int wHandle, int gHandle, int iHandle, int dwHandle,
int inputs, double learningRate, double momentum, int neurons, int optimizer)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
double *w, *g, *in, *dw;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, wHandle) || !ValidHandle(ctx, gHandle) || !ValidHandle(ctx, iHandle) || !ValidHandle(ctx, dwHandle))
return 0;
REQUIRE(ctx, neurons >= 0 && inputs >= 0);
REQUIRE(ctx, Size(ctx, gHandle) >= (size_t)neurons && Size(ctx, iHandle) >= (size_t)inputs);
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)neurons * (inputs + 1) && Size(ctx, dwHandle) >= (size_t)neurons * (inputs + 1));
w = ctx->buffers[wHandle].data.data();
g = ctx->buffers[gHandle].data.data();
in = ctx->buffers[iHandle].data.data();
dw = ctx->buffers[dwHandle].data.data();
}
if(!ctx->pool.ParallelFor(neurons, [=](int begin, int end)
{
for(int i = begin; i < end; i++)
for(int j = 0; j < inputs; j++)
{
int wi = i * (inputs + 1) + j;
double localGradient = g[i] * in[j];
// `optimizer` is retained (unused) only to keep this export's signature stable for
// callers. It used to select an index-parity sign flip on the gradient ("DFA"), i.e.
// permanent gradient ASCENT on half of every weight tensor - see
// ENUM_OPTIMIZATION's comment in AI\Network.mqh. Always plain descent now; all callers pass 0.
double delta = learningRate * localGradient + momentum * dw[wi];
dw[wi] = delta;
w[wi] = ClampWeight(w[wi] + delta);
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
WARRIORCPU_API int __stdcall CPU_UpdateWeightsAdam(CpuHandle handle, int wHandle, int gHandle, int iHandle, int mHandle, int vHandle,
int inputs, double lt, double b1, double b2, int neurons)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
double *w, *g, *in, *m, *v;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, wHandle) || !ValidHandle(ctx, gHandle) || !ValidHandle(ctx, iHandle) || !ValidHandle(ctx, mHandle) || !ValidHandle(ctx, vHandle))
return 0;
REQUIRE(ctx, neurons >= 0 && inputs >= 0);
REQUIRE(ctx, Size(ctx, gHandle) >= (size_t)neurons && Size(ctx, iHandle) >= (size_t)inputs);
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)neurons * (inputs + 1) && Size(ctx, mHandle) >= (size_t)neurons * (inputs + 1) && Size(ctx, vHandle) >= (size_t)neurons * (inputs + 1));
w = ctx->buffers[wHandle].data.data();
g = ctx->buffers[gHandle].data.data();
in = ctx->buffers[iHandle].data.data();
m = ctx->buffers[mHandle].data.data();
v = ctx->buffers[vHandle].data.data();
}
if(!ctx->pool.ParallelFor(neurons, [=](int begin, int end)
{
for(int i = begin; i < end; i++)
for(int j = 0; j <= inputs; j++)
{
int wi = i * (inputs + 1) + j;
double inp = (j < inputs) ? in[j] : 1.0;
double grad = g[i] * inp;
double mt = b1 * m[wi] + (1.0 - b1) * grad;
double vt = sqrt(b2 * v[wi] + (1.0 - b2) * grad * grad);
double delta = ClampDelta(lt * mt / (vt > 0.0 ? vt : lt * 10.0) - lt * WEIGHT_DECAY * w[wi]);
// No sign-agreement gate (removed 2026-07): gating each step on agreement with the
// CURRENT sample's gradient sign rectified the one-hot softmax-CCE stream - rare large
// true-class positives, frequent small wrong-class negatives - into a permanent downward
// ratchet on every output neuron, sinking all three logits into sigmoid saturation
// together (the all-Neutral collapse; IS error frozen at sqrt(1/3)=0.58). The stale-step
// overshoot it guarded against is covered by ClampDelta, AdamW WEIGHT_DECAY and shuffle-
// interleaved oversampling. Removed from all four backends in sync (AI\NeuronCPU.mqh /
// WarriorDML.cpp / Network.cl mirror this).
w[wi] = ClampWeight(w[wi] + delta);
m[wi] = mt;
v[wi] = vt;
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
//+------------------------------------------------------------------+
//| Convolution kernels - mirror AI\Network.cl's Conv kernels 1:1. A |
//| single (windowIn+1)*windowOut weight block is shared across every |
//| sliding position. |
//+------------------------------------------------------------------+
WARRIORCPU_API int __stdcall CPU_FeedForwardConv(CpuHandle handle, int wHandle, int iHandle, int oHandle,
int inputs, int step, int windowIn, int windowOut, int activation, int positions)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
double *w, *in, *out;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, wHandle) || !ValidHandle(ctx, iHandle) || !ValidHandle(ctx, oHandle))
return 0;
REQUIRE(ctx, positions >= 0 && inputs >= 0 && windowIn >= 0 && windowOut >= 0);
REQUIRE(ctx, Size(ctx, iHandle) >= (size_t)inputs);
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)(windowIn + 1) * windowOut);
REQUIRE(ctx, Size(ctx, oHandle) >= (size_t)positions * windowOut);
w = ctx->buffers[wHandle].data.data();
in = ctx->buffers[iHandle].data.data();
out = ctx->buffers[oHandle].data.data();
}
if(!ctx->pool.ParallelFor(positions, [=](int begin, int end)
{
for(int i = begin; i < end; i++)
{
int shiftOut = windowOut * i;
int shiftIn = step * i;
for(int o = 0; o < windowOut; o++)
{
int shift = (windowIn + 1) * o;
int stop = (windowIn <= (inputs - shiftIn)) ? windowIn : (inputs - shiftIn);
double sum = 0.0;
for(int k = 0; k < stop; k++)
sum += in[shiftIn + k] * w[shift + k];
sum += w[shift + windowIn];
out[o + shiftOut] = Activation(sum, activation);
}
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
WARRIORCPU_API int __stdcall CPU_CalcHiddenGradientConv(CpuHandle handle, int wHandle, int gHandle, int oHandle, int igHandle,
int outputs, int step, int windowIn, int windowOut, int activation, int inputCount)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
double *w, *g, *o, *ig;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, wHandle) || !ValidHandle(ctx, gHandle) || !ValidHandle(ctx, oHandle) || !ValidHandle(ctx, igHandle))
return 0;
REQUIRE(ctx, inputCount >= 0 && outputs >= 0 && windowIn >= 0 && windowOut > 0 && step > 0);
REQUIRE(ctx, Size(ctx, oHandle) >= (size_t)inputCount && Size(ctx, igHandle) >= (size_t)inputCount);
REQUIRE(ctx, Size(ctx, gHandle) >= (size_t)outputs);
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)(windowIn + 1) * windowOut);
w = ctx->buffers[wHandle].data.data();
g = ctx->buffers[gHandle].data.data();
o = ctx->buffers[oHandle].data.data();
ig = ctx->buffers[igHandle].data.data();
}
if(!ctx->pool.ParallelFor(inputCount, [=](int begin, int end)
{
for(int i = begin; i < end; i++)
{
double out_v = o[i];
int start = i - windowIn + step;
start = std::max((start - start % step) / step, 0);
int stop = (i - i % step) / step + 1;
if(stop > (outputs / windowOut))
stop = outputs / windowOut;
double sum = 0.0;
for(int h = 0; h < windowOut; h++)
for(int k = start; k < stop; k++)
{
int shiftW = (stop - k - 1) * step + i % step + h * (windowIn + 1);
int shiftG = k * windowOut + h;
if(shiftG >= outputs || shiftW >= (windowIn + 1) * windowOut)
break;
sum += g[shiftG] * w[shiftW];
}
if(activation == 0)
{
sum = std::max(-1.0, std::min(1.0, sum + out_v)) - out_v;
sum *= std::max(MIN_ACTIVATION_DERIVATIVE, 1.0 - out_v * out_v);
}
else if(activation == 1)
sum = (std::max(0.0, std::min(1.0, sum + out_v)) - out_v) * std::max(MIN_ACTIVATION_DERIVATIVE, out_v * (1.0 - out_v));
else if(activation == 2 && out_v < 0)
sum *= 0.01;
ig[i] = sum;
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
WARRIORCPU_API int __stdcall CPU_UpdateWeightsConvMomentum(CpuHandle handle, int wHandle, int gHandle, int iHandle, int dwHandle,
int inputs, double learningRate, double momentum, int windowIn, int windowOut, int step, int optimizer)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
int total;
size_t gSize, inSize;
double *w, *g, *in, *dw;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, wHandle) || !ValidHandle(ctx, gHandle) || !ValidHandle(ctx, iHandle) || !ValidHandle(ctx, dwHandle))
return 0;
REQUIRE(ctx, windowIn >= 0 && windowOut >= 0 && step > 0 && inputs >= 0);
total = (windowIn + 1) * windowOut;
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)total && Size(ctx, dwHandle) >= (size_t)total);
gSize = Size(ctx, gHandle);
inSize = Size(ctx, iHandle);
w = ctx->buffers[wHandle].data.data();
g = ctx->buffers[gHandle].data.data();
in = ctx->buffers[iHandle].data.data();
dw = ctx->buffers[dwHandle].data.data();
}
if(!ctx->pool.ParallelFor(total, [=](int begin, int end)
{
for(int i = begin; i < end; i++)
{
int shift = i % (windowIn + 1);
int shiftOut = (i - shift) / (windowIn + 1);
int t = (inputs - windowIn) % step;
t = (inputs - windowIn - t) / step + (t > 0 ? 1 : 0);
double grad = 0.0;
for(int k = 0; k < t; k++)
{
if(shift != windowIn && (shift + k * step) >= inputs)
break;
int gi = k * windowOut + shiftOut;
int ii = shift + k * step;
if((size_t)gi >= gSize || (shift != windowIn && (size_t)ii >= inSize))
break;
grad += g[gi] * (shift == windowIn ? 1.0 : in[ii]);
}
// `optimizer` retained unused - see CPU_UpdateWeightsMomentum's comment above.
double delta = learningRate * grad + momentum * dw[i];
dw[i] = delta;
w[i] = ClampWeight(w[i] + delta);
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
WARRIORCPU_API int __stdcall CPU_UpdateWeightsConvAdam(CpuHandle handle, int wHandle, int gHandle, int iHandle, int mHandle, int vHandle,
int inputs, double lt, double b1, double b2, int windowIn, int windowOut, int step)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
int total;
size_t gSize, inSize;
double *w, *g, *in, *m, *v;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, wHandle) || !ValidHandle(ctx, gHandle) || !ValidHandle(ctx, iHandle) || !ValidHandle(ctx, mHandle) || !ValidHandle(ctx, vHandle))
return 0;
REQUIRE(ctx, windowIn >= 0 && windowOut >= 0 && step > 0 && inputs >= 0);
total = (windowIn + 1) * windowOut;
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)total && Size(ctx, mHandle) >= (size_t)total && Size(ctx, vHandle) >= (size_t)total);
gSize = Size(ctx, gHandle);
inSize = Size(ctx, iHandle);
w = ctx->buffers[wHandle].data.data();
g = ctx->buffers[gHandle].data.data();
in = ctx->buffers[iHandle].data.data();
m = ctx->buffers[mHandle].data.data();
v = ctx->buffers[vHandle].data.data();
}
if(!ctx->pool.ParallelFor(windowIn + 1, [=](int begin, int end)
{
for(int i = begin; i < end; i++)
{
int t = (inputs - (windowIn - step)) % step;
t = (inputs - (windowIn - step) - t) / step + (t > 0 ? 1 : 0);
for(int out = 0; out < windowOut; out++)
{
int shiftW = i + out * (windowIn + 1);
double grad = 0.0;
for(int k = 0; k < t; k++)
{
if(i != windowIn && (i + k * step) >= inputs)
break;
int gi = k * windowOut + out;
int ii = i + k * step;
if((size_t)gi >= gSize || (i != windowIn && (size_t)ii >= inSize))
break;
grad += g[gi] * (i == windowIn ? 1.0 : in[ii]);
}
double mt = b1 * m[shiftW] + (1.0 - b1) * grad;
double vt = sqrt(b2 * v[shiftW] + (1.0 - b2) * grad * grad);
double delta = ClampDelta(lt * mt / (vt > 0.0 ? vt : lt * 10.0) - lt * WEIGHT_DECAY * w[shiftW]);
// Sign-agreement gate removed - see CPU_UpdateWeightsAdam's comment for why.
w[shiftW] = ClampWeight(w[shiftW] + delta);
m[shiftW] = mt;
v[shiftW] = vt;
}
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
//+------------------------------------------------------------------+
//| LSTM kernels - mirror AI\Network.cl's LSTM_* kernels 1:1. |
//+------------------------------------------------------------------+
WARRIORCPU_API int __stdcall CPU_LSTMGates(CpuHandle handle, int wHandle, int hiddenPrevHandle, int inputsHandle,
int concatenatedHandle, int hiddenSize, int inputSize)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
int perGate;
double *w, *hp, *in, *cat;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, wHandle) || !ValidHandle(ctx, hiddenPrevHandle) || !ValidHandle(ctx, inputsHandle) || !ValidHandle(ctx, concatenatedHandle))
return 0;
REQUIRE(ctx, hiddenSize >= 0 && inputSize >= 0);
perGate = hiddenSize * (hiddenSize + inputSize + 1);
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)4 * perGate);
REQUIRE(ctx, Size(ctx, hiddenPrevHandle) >= (size_t)hiddenSize && Size(ctx, inputsHandle) >= (size_t)inputSize);
REQUIRE(ctx, Size(ctx, concatenatedHandle) >= (size_t)4 * hiddenSize);
w = ctx->buffers[wHandle].data.data();
hp = ctx->buffers[hiddenPrevHandle].data.data();
in = ctx->buffers[inputsHandle].data.data();
cat = ctx->buffers[concatenatedHandle].data.data();
}
if(!ctx->pool.ParallelFor(hiddenSize, [=](int begin, int end)
{
for(int hid = begin; hid < end; hid++)
for(int gate = 0; gate < 4; gate++)
{
int shift = gate * perGate + hid * (hiddenSize + inputSize + 1);
double sum = 0.0;
for(int k = 0; k < hiddenSize; k++)
sum += hp[k] * w[shift + k];
for(int k = 0; k < inputSize; k++)
sum += in[k] * w[shift + hiddenSize + k];
sum += w[shift + hiddenSize + inputSize];
double val = (gate < 3) ? (1.0 / (1.0 + exp(-sum))) : tanh(sum);
cat[gate * hiddenSize + hid] = val;
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
WARRIORCPU_API int __stdcall CPU_LSTMState(CpuHandle handle, int concatenatedHandle, int memoryHandle, int hiddenPrevHandle,
int hiddenCacheHandle, int outputHandle, int hiddenSize)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
double *cat, *mem, *hp, *hc, *out;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, concatenatedHandle) || !ValidHandle(ctx, memoryHandle) || !ValidHandle(ctx, hiddenPrevHandle) ||
!ValidHandle(ctx, hiddenCacheHandle) || !ValidHandle(ctx, outputHandle))
return 0;
REQUIRE(ctx, hiddenSize >= 0);
REQUIRE(ctx, Size(ctx, concatenatedHandle) >= (size_t)4 * hiddenSize);
REQUIRE(ctx, Size(ctx, memoryHandle) >= (size_t)2 * hiddenSize);
REQUIRE(ctx, Size(ctx, hiddenPrevHandle) >= (size_t)hiddenSize && Size(ctx, hiddenCacheHandle) >= (size_t)hiddenSize && Size(ctx, outputHandle) >= (size_t)hiddenSize);
cat = ctx->buffers[concatenatedHandle].data.data();
mem = ctx->buffers[memoryHandle].data.data();
hp = ctx->buffers[hiddenPrevHandle].data.data();
hc = ctx->buffers[hiddenCacheHandle].data.data();
out = ctx->buffers[outputHandle].data.data();
}
if(!ctx->pool.ParallelFor(hiddenSize, [=](int begin, int end)
{
for(int i = begin; i < end; i++)
{
double f = cat[i];
double ii = cat[hiddenSize + i];
double o = cat[2 * hiddenSize + i];
double gg = cat[3 * hiddenSize + i];
double c_prev = mem[i];
mem[hiddenSize + i] = c_prev;
double c_t = f * c_prev + ii * gg;
mem[i] = c_t;
hc[i] = hp[i];
out[i] = o * tanh(c_t);
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
WARRIORCPU_API int __stdcall CPU_LSTMGateGradient(CpuHandle handle, int gradientHandle, int memoryHandle, int concatenatedHandle,
int concatenatedGradientHandle, int hiddenSize)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
double *grad, *mem, *cat, *cg;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, gradientHandle) || !ValidHandle(ctx, memoryHandle) || !ValidHandle(ctx, concatenatedHandle) || !ValidHandle(ctx, concatenatedGradientHandle))
return 0;
REQUIRE(ctx, hiddenSize >= 0);
REQUIRE(ctx, Size(ctx, gradientHandle) >= (size_t)hiddenSize);
REQUIRE(ctx, Size(ctx, memoryHandle) >= (size_t)2 * hiddenSize);
REQUIRE(ctx, Size(ctx, concatenatedHandle) >= (size_t)4 * hiddenSize && Size(ctx, concatenatedGradientHandle) >= (size_t)4 * hiddenSize);
grad = ctx->buffers[gradientHandle].data.data();
mem = ctx->buffers[memoryHandle].data.data();
cat = ctx->buffers[concatenatedHandle].data.data();
cg = ctx->buffers[concatenatedGradientHandle].data.data();
}
if(!ctx->pool.ParallelFor(hiddenSize, [=](int begin, int end)
{
for(int i = begin; i < end; i++)
{
double c_t = mem[i];
double c_prev = mem[hiddenSize + i];
double f = cat[i];
double ii = cat[hiddenSize + i];
double o = cat[2 * hiddenSize + i];
double g = cat[3 * hiddenSize + i];
double t = tanh(c_t);
double dh = grad[i];
// Derivative floor on every gate below - see MIN_ACTIVATION_DERIVATIVE's declaration
// comment. LSTM gates are sigmoid/tanh by design and routinely sit near their saturated
// extremes (a fully "open" or "closed" gate), which is exactly where an unfloored
// derivative goes to zero and blocks gradient from flowing through the recurrent path at
// all.
double dc = dh * o * std::max(MIN_ACTIVATION_DERIVATIVE, 1.0 - t * t);
cg[2 * hiddenSize + i] = dh * t * std::max(MIN_ACTIVATION_DERIVATIVE, o * (1.0 - o));
cg[i] = dc * c_prev * std::max(MIN_ACTIVATION_DERIVATIVE, f * (1.0 - f));
cg[hiddenSize + i] = dc * g * std::max(MIN_ACTIVATION_DERIVATIVE, ii * (1.0 - ii));
cg[3 * hiddenSize + i] = dc * ii * std::max(MIN_ACTIVATION_DERIVATIVE, 1.0 - g * g);
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
WARRIORCPU_API int __stdcall CPU_LSTMWeightsGradient(CpuHandle handle, int concatenatedGradientHandle, int hiddenCacheHandle,
int inputsHandle, int weightsGradientHandle, int hiddenSize, int inputSize)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
int perGate, total;
double *cg, *hc, *in, *wg;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, concatenatedGradientHandle) || !ValidHandle(ctx, hiddenCacheHandle) || !ValidHandle(ctx, inputsHandle) || !ValidHandle(ctx, weightsGradientHandle))
return 0;
REQUIRE(ctx, hiddenSize >= 0 && inputSize >= 0);
perGate = hiddenSize * (hiddenSize + inputSize + 1);
total = 4 * perGate;
REQUIRE(ctx, Size(ctx, concatenatedGradientHandle) >= (size_t)4 * hiddenSize);
REQUIRE(ctx, Size(ctx, hiddenCacheHandle) >= (size_t)hiddenSize && Size(ctx, inputsHandle) >= (size_t)inputSize);
REQUIRE(ctx, Size(ctx, weightsGradientHandle) >= (size_t)total);
cg = ctx->buffers[concatenatedGradientHandle].data.data();
hc = ctx->buffers[hiddenCacheHandle].data.data();
in = ctx->buffers[inputsHandle].data.data();
wg = ctx->buffers[weightsGradientHandle].data.data();
}
if(!ctx->pool.ParallelFor(total, [=](int begin, int end)
{
for(int wi = begin; wi < end; wi++)
{
int gate = wi / perGate;
int rem = wi % perGate;
int hid = rem / (hiddenSize + inputSize + 1);
int k = rem % (hiddenSize + inputSize + 1);
double inp = (k < hiddenSize) ? hc[k] : ((k < hiddenSize + inputSize) ? in[k - hiddenSize] : 1.0);
wg[wi] = cg[gate * hiddenSize + hid] * inp;
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
WARRIORCPU_API int __stdcall CPU_LSTMInputsGradient(CpuHandle handle, int concatenatedGradientHandle, int wHandle,
int inputsGradientHandle, int hiddenSize, int inputSize)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
int perGate;
double *cg, *w, *ig;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, concatenatedGradientHandle) || !ValidHandle(ctx, wHandle) || !ValidHandle(ctx, inputsGradientHandle))
return 0;
REQUIRE(ctx, hiddenSize >= 0 && inputSize >= 0);
perGate = hiddenSize * (hiddenSize + inputSize + 1);
REQUIRE(ctx, Size(ctx, concatenatedGradientHandle) >= (size_t)4 * hiddenSize);
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)4 * perGate);
REQUIRE(ctx, Size(ctx, inputsGradientHandle) >= (size_t)inputSize);
cg = ctx->buffers[concatenatedGradientHandle].data.data();
w = ctx->buffers[wHandle].data.data();
ig = ctx->buffers[inputsGradientHandle].data.data();
}
if(!ctx->pool.ParallelFor(inputSize, [=](int begin, int end)
{
for(int j = begin; j < end; j++)
{
double sum = 0.0;
for(int gate = 0; gate < 4; gate++)
for(int hid = 0; hid < hiddenSize; hid++)
sum += cg[gate * hiddenSize + hid] * w[gate * perGate + hid * (hiddenSize + inputSize + 1) + hiddenSize + j];
ig[j] = sum;
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
WARRIORCPU_API int __stdcall CPU_LSTMUpdateWeightsAdam(CpuHandle handle, int wHandle, int weightsGradientHandle,
int mHandle, int vHandle, double l, double b1, double b2, int total)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
double *w, *wg, *m, *v;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, wHandle) || !ValidHandle(ctx, weightsGradientHandle) || !ValidHandle(ctx, mHandle) || !ValidHandle(ctx, vHandle))
return 0;
REQUIRE(ctx, total >= 0);
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)total && Size(ctx, weightsGradientHandle) >= (size_t)total);
REQUIRE(ctx, Size(ctx, mHandle) >= (size_t)total && Size(ctx, vHandle) >= (size_t)total);
w = ctx->buffers[wHandle].data.data();
wg = ctx->buffers[weightsGradientHandle].data.data();
m = ctx->buffers[mHandle].data.data();
v = ctx->buffers[vHandle].data.data();
}
if(!ctx->pool.ParallelFor(total, [=](int begin, int end)
{
for(int wi = begin; wi < end; wi++)
{
double g = wg[wi];
double mt = b1 * m[wi] + (1.0 - b1) * g;
double vt = sqrt(b2 * v[wi] + (1.0 - b2) * g * g);
double delta = ClampDelta(l * mt / (vt > 0.0 ? vt : l * 10.0) - l * WEIGHT_DECAY * w[wi]);
// Sign-agreement gate removed - see CPU_UpdateWeightsAdam's comment for why.
w[wi] = ClampWeight(w[wi] + delta);
m[wi] = mt;
v[wi] = vt;
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
// SGD+momentum counterpart to CPU_LSTMUpdateWeightsAdam - same flat
// (already-elementwise) weights_gradient/DeltaWeightsLSTM layout, just the
// classic heavy-ball update instead of Adam's per-parameter adaptive step.
// See CPU_UpdateWeightsMomentum's matching comment for why the dense-layer
// version doesn't need this treatment: this is the LSTM path's counterpart,
// so it always uses the flat total-weight indexing already established by
// CPU_LSTMUpdateWeightsAdam above.
WARRIORCPU_API int __stdcall CPU_LSTMUpdateWeightsMomentum(CpuHandle handle, int wHandle, int weightsGradientHandle,
int dwHandle, double learningRate, double momentum, int total, int optimizer)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
double *w, *wg, *dw;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, wHandle) || !ValidHandle(ctx, weightsGradientHandle) || !ValidHandle(ctx, dwHandle))
return 0;
REQUIRE(ctx, total >= 0);
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)total && Size(ctx, weightsGradientHandle) >= (size_t)total);
REQUIRE(ctx, Size(ctx, dwHandle) >= (size_t)total);
w = ctx->buffers[wHandle].data.data();
wg = ctx->buffers[weightsGradientHandle].data.data();
dw = ctx->buffers[dwHandle].data.data();
}
if(!ctx->pool.ParallelFor(total, [=](int begin, int end)
{
for(int wi = begin; wi < end; wi++)
{
// `optimizer` retained unused - see CPU_UpdateWeightsMomentum's comment above.
double delta = learningRate * wg[wi] + momentum * dw[wi];
dw[wi] = delta;
w[wi] = ClampWeight(w[wi] + delta);
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
//+------------------------------------------------------------------+
//| Max-pooling layer - no weights, mirrors AI\Network.cl's |
//| FeedForwardProof/CalcInputGradientProof. |
//+------------------------------------------------------------------+
WARRIORCPU_API int __stdcall CPU_FeedForwardProof(CpuHandle handle, int iHandle, int oHandle, int inputs, int window, int step, int outputs)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
size_t inSize;
double *in, *out;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, iHandle) || !ValidHandle(ctx, oHandle))
return 0;
REQUIRE(ctx, outputs >= 0 && inputs >= 0 && step > 0);
REQUIRE(ctx, Size(ctx, iHandle) >= (size_t)inputs && Size(ctx, oHandle) >= (size_t)outputs);
inSize = Size(ctx, iHandle);
in = ctx->buffers[iHandle].data.data();
out = ctx->buffers[oHandle].data.data();
}
if(!ctx->pool.ParallelFor(outputs, [=](int begin, int end)
{
for(int i = begin; i < end; i++)
{
int pos = i * step;
if((size_t)pos >= inSize)
{
out[i] = 0.0;
continue;
}
double result = in[pos];
for(int k = 1; k < window; k++)
{
int shift = k + pos;
if(shift >= inputs || (size_t)shift >= inSize)
break;
result = std::max(result, in[shift]);
}
out[i] = result;
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}
WARRIORCPU_API int __stdcall CPU_CalcInputGradientProof(CpuHandle handle, int iHandle, int gHandle, int oHandle, int igHandle,
int outputs, int window, int step, int inputs)
{
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
if(!ctx)
return 0;
double *in, *g, *o, *ig;
{
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
std::lock_guard<std::mutex> lock(ctx->mutex);
if(!ValidHandle(ctx, iHandle) || !ValidHandle(ctx, gHandle) || !ValidHandle(ctx, oHandle) || !ValidHandle(ctx, igHandle))
return 0;
REQUIRE(ctx, inputs >= 0 && outputs >= 0 && step > 0);
REQUIRE(ctx, Size(ctx, iHandle) >= (size_t)inputs && Size(ctx, igHandle) >= (size_t)inputs);
REQUIRE(ctx, Size(ctx, oHandle) >= (size_t)outputs && Size(ctx, gHandle) >= (size_t)outputs);
in = ctx->buffers[iHandle].data.data();
g = ctx->buffers[gHandle].data.data();
o = ctx->buffers[oHandle].data.data();
ig = ctx->buffers[igHandle].data.data();
}
if(!ctx->pool.ParallelFor(inputs, [=](int begin, int end)
{
for(int i = begin; i < end; i++)
{
double value = in[i];
int start = i - window + step;
start = (start - start % step) / step;
int stop = (i - i % step) / step + 1;
double prevGrad = 0.0;
for(int out = std::max(0, start); out < std::min(outputs, stop); out++)
if(value == o[out])
prevGrad += g[out];
ig[i] = prevGrad;
}
}))
{
ctx->lastError = 201;
return 0;
}
return 1;
}