Warrior_EA/DirectML/WarriorCPU.cpp

1413 lines
60 KiB
C++
Raw Permalink Normal View History

//+------------------------------------------------------------------+
//| 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)
{
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 delta = learningRate * g[i] * in[j] + 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)
{
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]);
}
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)
{
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++)
{
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;
}