Files
alibaba--mnn/test/op/LinearAttentionTest.cpp
2026-07-13 13:33:03 +08:00

1317 lines
60 KiB
C++

//
// LinearAttentionTest.cpp
// MNNTests
//
// Created by MNN on 2026/02/11.
// Copyright © 2018, Alibaba Group Holding Limited
//
#ifdef MNN_SUPPORT_TRANSFORMER_FUSE
#include <MNN/expr/Expr.hpp>
#include <MNN/expr/ExprCreator.hpp>
#include <MNN/expr/Module.hpp>
#include "core/OpCommonUtils.hpp"
#include "MNNTestSuite.h"
#include "TestUtils.h"
#include <cmath>
#include <cstdio>
#include <cstring>
#include <vector>
#include <numeric>
#include <algorithm>
#include <sys/stat.h>
using namespace MNN::Express;
// ─── Naive reference implementation of the Gated Delta Rule ───
// This function replicates the full pipeline: Conv1D + SiLU → Split QKV → GQA → L2Norm → Gated Delta Rule
// All shapes follow the convention used in CPULinearAttention.cpp.
struct NaiveLinearAttention {
// Conv1D state: [B, D, convStateSize]
std::vector<float> convState;
// Recurrent state S: [B, H, d_k, d_v]
std::vector<float> rnnState;
bool initialized = false;
int B, D, convStateSize, H, dk, dv;
void init(int batch, int convDim, int convKernel, int numVHeads, int headKDim, int headVDim) {
B = batch;
D = convDim;
convStateSize = convKernel - 1;
H = numVHeads;
dk = headKDim;
dv = headVDim;
convState.assign(B * D * convStateSize, 0.0f);
rnnState.assign(B * H * dk * dv, 0.0f);
initialized = true;
}
// qkv: [B, D, L], gate: [B, L, H], beta: [B, L, H], convW: [D, 1, K]
// output: [B, L, H_v, d_v]
std::vector<float> forward(
const float* qkvPtr, const float* gatePtr, const float* betaPtr, const float* convWPtr,
int batch, int L, int convDim, int K_conv,
int numKHeads, int numVHeads, int headKDim, int headVDim,
bool useL2Norm)
{
const int key_dim = numKHeads * headKDim;
const int val_dim = numVHeads * headVDim;
const int gqa_factor = (numVHeads > numKHeads) ? (numVHeads / numKHeads) : 1;
const int HH = numVHeads;
const int ddk = headKDim;
const int ddv = headVDim;
// Step 1: Build conv_input = cat(convState, qkv) along dim L
const int totalLen = convStateSize + L;
std::vector<float> convInput(B * D * totalLen, 0.0f);
for (int b = 0; b < B; ++b) {
for (int d = 0; d < D; ++d) {
float* dst = convInput.data() + b * D * totalLen + d * totalLen;
const float* stateChannel = convState.data() + b * D * convStateSize + d * convStateSize;
::memcpy(dst, stateChannel, convStateSize * sizeof(float));
const float* inputChannel = qkvPtr + b * D * L + d * L;
::memcpy(dst + convStateSize, inputChannel, L * sizeof(float));
}
}
// Depthwise Conv1D padding=0, output length = L
std::vector<float> convOut(B * D * L, 0.0f);
for (int b = 0; b < B; ++b) {
for (int d = 0; d < D; ++d) {
const float* src = convInput.data() + b * D * totalLen + d * totalLen;
const float* weight = convWPtr + d * K_conv;
float* out = convOut.data() + b * D * L + d * L;
for (int l = 0; l < L; ++l) {
float sum = 0.0f;
for (int k = 0; k < K_conv; ++k) {
sum += src[l + k] * weight[k];
}
float sigmoid_val = 1.0f / (1.0f + expf(-sum));
out[l] = sum * sigmoid_val;
}
}
}
// Update convState
for (int b = 0; b < B; ++b) {
for (int d = 0; d < D; ++d) {
const float* src = convInput.data() + b * D * totalLen + d * totalLen + (totalLen - convStateSize);
float* dst = convState.data() + b * D * convStateSize + d * convStateSize;
::memcpy(dst, src, convStateSize * sizeof(float));
}
}
// Step 2: Split Q, K, V with GQA expansion
std::vector<float> Q(B * L * HH * ddk, 0.0f);
std::vector<float> K(B * L * HH * ddk, 0.0f);
std::vector<float> V(B * L * HH * ddv, 0.0f);
for (int b = 0; b < B; ++b) {
for (int l = 0; l < L; ++l) {
for (int h = 0; h < numKHeads; ++h) {
for (int di = 0; di < ddk; ++di) {
int srcChannel = h * ddk + di;
float val = convOut[b * D * L + srcChannel * L + l];
for (int r = 0; r < gqa_factor; ++r) {
int dstHead = h * gqa_factor + r;
Q[(b * L + l) * HH * ddk + dstHead * ddk + di] = val;
}
}
}
for (int h = 0; h < numKHeads; ++h) {
for (int di = 0; di < ddk; ++di) {
int srcChannel = key_dim + h * ddk + di;
float val = convOut[b * D * L + srcChannel * L + l];
for (int r = 0; r < gqa_factor; ++r) {
int dstHead = h * gqa_factor + r;
K[(b * L + l) * HH * ddk + dstHead * ddk + di] = val;
}
}
}
for (int h = 0; h < numVHeads; ++h) {
for (int di = 0; di < ddv; ++di) {
int srcChannel = 2 * key_dim + h * ddv + di;
float val = convOut[b * D * L + srcChannel * L + l];
V[(b * L + l) * HH * ddv + h * ddv + di] = val;
}
}
}
}
// Step 3: L2 Norm
if (useL2Norm) {
const float eps = 1e-6f;
for (int i = 0; i < B * L * HH; ++i) {
float* qHead = Q.data() + i * ddk;
float sumSq = 0.0f;
for (int di = 0; di < ddk; ++di) sumSq += qHead[di] * qHead[di];
float invNorm = 1.0f / sqrtf(sumSq + eps);
for (int di = 0; di < ddk; ++di) qHead[di] *= invNorm;
float* kHead = K.data() + i * ddk;
sumSq = 0.0f;
for (int di = 0; di < ddk; ++di) sumSq += kHead[di] * kHead[di];
invNorm = 1.0f / sqrtf(sumSq + eps);
for (int di = 0; di < ddk; ++di) kHead[di] *= invNorm;
}
}
// Step 4: Scale Q
const float qScale = 1.0f / sqrtf((float)ddk);
for (int i = 0; i < B * L * HH * ddk; ++i) {
Q[i] *= qScale;
}
// Step 5: Gated Delta Rule with persistent state
std::vector<float> output(B * L * HH * ddv, 0.0f);
for (int b = 0; b < B; ++b) {
for (int t = 0; t < L; ++t) {
for (int h = 0; h < HH; ++h) {
float* state = rnnState.data() + (b * HH + h) * ddk * ddv;
const float* q_t = Q.data() + (b * L + t) * HH * ddk + h * ddk;
const float* k_t = K.data() + (b * L + t) * HH * ddk + h * ddk;
const float* v_t = V.data() + (b * L + t) * HH * ddv + h * ddv;
float g_t = gatePtr[b * L * HH + t * HH + h];
float beta_t = betaPtr[b * L * HH + t * HH + h];
// Decay
float decay = expf(g_t);
for (int i = 0; i < ddk * ddv; ++i) state[i] *= decay;
// Read: v_pred = S^T @ k_t
std::vector<float> v_pred(ddv, 0.0f);
for (int di = 0; di < ddk; ++di) {
for (int dj = 0; dj < ddv; ++dj) {
v_pred[dj] += state[di * ddv + dj] * k_t[di];
}
}
// Delta
std::vector<float> delta(ddv);
for (int dj = 0; dj < ddv; ++dj) {
delta[dj] = beta_t * (v_t[dj] - v_pred[dj]);
}
// Write: S += k_t @ delta^T
for (int di = 0; di < ddk; ++di) {
for (int dj = 0; dj < ddv; ++dj) {
state[di * ddv + dj] += k_t[di] * delta[dj];
}
}
// Query: o_t = S^T @ q_t
float* o_t = output.data() + (b * L + t) * HH * ddv + h * ddv;
for (int dj = 0; dj < ddv; ++dj) {
float sum = 0.0f;
for (int di = 0; di < ddk; ++di) {
sum += state[di * ddv + dj] * q_t[di];
}
o_t[dj] = sum;
}
}
}
}
return output;
}
};
// ─── Helper: create a LinearAttention Module via FlatBuffers ───
static std::shared_ptr<Module> _makeLinearAttentionModule(
int numKHeads, int numVHeads, int headKDim, int headVDim, bool useL2Norm,
const std::string& attnType = "gated_delta_rule")
{
auto qkv = _Input();
auto gate = _Input();
auto beta = _Input();
auto convW = _Input();
std::shared_ptr<MNN::OpT> op(new MNN::OpT);
op->type = MNN::OpType_LinearAttention;
op->main.type = MNN::OpParameter_LinearAttentionParam;
op->main.value = new MNN::LinearAttentionParamT;
auto* param = op->main.AsLinearAttentionParam();
param->attn_type = attnType;
param->num_k_heads = numKHeads;
param->num_v_heads = numVHeads;
param->head_k_dim = headKDim;
param->head_v_dim = headVDim;
param->use_qk_l2norm = useL2Norm;
auto o = Variable::create(Expr::create(op.get(), {qkv, gate, beta, convW}));
auto buffer = Variable::save({o});
MNN::ScheduleConfig config;
auto status = MNNTestSuite::get()->pStaus;
config.type = (MNNForwardType)status.forwardType;
MNN::BackendConfig bnConfig;
bnConfig.memory = (MNN::BackendConfig::MemoryMode)status.memory;
bnConfig.precision = (MNN::BackendConfig::PrecisionMode)status.precision;
bnConfig.power = (MNN::BackendConfig::PowerMode)status.power;
config.backendConfig = &bnConfig;
config.numThread = 1;
std::shared_ptr<Executor::RuntimeManager> rtmgr(Executor::RuntimeManager::createRuntimeManager(config));
std::shared_ptr<Module> m(Module::load({}, {}, (uint8_t*)buffer.data(), buffer.size(), rtmgr));
return m;
}
// ─── Helper: generate deterministic float data ───
static void fillDeterministic(float* data, int size, float scale = 0.1f, float offset = 0.0f) {
for (int i = 0; i < size; ++i) {
data[i] = ((i % 17) - 8) * scale + offset;
}
}
// ─── Helper: generate conv weight (small values so conv output stays reasonable) ───
static void fillConvWeight(float* data, int size) {
for (int i = 0; i < size; ++i) {
data[i] = ((i % 7) - 3) * 0.05f;
}
}
// ─── Helper: generate gate values (negative, for exp decay < 1) ───
static void fillGate(float* data, int size) {
for (int i = 0; i < size; ++i) {
data[i] = -0.1f * ((i % 5) + 1); // range [-0.1, -0.5]
}
}
// ─── Helper: generate beta values (learning rate in [0, 1]) ───
static void fillBeta(float* data, int size) {
for (int i = 0; i < size; ++i) {
data[i] = 0.1f * ((i % 9) + 1); // range [0.1, 0.9]
}
}
// ─── Test class ───
class LinearAttentionTest : public MNNTestCase {
public:
LinearAttentionTest() = default;
virtual ~LinearAttentionTest() = default;
virtual bool run(int precision) {
// Test parameters
const int B = 1;
const int numKHeads = 2;
const int numVHeads = 2;
const int headKDim = 4;
const int headVDim = 4;
const int K_conv = 4; // conv kernel size
const int key_dim = numKHeads * headKDim;
const int val_dim = numVHeads * headVDim;
const int D = 2 * key_dim + val_dim; // conv_dim
const bool useL2Norm = true;
const float tolerance = 0.001f;
// ─── Test 1: Prefill (seq_len > 1) ───
{
const int L = 4;
// Create Module
auto module = _makeLinearAttentionModule(numKHeads, numVHeads, headKDim, headVDim, useL2Norm);
if (!module) {
MNN_PRINT("Error: Failed to create LinearAttention module\n");
return false;
}
// Prepare inputs
auto qkvVar = _Input({B, D, L}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
auto convWVar = _Input({D, 1, K_conv}, NCHW, halide_type_of<float>());
fillDeterministic(qkvVar->writeMap<float>(), B * D * L, 0.1f);
fillGate(gateVar->writeMap<float>(), B * L * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * L * numVHeads);
fillConvWeight(convWVar->writeMap<float>(), D * 1 * K_conv);
// Naive reference
NaiveLinearAttention naive;
naive.init(B, D, K_conv, numVHeads, headKDim, headVDim);
auto expected = naive.forward(
qkvVar->readMap<float>(), gateVar->readMap<float>(),
betaVar->readMap<float>(), convWVar->readMap<float>(),
B, L, D, K_conv, numKHeads, numVHeads, headKDim, headVDim, useL2Norm);
// Run MNN op
auto outputs = module->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("Error: LinearAttention module returned empty output\n");
return false;
}
auto output = outputs[0];
const float* resultPtr = output->readMap<float>();
const int outSize = B * L * numVHeads * headVDim;
// Compare
for (int i = 0; i < outSize; ++i) {
float diff = fabs(resultPtr[i] - expected[i]);
if (diff > tolerance) {
MNN_PRINT("Prefill Test FAILED at index %d: expected %.6f, got %.6f (diff=%.6f)\n",
i, expected[i], resultPtr[i], diff);
return false;
}
}
MNN_PRINT("LinearAttention Prefill Test (L=%d) PASSED\n", L);
}
// ─── Test 2: Multi-step decode (seq_len = 1, tests state persistence) ───
{
const int decodeSteps = 4;
auto module = _makeLinearAttentionModule(numKHeads, numVHeads, headKDim, headVDim, useL2Norm);
if (!module) {
MNN_PRINT("Error: Failed to create LinearAttention module for decode test\n");
return false;
}
NaiveLinearAttention naive;
naive.init(B, D, K_conv, numVHeads, headKDim, headVDim);
auto convWVar = _Input({D, 1, K_conv}, NCHW, halide_type_of<float>());
fillConvWeight(convWVar->writeMap<float>(), D * 1 * K_conv);
for (int step = 0; step < decodeSteps; ++step) {
const int L = 1;
auto qkvVar = _Input({B, D, L}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
// Slightly different input per step
fillDeterministic(qkvVar->writeMap<float>(), B * D * L, 0.1f, 0.01f * step);
fillGate(gateVar->writeMap<float>(), B * L * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * L * numVHeads);
// Naive reference
auto expected = naive.forward(
qkvVar->readMap<float>(), gateVar->readMap<float>(),
betaVar->readMap<float>(), convWVar->readMap<float>(),
B, L, D, K_conv, numKHeads, numVHeads, headKDim, headVDim, useL2Norm);
// Run MNN op
auto outputs = module->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("Error: Decode step %d returned empty output\n", step);
return false;
}
auto output = outputs[0];
const float* resultPtr = output->readMap<float>();
const int outSize = B * L * numVHeads * headVDim;
for (int i = 0; i < outSize; ++i) {
float diff = fabs(resultPtr[i] - expected[i]);
if (diff > tolerance) {
MNN_PRINT("Decode Test FAILED at step %d, index %d: expected %.6f, got %.6f (diff=%.6f)\n",
step, i, expected[i], resultPtr[i], diff);
return false;
}
}
}
MNN_PRINT("LinearAttention Multi-step Decode Test (%d steps) PASSED\n", decodeSteps);
}
// ─── Test 3: Without L2 Normalization ───
{
const int L = 3;
const bool noL2Norm = false;
auto module = _makeLinearAttentionModule(numKHeads, numVHeads, headKDim, headVDim, noL2Norm);
if (!module) {
MNN_PRINT("Error: Failed to create LinearAttention module (no L2)\n");
return false;
}
auto qkvVar = _Input({B, D, L}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
auto convWVar = _Input({D, 1, K_conv}, NCHW, halide_type_of<float>());
fillDeterministic(qkvVar->writeMap<float>(), B * D * L, 0.05f);
fillGate(gateVar->writeMap<float>(), B * L * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * L * numVHeads);
fillConvWeight(convWVar->writeMap<float>(), D * 1 * K_conv);
NaiveLinearAttention naive;
naive.init(B, D, K_conv, numVHeads, headKDim, headVDim);
auto expected = naive.forward(
qkvVar->readMap<float>(), gateVar->readMap<float>(),
betaVar->readMap<float>(), convWVar->readMap<float>(),
B, L, D, K_conv, numKHeads, numVHeads, headKDim, headVDim, noL2Norm);
auto outputs = module->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("Error: LinearAttention module (no L2) returned empty output\n");
return false;
}
auto output = outputs[0];
const float* resultPtr = output->readMap<float>();
const int outSize = B * L * numVHeads * headVDim;
for (int i = 0; i < outSize; ++i) {
float diff = fabs(resultPtr[i] - expected[i]);
if (diff > tolerance) {
MNN_PRINT("No-L2Norm Test FAILED at index %d: expected %.6f, got %.6f (diff=%.6f)\n",
i, expected[i], resultPtr[i], diff);
return false;
}
}
MNN_PRINT("LinearAttention No-L2Norm Test (L=%d) PASSED\n", L);
}
return true;
}
};
MNNTestSuiteRegister(LinearAttentionTest, "op/linear_attention");
// ─── Decode fast path test: focuses on L=1 correctness and state consistency ───
class LinearAttentionDecodeTest : public MNNTestCase {
public:
LinearAttentionDecodeTest() = default;
virtual ~LinearAttentionDecodeTest() = default;
virtual bool run(int precision) {
const float tolerance = 0.001f;
// ─── Test 1: Single decode step (L=1) basic correctness ───
{
const int B = 1, numKHeads = 2, numVHeads = 2;
const int headKDim = 8, headVDim = 8, K_conv = 4;
const int key_dim = numKHeads * headKDim;
const int val_dim = numVHeads * headVDim;
const int D = 2 * key_dim + val_dim;
const int L = 1;
auto module = _makeLinearAttentionModule(numKHeads, numVHeads, headKDim, headVDim, true);
if (!module) {
MNN_PRINT("Error: Failed to create module for decode single step test\n");
return false;
}
auto qkvVar = _Input({B, D, L}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
auto convWVar = _Input({D, 1, K_conv}, NCHW, halide_type_of<float>());
fillDeterministic(qkvVar->writeMap<float>(), B * D * L, 0.1f);
fillGate(gateVar->writeMap<float>(), B * L * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * L * numVHeads);
fillConvWeight(convWVar->writeMap<float>(), D * 1 * K_conv);
NaiveLinearAttention naive;
naive.init(B, D, K_conv, numVHeads, headKDim, headVDim);
auto expected = naive.forward(qkvVar->readMap<float>(), gateVar->readMap<float>(),
betaVar->readMap<float>(), convWVar->readMap<float>(), B, L, D, K_conv,
numKHeads, numVHeads, headKDim, headVDim, true);
auto outputs = module->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("Error: Decode single step returned empty output\n");
return false;
}
const float* resultPtr = outputs[0]->readMap<float>();
const int outSize = B * L * numVHeads * headVDim;
for (int i = 0; i < outSize; ++i) {
float diff = fabs(resultPtr[i] - expected[i]);
if (diff > tolerance) {
MNN_PRINT("Decode single step FAILED at index %d: expected %.6f, got %.6f (diff=%.6f)\n", i,
expected[i], resultPtr[i], diff);
return false;
}
}
MNN_PRINT("LinearAttention Decode single step (dk=%d, dv=%d) PASSED\n", headKDim, headVDim);
}
// ─── Test 2: Prefill then multi-step decode (state continuity) ───
{
const int B = 1, numKHeads = 2, numVHeads = 2;
const int headKDim = 4, headVDim = 4, K_conv = 4;
const int key_dim = numKHeads * headKDim;
const int val_dim = numVHeads * headVDim;
const int D = 2 * key_dim + val_dim;
const int prefillLen = 3;
const int decodeSteps = 6;
auto module = _makeLinearAttentionModule(numKHeads, numVHeads, headKDim, headVDim, true);
if (!module) {
MNN_PRINT("Error: Failed to create module for prefill+decode test\n");
return false;
}
NaiveLinearAttention naive;
naive.init(B, D, K_conv, numVHeads, headKDim, headVDim);
auto convWVar = _Input({D, 1, K_conv}, NCHW, halide_type_of<float>());
fillConvWeight(convWVar->writeMap<float>(), D * 1 * K_conv);
// Prefill phase
{
auto qkvVar = _Input({B, D, prefillLen}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, prefillLen, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, prefillLen, numVHeads}, NCHW, halide_type_of<float>());
fillDeterministic(qkvVar->writeMap<float>(), B * D * prefillLen, 0.08f, 0.02f);
fillGate(gateVar->writeMap<float>(), B * prefillLen * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * prefillLen * numVHeads);
auto expected = naive.forward(qkvVar->readMap<float>(), gateVar->readMap<float>(),
betaVar->readMap<float>(), convWVar->readMap<float>(), B, prefillLen, D,
K_conv, numKHeads, numVHeads, headKDim, headVDim, true);
auto outputs = module->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("Error: Prefill phase returned empty output\n");
return false;
}
const float* resultPtr = outputs[0]->readMap<float>();
const int outSize = B * prefillLen * numVHeads * headVDim;
for (int i = 0; i < outSize; ++i) {
float diff = fabs(resultPtr[i] - expected[i]);
if (diff > tolerance) {
MNN_PRINT("Prefill+Decode: Prefill FAILED at index %d: expected %.6f, got %.6f\n", i,
expected[i], resultPtr[i]);
return false;
}
}
}
// Decode phase (L=1 per step, state should carry over from prefill)
for (int step = 0; step < decodeSteps; ++step) {
const int L = 1;
auto qkvVar = _Input({B, D, L}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
fillDeterministic(qkvVar->writeMap<float>(), B * D * L, 0.1f, 0.03f * step);
fillGate(gateVar->writeMap<float>(), B * L * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * L * numVHeads);
auto expected = naive.forward(qkvVar->readMap<float>(), gateVar->readMap<float>(),
betaVar->readMap<float>(), convWVar->readMap<float>(), B, L, D, K_conv,
numKHeads, numVHeads, headKDim, headVDim, true);
auto outputs = module->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("Error: Decode step %d returned empty output\n", step);
return false;
}
const float* resultPtr = outputs[0]->readMap<float>();
const int outSize = B * L * numVHeads * headVDim;
for (int i = 0; i < outSize; ++i) {
float diff = fabs(resultPtr[i] - expected[i]);
if (diff > tolerance) {
MNN_PRINT("Prefill+Decode: Decode step %d FAILED at index %d: expected %.6f, got %.6f\n", step,
i, expected[i], resultPtr[i]);
return false;
}
}
}
MNN_PRINT("LinearAttention Prefill(%d)+Decode(%d steps) state continuity PASSED\n", prefillLen,
decodeSteps);
}
// ─── Test 3: Decode without L2 Norm ───
{
const int B = 1, numKHeads = 2, numVHeads = 2;
const int headKDim = 4, headVDim = 4, K_conv = 4;
const int key_dim = numKHeads * headKDim;
const int val_dim = numVHeads * headVDim;
const int D = 2 * key_dim + val_dim;
const int decodeSteps = 4;
auto module = _makeLinearAttentionModule(numKHeads, numVHeads, headKDim, headVDim, false);
if (!module) {
MNN_PRINT("Error: Failed to create module for decode no-L2 test\n");
return false;
}
NaiveLinearAttention naive;
naive.init(B, D, K_conv, numVHeads, headKDim, headVDim);
auto convWVar = _Input({D, 1, K_conv}, NCHW, halide_type_of<float>());
fillConvWeight(convWVar->writeMap<float>(), D * 1 * K_conv);
for (int step = 0; step < decodeSteps; ++step) {
const int L = 1;
auto qkvVar = _Input({B, D, L}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
fillDeterministic(qkvVar->writeMap<float>(), B * D * L, 0.05f, 0.02f * step);
fillGate(gateVar->writeMap<float>(), B * L * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * L * numVHeads);
auto expected = naive.forward(qkvVar->readMap<float>(), gateVar->readMap<float>(),
betaVar->readMap<float>(), convWVar->readMap<float>(), B, L, D, K_conv,
numKHeads, numVHeads, headKDim, headVDim, false);
auto outputs = module->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("Error: Decode no-L2 step %d returned empty output\n", step);
return false;
}
const float* resultPtr = outputs[0]->readMap<float>();
const int outSize = B * L * numVHeads * headVDim;
for (int i = 0; i < outSize; ++i) {
float diff = fabs(resultPtr[i] - expected[i]);
if (diff > tolerance) {
MNN_PRINT("Decode no-L2 step %d FAILED at index %d: expected %.6f, got %.6f\n", step, i,
expected[i], resultPtr[i]);
return false;
}
}
}
MNN_PRINT("LinearAttention Decode no-L2Norm (%d steps) PASSED\n", decodeSteps);
}
// ─── Test 4: Decode with GQA (numVHeads > numKHeads) ───
{
const int B = 1, numKHeads = 2, numVHeads = 4;
const int headKDim = 4, headVDim = 4, K_conv = 4;
const int key_dim = numKHeads * headKDim;
const int val_dim = numVHeads * headVDim;
const int D = 2 * key_dim + val_dim;
const int decodeSteps = 3;
auto module = _makeLinearAttentionModule(numKHeads, numVHeads, headKDim, headVDim, true);
if (!module) {
MNN_PRINT("Error: Failed to create module for decode GQA test\n");
return false;
}
NaiveLinearAttention naive;
naive.init(B, D, K_conv, numVHeads, headKDim, headVDim);
auto convWVar = _Input({D, 1, K_conv}, NCHW, halide_type_of<float>());
fillConvWeight(convWVar->writeMap<float>(), D * 1 * K_conv);
for (int step = 0; step < decodeSteps; ++step) {
const int L = 1;
auto qkvVar = _Input({B, D, L}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
fillDeterministic(qkvVar->writeMap<float>(), B * D * L, 0.1f, 0.05f * step);
fillGate(gateVar->writeMap<float>(), B * L * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * L * numVHeads);
auto expected = naive.forward(qkvVar->readMap<float>(), gateVar->readMap<float>(),
betaVar->readMap<float>(), convWVar->readMap<float>(), B, L, D, K_conv,
numKHeads, numVHeads, headKDim, headVDim, true);
auto outputs = module->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("Error: Decode GQA step %d returned empty output\n", step);
return false;
}
const float* resultPtr = outputs[0]->readMap<float>();
const int outSize = B * L * numVHeads * headVDim;
for (int i = 0; i < outSize; ++i) {
float diff = fabs(resultPtr[i] - expected[i]);
if (diff > tolerance) {
MNN_PRINT("Decode GQA step %d FAILED at index %d: expected %.6f, got %.6f\n", step, i,
expected[i], resultPtr[i]);
return false;
}
}
}
MNN_PRINT("LinearAttention Decode GQA (H_k=%d, H_v=%d, %d steps) PASSED\n", numKHeads, numVHeads,
decodeSteps);
}
// ─── Test 5: Decode with larger head dimensions ───
{
const int B = 1, numKHeads = 1, numVHeads = 1;
const int headKDim = 16, headVDim = 16, K_conv = 4;
const int key_dim = numKHeads * headKDim;
const int val_dim = numVHeads * headVDim;
const int D = 2 * key_dim + val_dim;
const int decodeSteps = 3;
auto module = _makeLinearAttentionModule(numKHeads, numVHeads, headKDim, headVDim, true);
if (!module) {
MNN_PRINT("Error: Failed to create module for decode large-dim test\n");
return false;
}
NaiveLinearAttention naive;
naive.init(B, D, K_conv, numVHeads, headKDim, headVDim);
auto convWVar = _Input({D, 1, K_conv}, NCHW, halide_type_of<float>());
fillConvWeight(convWVar->writeMap<float>(), D * 1 * K_conv);
for (int step = 0; step < decodeSteps; ++step) {
const int L = 1;
auto qkvVar = _Input({B, D, L}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
fillDeterministic(qkvVar->writeMap<float>(), B * D * L, 0.08f, 0.01f * step);
fillGate(gateVar->writeMap<float>(), B * L * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * L * numVHeads);
auto expected = naive.forward(qkvVar->readMap<float>(), gateVar->readMap<float>(),
betaVar->readMap<float>(), convWVar->readMap<float>(), B, L, D, K_conv,
numKHeads, numVHeads, headKDim, headVDim, true);
auto outputs = module->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("Error: Decode large-dim step %d returned empty output\n", step);
return false;
}
const float* resultPtr = outputs[0]->readMap<float>();
const int outSize = B * L * numVHeads * headVDim;
for (int i = 0; i < outSize; ++i) {
float diff = fabs(resultPtr[i] - expected[i]);
if (diff > tolerance) {
MNN_PRINT("Decode large-dim step %d FAILED at index %d: expected %.6f, got %.6f\n", step, i,
expected[i], resultPtr[i]);
return false;
}
}
}
MNN_PRINT("LinearAttention Decode large-dim (dk=%d, dv=%d, %d steps) PASSED\n", headKDim, headVDim,
decodeSteps);
}
// ─── Test 6: Decode with batch size > 1 ───
{
const int B = 3, numKHeads = 2, numVHeads = 2;
const int headKDim = 4, headVDim = 4, K_conv = 4;
const int key_dim = numKHeads * headKDim;
const int val_dim = numVHeads * headVDim;
const int D = 2 * key_dim + val_dim;
const int decodeSteps = 4;
auto module = _makeLinearAttentionModule(numKHeads, numVHeads, headKDim, headVDim, true);
if (!module) {
MNN_PRINT("Error: Failed to create module for decode batch test\n");
return false;
}
NaiveLinearAttention naive;
naive.init(B, D, K_conv, numVHeads, headKDim, headVDim);
auto convWVar = _Input({D, 1, K_conv}, NCHW, halide_type_of<float>());
fillConvWeight(convWVar->writeMap<float>(), D * 1 * K_conv);
for (int step = 0; step < decodeSteps; ++step) {
const int L = 1;
auto qkvVar = _Input({B, D, L}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, L, numVHeads}, NCHW, halide_type_of<float>());
// Use different offsets per step so each batch element gets distinct data
fillDeterministic(qkvVar->writeMap<float>(), B * D * L, 0.1f, 0.02f * step);
fillGate(gateVar->writeMap<float>(), B * L * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * L * numVHeads);
auto expected = naive.forward(qkvVar->readMap<float>(), gateVar->readMap<float>(),
betaVar->readMap<float>(), convWVar->readMap<float>(), B, L, D, K_conv,
numKHeads, numVHeads, headKDim, headVDim, true);
auto outputs = module->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("Error: Decode batch step %d returned empty output\n", step);
return false;
}
const float* resultPtr = outputs[0]->readMap<float>();
const int outSize = B * L * numVHeads * headVDim;
for (int i = 0; i < outSize; ++i) {
float diff = fabs(resultPtr[i] - expected[i]);
if (diff > tolerance) {
MNN_PRINT(
"Decode batch(B=%d) step %d FAILED at index %d: expected %.6f, got %.6f (diff=%.6f)\n", B,
step, i, expected[i], resultPtr[i], diff);
return false;
}
}
}
MNN_PRINT("LinearAttention Decode batch (B=%d, %d steps) PASSED\n", B, decodeSteps);
}
return true;
}
};
MNNTestSuiteRegister(LinearAttentionDecodeTest, "op/linear_attention_decode");
// ─── Local mirror of MNN::Transformer::KVMeta (must match layout) ───
// Used to drive the rollback signal mMeta->remove without depending on the
// internal Llm header. See test/op/AttentionTest.cpp for the same pattern.
struct LATestKVMeta {
enum { NoChange, PendingWrite, PendingRead } file_operation;
size_t block = 4096;
size_t previous = 0;
size_t remove = 0;
int* reserve = nullptr;
int n_reserve = 0;
size_t add = 0;
std::string file_name = "";
int file_flag = NoChange;
int seqlen_in_disk = 0;
int layer_index = 0;
int layer_nums = 0;
std::vector<int> reserveHost;
};
// Variant of _makeLinearAttentionModule that wires a caller-owned KVMeta
// pointer via the runtime KVCACHE_INFO hint. The CPULinearAttention op reads
// this through backend->getMetaPtr() in its constructor.
static std::shared_ptr<Module> _makeLinearAttentionModuleWithMeta(int numKHeads, int numVHeads, int headKDim,
int headVDim, bool useL2Norm, LATestKVMeta* meta) {
auto qkv = _Input();
auto gate = _Input();
auto beta = _Input();
auto convW = _Input();
std::shared_ptr<MNN::OpT> op(new MNN::OpT);
op->type = MNN::OpType_LinearAttention;
op->main.type = MNN::OpParameter_LinearAttentionParam;
op->main.value = new MNN::LinearAttentionParamT;
auto* param = op->main.AsLinearAttentionParam();
param->attn_type = "gated_delta_rule";
param->num_k_heads = numKHeads;
param->num_v_heads = numVHeads;
param->head_k_dim = headKDim;
param->head_v_dim = headVDim;
param->use_qk_l2norm = useL2Norm;
auto o = Variable::create(Expr::create(op.get(), {qkv, gate, beta, convW}));
auto buffer = Variable::save({o});
MNN::ScheduleConfig config;
auto status = MNNTestSuite::get()->pStaus;
config.type = (MNNForwardType)status.forwardType;
MNN::BackendConfig bnConfig;
bnConfig.memory = (MNN::BackendConfig::MemoryMode)status.memory;
bnConfig.precision = (MNN::BackendConfig::PrecisionMode)status.precision;
bnConfig.power = (MNN::BackendConfig::PowerMode)status.power;
config.backendConfig = &bnConfig;
config.numThread = 1;
std::shared_ptr<Executor::RuntimeManager> rtmgr(Executor::RuntimeManager::createRuntimeManager(config));
rtmgr->setHintPtr(MNN::Interpreter::KVCACHE_INFO, meta);
std::shared_ptr<Module> m(Module::load({}, {}, (uint8_t*)buffer.data(), buffer.size(), rtmgr));
return m;
}
// ─── Rollback test: verify the explicit-rollback path in CPULinearAttention::onResize ───
//
// Background: LinearAttention's recurrent state has no token-level structure,
// so Llm::eraseHistory() cannot truncate it. The fix introduces:
// (a) a snapshot of the post-prefix state taken inside the prefix-cache
// disk file_flag branches (PendingRead/PendingWrite), and
// (b) an explicit-rollback branch in onResize that, when mMeta->remove > 0,
// restores from the snapshot if mSnapshotValid is true, otherwise zeros
// the state.
//
// This test covers branch (b) without snapshot (mSnapshotValid=false), the
// most common rollback path when prefix cache is not in use:
//
// Module A: prefill(X) -> internal state advances (no snapshot taken,
// since file_flag stays NoChange throughout)
// [simulate Llm: meta.previous = X.len, meta.remove = X.len]
// prefill(Y) -> isExplicitRollback fires, mSnapshotValid=false,
// so state is zeroed before Y is applied.
//
// Module B: prefill(Y) on a brand-new module starting from zero state.
//
// Module A's second-prefill output and Module B's only-prefill output must
// match byte-for-byte (within float tolerance), proving:
// - the rollback branch is hit when remove>0 in prefill,
// - it correctly clears state to zero when no snapshot is available, and
// - subsequent computation is equivalent to a fresh-init module.
//
// The "snapshot exists" path (mSnapshotValid=true) requires real prefix-cache
// disk files (.k/.v + setExternalPath(EXTERNAL_PATH_PREFIXCACHE_DIR, ...)) and
// is exercised by rollback_demo Stage 3 against actual model bundles.
class LinearAttentionRollbackTest : public MNNTestCase {
public:
LinearAttentionRollbackTest() = default;
virtual ~LinearAttentionRollbackTest() = default;
virtual bool run(int precision) {
const int B = 1, numKHeads = 2, numVHeads = 2;
const int headKDim = 4, headVDim = 4, K_conv = 4;
const int key_dim = numKHeads * headKDim;
const int val_dim = numVHeads * headVDim;
const int D = 2 * key_dim + val_dim;
const int prefillLen = 4;
const float tolerance = 0.001f;
const int outSize = B * prefillLen * numVHeads * headVDim;
// Shared conv weight across both modules so the only variable is state.
auto convWVar = _Input({D, 1, K_conv}, NCHW, halide_type_of<float>());
fillConvWeight(convWVar->writeMap<float>(), D * 1 * K_conv);
// ─── Module A: prefill(X) -> simulate eraseHistory -> prefill(Y) ───
LATestKVMeta metaA;
auto moduleA = _makeLinearAttentionModuleWithMeta(numKHeads, numVHeads, headKDim, headVDim, true, &metaA);
if (!moduleA) {
MNN_PRINT("RollbackTest: failed to create moduleA\n");
return false;
}
// Step 1: prefill X. metaA.previous=0 here makes onResize treat this as
// a fresh prefill (zeros state, drops any snapshot — none yet anyway).
{
auto qkvVar = _Input({B, D, prefillLen}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, prefillLen, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, prefillLen, numVHeads}, NCHW, halide_type_of<float>());
fillDeterministic(qkvVar->writeMap<float>(), B * D * prefillLen, 0.07f, 0.0f);
fillGate(gateVar->writeMap<float>(), B * prefillLen * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * prefillLen * numVHeads);
auto outputs = moduleA->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("RollbackTest: moduleA prefill X returned empty output\n");
return false;
}
// Force evaluation so internal state is fully updated before next call.
(void)outputs[0]->readMap<float>();
}
// Step 2: simulate the meta updates Llm performs around eraseHistory.
// - updateContext after prefill X: meta.previous += prefillLen
// - eraseHistory(0, previous): meta.remove = previous
metaA.previous = prefillLen;
metaA.remove = prefillLen;
// Step 3: prefill Y. onResize sees remove>0 -> isExplicitRollback;
// mSnapshotValid=false (no PendingRead/PendingWrite ever fired), so
// the rollback branch zeros mConvState/mRecurrentState before forward.
std::vector<float> outputA(outSize, 0.0f);
{
auto qkvVar = _Input({B, D, prefillLen}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, prefillLen, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, prefillLen, numVHeads}, NCHW, halide_type_of<float>());
// Use distinct input from X so we don't accidentally pass the test
// when the rollback is silently skipped (state would carry X's effect).
fillDeterministic(qkvVar->writeMap<float>(), B * D * prefillLen, 0.05f, 0.1f);
fillGate(gateVar->writeMap<float>(), B * prefillLen * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * prefillLen * numVHeads);
auto outputs = moduleA->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("RollbackTest: moduleA prefill Y after rollback returned empty output\n");
return false;
}
const float* p = outputs[0]->readMap<float>();
::memcpy(outputA.data(), p, outSize * sizeof(float));
}
// ─── Module B: fresh prefill(Y) baseline ───
LATestKVMeta metaB;
auto moduleB = _makeLinearAttentionModuleWithMeta(numKHeads, numVHeads, headKDim, headVDim, true, &metaB);
if (!moduleB) {
MNN_PRINT("RollbackTest: failed to create moduleB\n");
return false;
}
std::vector<float> outputB(outSize, 0.0f);
{
auto qkvVar = _Input({B, D, prefillLen}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, prefillLen, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, prefillLen, numVHeads}, NCHW, halide_type_of<float>());
// Identical Y inputs as moduleA's step 3.
fillDeterministic(qkvVar->writeMap<float>(), B * D * prefillLen, 0.05f, 0.1f);
fillGate(gateVar->writeMap<float>(), B * prefillLen * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * prefillLen * numVHeads);
auto outputs = moduleB->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("RollbackTest: moduleB fresh prefill Y returned empty output\n");
return false;
}
const float* p = outputs[0]->readMap<float>();
::memcpy(outputB.data(), p, outSize * sizeof(float));
}
// ─── Compare: A's post-rollback prefill must equal B's fresh prefill ───
for (int i = 0; i < outSize; ++i) {
float diff = fabs(outputA[i] - outputB[i]);
if (diff > tolerance) {
MNN_PRINT(
"Rollback (mSnapshotValid=false) FAILED at index %d: "
"rollback=%.6f fresh=%.6f diff=%.6f\n",
i, outputA[i], outputB[i], diff);
return false;
}
}
MNN_PRINT("LinearAttention Rollback (no snapshot, state zeroed) PASSED\n");
return true;
}
};
MNNTestSuiteRegister(LinearAttentionRollbackTest, "op/linear_attention_rollback");
// ─── Chunked prefix-cache layer_index drift test ───
//
// Background: prefix-cache file naming uses mMeta->layer_index as a counter
// that each layer's onExecute advances by 1, wrapping mod layer_nums. The
// counter is shared between LinearAttention and CPUKVCacheManager (Full
// Attention). CPUKVCacheManager advances it ONLY inside onAlloc, which fires
// on chunk 1 (when mMeta->previous == mMeta->remove); chunks 2..N go through
// onRealloc, which does NOT touch layer_index.
//
// Before the fix, CPULinearAttention advanced layer_index inside its
// PendingWrite/PendingRead branches on EVERY chunk's onExecute. In hybrid
// models (attention_type="mix"), this caused LinearAttention's counter to
// drift past Full Attention's layer positions on chunks 2..N — LA would
// compute the wrong file index and overwrite Full Attention's prefix cache
// .k/.v files, corrupting their live mmap regions and triggering SIGBUS on
// subsequent FA access.
//
// The fix captures layer_index ONCE per session (when previous == remove)
// into mStateCache->mPrefixLayerIndex; subsequent chunks reuse the cached
// value and do NOT touch mMeta->layer_index. This mirrors CPUKVCacheManager's
// once-per-session advancement semantics so the two co-exist correctly.
//
// This test exercises the layer_index lifecycle directly on a single
// LinearAttention op (no FA dependency needed to expose the regression):
// chunk 1 (previous == remove == 0): expect layer_index to advance by 1.
// chunk 2 (previous > 0, remove == 0): expect layer_index UNCHANGED.
// A failure here means chunks 2..N would clobber some other layer's file.
class LinearAttentionChunkedLayerIndexTest : public MNNTestCase {
public:
LinearAttentionChunkedLayerIndexTest() = default;
virtual ~LinearAttentionChunkedLayerIndexTest() = default;
virtual bool run(int precision) {
const int B = 1, numKHeads = 2, numVHeads = 2;
const int headKDim = 4, headVDim = 4, K_conv = 4;
const int key_dim = numKHeads * headKDim;
const int val_dim = numVHeads * headVDim;
const int D = 2 * key_dim + val_dim;
const int prefillLen = 4;
// Starting layer_index value chosen to be non-zero so we can
// distinguish "no advance" from "reset to zero".
const int kInitialLayerIndex = 5;
const int kLayerNums = 24;
// Shared conv weight across both chunks.
auto convWVar = _Input({D, 1, K_conv}, NCHW, halide_type_of<float>());
fillConvWeight(convWVar->writeMap<float>(), D * 1 * K_conv);
// Simulate the meta state at the start of a chunked prefix-cache
// write session:
// - file_name + file_flag=PendingWrite trigger the prefix-cache
// write branch in CPULinearAttention::onExecute.
// - layer_index = 5 simulates this op being not-first in a multi-
// layer forward pass (previous layers' onExecute have already
// advanced the counter).
// - previous = 0, remove = 0 marks "first chunk" — the per-session
// capture-and-advance block should fire on this call only.
LATestKVMeta meta;
meta.file_name = "test_chunked_layer_index";
meta.file_flag = LATestKVMeta::PendingWrite;
meta.layer_index = kInitialLayerIndex;
meta.layer_nums = kLayerNums;
meta.previous = 0;
meta.remove = 0;
auto module = _makeLinearAttentionModuleWithMeta(numKHeads, numVHeads, headKDim, headVDim, true, &meta);
if (!module) {
MNN_PRINT("ChunkedLayerIndexTest: failed to create module\n");
return false;
}
auto runChunk = [&](float seed_offset) -> bool {
auto qkvVar = _Input({B, D, prefillLen}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, prefillLen, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, prefillLen, numVHeads}, NCHW, halide_type_of<float>());
fillDeterministic(qkvVar->writeMap<float>(), B * D * prefillLen, 0.07f, seed_offset);
fillGate(gateVar->writeMap<float>(), B * prefillLen * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * prefillLen * numVHeads);
auto outputs = module->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
return false;
}
// Force evaluation so onExecute (and its meta-state mutations) runs.
(void)outputs[0]->readMap<float>();
return true;
};
// ─── Chunk 1: should capture layer_index=5 and advance to 6 ───
if (!runChunk(0.0f)) {
MNN_PRINT("ChunkedLayerIndexTest: chunk 1 forward failed\n");
return false;
}
if (meta.layer_index != kInitialLayerIndex + 1) {
MNN_PRINT(
"ChunkedLayerIndexTest FAIL: after chunk 1, layer_index = %d, "
"expected %d (capture-and-advance must bump once on the first "
"PendingWrite call of a session)\n",
meta.layer_index, kInitialLayerIndex + 1);
return false;
}
// ─── Between chunks: simulate the meta updates Llm performs ───
// sync() at end of forwardRaw: previous += add (= prefillLen), remove resets
// layer_index is intentionally NOT touched here — in real hybrid
// models, FA layers' onRealloc on chunks 2..N also does NOT touch it.
meta.previous = prefillLen;
meta.remove = 0;
int layer_index_before_chunk2 = meta.layer_index;
// ─── Chunk 2: must NOT re-advance layer_index ───
if (!runChunk(0.1f)) {
MNN_PRINT("ChunkedLayerIndexTest: chunk 2 forward failed\n");
return false;
}
if (meta.layer_index != layer_index_before_chunk2) {
MNN_PRINT(
"ChunkedLayerIndexTest FAIL: after chunk 2, layer_index = %d, "
"expected %d (chunks 2..N must reuse mStateCache->mPrefixLayerIndex "
"without re-advancing mMeta->layer_index — the drift was the "
"root cause of LA overwriting FA's prefix cache files in hybrid "
"models, manifesting as SIGBUS on subsequent FA mmap access)\n",
meta.layer_index, layer_index_before_chunk2);
return false;
}
// Cleanup: PendingWrite branch writes the per-layer prefix cache files
// as a side effect. Default prefix cache dir relative to CWD is
// "prefixcache/". Remove them so we don't leave artifacts behind.
::remove("prefixcache/test_chunked_layer_index_5.k");
::remove("prefixcache/test_chunked_layer_index_5.v");
// (leave the empty prefixcache/ dir behind; harmless and cross-platform-friendly)
MNN_PRINT("LinearAttention Chunked LayerIndex (per-session capture) PASSED\n");
return true;
}
};
MNNTestSuiteRegister(LinearAttentionChunkedLayerIndexTest, "op/linear_attention_chunked_layer_index");
// ─── Edge case: PendingWrite when previous != remove (capture must be skipped) ───
//
// The capture-and-advance block in CPULinearAttention::onExecute only fires
// when (file_name set, file_flag in {PendingWrite, PendingRead}, previous ==
// remove). The `previous == remove` predicate identifies "first call of a
// fresh-or-fully-rolled-back session" (chunk 1 of a new write, or chunk 1
// after eraseHistory(0, previous)).
//
// If something triggers PendingWrite/PendingRead outside that entry path
// (e.g. partial eraseHistory(begin>0, end) followed by a forced cache write
// while `mMeta->remove < mMeta->previous`), the capture block is skipped and
// mStateCache->mPrefixLayerIndex stays at its initial sentinel -1.
//
// The PendingWrite branch then constructs a file path using -1 as the layer
// index, writing junk to "<dir>/<name>_-1.k". This corrupts the prefix cache
// directory layout — silent on success but reads as a phantom layer to any
// future PendingRead pass.
//
// This test pins down the desired behavior on that mismatched-meta path:
// (a) mMeta->layer_index must NOT advance (consistent with all advancement
// being moved into the capture block), and
// (b) no junk "_-1.{k,v}" file should be created.
//
// Failure on (b) means production code needs either a fallback (use
// mMeta->layer_index when mPrefixLayerIndex == -1) or an early-out guard
// inside the PendingWrite/PendingRead branches. The test cleans up any junk
// it may have produced so subsequent runs are not affected by today's bug.
class LinearAttentionPendingWriteUnsyncedTest : public MNNTestCase {
public:
LinearAttentionPendingWriteUnsyncedTest() = default;
virtual ~LinearAttentionPendingWriteUnsyncedTest() = default;
virtual bool run(int precision) {
const int B = 1, numKHeads = 2, numVHeads = 2;
const int headKDim = 4, headVDim = 4, K_conv = 4;
const int key_dim = numKHeads * headKDim;
const int val_dim = numVHeads * headVDim;
const int D = 2 * key_dim + val_dim;
const int prefillLen = 4;
const int kInitialLayerIndex = 5;
const int kLayerNums = 24;
const std::string cacheName = "test_pending_write_unsynced";
const std::string junkK = "prefixcache/" + cacheName + "_-1.k";
const std::string junkV = "prefixcache/" + cacheName + "_-1.v";
// Defensive: remove any pre-existing junk from a previous failing run
// so we measure THIS run's behavior.
::remove(junkK.c_str());
::remove(junkV.c_str());
auto convWVar = _Input({D, 1, K_conv}, NCHW, halide_type_of<float>());
fillConvWeight(convWVar->writeMap<float>(), D * 1 * K_conv);
// Construct meta where PendingWrite fires but the capture-and-advance
// condition fails (previous != remove). 4/2 mimics a partial
// eraseHistory(begin=2, end=4) followed by a forced cache write.
LATestKVMeta meta;
meta.file_name = cacheName;
meta.file_flag = LATestKVMeta::PendingWrite;
meta.layer_index = kInitialLayerIndex;
meta.layer_nums = kLayerNums;
meta.previous = 4;
meta.remove = 2;
auto module = _makeLinearAttentionModuleWithMeta(numKHeads, numVHeads, headKDim, headVDim, true, &meta);
if (!module) {
MNN_PRINT("PendingWriteUnsyncedTest: failed to create module\n");
return false;
}
auto qkvVar = _Input({B, D, prefillLen}, NCHW, halide_type_of<float>());
auto gateVar = _Input({B, prefillLen, numVHeads}, NCHW, halide_type_of<float>());
auto betaVar = _Input({B, prefillLen, numVHeads}, NCHW, halide_type_of<float>());
fillDeterministic(qkvVar->writeMap<float>(), B * D * prefillLen, 0.07f, 0.0f);
fillGate(gateVar->writeMap<float>(), B * prefillLen * numVHeads);
fillBeta(betaVar->writeMap<float>(), B * prefillLen * numVHeads);
auto outputs = module->onForward({qkvVar, gateVar, betaVar, convWVar});
if (outputs.empty()) {
MNN_PRINT("PendingWriteUnsyncedTest: forward failed\n");
return false;
}
(void)outputs[0]->readMap<float>();
// (a) layer_index must NOT have advanced
bool layerIndexOk = (meta.layer_index == kInitialLayerIndex);
if (!layerIndexOk) {
MNN_PRINT(
"PendingWriteUnsyncedTest FAIL (a): layer_index = %d, expected %d "
"(capture-and-advance must not fire when previous != remove)\n",
meta.layer_index, kInitialLayerIndex);
}
// (b) no junk "_-1.{k,v}" file should be written
struct stat st;
bool junkKExists = (::stat(junkK.c_str(), &st) == 0);
bool junkVExists = (::stat(junkV.c_str(), &st) == 0);
bool junkOk = !junkKExists && !junkVExists;
if (!junkOk) {
MNN_PRINT(
"PendingWriteUnsyncedTest FAIL (b): junk files written at sentinel "
"index -1: %s=%d %s=%d. Production code should either skip the disk "
"write or fall back to mMeta->layer_index when mPrefixLayerIndex is -1.\n",
junkK.c_str(), (int)junkKExists, junkV.c_str(), (int)junkVExists);
}
// Cleanup regardless of pass/fail so subsequent runs start clean.
::remove(junkK.c_str());
::remove(junkV.c_str());
bool ok = layerIndexOk && junkOk;
if (ok) {
MNN_PRINT("LinearAttention PendingWrite-Unsynced (no capture, no junk write) PASSED\n");
}
return ok;
}
};
MNNTestSuiteRegister(LinearAttentionPendingWriteUnsyncedTest, "op/linear_attention_pending_write_unsynced");
#endif // MNN_SUPPORT_TRANSFORMER_FUSE