Skip to content

Commit e68ad64

Browse files
authored
Merge pull request #694 from Aharrypotter/perf/gdn-lane8-product
perf(cpu): raise GDN recurrence lane cap 4 -> 8
1 parent 9a0a21d commit e68ad64

4 files changed

Lines changed: 105 additions & 16 deletions

File tree

mllm/backends/cpu/kernels/common/gdn/gated_delta_net.cpp

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,11 @@ namespace mllm::cpu::gdn {
2323
namespace {
2424

2525
constexpr int kMaxStackNormalizedHeadDim = 256;
26-
// GDN state updates are bandwidth-heavy on heterogeneous mobile CPUs. A small
27-
// fixed lane cap avoids making every efficiency core part of the per-layer
28-
// completion barrier.
29-
constexpr int kMaxParallelGDNLanes = 4;
26+
// GDN state updates are bandwidth-heavy on heterogeneous mobile CPUs. The lane
27+
// cap bounds how many tasks share the per-layer completion barrier. 8 lanes
28+
// matches the 8-core phones this kernel targets (4B has 32 recurrence tasks,
29+
// so all 8 cores participate); bitwise-safe because tasks are disjoint.
30+
constexpr int kMaxParallelGDNLanes = 8;
3031
// Scalar state elements updated across all [batch, value_head] tasks.
3132
constexpr std::size_t kMinParallelGDNWork = 65536;
3233

mllm/engine/HpcThreadPool.cpp

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ void HpcThreadPool::wakeup() {
5050

5151
int HpcThreadPool::acquireTaskSlot() {
5252
std::lock_guard<std::mutex> _l(queue_mutex_);
53-
for (int i = 0; i < MLLM_HPC_THREAD_POOL_TASK_LIMITS; ++i) {
53+
for (int i = 0; i < kHpcThreadPoolTaskLimit; ++i) {
5454
if (task_available_[i]) {
5555
task_available_[i] = false;
5656
return i;
@@ -60,7 +60,7 @@ int HpcThreadPool::acquireTaskSlot() {
6060
}
6161

6262
void HpcThreadPool::releaseTaskSlot(int task_slot_idx) {
63-
if (task_slot_idx < 0 || task_slot_idx >= MLLM_HPC_THREAD_POOL_TASK_LIMITS) { return; }
63+
if (task_slot_idx < 0 || task_slot_idx >= kHpcThreadPoolTaskLimit) { return; }
6464
std::lock_guard<std::mutex> _l(queue_mutex_);
6565
task_available_[task_slot_idx] = true;
6666
}
@@ -95,9 +95,14 @@ void HpcThreadPool::splitTask(HpcThreadPoolTask&& task, int task_slot_idx) {
9595
// e.g.: threads is 4, tiles_name is 12.
9696
// 0, 1, 2, 3, 0, 1, 2, 3, 0, 1, 2, 3
9797
if (tiles_num > thread_cnt_) {
98+
// Capture task and true_idx BY VALUE: splitTask's parameters (a moved-in
99+
// rvalue task and a stack vector) die when this function returns, but the
100+
// worker threads execute this lambda asynchronously — capturing by
101+
// reference leaves a dangling reference (use-after-free). This was latent
102+
// with 4 lanes and became reachable at 8 lanes.
98103
tasks_[task_slot_idx].first = {
99104
.func =
100-
[tiles_num, &task, &true_idx, this](int thread_idx) {
105+
[tiles_num, task, true_idx, this](int thread_idx) {
101106
for (int v = thread_idx; v < tiles_num; v += thread_cnt_) { task.func(true_idx[v]); }
102107
},
103108
.start = 0,
@@ -107,7 +112,7 @@ void HpcThreadPool::splitTask(HpcThreadPoolTask&& task, int task_slot_idx) {
107112
tiles_num = thread_cnt_;
108113
} else {
109114
tasks_[task_slot_idx].first = {
110-
.func = [tiles_num, &task, &true_idx, this](int thread_idx) { task.func(true_idx[thread_idx]); },
115+
.func = [tiles_num, task, true_idx, this](int thread_idx) { task.func(true_idx[thread_idx]); },
111116
.start = 0,
112117
.end = tiles_num,
113118
.step = 1,
@@ -140,11 +145,11 @@ HpcThreadPool::HpcThreadPool(int thread_cnt) {
140145
thread_cnt_ = thread_cnt;
141146
available_task_slots_ = 0;
142147
available_task_slots_old_ = 0;
143-
task_available_.resize(MLLM_HPC_THREAD_POOL_TASK_LIMITS);
144-
tasks_.resize(MLLM_HPC_THREAD_POOL_TASK_LIMITS);
148+
task_available_.resize(kHpcThreadPoolTaskLimit);
149+
tasks_.resize(kHpcThreadPoolTaskLimit);
145150

146151
// Each task should hold some thread ok flag that mark this thread's work is done.
147-
for (int t = 0; t < MLLM_HPC_THREAD_POOL_TASK_LIMITS; ++t) {
152+
for (int t = 0; t < kHpcThreadPoolTaskLimit; ++t) {
148153
task_available_[t] = true;
149154
for (int i = 0; i < thread_cnt_; ++i) { tasks_[t].second.emplace_back(new std::atomic_bool{false}); }
150155
}
@@ -154,7 +159,7 @@ HpcThreadPool::HpcThreadPool(int thread_cnt) {
154159
workers_.emplace_back([this, thread_idx]() {
155160
while (!stop_) {
156161
while (available_task_slots_ > 0) {
157-
for (int i = 0; i < MLLM_HPC_THREAD_POOL_TASK_LIMITS; ++i) {
162+
for (int i = 0; i < kHpcThreadPoolTaskLimit; ++i) {
158163
if (*tasks_[i].second[thread_idx]) {
159164
tasks_[i].first.func(thread_idx);
160165
{ *tasks_[i].second[thread_idx] = false; }

mllm/engine/HpcThreadPool.hpp

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,13 @@
1313
#include <functional>
1414
#include <condition_variable>
1515

16-
#define MLLM_HPC_THREAD_POOL_TASK_LIMITS 2
17-
1816
namespace mllm {
1917

18+
// One task slot per expected concurrent op. 8 covers the GDN recurrence
19+
// 8-lane cap plus sibling parallel ops; too small a limit makes splitTask fall
20+
// back to serial on the main thread under multi-layer concurrency.
21+
inline constexpr int kHpcThreadPoolTaskLimit = 8;
22+
2023
struct HpcThreadPoolTask {
2124
std::function<void(int)> func;
2225
int start = 0;

tests/cpu/Qwen35GDNTest.cpp

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@ using mllm::cpu::gdn::gatedDeltaRuleF32;
1818
class 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

Comments
 (0)