2026-07-13 03:23:39 -04:00
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| 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"
|
2026-07-14 18:04:48 -04:00
|
|
|
#define NOMINMAX
|
|
|
|
|
#include <windows.h>
|
2026-07-13 03:23:39 -04:00
|
|
|
#include <vector>
|
|
|
|
|
#include <queue>
|
|
|
|
|
#include <thread>
|
|
|
|
|
#include <mutex>
|
|
|
|
|
#include <condition_variable>
|
|
|
|
|
#include <functional>
|
|
|
|
|
#include <atomic>
|
|
|
|
|
#include <cmath>
|
|
|
|
|
#include <algorithm>
|
2026-07-14 22:36:27 -04:00
|
|
|
#include <future>
|
|
|
|
|
#include <chrono>
|
feat(dll): fused sequence-LSTM kernels with real backpropagation-through-time
The per-step entry points cannot express a sequence model. CPU_LSTMGates takes
the ENTIRE flattened input as one timestep, and CPU_LSTMGateGradient has no
parameter for dc arriving from the following step - so the recurrent gradient
path does not exist and cannot be assembled from these primitives at any call
pattern. The layer built on them is a gated dense layer that the class comment
already described honestly: "single-timestep-truncated BPTT".
Adds CPU_LSTMSeqForward / CPU_LSTMSeqBackward: the whole unrolled sequence in
one call each, weights shared across timesteps, dW accumulated over all of them
(the per-step CPU_LSTMWeightsGradient assigns rather than accumulates, so it
could not have been reused even with the dc term). Fused rather than dispatched
per step because the recurrence is sequential - T round trips would serialise T
lock/dispatch pairs for a few thousand FLOPs each.
h_{-1} and c_{-1} are zero per sample. The old layer carried its cell state
across forward passes, so under shuffled training every sample inherited the
state of an unrelated one.
DirectML gets the same math host-side (readback, compute in double, upload)
rather than HLSL: the recurrence needs a barrier per timestep, the GPU buffers
are float and BPTT accumulation is where that hurts most, and no D3D12 device
exists on this machine to test a shader against. Documented at the definition.
Verified with lstm_seq_gradcheck.cpp - central-difference check of dW and dX
against an asymmetric loss over the final hidden state. Max relative error
2.3e-10 on both, with a non-trivial gradient magnitude asserted so the check
cannot pass on an all-zero result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:13:50 -04:00
|
|
|
#include <cstring> // memcpy/memset in the fused sequence-LSTM kernels
|
2026-07-13 03:23:39 -04:00
|
|
|
|
|
|
|
|
namespace
|
|
|
|
|
{
|
2026-07-14 18:04:48 -04:00
|
|
|
// 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);
|
|
|
|
|
|
2026-07-13 03:23:39 -04:00
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| 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. |
|
2026-07-15 21:47:09 -04:00
|
|
|
//| 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. |
|
2026-07-13 03:23:39 -04:00
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
class ThreadPool
|
|
|
|
|
{
|
|
|
|
|
public:
|
|
|
|
|
void Start(int threads)
|
|
|
|
|
{
|
|
|
|
|
Stop();
|
|
|
|
|
if(threads <= 0)
|
|
|
|
|
threads = (int)std::thread::hardware_concurrency();
|
|
|
|
|
if(threads <= 0)
|
|
|
|
|
threads = 1;
|
2026-07-14 18:04:48 -04:00
|
|
|
{
|
|
|
|
|
std::lock_guard<std::mutex> lock(m_mutex);
|
|
|
|
|
m_stop = false;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
for(int i = 0; i < threads; i++)
|
|
|
|
|
m_workers.emplace_back([this]{ WorkerLoop(); });
|
2026-07-14 18:04:48 -04:00
|
|
|
m_workerCount.store(threads);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 03:23:39 -04:00
|
|
|
void Stop()
|
|
|
|
|
{
|
|
|
|
|
if(m_workers.empty())
|
|
|
|
|
return;
|
|
|
|
|
{
|
|
|
|
|
std::lock_guard<std::mutex> lock(m_mutex);
|
|
|
|
|
m_stop = true;
|
|
|
|
|
}
|
|
|
|
|
m_cv.notify_all();
|
2026-07-14 22:36:27 -04:00
|
|
|
// 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
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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
|
2026-07-14 22:36:27 -04:00
|
|
|
// 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);
|
2026-07-13 03:23:39 -04:00
|
|
|
for(auto &t : m_workers)
|
2026-07-14 22:36:27 -04:00
|
|
|
{
|
|
|
|
|
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();
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
m_workers.clear();
|
2026-07-14 18:04:48 -04:00
|
|
|
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;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-14 18:04:48 -04:00
|
|
|
int ThreadCount() const { return m_workerCount.load(); }
|
2026-07-13 03:23:39 -04:00
|
|
|
|
|
|
|
|
~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.
|
2026-07-14 18:04:48 -04:00
|
|
|
// 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.
|
2026-07-15 21:47:09 -04:00
|
|
|
//
|
|
|
|
|
// 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)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
if(count <= 0)
|
2026-07-14 18:04:48 -04:00
|
|
|
return true;
|
2026-07-17 19:30:10 -04:00
|
|
|
// 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;
|
2026-07-14 18:04:48 -04:00
|
|
|
int workers = std::max(1, m_workerCount.load());
|
2026-07-13 03:23:39 -04:00
|
|
|
int chunks = std::min(workers, count);
|
2026-07-17 19:30:10 -04:00
|
|
|
if(chunks <= 1 || count < kInlineThreshold)
|
2026-07-14 18:04:48 -04:00
|
|
|
return SehCallFn(fn, 0, count);
|
2026-07-13 03:23:39 -04:00
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
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));
|
2026-07-13 03:23:39 -04:00
|
|
|
|
|
|
|
|
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;
|
2026-07-15 21:47:09 -04:00
|
|
|
Enqueue([state, fnPtr, begin, end]
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-14 18:04:48 -04:00
|
|
|
// 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.
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!SehCallFn(*fnPtr, begin, end))
|
|
|
|
|
state->allOk.store(false);
|
|
|
|
|
if(state->remaining.fetch_sub(1) == 1)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
std::lock_guard<std::mutex> lock(state->doneMutex);
|
|
|
|
|
state->doneCv.notify_one();
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
std::unique_lock<std::mutex> lock(state->doneMutex);
|
|
|
|
|
state->doneCv.wait(lock, [state]{ return state->remaining.load() == 0; });
|
|
|
|
|
return state->allOk.load();
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
2026-07-14 18:04:48 -04:00
|
|
|
std::atomic<int> m_workerCount{0};
|
2026-07-13 03:23:39 -04:00
|
|
|
};
|
|
|
|
|
|
2026-07-14 18:04:48 -04:00
|
|
|
bool SehStartPool(ThreadPool &pool, int threads)
|
|
|
|
|
{
|
|
|
|
|
__try
|
|
|
|
|
{
|
|
|
|
|
pool.Start(threads);
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
__except(EXCEPTION_EXECUTE_HANDLER)
|
|
|
|
|
{
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 03:23:39 -04:00
|
|
|
struct Buffer
|
|
|
|
|
{
|
|
|
|
|
std::vector<double> data;
|
|
|
|
|
bool inUse = false;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| 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)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
for(size_t i = 0; i < ctx->buffers.size(); i++)
|
|
|
|
|
if(!ctx->buffers[i].inUse)
|
2026-07-13 03:23:39 -04:00
|
|
|
return (int)i;
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->buffers.push_back(Buffer());
|
|
|
|
|
return (int)ctx->buffers.size() - 1;
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
bool ValidHandle(CpuContext *ctx, int h)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
return h >= 0 && (size_t)h < ctx->buffers.size() && ctx->buffers[h].inUse;
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
inline size_t Size(CpuContext *ctx, int h) { return ctx->buffers[h].data.size(); }
|
2026-07-13 07:31:47 -04:00
|
|
|
// 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.
|
2026-07-15 21:47:09 -04:00
|
|
|
#define REQUIRE(ctx, cond) do { if(!(cond)) { (ctx)->lastError = 100; return 0; } } while(0)
|
|
|
|
|
|
2026-08-02 01:09:18 -04:00
|
|
|
// Error codes reported through CPU_GetLastError(). 100 (a REQUIRE range-validation failure) and 201
|
|
|
|
|
// (a kernel fault caught by the guards above) are the historical two; the rest exist because those
|
|
|
|
|
// two were not enough to tell three very different failures apart.
|
|
|
|
|
//
|
|
|
|
|
// lastError is STICKY - nothing ever cleared it - so a call that failed for a reason which set no
|
|
|
|
|
// code at all would report whatever some earlier call had left behind. On 2026-08-02 a BufferWrite
|
|
|
|
|
// failure reported "error 100" and that was read as a range-validation failure, when 100 could
|
|
|
|
|
// equally have been a leftover from any kernel that ran minutes earlier. CLEAR_ERR() at the top of
|
|
|
|
|
// each entry point below is what makes a reported code describe THIS call, and the distinct codes
|
|
|
|
|
// mean a handle problem, a size problem and a non-finite payload can never again be confused for
|
|
|
|
|
// each other. Any new entry point must call CLEAR_ERR() first.
|
|
|
|
|
#define CPU_ERR_BAD_HANDLE 101 // buffer handle out of range, or refers to a freed slot
|
|
|
|
|
#define CPU_ERR_BAD_SIZE 102 // count argument exceeds the buffer's actual allocation
|
|
|
|
|
#define CPU_ERR_NOT_FINITE 103 // payload contained a NaN or an infinity - rejected at the boundary
|
|
|
|
|
#define CLEAR_ERR(ctx) do { (ctx)->lastError = 0; } while(0)
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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.
|
2026-07-15 21:47:37 -04:00
|
|
|
// 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;
|
2026-07-15 21:47:09 -04:00
|
|
|
inline double ClampWeight(double w) { return std::max(-MAX_WEIGHT, std::min(MAX_WEIGHT, w)); }
|
2026-07-13 07:31:47 -04:00
|
|
|
|
2026-07-15 21:47:37 -04:00
|
|
|
// 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
|
2026-07-19 17:05:58 -04:00
|
|
|
// 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.
|
2026-07-15 21:47:37 -04:00
|
|
|
// 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.
|
2026-07-19 17:05:58 -04:00
|
|
|
const double WEIGHT_DECAY = 0.001;
|
2026-07-15 21:47:37 -04:00
|
|
|
// 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)); }
|
|
|
|
|
|
2026-07-13 03:23:39 -04:00
|
|
|
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 |
|
|
|
|
|
//+------------------------------------------------------------------+
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API CpuHandle __stdcall CPU_Init(int threads)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = new CpuContext();
|
|
|
|
|
if(!SehStartPool(ctx->pool, threads))
|
2026-07-14 18:04:48 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
delete ctx; // hardware fault during pool startup - see SehStartPool
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
return reinterpret_cast<CpuHandle>(ctx);
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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.
|
2026-07-14 18:04:48 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_GetHardwareConcurrency()
|
|
|
|
|
{
|
|
|
|
|
int n = (int)std::thread::hardware_concurrency();
|
|
|
|
|
return n > 0 ? n : 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API void __stdcall CPU_Shutdown(CpuHandle handle)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return;
|
|
|
|
|
ctx->pool.Stop();
|
|
|
|
|
delete ctx;
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_GetLastError(CpuHandle handle)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
|
|
|
|
std::lock_guard<std::mutex> lock(ctx->mutex);
|
|
|
|
|
return ctx->lastError;
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_GetThreadCount(CpuHandle handle)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
|
|
|
|
return ctx->pool.ThreadCount();
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| Buffers |
|
|
|
|
|
//+------------------------------------------------------------------+
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_BufferCreate(CpuHandle handle, int elementCount)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx || elementCount <= 0)
|
2026-07-13 03:23:39 -04:00
|
|
|
return -1;
|
2026-07-15 21:47:09 -04:00
|
|
|
std::lock_guard<std::mutex> lock(ctx->mutex);
|
2026-08-02 01:09:18 -04:00
|
|
|
CLEAR_ERR(ctx);
|
2026-07-15 21:47:09 -04:00
|
|
|
int slot = AllocBufferSlot(ctx);
|
|
|
|
|
ctx->buffers[slot].data.assign((size_t)elementCount, 0.0);
|
|
|
|
|
ctx->buffers[slot].inUse = true;
|
2026-07-13 03:23:39 -04:00
|
|
|
return slot;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API void __stdcall CPU_BufferFree(CpuHandle handle, int bufHandle)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
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())
|
2026-07-13 03:23:39 -04:00
|
|
|
return;
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->buffers[bufHandle] = Buffer();
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_BufferWrite(CpuHandle handle, int bufHandle, const double *data, int count)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
|
|
|
|
std::lock_guard<std::mutex> lock(ctx->mutex);
|
2026-08-02 01:09:18 -04:00
|
|
|
CLEAR_ERR(ctx);
|
|
|
|
|
if(!ValidHandle(ctx, bufHandle))
|
|
|
|
|
{
|
|
|
|
|
ctx->lastError = CPU_ERR_BAD_HANDLE;
|
2026-07-13 03:23:39 -04:00
|
|
|
return 0;
|
2026-08-02 01:09:18 -04:00
|
|
|
}
|
|
|
|
|
if((size_t)count > ctx->buffers[bufHandle].data.size())
|
|
|
|
|
{
|
|
|
|
|
ctx->lastError = CPU_ERR_BAD_SIZE;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
// 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++)
|
2026-08-02 01:09:18 -04:00
|
|
|
if(!std::isfinite(data[i]))
|
|
|
|
|
{
|
|
|
|
|
ctx->lastError = CPU_ERR_NOT_FINITE;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
std::copy(data, data + count, ctx->buffers[bufHandle].data.begin());
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_BufferRead(CpuHandle handle, int bufHandle, double *data, int count)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
|
|
|
|
std::lock_guard<std::mutex> lock(ctx->mutex);
|
2026-08-02 01:09:18 -04:00
|
|
|
CLEAR_ERR(ctx);
|
|
|
|
|
if(!ValidHandle(ctx, bufHandle))
|
|
|
|
|
{
|
|
|
|
|
ctx->lastError = CPU_ERR_BAD_HANDLE;
|
2026-07-13 03:23:39 -04:00
|
|
|
return 0;
|
2026-08-02 01:09:18 -04:00
|
|
|
}
|
|
|
|
|
if((size_t)count > ctx->buffers[bufHandle].data.size())
|
|
|
|
|
{
|
|
|
|
|
ctx->lastError = CPU_ERR_BAD_SIZE;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
std::copy(ctx->buffers[bufHandle].data.begin(), ctx->buffers[bufHandle].data.begin() + count, data);
|
2026-07-13 03:23:39 -04:00
|
|
|
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. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_FeedForward(CpuHandle handle, int wHandle, int iHandle, int oHandle, int inputs, int activation)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
int neurons;
|
|
|
|
|
double *w, *in, *out;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(neurons, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_CalcOutputGradient(CpuHandle handle, int tHandle, int oHandle, int igHandle, int activation, int count)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
double *t, *o, *ig;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(count, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
for(int i = begin; i < end; i++)
|
|
|
|
|
{
|
|
|
|
|
double out_v = o[i];
|
|
|
|
|
double temp = 0.0;
|
|
|
|
|
if(activation == 0)
|
|
|
|
|
{
|
2026-07-13 08:23:30 -04:00
|
|
|
// 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.
|
2026-07-13 03:23:39 -04:00
|
|
|
temp = std::max(-1.0, std::min(1.0, t[i])) - out_v;
|
|
|
|
|
}
|
|
|
|
|
else if(activation == 1)
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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).
|
2026-07-13 03:23:39 -04:00
|
|
|
temp = std::max(0.0, std::min(1.0, t[i])) - out_v;
|
2026-07-15 21:47:09 -04:00
|
|
|
}
|
|
|
|
|
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;
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
ig[i] = temp;
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_CalcHiddenGradient(CpuHandle handle, int wHandle, int gHandle, int oHandle, int igHandle,
|
2026-07-13 03:23:39 -04:00
|
|
|
int outputs, int activation, int count)
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
double *w, *g, *o, *ig;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
REQUIRE(ctx, count >= 0 && outputs >= 0);
|
fix: dense backprop read the weight matrix transposed - on every backend
CaclHiddenGradient computed this layer's gradient as matrix_w[(outputs+1)*i + k]
against a buffer whose actual layout (one row per NEXT-layer neuron, stride
inputs+1) makes the correct read matrix_w[k*(inputs+1) + i]: the transpose for
square layers, and for the non-square boundaries this EA actually builds
(tapered stacks, the 3-neuron head) a mis-strided walk that ran past the buffer
end - garbage on OpenCL, zeroed reads on the CPU DLL, so the tiers did not even
agree with each other. Every gradient crossing a dense boundary on its way down
- the entire learning signal reaching the BN/conv/LSTM front ends - passed
through a fixed wrong matrix: feedback-alignment dynamics, not backprop, which
is why nets still "learned something" and this survived. The book reference
(NeuroNet_DNG) fixed this in a later article version; our kernel descended from
the earlier one. Confounds every model-based negative verdict to date.
Also in this commit, same root cause family:
- per-sample UpdateWeightsAdam (OpenCL): input for slot group j was read at
matrix_i[j] instead of matrix_i[j*4] (corrupted outer product past group 0),
and dispatch dim 1 sized on ceil(inputs/4) left the bias column unreachable
whenever inputs%4==0 - dense biases never trained on OpenCL. Rewritten as a
lane-guarded scalar loop keeping our Adam conventions (sqrt-stored v,
decoupled decay, both clamps, no sign gate). The batched accum path never had
either bug; this kernel is what SetBatchSize(1) runs - including online
continual learning on client machines, where OpenCL is the only tier.
- conv backward passed raw (int)Activation() where the kernels expect
NativeActivationCode(): NONE took the tanh branch (clamping a BN layer's
unbounded z-scores), TANH took sigmoid, PRELU took none. Dormant only because
the conv sits at layer 1 today.
- hidden-gradient dispatch over Neurons()+1 dropped to Neurons(): biases get no
backprop gradient and the extra work-item only ever read past matrix_o.
All three backends (Network.cl, WarriorCPU.cpp, WarriorDML.cpp HLSL) changed in
lockstep; DML gained an `inputs` constant to derive the row stride. New
dense_backprop_check.cpp proves the CPU kernel is central-finite-difference
consistent with the real forward kernel on 8x8, 64x3, 33x64, 5x3 (max diff
3e-9) and that all three activation branches match transcription. All 16 checks
pass. Offline math check only - the in-situ proof remains the per-layer dW/W
report on a real era.
FORCES FULL RETRAIN. Both DLLs rebuilt and redeployed to MQL5\Libraries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:06:09 -04:00
|
|
|
// count is exactly Neurons() since the 2026-08-11 transpose fix (the old Neurons()+1 "bias
|
|
|
|
|
// row" only ever produced out-of-bounds reads - biases receive no backprop gradient). The
|
|
|
|
|
// weight matrix is this layer's OUTGOING one, laid out as CPU_FeedForward consumes it: one
|
|
|
|
|
// row per next-layer neuron k, row stride (count + 1), so the weight FROM this neuron i INTO
|
|
|
|
|
// next-layer neuron k is w[k * (count + 1) + i] - a column read, dL/dout_i = sum_k g[k] *
|
|
|
|
|
// W[k][i]. The pre-fix code read w[(outputs + 1) * i + k]: the TRANSPOSE for square layers
|
|
|
|
|
// and a mis-strided walk for non-square ones, i.e. feedback alignment instead of backprop
|
|
|
|
|
// for everything below the top dense boundary. Must stay in lockstep with AI\Network.cl's
|
|
|
|
|
// CaclHiddenGradient and WarriorDML.cpp's kHlslHiddenGradient.
|
2026-07-15 21:47:09 -04:00
|
|
|
REQUIRE(ctx, Size(ctx, igHandle) >= (size_t)count);
|
fix: dense backprop read the weight matrix transposed - on every backend
CaclHiddenGradient computed this layer's gradient as matrix_w[(outputs+1)*i + k]
against a buffer whose actual layout (one row per NEXT-layer neuron, stride
inputs+1) makes the correct read matrix_w[k*(inputs+1) + i]: the transpose for
square layers, and for the non-square boundaries this EA actually builds
(tapered stacks, the 3-neuron head) a mis-strided walk that ran past the buffer
end - garbage on OpenCL, zeroed reads on the CPU DLL, so the tiers did not even
agree with each other. Every gradient crossing a dense boundary on its way down
- the entire learning signal reaching the BN/conv/LSTM front ends - passed
through a fixed wrong matrix: feedback-alignment dynamics, not backprop, which
is why nets still "learned something" and this survived. The book reference
(NeuroNet_DNG) fixed this in a later article version; our kernel descended from
the earlier one. Confounds every model-based negative verdict to date.
Also in this commit, same root cause family:
- per-sample UpdateWeightsAdam (OpenCL): input for slot group j was read at
matrix_i[j] instead of matrix_i[j*4] (corrupted outer product past group 0),
and dispatch dim 1 sized on ceil(inputs/4) left the bias column unreachable
whenever inputs%4==0 - dense biases never trained on OpenCL. Rewritten as a
lane-guarded scalar loop keeping our Adam conventions (sqrt-stored v,
decoupled decay, both clamps, no sign gate). The batched accum path never had
either bug; this kernel is what SetBatchSize(1) runs - including online
continual learning on client machines, where OpenCL is the only tier.
- conv backward passed raw (int)Activation() where the kernels expect
NativeActivationCode(): NONE took the tanh branch (clamping a BN layer's
unbounded z-scores), TANH took sigmoid, PRELU took none. Dormant only because
the conv sits at layer 1 today.
- hidden-gradient dispatch over Neurons()+1 dropped to Neurons(): biases get no
backprop gradient and the extra work-item only ever read past matrix_o.
All three backends (Network.cl, WarriorCPU.cpp, WarriorDML.cpp HLSL) changed in
lockstep; DML gained an `inputs` constant to derive the row stride. New
dense_backprop_check.cpp proves the CPU kernel is central-finite-difference
consistent with the real forward kernel on 8x8, 64x3, 33x64, 5x3 (max diff
3e-9) and that all three activation branches match transcription. All 16 checks
pass. Offline math check only - the in-situ proof remains the per-layer dW/W
report on a real era.
FORCES FULL RETRAIN. Both DLLs rebuilt and redeployed to MQL5\Libraries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:06:09 -04:00
|
|
|
REQUIRE(ctx, Size(ctx, oHandle) >= (size_t)count);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, gHandle) >= (size_t)outputs);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)outputs * ((size_t)count + 1));
|
2026-07-15 21:47:09 -04:00
|
|
|
w = ctx->buffers[wHandle].data.data();
|
|
|
|
|
g = ctx->buffers[gHandle].data.data();
|
|
|
|
|
o = ctx->buffers[oHandle].data.data();
|
|
|
|
|
ig = ctx->buffers[igHandle].data.data();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
fix: dense backprop read the weight matrix transposed - on every backend
CaclHiddenGradient computed this layer's gradient as matrix_w[(outputs+1)*i + k]
against a buffer whose actual layout (one row per NEXT-layer neuron, stride
inputs+1) makes the correct read matrix_w[k*(inputs+1) + i]: the transpose for
square layers, and for the non-square boundaries this EA actually builds
(tapered stacks, the 3-neuron head) a mis-strided walk that ran past the buffer
end - garbage on OpenCL, zeroed reads on the CPU DLL, so the tiers did not even
agree with each other. Every gradient crossing a dense boundary on its way down
- the entire learning signal reaching the BN/conv/LSTM front ends - passed
through a fixed wrong matrix: feedback-alignment dynamics, not backprop, which
is why nets still "learned something" and this survived. The book reference
(NeuroNet_DNG) fixed this in a later article version; our kernel descended from
the earlier one. Confounds every model-based negative verdict to date.
Also in this commit, same root cause family:
- per-sample UpdateWeightsAdam (OpenCL): input for slot group j was read at
matrix_i[j] instead of matrix_i[j*4] (corrupted outer product past group 0),
and dispatch dim 1 sized on ceil(inputs/4) left the bias column unreachable
whenever inputs%4==0 - dense biases never trained on OpenCL. Rewritten as a
lane-guarded scalar loop keeping our Adam conventions (sqrt-stored v,
decoupled decay, both clamps, no sign gate). The batched accum path never had
either bug; this kernel is what SetBatchSize(1) runs - including online
continual learning on client machines, where OpenCL is the only tier.
- conv backward passed raw (int)Activation() where the kernels expect
NativeActivationCode(): NONE took the tanh branch (clamping a BN layer's
unbounded z-scores), TANH took sigmoid, PRELU took none. Dormant only because
the conv sits at layer 1 today.
- hidden-gradient dispatch over Neurons()+1 dropped to Neurons(): biases get no
backprop gradient and the extra work-item only ever read past matrix_o.
All three backends (Network.cl, WarriorCPU.cpp, WarriorDML.cpp HLSL) changed in
lockstep; DML gained an `inputs` constant to derive the row stride. New
dense_backprop_check.cpp proves the CPU kernel is central-finite-difference
consistent with the real forward kernel on 8x8, 64x3, 33x64, 5x3 (max diff
3e-9) and that all three activation branches match transcription. All 16 checks
pass. Offline math check only - the in-situ proof remains the per-layer dW/W
report on a real era.
FORCES FULL RETRAIN. Both DLLs rebuilt and redeployed to MQL5\Libraries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:06:09 -04:00
|
|
|
const size_t stride = (size_t)count + 1;
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(count, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
for(int i = begin; i < end; i++)
|
|
|
|
|
{
|
fix: dense backprop read the weight matrix transposed - on every backend
CaclHiddenGradient computed this layer's gradient as matrix_w[(outputs+1)*i + k]
against a buffer whose actual layout (one row per NEXT-layer neuron, stride
inputs+1) makes the correct read matrix_w[k*(inputs+1) + i]: the transpose for
square layers, and for the non-square boundaries this EA actually builds
(tapered stacks, the 3-neuron head) a mis-strided walk that ran past the buffer
end - garbage on OpenCL, zeroed reads on the CPU DLL, so the tiers did not even
agree with each other. Every gradient crossing a dense boundary on its way down
- the entire learning signal reaching the BN/conv/LSTM front ends - passed
through a fixed wrong matrix: feedback-alignment dynamics, not backprop, which
is why nets still "learned something" and this survived. The book reference
(NeuroNet_DNG) fixed this in a later article version; our kernel descended from
the earlier one. Confounds every model-based negative verdict to date.
Also in this commit, same root cause family:
- per-sample UpdateWeightsAdam (OpenCL): input for slot group j was read at
matrix_i[j] instead of matrix_i[j*4] (corrupted outer product past group 0),
and dispatch dim 1 sized on ceil(inputs/4) left the bias column unreachable
whenever inputs%4==0 - dense biases never trained on OpenCL. Rewritten as a
lane-guarded scalar loop keeping our Adam conventions (sqrt-stored v,
decoupled decay, both clamps, no sign gate). The batched accum path never had
either bug; this kernel is what SetBatchSize(1) runs - including online
continual learning on client machines, where OpenCL is the only tier.
- conv backward passed raw (int)Activation() where the kernels expect
NativeActivationCode(): NONE took the tanh branch (clamping a BN layer's
unbounded z-scores), TANH took sigmoid, PRELU took none. Dormant only because
the conv sits at layer 1 today.
- hidden-gradient dispatch over Neurons()+1 dropped to Neurons(): biases get no
backprop gradient and the extra work-item only ever read past matrix_o.
All three backends (Network.cl, WarriorCPU.cpp, WarriorDML.cpp HLSL) changed in
lockstep; DML gained an `inputs` constant to derive the row stride. New
dense_backprop_check.cpp proves the CPU kernel is central-finite-difference
consistent with the real forward kernel on 8x8, 64x3, 33x64, 5x3 (max diff
3e-9) and that all three activation branches match transcription. All 16 checks
pass. Offline math check only - the in-situ proof remains the per-layer dW/W
report on a real era.
FORCES FULL RETRAIN. Both DLLs rebuilt and redeployed to MQL5\Libraries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:06:09 -04:00
|
|
|
double out_v = o[i];
|
2026-07-13 03:23:39 -04:00
|
|
|
double sum = 0.0;
|
|
|
|
|
for(int k = 0; k < outputs; k++)
|
fix: dense backprop read the weight matrix transposed - on every backend
CaclHiddenGradient computed this layer's gradient as matrix_w[(outputs+1)*i + k]
against a buffer whose actual layout (one row per NEXT-layer neuron, stride
inputs+1) makes the correct read matrix_w[k*(inputs+1) + i]: the transpose for
square layers, and for the non-square boundaries this EA actually builds
(tapered stacks, the 3-neuron head) a mis-strided walk that ran past the buffer
end - garbage on OpenCL, zeroed reads on the CPU DLL, so the tiers did not even
agree with each other. Every gradient crossing a dense boundary on its way down
- the entire learning signal reaching the BN/conv/LSTM front ends - passed
through a fixed wrong matrix: feedback-alignment dynamics, not backprop, which
is why nets still "learned something" and this survived. The book reference
(NeuroNet_DNG) fixed this in a later article version; our kernel descended from
the earlier one. Confounds every model-based negative verdict to date.
Also in this commit, same root cause family:
- per-sample UpdateWeightsAdam (OpenCL): input for slot group j was read at
matrix_i[j] instead of matrix_i[j*4] (corrupted outer product past group 0),
and dispatch dim 1 sized on ceil(inputs/4) left the bias column unreachable
whenever inputs%4==0 - dense biases never trained on OpenCL. Rewritten as a
lane-guarded scalar loop keeping our Adam conventions (sqrt-stored v,
decoupled decay, both clamps, no sign gate). The batched accum path never had
either bug; this kernel is what SetBatchSize(1) runs - including online
continual learning on client machines, where OpenCL is the only tier.
- conv backward passed raw (int)Activation() where the kernels expect
NativeActivationCode(): NONE took the tanh branch (clamping a BN layer's
unbounded z-scores), TANH took sigmoid, PRELU took none. Dormant only because
the conv sits at layer 1 today.
- hidden-gradient dispatch over Neurons()+1 dropped to Neurons(): biases get no
backprop gradient and the extra work-item only ever read past matrix_o.
All three backends (Network.cl, WarriorCPU.cpp, WarriorDML.cpp HLSL) changed in
lockstep; DML gained an `inputs` constant to derive the row stride. New
dense_backprop_check.cpp proves the CPU kernel is central-finite-difference
consistent with the real forward kernel on 8x8, 64x3, 33x64, 5x3 (max diff
3e-9) and that all three activation branches match transcription. All 16 checks
pass. Offline math check only - the in-situ proof remains the per-layer dW/W
report on a real era.
FORCES FULL RETRAIN. Both DLLs rebuilt and redeployed to MQL5\Libraries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 18:06:09 -04:00
|
|
|
sum += g[k] * w[(size_t)k * stride + (size_t)i];
|
2026-07-13 03:23:39 -04:00
|
|
|
if(activation == 0)
|
|
|
|
|
{
|
|
|
|
|
sum = std::max(-1.0, std::min(1.0, sum + out_v)) - out_v;
|
2026-07-15 21:47:37 -04:00
|
|
|
sum *= std::max(MIN_ACTIVATION_DERIVATIVE, 1.0 - out_v * out_v);
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
else if(activation == 1)
|
|
|
|
|
{
|
|
|
|
|
sum = std::max(0.0, std::min(1.0, sum + out_v)) - out_v;
|
2026-07-15 21:47:37 -04:00
|
|
|
sum *= std::max(MIN_ACTIVATION_DERIVATIVE, out_v * (1.0 - out_v));
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
2026-07-17 23:21:12 -04:00
|
|
|
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;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
ig[i] = sum;
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Bias weight (j == inputs) is intentionally never touched here, matching
|
|
|
|
|
// the OpenCL/DirectML UpdateWeightsMomentum dispatch range (0..inputs-1).
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_UpdateWeightsMomentum(CpuHandle handle, int wHandle, int gHandle, int iHandle, int dwHandle,
|
2026-07-28 15:01:40 -04:00
|
|
|
int inputs, double learningRate, double momentum, int neurons, int optimizer)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
double *w, *g, *in, *dw;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(neurons, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
for(int i = begin; i < end; i++)
|
|
|
|
|
for(int j = 0; j < inputs; j++)
|
|
|
|
|
{
|
|
|
|
|
int wi = i * (inputs + 1) + j;
|
2026-07-28 15:01:40 -04:00
|
|
|
double localGradient = g[i] * in[j];
|
2026-07-29 00:03:54 -04:00
|
|
|
// `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];
|
2026-07-13 03:23:39 -04:00
|
|
|
dw[wi] = delta;
|
2026-07-15 21:47:09 -04:00
|
|
|
w[wi] = ClampWeight(w[wi] + delta);
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_UpdateWeightsAdam(CpuHandle handle, int wHandle, int gHandle, int iHandle, int mHandle, int vHandle,
|
2026-07-13 03:23:39 -04:00
|
|
|
int inputs, double lt, double b1, double b2, int neurons)
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
double *w, *g, *in, *m, *v;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(neurons, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
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;
|
fix: the Adam second moment was never Adam - all four tiers
Root cause of the B=32 regression, and it predates F4 entirely. Every Adam
kernel stored v already square-rooted and then fed that stored value back in
as if it were the variance:
v_new = sqrt(b2 * v_old + (1 - b2) * g^2)
That recursion has a fixed point at v ~= b2 = 0.999 for ANY gradient below
unit scale, so the denominator stops tracking the gradient and Adam degrades
into plain SGD with lr = lt. Measured against the shipped WarriorCPU.dll
(batch_accum_check.cpp, TestOptimizerScaleInvariance), 4000 steps of a
constant gradient: 3285x less displacement at |g|=1e-5 than at |g|=1, where
a scale-invariant optimizer gives the same distance for both. After the fix
all six magnitudes read 1.199 and v tracks |g| exactly.
It hit conv/LSTM specifically because they sit behind a batch-norm with
running variance ~2.6e+05, so their gradients arrive divided by ~500 - deep
in the degraded regime - while the dense stack near the loss stayed in the
working one. In situ on SP500 H1: lstm1 dW/W 2.62/10.0/7.14% -> 0.024/0.022/
0.003%, conv1 decaying to 0.000% by era 30. NeuronBatchNorm.mqh already
squared v back for gamma/beta and its comment named the kernels as wrong,
which is exactly why gamma/beta kept training while the stages behind froze.
Persisted .nnw needs no migration - v keeps its std-dev meaning.
Also, the two ways F4 exposed it, both mine:
- No LR compensation for B fewer steps per era. sqrt(B) for adaptive methods
(Krizhevsky 2014; Granziol et al. 2022), applied once in
InitialEtaForOptimizer(). Linear scaling (Goyal et al. 2017) is for SGD.
- Plateau patience denominated in eras, so raising B made the ladder 32x more
impatient in its only unit. PAI converged at era 41 on ~49k updates where
the same config had been finding new bests at era 1028.
TrainPlateauPatienceEras() stretches it by the same sqrt(B).
TRAIN_BATCH_SIZE 32 -> 8 so the patience stretch stays affordable (8 -> 23
eras per stage, not 8 -> 45). Both helpers are identities at B=1.
Deploy gate: DEPLOY_MIN_SIDE_RECALL_PCT (10%) folded into tradeableOK. The
perceptron reported Sell:0% recall in all 41 eras, cleared the floor on Buy
alone at 36.6% vs 34% chance, deployed, and sprayed buy arrows. Folded into
the ranking key rather than checked at deploy time so a one-sided era cannot
become best-so-far in the first place.
Deinit: the arrow purge now runs BEFORE ExtPanel.Destroy(), an unbounded
CAppDialog teardown that sat ahead of it - the same ordering inversion the
rule there exists to prevent. CONV was force-terminated 4.8 s into OnDeinit
(vs ~1.1 s for the three that finished) having reached none of its cleanup,
so its arrows stayed on the chart. Steps are now timed in the log.
PurgeChart's verification rescan filtered on OBJ_ARROW, the same blind spot
as the bulk delete, so "persisted 10 ... cleared 0" passed silently. It now
walks every object type and reports the object counts when both are zero.
Both build variants compile 0 errors / 0 warnings; both DLLs rebuilt.
FORCES A RETRAIN (already forced by N1) and both DLLs must ship with the .ex5.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:02:35 -04:00
|
|
|
// v is STORED square-rooted (a standard deviation), so it must be squared back before
|
|
|
|
|
// re-entering the recursion - see AI\Network.cl's UpdateWeightsAdam for the full note.
|
|
|
|
|
// Feeding the stored sqrt in as the variance pins the denominator at ~b2 for any |g|<1,
|
|
|
|
|
// which turns Adam into plain SGD for every stage behind a batch-norm. Fixed 2026-08-09.
|
|
|
|
|
double vt = sqrt(b2 * v[wi] * v[wi] + (1.0 - b2) * grad * grad);
|
2026-07-15 21:47:37 -04:00
|
|
|
double delta = ClampDelta(lt * mt / (vt > 0.0 ? vt : lt * 10.0) - lt * WEIGHT_DECAY * w[wi]);
|
2026-07-19 14:50:52 -04:00
|
|
|
// 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);
|
2026-07-13 03:23:39 -04:00
|
|
|
m[wi] = mt;
|
|
|
|
|
v[wi] = vt;
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
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. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_FeedForwardConv(CpuHandle handle, int wHandle, int iHandle, int oHandle,
|
2026-07-13 03:23:39 -04:00
|
|
|
int inputs, int step, int windowIn, int windowOut, int activation, int positions)
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
double *w, *in, *out;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(positions, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_CalcHiddenGradientConv(CpuHandle handle, int wHandle, int gHandle, int oHandle, int igHandle,
|
2026-07-13 03:23:39 -04:00
|
|
|
int outputs, int step, int windowIn, int windowOut, int activation, int inputCount)
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
double *w, *g, *o, *ig;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(inputCount, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
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;
|
2026-07-15 21:47:37 -04:00
|
|
|
sum *= std::max(MIN_ACTIVATION_DERIVATIVE, 1.0 - out_v * out_v);
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
else if(activation == 1)
|
2026-07-15 21:47:37 -04:00
|
|
|
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));
|
2026-07-13 03:23:39 -04:00
|
|
|
else if(activation == 2 && out_v < 0)
|
|
|
|
|
sum *= 0.01;
|
|
|
|
|
ig[i] = sum;
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
feat: mini-batch gradient accumulation (F4), front-end-aware capacity budget (F6), split Wyckoff categoricals (N1)
Completes the 2026-08-09 training audit. FORCES A RETRAIN of every
Wyckoff-enabled config (N1 re-keys the fingerprint), and BOTH DLLs must be
redeployed alongside the .ex5 - they carry new exports.
F4 - mini-batch accumulation, TRAIN_BATCH_SIZE=32. Training was pure online
SGD (one weight update per bar), which is the mechanical source of the
era-to-era whipsaw every downstream guard was built to cope with. The O(n^2)
outer product is native - AccumulateWeightGrad / AccumulateWeightGradConv /
AccumulateBufferInto in Network.cl, WarriorCPU and WarriorDML - while the
optimizer step is host-side MQL5 shared by all tiers (ApplyAccumToBlock), so
there is one Adam/SGD implementation instead of four that can drift.
- the LSTM needs no outer-product kernel (WeightsGradient already holds the
sample's full dW) but could NOT simply be left un-zeroed between samples:
CPU_LSTMSeqBackward/DML_LSTMSeqBackward memset it on entry. Hence a
separate accumulator plus an elementwise add.
- batch-norm gamma/beta accumulate in host arrays, not new BatchOptions
slots - BN_OPT_STRIDE is baked into every persisted .nnw.
- scoped to pass 2; online learning keeps immediate updates. Every save /
checkpoint / scoring boundary flushes, scaling by the real sample count.
- degrades to per-sample updates (one log line) on a tier that cannot
accumulate, so old devices and DLL-free builds are unaffected.
- verified offline: DirectML/batch_accum_check.cpp drives the real exports
against an independent reference; at B=1 the accumulator matches the
shipped unbatched kernel's own gradient to 1.1e-16. Math only - the
in-situ check remains the per-layer dW/W report on a real era.
F6 - ComputeFirstLayerWidth budgeted against the RAW input width even where a
conv/LSTM front end had already reduced it, so an LSTM's dense stack was
charged for 1,280 inputs when it receives 64. Confirmed from the deployed
.cfg files: CONV, LSTM and HYBRID were all pinned at the 16-unit floor. Now
budgeted against the front-end output and capped at it (never fan out), with
the derivation reordered so both stages settle first.
N1 - EventCode/EventPhase/StructuralPhase are signed categoricals packing
direction and Wyckoff stage into one scalar across a sign discontinuity. Split
into direction + [0,1] magnitude, the same convention the base OHLC block uses.
Information-preserving; 13 readings now occupy 16 inputs.
Compiled clean (0 errors, 0 warnings); both DLLs rebuilt.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 11:48:03 -04:00
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| Mini-batch gradient accumulation - see WarriorCPU.h and the block |
|
|
|
|
|
//| comment above AccumulateWeightGrad in AI\Network.cl. The |
|
|
|
|
|
//| per-weight gradient expressions here are copied verbatim from |
|
|
|
|
|
//| CPU_UpdateWeightsAdam / CPU_UpdateWeightsConvAdam above and MUST |
|
|
|
|
|
//| stay identical to them: batch size 1 has to reproduce the |
|
|
|
|
|
//| unbatched path exactly. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
WARRIORCPU_API int __stdcall CPU_AccumulateWeightGrad(CpuHandle handle, int accHandle, int gHandle, int iHandle,
|
|
|
|
|
int inputs, int neurons)
|
|
|
|
|
{
|
|
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
|
|
|
|
double *acc, *g, *in;
|
|
|
|
|
{
|
|
|
|
|
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
|
|
|
|
|
std::lock_guard<std::mutex> lock(ctx->mutex);
|
|
|
|
|
if(!ValidHandle(ctx, accHandle) || !ValidHandle(ctx, gHandle) || !ValidHandle(ctx, iHandle))
|
|
|
|
|
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, accHandle) >= (size_t)neurons * (inputs + 1));
|
|
|
|
|
acc = ctx->buffers[accHandle].data.data();
|
|
|
|
|
g = ctx->buffers[gHandle].data.data();
|
|
|
|
|
in = ctx->buffers[iHandle].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;
|
|
|
|
|
acc[wi] += g[i] * inp;
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
{
|
|
|
|
|
ctx->lastError = 201;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
WARRIORCPU_API int __stdcall CPU_AccumulateWeightGradConv(CpuHandle handle, int accHandle, int gHandle, int iHandle,
|
|
|
|
|
int inputs, int windowIn, int windowOut, int step)
|
|
|
|
|
{
|
|
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
|
|
|
|
int total;
|
|
|
|
|
size_t gSize, inSize;
|
|
|
|
|
double *acc, *g, *in;
|
|
|
|
|
{
|
|
|
|
|
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
|
|
|
|
|
std::lock_guard<std::mutex> lock(ctx->mutex);
|
|
|
|
|
if(!ValidHandle(ctx, accHandle) || !ValidHandle(ctx, gHandle) || !ValidHandle(ctx, iHandle))
|
|
|
|
|
return 0;
|
|
|
|
|
REQUIRE(ctx, windowIn >= 0 && windowOut >= 0 && step > 0 && inputs >= 0);
|
|
|
|
|
total = (windowIn + 1) * windowOut;
|
|
|
|
|
REQUIRE(ctx, Size(ctx, accHandle) >= (size_t)total);
|
|
|
|
|
gSize = Size(ctx, gHandle);
|
|
|
|
|
inSize = Size(ctx, iHandle);
|
|
|
|
|
acc = ctx->buffers[accHandle].data.data();
|
|
|
|
|
g = ctx->buffers[gHandle].data.data();
|
|
|
|
|
in = ctx->buffers[iHandle].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);
|
|
|
|
|
// Position count from the ADAM variant, which is the correct one - see the note in
|
|
|
|
|
// AI\Network.cl's AccumulateWeightGradConv for why the Momentum variant's differs.
|
|
|
|
|
int t = (inputs - (windowIn - step)) % step;
|
|
|
|
|
t = (inputs - (windowIn - step) - 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]);
|
|
|
|
|
}
|
|
|
|
|
acc[i] += grad;
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
{
|
|
|
|
|
ctx->lastError = 201;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// dst += src, elementwise - see AI\Network.cl's AccumulateBufferInto for why the LSTM's mini-batch
|
|
|
|
|
// path needs it (CPU_LSTMSeqBackward memsets its weights-gradient output, so the running batch total
|
|
|
|
|
// cannot live in that buffer).
|
|
|
|
|
WARRIORCPU_API int __stdcall CPU_AccumulateBufferInto(CpuHandle handle, int dstHandle, int srcHandle, int count)
|
|
|
|
|
{
|
|
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
|
|
|
|
double *dst, *src;
|
|
|
|
|
{
|
|
|
|
|
// see CpuContext's declaration comment - lock scope stops here, before the blocking ParallelFor
|
|
|
|
|
std::lock_guard<std::mutex> lock(ctx->mutex);
|
|
|
|
|
if(!ValidHandle(ctx, dstHandle) || !ValidHandle(ctx, srcHandle))
|
|
|
|
|
return 0;
|
|
|
|
|
REQUIRE(ctx, count >= 0);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, dstHandle) >= (size_t)count && Size(ctx, srcHandle) >= (size_t)count);
|
|
|
|
|
dst = ctx->buffers[dstHandle].data.data();
|
|
|
|
|
src = ctx->buffers[srcHandle].data.data();
|
|
|
|
|
}
|
|
|
|
|
if(!ctx->pool.ParallelFor(count, [=](int begin, int end)
|
|
|
|
|
{
|
|
|
|
|
for(int i = begin; i < end; i++)
|
|
|
|
|
dst[i] += src[i];
|
|
|
|
|
}))
|
|
|
|
|
{
|
|
|
|
|
ctx->lastError = 201;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_UpdateWeightsConvMomentum(CpuHandle handle, int wHandle, int gHandle, int iHandle, int dwHandle,
|
2026-07-28 15:01:40 -04:00
|
|
|
int inputs, double learningRate, double momentum, int windowIn, int windowOut, int step, int optimizer)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
int total;
|
|
|
|
|
size_t gSize, inSize;
|
|
|
|
|
double *w, *g, *in, *dw;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
REQUIRE(ctx, windowIn >= 0 && windowOut >= 0 && step > 0 && inputs >= 0);
|
2026-07-14 18:04:48 -04:00
|
|
|
total = (windowIn + 1) * windowOut;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(total, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
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;
|
2026-07-13 07:31:47 -04:00
|
|
|
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]);
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
2026-07-29 00:03:54 -04:00
|
|
|
// `optimizer` retained unused - see CPU_UpdateWeightsMomentum's comment above.
|
|
|
|
|
double delta = learningRate * grad + momentum * dw[i];
|
2026-07-13 03:23:39 -04:00
|
|
|
dw[i] = delta;
|
2026-07-15 21:47:09 -04:00
|
|
|
w[i] = ClampWeight(w[i] + delta);
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_UpdateWeightsConvAdam(CpuHandle handle, int wHandle, int gHandle, int iHandle, int mHandle, int vHandle,
|
2026-07-13 03:23:39 -04:00
|
|
|
int inputs, double lt, double b1, double b2, int windowIn, int windowOut, int step)
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
int total;
|
|
|
|
|
size_t gSize, inSize;
|
|
|
|
|
double *w, *g, *in, *m, *v;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
REQUIRE(ctx, windowIn >= 0 && windowOut >= 0 && step > 0 && inputs >= 0);
|
2026-07-14 18:04:48 -04:00
|
|
|
total = (windowIn + 1) * windowOut;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(windowIn + 1, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
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;
|
2026-07-13 07:31:47 -04:00
|
|
|
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]);
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
|
|
|
|
double mt = b1 * m[shiftW] + (1.0 - b1) * grad;
|
fix: the Adam second moment was never Adam - all four tiers
Root cause of the B=32 regression, and it predates F4 entirely. Every Adam
kernel stored v already square-rooted and then fed that stored value back in
as if it were the variance:
v_new = sqrt(b2 * v_old + (1 - b2) * g^2)
That recursion has a fixed point at v ~= b2 = 0.999 for ANY gradient below
unit scale, so the denominator stops tracking the gradient and Adam degrades
into plain SGD with lr = lt. Measured against the shipped WarriorCPU.dll
(batch_accum_check.cpp, TestOptimizerScaleInvariance), 4000 steps of a
constant gradient: 3285x less displacement at |g|=1e-5 than at |g|=1, where
a scale-invariant optimizer gives the same distance for both. After the fix
all six magnitudes read 1.199 and v tracks |g| exactly.
It hit conv/LSTM specifically because they sit behind a batch-norm with
running variance ~2.6e+05, so their gradients arrive divided by ~500 - deep
in the degraded regime - while the dense stack near the loss stayed in the
working one. In situ on SP500 H1: lstm1 dW/W 2.62/10.0/7.14% -> 0.024/0.022/
0.003%, conv1 decaying to 0.000% by era 30. NeuronBatchNorm.mqh already
squared v back for gamma/beta and its comment named the kernels as wrong,
which is exactly why gamma/beta kept training while the stages behind froze.
Persisted .nnw needs no migration - v keeps its std-dev meaning.
Also, the two ways F4 exposed it, both mine:
- No LR compensation for B fewer steps per era. sqrt(B) for adaptive methods
(Krizhevsky 2014; Granziol et al. 2022), applied once in
InitialEtaForOptimizer(). Linear scaling (Goyal et al. 2017) is for SGD.
- Plateau patience denominated in eras, so raising B made the ladder 32x more
impatient in its only unit. PAI converged at era 41 on ~49k updates where
the same config had been finding new bests at era 1028.
TrainPlateauPatienceEras() stretches it by the same sqrt(B).
TRAIN_BATCH_SIZE 32 -> 8 so the patience stretch stays affordable (8 -> 23
eras per stage, not 8 -> 45). Both helpers are identities at B=1.
Deploy gate: DEPLOY_MIN_SIDE_RECALL_PCT (10%) folded into tradeableOK. The
perceptron reported Sell:0% recall in all 41 eras, cleared the floor on Buy
alone at 36.6% vs 34% chance, deployed, and sprayed buy arrows. Folded into
the ranking key rather than checked at deploy time so a one-sided era cannot
become best-so-far in the first place.
Deinit: the arrow purge now runs BEFORE ExtPanel.Destroy(), an unbounded
CAppDialog teardown that sat ahead of it - the same ordering inversion the
rule there exists to prevent. CONV was force-terminated 4.8 s into OnDeinit
(vs ~1.1 s for the three that finished) having reached none of its cleanup,
so its arrows stayed on the chart. Steps are now timed in the log.
PurgeChart's verification rescan filtered on OBJ_ARROW, the same blind spot
as the bulk delete, so "persisted 10 ... cleared 0" passed silently. It now
walks every object type and reports the object counts when both are zero.
Both build variants compile 0 errors / 0 warnings; both DLLs rebuilt.
FORCES A RETRAIN (already forced by N1) and both DLLs must ship with the .ex5.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:02:35 -04:00
|
|
|
// v squared back before the recursion - see CPU_UpdateWeightsAdam above for why.
|
|
|
|
|
double vt = sqrt(b2 * v[shiftW] * v[shiftW] + (1.0 - b2) * grad * grad);
|
2026-07-15 21:47:37 -04:00
|
|
|
double delta = ClampDelta(lt * mt / (vt > 0.0 ? vt : lt * 10.0) - lt * WEIGHT_DECAY * w[shiftW]);
|
2026-07-19 14:50:52 -04:00
|
|
|
// Sign-agreement gate removed - see CPU_UpdateWeightsAdam's comment for why.
|
|
|
|
|
w[shiftW] = ClampWeight(w[shiftW] + delta);
|
2026-07-13 03:23:39 -04:00
|
|
|
m[shiftW] = mt;
|
|
|
|
|
v[shiftW] = vt;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| LSTM kernels - mirror AI\Network.cl's LSTM_* kernels 1:1. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_LSTMGates(CpuHandle handle, int wHandle, int hiddenPrevHandle, int inputsHandle,
|
2026-07-13 03:23:39 -04:00
|
|
|
int concatenatedHandle, int hiddenSize, int inputSize)
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
int perGate;
|
|
|
|
|
double *w, *hp, *in, *cat;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
REQUIRE(ctx, hiddenSize >= 0 && inputSize >= 0);
|
2026-07-14 18:04:48 -04:00
|
|
|
perGate = hiddenSize * (hiddenSize + inputSize + 1);
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(hiddenSize, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_LSTMState(CpuHandle handle, int concatenatedHandle, int memoryHandle, int hiddenPrevHandle,
|
2026-07-13 03:23:39 -04:00
|
|
|
int hiddenCacheHandle, int outputHandle, int hiddenSize)
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
double *cat, *mem, *hp, *hc, *out;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(hiddenSize, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
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);
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_LSTMGateGradient(CpuHandle handle, int gradientHandle, int memoryHandle, int concatenatedHandle,
|
2026-07-13 03:23:39 -04:00
|
|
|
int concatenatedGradientHandle, int hiddenSize)
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
double *grad, *mem, *cat, *cg;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(hiddenSize, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
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];
|
2026-07-15 21:47:37 -04:00
|
|
|
// 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);
|
2026-07-13 03:23:39 -04:00
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_LSTMWeightsGradient(CpuHandle handle, int concatenatedGradientHandle, int hiddenCacheHandle,
|
2026-07-13 03:23:39 -04:00
|
|
|
int inputsHandle, int weightsGradientHandle, int hiddenSize, int inputSize)
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
int perGate, total;
|
|
|
|
|
double *cg, *hc, *in, *wg;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
REQUIRE(ctx, hiddenSize >= 0 && inputSize >= 0);
|
2026-07-14 18:04:48 -04:00
|
|
|
perGate = hiddenSize * (hiddenSize + inputSize + 1);
|
|
|
|
|
total = 4 * perGate;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(total, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_LSTMInputsGradient(CpuHandle handle, int concatenatedGradientHandle, int wHandle,
|
2026-07-13 03:23:39 -04:00
|
|
|
int inputsGradientHandle, int hiddenSize, int inputSize)
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
int perGate;
|
|
|
|
|
double *cg, *w, *ig;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
REQUIRE(ctx, hiddenSize >= 0 && inputSize >= 0);
|
2026-07-14 18:04:48 -04:00
|
|
|
perGate = hiddenSize * (hiddenSize + inputSize + 1);
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(inputSize, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
feat(dll): fused sequence-LSTM kernels with real backpropagation-through-time
The per-step entry points cannot express a sequence model. CPU_LSTMGates takes
the ENTIRE flattened input as one timestep, and CPU_LSTMGateGradient has no
parameter for dc arriving from the following step - so the recurrent gradient
path does not exist and cannot be assembled from these primitives at any call
pattern. The layer built on them is a gated dense layer that the class comment
already described honestly: "single-timestep-truncated BPTT".
Adds CPU_LSTMSeqForward / CPU_LSTMSeqBackward: the whole unrolled sequence in
one call each, weights shared across timesteps, dW accumulated over all of them
(the per-step CPU_LSTMWeightsGradient assigns rather than accumulates, so it
could not have been reused even with the dc term). Fused rather than dispatched
per step because the recurrence is sequential - T round trips would serialise T
lock/dispatch pairs for a few thousand FLOPs each.
h_{-1} and c_{-1} are zero per sample. The old layer carried its cell state
across forward passes, so under shuffled training every sample inherited the
state of an unrelated one.
DirectML gets the same math host-side (readback, compute in double, upload)
rather than HLSL: the recurrence needs a barrier per timestep, the GPU buffers
are float and BPTT accumulation is where that hurts most, and no D3D12 device
exists on this machine to test a shader against. Documented at the definition.
Verified with lstm_seq_gradcheck.cpp - central-difference check of dW and dX
against an asymmetric loss over the final hidden state. Max relative error
2.3e-10 on both, with a non-trivial gradient magnitude asserted so the check
cannot pass on an all-zero result.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:13:50 -04:00
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
|
// Fused sequence LSTM: the whole unrolled forward / backward-through-time in
|
|
|
|
|
// one call each.
|
|
|
|
|
//
|
|
|
|
|
// The per-step entry points above (CPU_LSTMGates / CPU_LSTMState / ...) treat
|
|
|
|
|
// the ENTIRE flattened input vector as one timestep, so the layer built on them
|
|
|
|
|
// was a gated dense layer, not a recurrence - and CPU_LSTMGateGradient has no
|
|
|
|
|
// way to accept dc from the following step, which is what makes real BPTT
|
|
|
|
|
// impossible to assemble from them. They are kept for the legacy path; these
|
|
|
|
|
// two replace them for sequence models.
|
|
|
|
|
//
|
|
|
|
|
// Fused rather than dispatched per step for two reasons: the recurrence is
|
|
|
|
|
// sequential, so T separate calls would serialise T lock/dispatch round trips
|
|
|
|
|
// for a few thousand FLOPs each; and dW must ACCUMULATE across timesteps (the
|
|
|
|
|
// weights are shared), which the per-step CPU_LSTMWeightsGradient cannot do -
|
|
|
|
|
// it assigns (wg[wi] = ...) rather than accumulating.
|
|
|
|
|
//
|
|
|
|
|
// Layouts (all doubles, matching the per-step kernels so the weight buffer is
|
|
|
|
|
// bit-compatible):
|
|
|
|
|
// w 4 * perGate, perGate = H * (H + Iw + 1)
|
|
|
|
|
// gate order [f, i, o, g]; within a gate, row `hid` is
|
|
|
|
|
// [ H recurrent weights | Iw input weights | 1 bias ]
|
|
|
|
|
// inputs T * Iw, timestep-major (step t at t*Iw)
|
|
|
|
|
// cacheGates T * 4H gate ACTIVATIONS per step (post-sigmoid/tanh)
|
|
|
|
|
// cacheCell T * H c_t per step
|
|
|
|
|
// cacheHidden T * H h_t per step
|
|
|
|
|
// output H h_{T-1} - what the next layer sees
|
|
|
|
|
// h_{-1} and c_{-1} are zero: state does NOT persist across samples. That is
|
|
|
|
|
// deliberate and is the other half of the fix - the old layer carried its cell
|
|
|
|
|
// state across forward passes, so under shuffled training each sample inherited
|
|
|
|
|
// the state of an unrelated one.
|
|
|
|
|
WARRIORCPU_API int __stdcall CPU_LSTMSeqForward(CpuHandle handle, int wHandle, int inputsHandle,
|
|
|
|
|
int cacheGatesHandle, int cacheCellHandle, int cacheHiddenHandle, int outputHandle,
|
|
|
|
|
int hiddenSize, int stepInputs, int steps)
|
|
|
|
|
{
|
|
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
|
|
|
|
int perGate, H, Iw, T;
|
|
|
|
|
double *w, *in, *cg, *cc, *ch, *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, inputsHandle) || !ValidHandle(ctx, cacheGatesHandle) ||
|
|
|
|
|
!ValidHandle(ctx, cacheCellHandle) || !ValidHandle(ctx, cacheHiddenHandle) || !ValidHandle(ctx, outputHandle))
|
|
|
|
|
return 0;
|
|
|
|
|
REQUIRE(ctx, hiddenSize > 0 && stepInputs > 0 && steps > 0);
|
|
|
|
|
H = hiddenSize;
|
|
|
|
|
Iw = stepInputs;
|
|
|
|
|
T = steps;
|
|
|
|
|
perGate = H * (H + Iw + 1);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)4 * perGate);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, inputsHandle) >= (size_t)T * Iw);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, cacheGatesHandle) >= (size_t)T * 4 * H);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, cacheCellHandle) >= (size_t)T * H);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, cacheHiddenHandle) >= (size_t)T * H);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, outputHandle) >= (size_t)H);
|
|
|
|
|
w = ctx->buffers[wHandle].data.data();
|
|
|
|
|
in = ctx->buffers[inputsHandle].data.data();
|
|
|
|
|
cg = ctx->buffers[cacheGatesHandle].data.data();
|
|
|
|
|
cc = ctx->buffers[cacheCellHandle].data.data();
|
|
|
|
|
ch = ctx->buffers[cacheHiddenHandle].data.data();
|
|
|
|
|
out = ctx->buffers[outputHandle].data.data();
|
|
|
|
|
}
|
|
|
|
|
for(int t = 0; t < T; t++)
|
|
|
|
|
{
|
|
|
|
|
const double *x = in + (size_t)t * Iw;
|
|
|
|
|
const double *hPrev = (t == 0) ? nullptr : ch + (size_t)(t - 1) * H;
|
|
|
|
|
const double *cPrev = (t == 0) ? nullptr : cc + (size_t)(t - 1) * H;
|
|
|
|
|
double *gates = cg + (size_t)t * 4 * H;
|
|
|
|
|
double *cell = cc + (size_t)t * H;
|
|
|
|
|
double *hid = ch + (size_t)t * H;
|
|
|
|
|
// Parallelise across hidden units WITHIN a step; across steps is sequential by definition.
|
|
|
|
|
if(!ctx->pool.ParallelFor(H, [=](int begin, int end)
|
|
|
|
|
{
|
|
|
|
|
for(int i = begin; i < end; i++)
|
|
|
|
|
{
|
|
|
|
|
double g[4];
|
|
|
|
|
for(int gate = 0; gate < 4; gate++)
|
|
|
|
|
{
|
|
|
|
|
int shift = gate * perGate + i * (H + Iw + 1);
|
|
|
|
|
double sum = 0.0;
|
|
|
|
|
if(hPrev)
|
|
|
|
|
for(int k = 0; k < H; k++)
|
|
|
|
|
sum += hPrev[k] * w[shift + k];
|
|
|
|
|
for(int k = 0; k < Iw; k++)
|
|
|
|
|
sum += x[k] * w[shift + H + k];
|
|
|
|
|
sum += w[shift + H + Iw];
|
|
|
|
|
g[gate] = (gate < 3) ? (1.0 / (1.0 + exp(-sum))) : tanh(sum);
|
|
|
|
|
gates[gate * H + i] = g[gate];
|
|
|
|
|
}
|
|
|
|
|
double cp = cPrev ? cPrev[i] : 0.0;
|
|
|
|
|
double c_t = g[0] * cp + g[1] * g[3];
|
|
|
|
|
cell[i] = c_t;
|
|
|
|
|
hid[i] = g[2] * tanh(c_t);
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
{
|
|
|
|
|
ctx->lastError = 201;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
memcpy(out, ch + (size_t)(T - 1) * H, (size_t)H * sizeof(double));
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Backward-through-time. `outGradient` is dL/dh_{T-1} (H values, from the layer
|
|
|
|
|
// above); only the last step receives gradient from outside, every earlier step
|
|
|
|
|
// gets its dh purely through the recurrence. weightsGradient is ACCUMULATED over
|
|
|
|
|
// all T steps and OVERWRITTEN on entry (it is a per-sample gradient, matching
|
|
|
|
|
// the per-step kernel's assign-not-accumulate contract at the call boundary).
|
|
|
|
|
// inputsGradient is T * Iw, written in the same timestep-major layout as inputs.
|
|
|
|
|
WARRIORCPU_API int __stdcall CPU_LSTMSeqBackward(CpuHandle handle, int wHandle, int inputsHandle,
|
|
|
|
|
int cacheGatesHandle, int cacheCellHandle, int cacheHiddenHandle, int outGradientHandle,
|
|
|
|
|
int weightsGradientHandle, int inputsGradientHandle, int hiddenSize, int stepInputs, int steps)
|
|
|
|
|
{
|
|
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
|
|
|
|
int perGate, H, Iw, T, row;
|
|
|
|
|
double *w, *in, *cg, *cc, *ch, *og, *wg, *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, inputsHandle) || !ValidHandle(ctx, cacheGatesHandle) ||
|
|
|
|
|
!ValidHandle(ctx, cacheCellHandle) || !ValidHandle(ctx, cacheHiddenHandle) || !ValidHandle(ctx, outGradientHandle) ||
|
|
|
|
|
!ValidHandle(ctx, weightsGradientHandle) || !ValidHandle(ctx, inputsGradientHandle))
|
|
|
|
|
return 0;
|
|
|
|
|
REQUIRE(ctx, hiddenSize > 0 && stepInputs > 0 && steps > 0);
|
|
|
|
|
H = hiddenSize;
|
|
|
|
|
Iw = stepInputs;
|
|
|
|
|
T = steps;
|
|
|
|
|
row = H + Iw + 1;
|
|
|
|
|
perGate = H * row;
|
|
|
|
|
REQUIRE(ctx, Size(ctx, wHandle) >= (size_t)4 * perGate);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, inputsHandle) >= (size_t)T * Iw);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, cacheGatesHandle) >= (size_t)T * 4 * H);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, cacheCellHandle) >= (size_t)T * H);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, cacheHiddenHandle) >= (size_t)T * H);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, outGradientHandle) >= (size_t)H);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, weightsGradientHandle) >= (size_t)4 * perGate);
|
|
|
|
|
REQUIRE(ctx, Size(ctx, inputsGradientHandle) >= (size_t)T * Iw);
|
|
|
|
|
w = ctx->buffers[wHandle].data.data();
|
|
|
|
|
in = ctx->buffers[inputsHandle].data.data();
|
|
|
|
|
cg = ctx->buffers[cacheGatesHandle].data.data();
|
|
|
|
|
cc = ctx->buffers[cacheCellHandle].data.data();
|
|
|
|
|
ch = ctx->buffers[cacheHiddenHandle].data.data();
|
|
|
|
|
og = ctx->buffers[outGradientHandle].data.data();
|
|
|
|
|
wg = ctx->buffers[weightsGradientHandle].data.data();
|
|
|
|
|
ig = ctx->buffers[inputsGradientHandle].data.data();
|
|
|
|
|
}
|
|
|
|
|
std::vector<double> dh(og, og + H); // running dL/dh_t
|
|
|
|
|
std::vector<double> dc((size_t)H, 0.0); // running dL/dc_t carried from step t+1
|
|
|
|
|
std::vector<double> gateGrad((size_t)4 * H, 0.0);
|
|
|
|
|
std::vector<double> dhPrev((size_t)H, 0.0);
|
|
|
|
|
memset(wg, 0, (size_t)4 * perGate * sizeof(double));
|
|
|
|
|
memset(ig, 0, (size_t)T * Iw * sizeof(double));
|
|
|
|
|
for(int t = T - 1; t >= 0; t--)
|
|
|
|
|
{
|
|
|
|
|
const double *x = in + (size_t)t * Iw;
|
|
|
|
|
const double *gates = cg + (size_t)t * 4 * H;
|
|
|
|
|
const double *cell = cc + (size_t)t * H;
|
|
|
|
|
const double *hPrev = (t == 0) ? nullptr : ch + (size_t)(t - 1) * H;
|
|
|
|
|
const double *cPrev = (t == 0) ? nullptr : cc + (size_t)(t - 1) * H;
|
|
|
|
|
double *gg = gateGrad.data();
|
|
|
|
|
double *dhp = dhPrev.data();
|
|
|
|
|
double *dhv = dh.data();
|
|
|
|
|
double *dcv = dc.data();
|
|
|
|
|
// Gate gradients for this step. Same derivative floors as CPU_LSTMGateGradient - the gates sit
|
|
|
|
|
// near saturation by design and an unfloored derivative closes the recurrent path entirely.
|
|
|
|
|
if(!ctx->pool.ParallelFor(H, [=](int begin, int end)
|
|
|
|
|
{
|
|
|
|
|
for(int i = begin; i < end; i++)
|
|
|
|
|
{
|
|
|
|
|
double f = gates[i], ii = gates[H + i], o = gates[2 * H + i], g = gates[3 * H + i];
|
|
|
|
|
double c_t = cell[i];
|
|
|
|
|
double cp = cPrev ? cPrev[i] : 0.0;
|
|
|
|
|
double tc = tanh(c_t);
|
|
|
|
|
// dc from THIS step's output path plus dc carried back from step t+1 - the term the
|
|
|
|
|
// per-step kernel could not express, and the whole point of this rewrite.
|
|
|
|
|
double dcTot = dhv[i] * o * std::max(MIN_ACTIVATION_DERIVATIVE, 1.0 - tc * tc) + dcv[i];
|
|
|
|
|
gg[2 * H + i] = dhv[i] * tc * std::max(MIN_ACTIVATION_DERIVATIVE, o * (1.0 - o));
|
|
|
|
|
gg[i] = dcTot * cp * std::max(MIN_ACTIVATION_DERIVATIVE, f * (1.0 - f));
|
|
|
|
|
gg[H + i] = dcTot * g * std::max(MIN_ACTIVATION_DERIVATIVE, ii * (1.0 - ii));
|
|
|
|
|
gg[3 * H + i] = dcTot * ii * std::max(MIN_ACTIVATION_DERIVATIVE, 1.0 - g * g);
|
|
|
|
|
// c_{t-1} receives gradient through the forget gate.
|
|
|
|
|
dcv[i] = dcTot * f;
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
{
|
|
|
|
|
ctx->lastError = 201;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
// dW += gateGrad (x) [h_prev | x_t | 1], accumulated across steps because the weights are shared.
|
|
|
|
|
// Parallelised over weight index so the accumulation has no cross-thread contention.
|
|
|
|
|
if(!ctx->pool.ParallelFor(4 * perGate, [=](int begin, int end)
|
|
|
|
|
{
|
|
|
|
|
for(int wi = begin; wi < end; wi++)
|
|
|
|
|
{
|
|
|
|
|
int gate = wi / perGate;
|
|
|
|
|
int rem = wi % perGate;
|
|
|
|
|
int hid = rem / row;
|
|
|
|
|
int k = rem % row;
|
|
|
|
|
double inp = (k < H) ? (hPrev ? hPrev[k] : 0.0)
|
|
|
|
|
: ((k < H + Iw) ? x[k - H] : 1.0);
|
|
|
|
|
wg[wi] += gg[gate * H + hid] * inp;
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
{
|
|
|
|
|
ctx->lastError = 201;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
// dx_t (this step's slice of the input gradient) and dh_{t-1} (the recurrent path).
|
|
|
|
|
double *igt = ig + (size_t)t * Iw;
|
|
|
|
|
if(!ctx->pool.ParallelFor(Iw + H, [=](int begin, int end)
|
|
|
|
|
{
|
|
|
|
|
for(int j = begin; j < end; j++)
|
|
|
|
|
{
|
|
|
|
|
// one flat range over both outputs so a short Iw still parallelises
|
|
|
|
|
int col = (j < Iw) ? (H + j) : (j - Iw);
|
|
|
|
|
double sum = 0.0;
|
|
|
|
|
for(int gate = 0; gate < 4; gate++)
|
|
|
|
|
for(int hid = 0; hid < H; hid++)
|
|
|
|
|
sum += gg[gate * H + hid] * w[gate * perGate + hid * row + col];
|
|
|
|
|
if(j < Iw)
|
|
|
|
|
igt[j] = sum;
|
|
|
|
|
else
|
|
|
|
|
dhp[j - Iw] = sum;
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
{
|
|
|
|
|
ctx->lastError = 201;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
memcpy(dhv, dhp, (size_t)H * sizeof(double));
|
|
|
|
|
}
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_LSTMUpdateWeightsAdam(CpuHandle handle, int wHandle, int weightsGradientHandle,
|
2026-07-13 03:23:39 -04:00
|
|
|
int mHandle, int vHandle, double l, double b1, double b2, int total)
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
double *w, *wg, *m, *v;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(total, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
for(int wi = begin; wi < end; wi++)
|
|
|
|
|
{
|
|
|
|
|
double g = wg[wi];
|
|
|
|
|
double mt = b1 * m[wi] + (1.0 - b1) * g;
|
fix: the Adam second moment was never Adam - all four tiers
Root cause of the B=32 regression, and it predates F4 entirely. Every Adam
kernel stored v already square-rooted and then fed that stored value back in
as if it were the variance:
v_new = sqrt(b2 * v_old + (1 - b2) * g^2)
That recursion has a fixed point at v ~= b2 = 0.999 for ANY gradient below
unit scale, so the denominator stops tracking the gradient and Adam degrades
into plain SGD with lr = lt. Measured against the shipped WarriorCPU.dll
(batch_accum_check.cpp, TestOptimizerScaleInvariance), 4000 steps of a
constant gradient: 3285x less displacement at |g|=1e-5 than at |g|=1, where
a scale-invariant optimizer gives the same distance for both. After the fix
all six magnitudes read 1.199 and v tracks |g| exactly.
It hit conv/LSTM specifically because they sit behind a batch-norm with
running variance ~2.6e+05, so their gradients arrive divided by ~500 - deep
in the degraded regime - while the dense stack near the loss stayed in the
working one. In situ on SP500 H1: lstm1 dW/W 2.62/10.0/7.14% -> 0.024/0.022/
0.003%, conv1 decaying to 0.000% by era 30. NeuronBatchNorm.mqh already
squared v back for gamma/beta and its comment named the kernels as wrong,
which is exactly why gamma/beta kept training while the stages behind froze.
Persisted .nnw needs no migration - v keeps its std-dev meaning.
Also, the two ways F4 exposed it, both mine:
- No LR compensation for B fewer steps per era. sqrt(B) for adaptive methods
(Krizhevsky 2014; Granziol et al. 2022), applied once in
InitialEtaForOptimizer(). Linear scaling (Goyal et al. 2017) is for SGD.
- Plateau patience denominated in eras, so raising B made the ladder 32x more
impatient in its only unit. PAI converged at era 41 on ~49k updates where
the same config had been finding new bests at era 1028.
TrainPlateauPatienceEras() stretches it by the same sqrt(B).
TRAIN_BATCH_SIZE 32 -> 8 so the patience stretch stays affordable (8 -> 23
eras per stage, not 8 -> 45). Both helpers are identities at B=1.
Deploy gate: DEPLOY_MIN_SIDE_RECALL_PCT (10%) folded into tradeableOK. The
perceptron reported Sell:0% recall in all 41 eras, cleared the floor on Buy
alone at 36.6% vs 34% chance, deployed, and sprayed buy arrows. Folded into
the ranking key rather than checked at deploy time so a one-sided era cannot
become best-so-far in the first place.
Deinit: the arrow purge now runs BEFORE ExtPanel.Destroy(), an unbounded
CAppDialog teardown that sat ahead of it - the same ordering inversion the
rule there exists to prevent. CONV was force-terminated 4.8 s into OnDeinit
(vs ~1.1 s for the three that finished) having reached none of its cleanup,
so its arrows stayed on the chart. Steps are now timed in the log.
PurgeChart's verification rescan filtered on OBJ_ARROW, the same blind spot
as the bulk delete, so "persisted 10 ... cleared 0" passed silently. It now
walks every object type and reports the object counts when both are zero.
Both build variants compile 0 errors / 0 warnings; both DLLs rebuilt.
FORCES A RETRAIN (already forced by N1) and both DLLs must ship with the .ex5.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 14:02:35 -04:00
|
|
|
// v squared back before the recursion - see CPU_UpdateWeightsAdam above for why.
|
|
|
|
|
double vt = sqrt(b2 * v[wi] * v[wi] + (1.0 - b2) * g * g);
|
2026-07-15 21:47:37 -04:00
|
|
|
double delta = ClampDelta(l * mt / (vt > 0.0 ? vt : l * 10.0) - l * WEIGHT_DECAY * w[wi]);
|
2026-07-19 14:50:52 -04:00
|
|
|
// Sign-agreement gate removed - see CPU_UpdateWeightsAdam's comment for why.
|
|
|
|
|
w[wi] = ClampWeight(w[wi] + delta);
|
2026-07-13 03:23:39 -04:00
|
|
|
m[wi] = mt;
|
|
|
|
|
v[wi] = vt;
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-18 14:56:41 -04:00
|
|
|
// 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,
|
2026-07-28 15:01:40 -04:00
|
|
|
int dwHandle, double learningRate, double momentum, int total, int optimizer)
|
2026-07-18 14:56:41 -04:00
|
|
|
{
|
|
|
|
|
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++)
|
|
|
|
|
{
|
2026-07-29 00:03:54 -04:00
|
|
|
// `optimizer` retained unused - see CPU_UpdateWeightsMomentum's comment above.
|
|
|
|
|
double delta = learningRate * wg[wi] + momentum * dw[wi];
|
2026-07-18 14:56:41 -04:00
|
|
|
dw[wi] = delta;
|
|
|
|
|
w[wi] = ClampWeight(w[wi] + delta);
|
|
|
|
|
}
|
|
|
|
|
}))
|
|
|
|
|
{
|
|
|
|
|
ctx->lastError = 201;
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-13 03:23:39 -04:00
|
|
|
//+------------------------------------------------------------------+
|
|
|
|
|
//| Max-pooling layer - no weights, mirrors AI\Network.cl's |
|
|
|
|
|
//| FeedForwardProof/CalcInputGradientProof. |
|
|
|
|
|
//+------------------------------------------------------------------+
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_FeedForwardProof(CpuHandle handle, int iHandle, int oHandle, int inputs, int window, int step, int outputs)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
size_t inSize;
|
|
|
|
|
double *in, *out;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(outputs, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
for(int i = begin; i < end; i++)
|
|
|
|
|
{
|
|
|
|
|
int pos = i * step;
|
2026-07-13 07:31:47 -04:00
|
|
|
if((size_t)pos >= inSize)
|
|
|
|
|
{
|
|
|
|
|
out[i] = 0.0;
|
|
|
|
|
continue;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
double result = in[pos];
|
|
|
|
|
for(int k = 1; k < window; k++)
|
|
|
|
|
{
|
|
|
|
|
int shift = k + pos;
|
2026-07-13 07:31:47 -04:00
|
|
|
if(shift >= inputs || (size_t)shift >= inSize)
|
2026-07-13 03:23:39 -04:00
|
|
|
break;
|
|
|
|
|
result = std::max(result, in[shift]);
|
|
|
|
|
}
|
|
|
|
|
out[i] = result;
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-15 21:47:09 -04:00
|
|
|
WARRIORCPU_API int __stdcall CPU_CalcInputGradientProof(CpuHandle handle, int iHandle, int gHandle, int oHandle, int igHandle,
|
2026-07-13 03:23:39 -04:00
|
|
|
int outputs, int window, int step, int inputs)
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
CpuContext *ctx = reinterpret_cast<CpuContext*>(handle);
|
|
|
|
|
if(!ctx)
|
|
|
|
|
return 0;
|
2026-07-14 18:04:48 -04:00
|
|
|
double *in, *g, *o, *ig;
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
// 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))
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
2026-07-15 21:47:09 -04:00
|
|
|
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();
|
2026-07-14 18:04:48 -04:00
|
|
|
}
|
2026-07-15 21:47:09 -04:00
|
|
|
if(!ctx->pool.ParallelFor(inputs, [=](int begin, int end)
|
2026-07-13 03:23:39 -04:00
|
|
|
{
|
|
|
|
|
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;
|
|
|
|
|
}
|
2026-07-14 18:04:48 -04:00
|
|
|
}))
|
|
|
|
|
{
|
2026-07-15 21:47:09 -04:00
|
|
|
ctx->lastError = 201;
|
2026-07-14 18:04:48 -04:00
|
|
|
return 0;
|
|
|
|
|
}
|
2026-07-13 03:23:39 -04:00
|
|
|
return 1;
|
|
|
|
|
}
|