@@ -18,8 +18,11 @@ using mllm::cpu::gdn::gatedDeltaRuleF32;
1818class ScopedCpuOpThreads {
1919 public:
2020 explicit ScopedCpuOpThreads (int32_t thread_count) : original_thread_count_(mllm::Context::instance().getCpuOpThreads()) {
21+ // initializeContext() registers the CPU backend; SymbolTable::reg aborts on
22+ // a duplicate key, so call it exactly once (the tests have no fixture init).
23+ static const bool kContextInitialized = [] { mllm::initializeContext (); return true ; }();
24+ (void )kContextInitialized ;
2125 mllm::Context::instance ().setCpuOpThreads (thread_count);
22- mllm::initializeContext ();
2326 }
2427
2528 ~ScopedCpuOpThreads () { mllm::Context::instance ().setCpuOpThreads (original_thread_count_); }
@@ -218,7 +221,10 @@ TEST(Qwen35GDNTest, ParallelBatchValueHeadsMatchSerialBitwise) {
218221 constexpr int kValueHeads = 32 ;
219222 constexpr int kKeyDim = 128 ;
220223 constexpr int kValueDim = 128 ;
221- constexpr int kThreadCount = 4 ;
224+ // Exercises the parallel lane partition up to the 8-lane cap
225+ // (kMaxParallelGDNLanes); tasks are disjoint so output must be bitwise
226+ // identical regardless of how many lanes the scheduler picks.
227+ constexpr int kThreadCount = 8 ;
222228
223229 std::vector<float > q (kBatch * kSequence * kKeyHeads * kKeyDim );
224230 std::vector<float > k (q.size ());
@@ -266,4 +272,78 @@ TEST(Qwen35GDNTest, ParallelBatchValueHeadsMatchSerialBitwise) {
266272 }
267273}
268274
275+ // 4B real geometry (B=1, S=69, 16 key heads, 32 value heads, 128 dims) at the
276+ // 8-lane cap — exercises the full task fan-out (32 tasks) that the small
277+ // geometry above does not. Guards against the device crash observed on
278+ // OnePlus with the 8-lane product build.
279+ TEST (Qwen35GDNTest, FourBGeometry8LaneDoesNotCrash) {
280+ constexpr int kBatch = 1 ;
281+ constexpr int kSequence = 69 ;
282+ constexpr int kKeyHeads = 16 ;
283+ constexpr int kValueHeads = 32 ;
284+ constexpr int kKeyDim = 128 ;
285+ constexpr int kValueDim = 128 ;
286+ constexpr int kThreadCount = 8 ;
287+
288+ std::vector<float > q (kBatch * kSequence * kKeyHeads * kKeyDim );
289+ std::vector<float > k (q.size ());
290+ std::vector<float > v (kBatch * kSequence * kValueHeads * kValueDim );
291+ std::vector<float > a (kBatch * kSequence * kValueHeads );
292+ std::vector<float > b (a.size ());
293+ std::vector<float > a_log (kValueHeads );
294+ std::vector<float > dt_bias (kValueHeads );
295+
296+ for (std::size_t i = 0 ; i < q.size (); ++i) {
297+ q[i] = 0 .01F * static_cast <float >(static_cast <int >(i % 7 ) - 3 );
298+ k[i] = 0 .01F * static_cast <float >(static_cast <int >(i % 5 ) - 2 );
299+ }
300+ for (std::size_t i = 0 ; i < v.size (); ++i) { v[i] = 0 .01F * static_cast <float >(static_cast <int >(i % 11 ) - 5 ); }
301+ for (std::size_t i = 0 ; i < a.size (); ++i) {
302+ a[i] = 0 .001F * static_cast <float >(static_cast <int >(i % 3 ));
303+ b[i] = 0 .001F * static_cast <float >(static_cast <int >(i % 9 ));
304+ }
305+ for (int i = 0 ; i < kValueHeads ; ++i) { a_log[i] = -1 .0F ; dt_bias[i] = 0 .0F ; }
306+
307+ std::vector<float > state (kBatch * kValueHeads * kValueDim * kKeyDim , 0 .0F );
308+ std::vector<float > output (v.size ());
309+ std::vector<float > ref_output (v.size ());
310+ std::vector<float > ref_state = state;
311+
312+ // Serial reference, then 8-lane parallel — must be bitwise identical.
313+ gatedDeltaRuleF32 (q.data (), k.data (), v.data (), a.data (), b.data (), a_log.data (), dt_bias.data (), ref_state.data (),
314+ ref_output.data (), kBatch , kSequence , kKeyHeads , kValueHeads , kKeyDim , kValueDim ,
315+ /* thread_count=*/ 1 );
316+ const ScopedCpuOpThreads scoped_threads (kThreadCount );
317+ gatedDeltaRuleF32 (q.data (), k.data (), v.data (), a.data (), b.data (), a_log.data (), dt_bias.data (), state.data (),
318+ output.data (), kBatch , kSequence , kKeyHeads , kValueHeads , kKeyDim , kValueDim , kThreadCount );
319+
320+ for (std::size_t i = 0 ; i < output.size (); ++i) {
321+ ASSERT_EQ (ref_output[i], output[i]) << " output index " << i;
322+ }
323+ for (std::size_t i = 0 ; i < state.size (); ++i) {
324+ ASSERT_EQ (ref_state[i], state[i]) << " state index " << i;
325+ }
326+
327+ // Repeat the full 4B GDN pass 24 times (one per layer) to mimic the real
328+ // model's layer loop, which interleaves the recurrence with other parallel
329+ // ops on the shared thread pool. Context init is now once-only (see
330+ // ScopedCpuOpThreads), so this exercises multi-call thread-pool reuse.
331+ // Run the recurrence 24 times on a FRESH copy of the initial state each
332+ // time (mirroring one GDN layer per model layer from the same prefill input),
333+ // and compare each run's output to the serial reference for that same input.
334+ // This exercises repeated thread-pool push/acquire/release cycles — the
335+ // multi-call reuse pattern that crashed on device.
336+ for (int layer = 0 ; layer < 24 ; ++layer) {
337+ std::vector<float > layer_state (state.size (), 0 .0F );
338+ std::vector<float > layer_ref_state (state.size (), 0 .0F );
339+ gatedDeltaRuleF32 (q.data (), k.data (), v.data (), a.data (), b.data (), a_log.data (), dt_bias.data (), layer_ref_state.data (),
340+ ref_output.data (), kBatch , kSequence , kKeyHeads , kValueHeads , kKeyDim , kValueDim , /* thread_count=*/ 1 );
341+ gatedDeltaRuleF32 (q.data (), k.data (), v.data (), a.data (), b.data (), a_log.data (), dt_bias.data (), layer_state.data (),
342+ output.data (), kBatch , kSequence , kKeyHeads , kValueHeads , kKeyDim , kValueDim , kThreadCount );
343+ for (std::size_t i = 0 ; i < output.size (); ++i) {
344+ ASSERT_EQ (ref_output[i], output[i]) << " layer " << layer << " output index " << i;
345+ }
346+ }
347+ }
348+
269349} // namespace
0 commit comments