Skip to content

Commit ea8fa36

Browse files
authored
feat(model): Add LFM2.5-2.6B text generation on ARM CPU (#701)
* feat(cpu): add LFM2.5-2.6B support * refactor: register LFM2 hybrid compute paths * fix: synchronize parallel linear fallback threads * fix(cpu): address LFM2.5 runtime review findings * perf(cpu): extend KAI shared-input fusion to I8MM prefill Generalize matmul_shared_input_m1 into matmul_shared_input with an M dimension so gate/up and q/k/v projections share one packed LHS during prefill as well as decode. Work is distributed over the combined M x N tile grid with overflow guards, and matmul_shared_input_m1 stays as a thin wrapper. The product path remains gated on the existing I8MM prefill screen, so hosts without I8MM keep the per-projection fallback. Add two ARM benchmarks that compare the fused and independent paths and assert bitwise-equal sentinel hashes. * refactor(cpu): make the causal conv operation target-neutral Move the history-first depthwise causal convolution out of the gated delta net directory into kernels/common/causal_conv, where a reusable causal-convolution primitive belongs, and give it its own focused bitwise oracle instead of hosting it in the Qwen3.5 GDN test. Rename the activation hook from MLLM_LFM2_SHORT_CONV_TRACE to MLLM_CAUSAL_CONV1D_TRACE and emit one marker per accumulation order, so a framework-level operation no longer reports under a single model's name. Drop the remaining model-specific wording from the shared kernel and grouped-query attention comments. * refactor(models): share default RoPE tables and guard fused projection names Lift the plain default-RoPE inverse-frequency and sin/cos table helpers out of the LFM2 and MiniCPM5 model headers into one shared model-side header, with the input validation the model-local copies never had. Both models used the identical no-scaling variant, so the generated tables are unchanged. The helpers stay under mllm/models because they materialize constant operation inputs rather than performing tensor computation, which nn/llm_components must not host. ParallelLinear resolves parameters in its parent scope to keep original checkpoint names, which makes ambiguous projection names bind the wrong tensors. Reject duplicate, empty, and scope-escaping names in reshape and load, and document why the operation's own name is not part of the parameter path. * refactor: converge grouped-query attention into one operation Decode-only grouped-query attention and the general path were two framework operations with overlapping semantics, so a new model had no way to tell which one it should reach for. Fold the decode operation into GroupedQueryAttention as the DecodeNativeKV implementation: it keeps its own reduction order and single-query-position contract, and still runs the same decode kernel, so MiniCPM5 generation is unchanged. nn::functional::groupedQueryAttentionDecode stays as the public entry point. Graphs serialized under the old "GroupedQueryAttentionDecode" op type still reconstruct, and OpTypes value 76 is retired rather than reused so an old graph can never alias a different operation. * fix(cpu): honour the output stride in the GQA decode fallback The decode kernel declines any tensor whose last-dimension stride is not 1, so the scalar fallback is reached precisely when a non-unit output stride is possible — yet it indexed the output as if the value dimension were contiguous. Multiply by the output stride, matching what the DirectStrided path already does and what the kernel is handed. Not reachable today: reshape allocates the output through Tensor::empty, so the stride is 1 and the emitted addresses are unchanged. This keeps the fallback correct for any caller that supplies a strided output. * fix(benchmark): parse sizes with a fixed-width integer type .clang-tidy enables google-* with WarningsAsErrors '*', so the plain long from std::strtol trips google-runtime-int and fails the build. Parse with int64_t and std::strtoll in both benchmark drivers; the range guard and return type are unchanged. * fix(preprocessor): honour the checkpoint's ignore_merges flag The LFM2.5 checkpoint sets model.ignore_merges, which keeps a token that is already a vocabulary entry intact instead of rebuilding it from the merge table. The shared BPE ignored the flag, and roughly 2% of vocabulary entries longer than two characters cannot be reconstructed by merges alone, so ordinary prose produced different ids than the checkpoint's own tokenizer: "Croatia" became C/roat/ia rather than one token, and so did words like congruence, PREFIX, and Türkiye. Read the flag and short-circuit whole vocabulary entries when it is set. Every other checkpoint in the tree reports ignore_merges false, so their tokenization is bit-identical. The existing pinned-oracle strings happen to contain no merge-unreachable word, which is why they passed while the ids were wrong. Add a case that does contain one; it fails without this fix. * fix(cpu): release the fused projection's prefill workspace CPUParallelLinearOp cached its KleidiAI LHS-pack scratch for every M and only ever grew it, so a prefill-sized buffer stayed resident for the rest of the process. CPULinearOp already avoids this by returning a throwaway buffer whenever M != 1; the fused operation did not carry that policy over when the shared-input path was extended to prefill. On the 2.6B product configuration this pins a prefill workspace in each of the 38 fused projections while decode needs about two kilobytes per operation. Take M and apply the same policy. The workspace is fully rewritten before any tile reads it, so this does not affect results. * fix(benchmark): initialize the runtime context before measuring The parallel-linear driver never called mllm::initializeContext(), so on the default threading vendor every tile-parallel call aborts and the driver only survives at threads=1 - the one setting its multi-worker screen is not about. Its shared-input sibling and the other CPU benchmarks already initialize the context. * fix(lfm2): reject a zero head count before dividing by it head_dim's default divides hidden_size by num_attention_heads while parsing. That expression is an ordinary function argument, so it is evaluated whether or not the config supplies head_dim, and it runs long before validate() can reject the value. A config with num_attention_heads set to zero therefore divided by zero instead of throwing. Check it where it is read. * test(cpu): align LFM2 coverage with upstream layout * test(cpu): keep the unified kernel registry concise * test(nn): cover causal conv and parallel linear ops * refactor(cpu): decouple parallel linear dispatch * refactor(nn): encapsulate grouped attention options * refactor(nn): hide causal convolution op options * refactor(cpu): scope ARM OpenMP to owning sources
1 parent 50ad5a9 commit ea8fa36

85 files changed

Lines changed: 4910 additions & 553 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ The mllm framework integrates seamlessly with popular community frameworks' chec
107107
| [Qwen3.5-0.8B](https://huggingface.co/Qwen/Qwen3.5-0.8B) | [✔️ w4a8](./examples/qwen3_5/README.md) | | |
108108
| [Qwen3.5-4B](https://huggingface.co/Qwen/Qwen3.5-4B) | [✔️ w4a8](./examples/qwen3_5/README.md) | | |
109109
| [MiniCPM5-1B](https://huggingface.co/openbmb/MiniCPM5-1B) | [✔️ w4a8](./examples/minicpm5/README.md) | | |
110+
| [LFM2.5-2.6B](https://huggingface.co/LiquidAI/LFM2.5-2.6B) | [✔️ w4a8](./examples/lfm2/README.md) | | |
110111
| [DeepSeek-OCR](https://github.com/deepseek-ai/DeepSeek-OCR) | [✔️ w4a8](https://www.modelscope.cn/models/mllmTeam/DeepSeek-OCR-w4a8-i8mm-kai) | | |
111112
| [SmolLM3](https://huggingface.co/blog/smollm3)| [✔️ w4a8](https://www.modelscope.cn/models/mllmTeam/SmolLM3-3B-w4a8-i8mm-kai) | | |
112113
| [Qwen2-VL-2B-Instruct](https://qwenlm.github.io/zh/blog/qwen2-vl/)|[✔️ w4a8](https://www.modelscope.cn/models/mllmTeam/Qwen2-VL-2B-Instruct-w4a32kai) || |

benchmarks/cpu/CMakeLists.txt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
if(MLLM_BUILD_ARM_BACKEND)
22
add_executable(Mllm-Benchmark-ARM-HPC-Sgemm arm_mllm_blas_sgemm.cpp)
33
target_link_libraries(Mllm-Benchmark-ARM-HPC-Sgemm PRIVATE benchmark::benchmark MllmRT MllmCPUBackend)
4-
endif()
4+
5+
add_executable(Mllm-Benchmark-Lfm2-Parallel-Linear lfm2_parallel_linear.cpp)
6+
target_link_libraries(Mllm-Benchmark-Lfm2-Parallel-Linear PRIVATE MllmRT MllmCPUBackend)
7+
8+
add_executable(Mllm-Benchmark-Lfm2-Parallel-Linear-Shared-Mx lfm2_parallel_linear_shared_mx.cpp)
9+
target_link_libraries(Mllm-Benchmark-Lfm2-Parallel-Linear-Shared-Mx PRIVATE MllmRT MllmCPUBackend)
10+
endif()
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
// Copyright (c) MLLM Team.
2+
// Licensed under the MIT License.
3+
4+
#include <algorithm>
5+
#include <bit>
6+
#include <chrono>
7+
#include <cmath>
8+
#include <cstdint>
9+
#include <cstdio>
10+
#include <cstdlib>
11+
#include <limits>
12+
#include <numeric>
13+
#include <stdexcept>
14+
#include <string>
15+
#include <string_view>
16+
#include <vector>
17+
18+
#include "mllm/backends/cpu/kernels/arm/linear/kai.hpp"
19+
#include "mllm/mllm.hpp"
20+
21+
namespace {
22+
23+
using KaiHelper = mllm::cpu::arm::KaiLinear_f32_qai8dxp_qsi4c32p_mxk_nxk;
24+
using KaiTile = KaiHelper::Tiles;
25+
26+
constexpr int kInputChannels = 2048;
27+
constexpr KaiTile kDecodeTile = KaiTile::qai8dxp1x8_qsi4c32p8x8_1x8x32;
28+
constexpr KaiTile kPrefillTile = KaiTile::qai8dxp4x8_qsi4c32p8x8_4x8x32;
29+
30+
struct ShapeCase {
31+
std::string_view name;
32+
std::vector<int> output_channels;
33+
};
34+
35+
struct Buffers {
36+
std::vector<float> input;
37+
std::vector<float> weights;
38+
std::vector<std::vector<uint8_t>> separate_packed_weights;
39+
std::vector<uint8_t> merged_packed_weight;
40+
std::vector<uint8_t> workspace;
41+
std::vector<std::vector<float>> separate_outputs;
42+
std::vector<std::vector<float>> shared_outputs;
43+
std::vector<float> merged_output;
44+
};
45+
46+
struct Comparison {
47+
size_t bitwise_mismatches = 0;
48+
float max_absolute_error = 0.0F;
49+
};
50+
51+
uint32_t nextRandom(uint32_t& state) {
52+
state = state * 1664525U + 1013904223U;
53+
return state;
54+
}
55+
56+
float deterministicValue(uint32_t& state) {
57+
const int32_t centered = static_cast<int32_t>((nextRandom(state) >> 8U) % 2001U) - 1000;
58+
return static_cast<float>(centered) / 4096.0F;
59+
}
60+
61+
int parsePositiveInt(const char* value, const char* name) {
62+
char* end = nullptr;
63+
const int64_t parsed = std::strtoll(value, &end, 10);
64+
if (end == value || *end != '\0' || parsed <= 0 || parsed > std::numeric_limits<int>::max()) {
65+
throw std::invalid_argument(std::string(name) + " must be a positive integer");
66+
}
67+
return static_cast<int>(parsed);
68+
}
69+
70+
ShapeCase parseShape(std::string_view name) {
71+
if (name == "gate_up") { return {.name = "gate_up", .output_channels = {10752, 10752}}; }
72+
if (name == "qkv") { return {.name = "qkv", .output_channels = {2048, 512, 512}}; }
73+
throw std::invalid_argument("shape must be gate_up or qkv");
74+
}
75+
76+
size_t totalOutputChannels(const ShapeCase& shape) {
77+
return std::accumulate(shape.output_channels.begin(), shape.output_channels.end(), size_t{0});
78+
}
79+
80+
Buffers makeBuffers(const ShapeCase& shape, int m, KaiTile tile) {
81+
KaiHelper kai;
82+
Buffers buffers;
83+
const size_t total_n = totalOutputChannels(shape);
84+
85+
buffers.input.resize(static_cast<size_t>(m) * kInputChannels);
86+
buffers.weights.resize(total_n * kInputChannels);
87+
uint32_t random_state = 0x4C464D32U;
88+
std::generate(buffers.input.begin(), buffers.input.end(), [&] { return deterministicValue(random_state); });
89+
std::generate(buffers.weights.begin(), buffers.weights.end(), [&] { return deterministicValue(random_state); });
90+
91+
buffers.separate_packed_weights.reserve(shape.output_channels.size());
92+
buffers.separate_outputs.reserve(shape.output_channels.size());
93+
buffers.shared_outputs.reserve(shape.output_channels.size());
94+
size_t row_offset = 0;
95+
for (const int n : shape.output_channels) {
96+
const size_t packed_size = kai.quant_pack_rhs_size(n, kInputChannels, tile);
97+
auto& packed = buffers.separate_packed_weights.emplace_back(packed_size);
98+
kai.quant_pack_rhs_offline(packed.data(), buffers.weights.data() + row_offset * kInputChannels, nullptr, n, kInputChannels,
99+
tile);
100+
buffers.separate_outputs.emplace_back(static_cast<size_t>(m) * n);
101+
buffers.shared_outputs.emplace_back(static_cast<size_t>(m) * n);
102+
row_offset += static_cast<size_t>(n);
103+
}
104+
105+
buffers.merged_packed_weight.resize(kai.quant_pack_rhs_size(static_cast<int>(total_n), kInputChannels, tile));
106+
kai.quant_pack_rhs_offline(buffers.merged_packed_weight.data(), buffers.weights.data(), nullptr, static_cast<int>(total_n),
107+
kInputChannels, tile);
108+
buffers.workspace.resize(kai.workspace_size(m, kInputChannels, tile));
109+
buffers.merged_output.resize(static_cast<size_t>(m) * total_n);
110+
return buffers;
111+
}
112+
113+
void runIndependent(const ShapeCase& shape, int m, int threads, KaiTile tile, Buffers& buffers) {
114+
KaiHelper kai;
115+
for (size_t index = 0; index < shape.output_channels.size(); ++index) {
116+
kai.matmul(buffers.separate_outputs[index].data(), buffers.input.data(), buffers.separate_packed_weights[index].data(),
117+
buffers.workspace.data(), m, kInputChannels, shape.output_channels[index], tile, threads);
118+
}
119+
}
120+
121+
void runShared(const ShapeCase& shape, int m, int threads, KaiTile tile, Buffers& buffers) {
122+
if (m != 1) { throw std::invalid_argument("shared-input path requires M=1"); }
123+
std::vector<KaiHelper::SharedInputProjection> projections;
124+
projections.reserve(shape.output_channels.size());
125+
for (size_t index = 0; index < shape.output_channels.size(); ++index) {
126+
projections.push_back({.dst = buffers.shared_outputs[index].data(),
127+
.packed_weight_bias = buffers.separate_packed_weights[index].data(),
128+
.n = shape.output_channels[index]});
129+
}
130+
KaiHelper kai;
131+
if (!kai.matmul_shared_input_m1(buffers.input.data(), projections.data(), projections.size(), buffers.workspace.data(),
132+
kInputChannels, tile, threads)) {
133+
throw std::runtime_error("shared-input path rejected a valid LFM2 shape");
134+
}
135+
}
136+
137+
void runMerged(const ShapeCase& shape, int m, int threads, KaiTile tile, Buffers& buffers) {
138+
KaiHelper kai;
139+
kai.matmul(buffers.merged_output.data(), buffers.input.data(), buffers.merged_packed_weight.data(), buffers.workspace.data(),
140+
m, kInputChannels, static_cast<int>(totalOutputChannels(shape)), tile, threads);
141+
}
142+
143+
Comparison compareSeparate(const ShapeCase& shape, int m, const std::vector<std::vector<float>>& actual,
144+
const std::vector<std::vector<float>>& expected) {
145+
Comparison result;
146+
for (size_t group = 0; group < shape.output_channels.size(); ++group) {
147+
const size_t elements = static_cast<size_t>(m) * shape.output_channels[group];
148+
for (size_t index = 0; index < elements; ++index) {
149+
const float lhs = actual[group][index];
150+
const float rhs = expected[group][index];
151+
if (std::bit_cast<uint32_t>(lhs) != std::bit_cast<uint32_t>(rhs)) { ++result.bitwise_mismatches; }
152+
result.max_absolute_error = std::max(result.max_absolute_error, std::fabs(lhs - rhs));
153+
}
154+
}
155+
return result;
156+
}
157+
158+
Comparison compareMerged(const ShapeCase& shape, int m, const Buffers& buffers) {
159+
Comparison result;
160+
const size_t total_n = totalOutputChannels(shape);
161+
size_t group_offset = 0;
162+
for (size_t group = 0; group < shape.output_channels.size(); ++group) {
163+
const size_t group_n = static_cast<size_t>(shape.output_channels[group]);
164+
for (int row = 0; row < m; ++row) {
165+
for (size_t column = 0; column < group_n; ++column) {
166+
const float lhs = buffers.merged_output[static_cast<size_t>(row) * total_n + group_offset + column];
167+
const float rhs = buffers.separate_outputs[group][static_cast<size_t>(row) * group_n + column];
168+
if (std::bit_cast<uint32_t>(lhs) != std::bit_cast<uint32_t>(rhs)) { ++result.bitwise_mismatches; }
169+
result.max_absolute_error = std::max(result.max_absolute_error, std::fabs(lhs - rhs));
170+
}
171+
}
172+
group_offset += group_n;
173+
}
174+
return result;
175+
}
176+
177+
uint64_t outputHash(const ShapeCase& shape, int m, const Buffers& buffers, std::string_view variant) {
178+
constexpr uint64_t kOffset = 1469598103934665603ULL;
179+
constexpr uint64_t kPrime = 1099511628211ULL;
180+
uint64_t hash = kOffset;
181+
auto mix = [&](float value) {
182+
hash ^= std::bit_cast<uint32_t>(value);
183+
hash *= kPrime;
184+
};
185+
const size_t total_n = totalOutputChannels(shape);
186+
const int rows[] = {0, m / 2, m - 1};
187+
size_t group_offset = 0;
188+
for (size_t group = 0; group < shape.output_channels.size(); ++group) {
189+
const size_t group_n = static_cast<size_t>(shape.output_channels[group]);
190+
const size_t columns[] = {0, group_n / 2, group_n - 1};
191+
for (const int row : rows) {
192+
for (const size_t column : columns) {
193+
if (variant == "merged") {
194+
mix(buffers.merged_output[static_cast<size_t>(row) * total_n + group_offset + column]);
195+
} else {
196+
const auto& groups = variant == "shared" ? buffers.shared_outputs : buffers.separate_outputs;
197+
mix(groups[group][static_cast<size_t>(row) * group_n + column]);
198+
}
199+
}
200+
}
201+
group_offset += group_n;
202+
}
203+
return hash;
204+
}
205+
206+
template<typename Function>
207+
double timeMicros(Function&& function) {
208+
const auto start = std::chrono::steady_clock::now();
209+
function();
210+
const auto end = std::chrono::steady_clock::now();
211+
return std::chrono::duration<double, std::micro>(end - start).count();
212+
}
213+
214+
void runVariant(std::string_view variant, const ShapeCase& shape, int m, int threads, KaiTile tile, Buffers& buffers) {
215+
if (variant == "independent") {
216+
runIndependent(shape, m, threads, tile, buffers);
217+
} else if (variant == "shared") {
218+
runShared(shape, m, threads, tile, buffers);
219+
} else if (variant == "merged") {
220+
runMerged(shape, m, threads, tile, buffers);
221+
} else {
222+
throw std::invalid_argument("unknown benchmark variant");
223+
}
224+
}
225+
226+
void runPair(std::string_view pair_name, std::string_view baseline, std::string_view candidate, const ShapeCase& shape, int m,
227+
int threads, int repeats, KaiTile tile, Buffers& buffers) {
228+
constexpr std::string_view kSchedule = "ABBA-BAAB";
229+
for (int warmup = 0; warmup < 2; ++warmup) {
230+
runVariant(baseline, shape, m, threads, tile, buffers);
231+
runVariant(candidate, shape, m, threads, tile, buffers);
232+
}
233+
int sample = 0;
234+
for (int repeat = 0; repeat < repeats; ++repeat) {
235+
int position = 0;
236+
for (const char selector : kSchedule) {
237+
if (selector == '-') { continue; }
238+
const std::string_view variant = selector == 'A' ? baseline : candidate;
239+
const double latency_us = timeMicros([&] { runVariant(variant, shape, m, threads, tile, buffers); });
240+
const uint64_t hash = outputHash(shape, m, buffers, variant);
241+
std::printf("SAMPLE pair=%.*s repeat=%d position=%d sample=%d variant=%.*s latency_us=%.3f sentinel_hash=%016llx\n",
242+
static_cast<int>(pair_name.size()), pair_name.data(), repeat, position, sample,
243+
static_cast<int>(variant.size()), variant.data(), latency_us, static_cast<unsigned long long>(hash));
244+
++position;
245+
++sample;
246+
}
247+
}
248+
}
249+
250+
} // namespace
251+
252+
int main(int argc, char** argv) {
253+
try {
254+
mllm::initializeContext();
255+
if (argc < 4 || argc > 5) {
256+
std::fprintf(stderr, "usage: %s <gate_up|qkv> <M> <threads> [schedule_repeats]\n", argv[0]);
257+
return 2;
258+
}
259+
const ShapeCase shape = parseShape(argv[1]);
260+
const int m = parsePositiveInt(argv[2], "M");
261+
const int threads = parsePositiveInt(argv[3], "threads");
262+
const int repeats = argc == 5 ? parsePositiveInt(argv[4], "schedule_repeats") : 2;
263+
const KaiTile tile = m == 1 ? kDecodeTile : kPrefillTile;
264+
265+
std::printf("LFM2_PARALLEL_LINEAR_SCREEN_CONFIG shape=%.*s m=%d k=%d groups=%zu total_n=%zu threads=%d "
266+
"schedule=ABBA-BAAB repeats=%d tile=%s\n",
267+
static_cast<int>(shape.name.size()), shape.name.data(), m, kInputChannels, shape.output_channels.size(),
268+
totalOutputChannels(shape), threads, repeats, m == 1 ? "dotprod_1x8" : "i8mm_4x8");
269+
std::printf("LFM2_PARALLEL_LINEAR_SCREEN_PROVENANCE=replica\n");
270+
271+
Buffers buffers = makeBuffers(shape, m, tile);
272+
runIndependent(shape, m, threads, tile, buffers);
273+
runMerged(shape, m, threads, tile, buffers);
274+
const Comparison merged_comparison = compareMerged(shape, m, buffers);
275+
std::printf("CORRECTNESS variant=merged bitwise_mismatches=%zu max_abs_error=%.9g\n", merged_comparison.bitwise_mismatches,
276+
merged_comparison.max_absolute_error);
277+
if (merged_comparison.bitwise_mismatches != 0) { return 3; }
278+
279+
if (m == 1) {
280+
runShared(shape, m, threads, tile, buffers);
281+
const Comparison shared_comparison = compareSeparate(shape, m, buffers.shared_outputs, buffers.separate_outputs);
282+
std::printf("CORRECTNESS variant=shared bitwise_mismatches=%zu max_abs_error=%.9g\n",
283+
shared_comparison.bitwise_mismatches, shared_comparison.max_absolute_error);
284+
if (shared_comparison.bitwise_mismatches != 0) { return 4; }
285+
runPair("shared_vs_merged", "shared", "merged", shape, m, threads, repeats, tile, buffers);
286+
runPair("independent_vs_shared", "independent", "shared", shape, m, threads, repeats, tile, buffers);
287+
} else {
288+
runPair("independent_vs_merged", "independent", "merged", shape, m, threads, repeats, tile, buffers);
289+
}
290+
291+
std::printf("LFM2_PARALLEL_LINEAR_SCREEN_OK\n");
292+
return 0;
293+
} catch (const std::exception& error) {
294+
std::fprintf(stderr, "LFM2_PARALLEL_LINEAR_SCREEN_ERROR %s\n", error.what());
295+
return 1;
296+
}
297+
}

0 commit comments

Comments
 (0)