Foundry/metal/tests/test_metal_attn.c

931 lines
39 KiB
C

/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* Copyright (C) 2026 David (Corey) Herrera */
/*
* test_metal_attn.c — attention family (fm_attn_kv_append,
* fm_attn_flash_decode + fm_attn_flash_decode_reduce, fm_attn_flash_prefill)
*
* THERE ARE NO GOLDEN VECTORS FOR ATTENTION. The oracle is
* reference_attention_heads() below: a dense, double-precision, single-pass
* softmax written from the definition
*
* out[h] = sum_j softmax_j(scale * dot(q[h], k[h/groups][j])) * v[h/groups][j]
*
* with no tiling, no running max and no split — i.e. deliberately none of the
* structure the kernels use. It was written before the kernels and is not
* derived from them, so it cannot agree with a kernel by sharing its bug.
*
* EVERY assertion is against that reference or against the kernel's own input.
* The one GPU-vs-GPU comparison (single-split vs multi-split decode) is a
* SECONDARY check that runs only alongside the absolute one — Phase 1 showed
* GPU-vs-GPU arms stay green through breaks that change both arms.
*
* Depths tested: 1, 2, 16, 17, 513, 4097 live keys. 16 is exactly one tile,
* 17 is one full tile plus a one-key tail, 1 is the empty-cache case (the
* first token attends only itself), 4097 is a partial tail at long context.
*
* Non-bit-exactness is expected (tile order); the bar is ~1e-3 with a stable
* argmax (spec §11). Measured drift is printed per depth so a regression shows
* up as a number moving, not as a tolerance being widened.
*
* Exits 77 when no Metal device is present so GPU-less CI skips cleanly.
*/
#include "lib/test.h"
#include "metal/kernels/fm_kernel_attn.h"
#include <float.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* qwen35moe attention shape (spec §6). */
#define ATTN_HEAD_DIM 256
#define ATTN_N_HEAD 16
#define ATTN_N_HEAD_KV 2
/* A second shape, so nothing can pass by hard-coding the first. */
#define ALT_HEAD_DIM 64
#define ALT_N_HEAD 8
#define ALT_N_HEAD_KV 4
/* Spec §11 bar for the by-design non-bit-exact kernels: max_abs_err/RMS. */
#define ATTN_TOL_ABS 1e-3f
/* The working bar. Deep-context outputs are a weighted mean of thousands of
* bounded values, so they are SMALL — at depth 4097 the whole output has an
* RMS around 0.02, and a plain 1e-3 absolute bar there is a 5% error budget.
* Anything smaller than that would sail through, including a uniform 0.01%
* scaling of the result. Normalizing by the reference RMS makes the bar mean
* the same thing at every depth. Measured worst ratio is ~4e-6, so this sits
* ~25x above the real drift and ~3x below a 0.01% scale error. */
#define ATTN_TOL_RMS 1e-4f
static const int attn_depths[] = { 1, 2, 16, 17, 513, 4097 };
#define ATTN_N_DEPTHS ((int)(sizeof(attn_depths) / sizeof(attn_depths[0])))
/* Worst drift seen across every comparison in the run, reported at the end. */
static double attn_worst_abs = 0.0;
static double attn_worst_ratio = 0.0;
/* ================================================================
* Deterministic inputs
* ================================================================ */
/* xorshift32: same bytes on every machine and every run, so a failure is
* reproducible without shipping a data file. */
static uint32_t attn_rng_state = 0x2545F491u;
static void attn_rng_seed(uint32_t seed) {
attn_rng_state = seed ? seed : 1u;
}
static float attn_rng_signed(void) {
attn_rng_state ^= attn_rng_state << 13;
attn_rng_state ^= attn_rng_state >> 17;
attn_rng_state ^= attn_rng_state << 5;
return (float)((double)attn_rng_state / 2147483648.0 - 1.0);
}
static void attn_fill_random(float *dst, size_t n) {
for (size_t i = 0; i < n; i++) dst[i] = attn_rng_signed();
}
/* isfinite() is compiled out by the -ffast-math this file is built with
* (finite-math-only lets the compiler fold it to true), which would make the
* NaN check in the empty-split test vacuous. Read the exponent field instead
* — no floating-point comparison for the optimizer to reason about. */
static int attn_bits_are_finite(float v) {
uint32_t bits;
memcpy(&bits, &v, sizeof(bits));
return ((bits >> 23) & 0xFFu) != 0xFFu;
}
/* ================================================================
* The oracle
* ================================================================ */
/*
* Dense attention for one query row, all heads, in double precision.
*
* q_row [n_head][head_dim]
* k_cache [n_head_kv][max_seq][head_dim], rows [0, n_keys) live
* out_row [n_head][head_dim] (written)
*
* Query head h reads kv head h / kv_groups — the same GQA map the kernels
* use, stated here independently. Single global max, one exp pass, one
* normalize: no online rescaling, which is exactly what makes this a check
* on the kernels' rescaling rather than a copy of it.
*/
static void reference_attention_heads(const float *q_row, const float *k_cache,
const float *v_cache, float *out_row,
int n_head, int kv_groups, int head_dim,
int max_seq, int n_keys, float scale) {
double *prob = (double *)malloc((size_t)(n_keys > 0 ? n_keys : 1) * sizeof(double));
if (!prob) return;
for (int h = 0; h < n_head; h++) {
int kvh = h / kv_groups;
const float *kh = k_cache + (size_t)kvh * max_seq * head_dim;
const float *vh = v_cache + (size_t)kvh * max_seq * head_dim;
const float *qh = q_row + (size_t)h * head_dim;
/* -DBL_MAX, not -INFINITY: this file is built with -ffast-math and an
* infinite operand is exactly what that tells the compiler cannot
* happen. Every real score is far above it. */
double peak = -DBL_MAX;
for (int j = 0; j < n_keys; j++) {
double s = 0.0;
for (int e = 0; e < head_dim; e++)
s += (double)qh[e] * (double)kh[(size_t)j * head_dim + e];
s *= (double)scale;
prob[j] = s;
if (s > peak) peak = s;
}
double total = 0.0;
for (int j = 0; j < n_keys; j++) {
prob[j] = exp(prob[j] - peak);
total += prob[j];
}
for (int e = 0; e < head_dim; e++) {
double o = 0.0;
for (int j = 0; j < n_keys; j++)
o += prob[j] * (double)vh[(size_t)j * head_dim + e];
out_row[(size_t)h * head_dim + e] = (float)(o / total);
}
}
free(prob);
}
/* ================================================================
* Comparison
* ================================================================ */
/* Diff against the reference: worst absolute error, and that error over the
* reference's RMS (the scale-free form, which is what actually gets asserted).
* `label` names the case because several tests share this. */
static int case_compare(const char *label, const float *want, const float *got,
size_t n) {
double worst_abs = 0.0, sum_sq = 0.0;
size_t worst_i = 0;
for (size_t i = 0; i < n; i++) {
double d = fabs((double)want[i] - (double)got[i]);
if (d > worst_abs) { worst_abs = d; worst_i = i; }
sum_sq += (double)want[i] * (double)want[i];
}
double rms = sqrt(sum_sq / (double)(n ? n : 1));
double ratio = worst_abs / (rms + 1e-12);
if (worst_abs > attn_worst_abs) attn_worst_abs = worst_abs;
if (ratio > attn_worst_ratio) attn_worst_ratio = ratio;
printf(" %-44s max_abs %.3e /rms %.3e\n", label, worst_abs, ratio);
if (worst_abs > (double)ATTN_TOL_ABS || ratio > (double)ATTN_TOL_RMS) {
printf(" worst element %zu: want %.9g got %.9g (rms %.4g)\n",
worst_i, (double)want[worst_i], (double)got[worst_i], rms);
return 0;
}
return 1;
}
/* Per-head argmax over head_dim must be identical — the stable-argmax half
* of the spec §11 bar. A drift that only shows up as a flipped greedy pick
* would slip past a pure magnitude check on a near-tie. */
static int case_argmax_matches(const float *want, const float *got,
int n_head, int head_dim) {
for (int h = 0; h < n_head; h++) {
int wi = 0, gi = 0;
for (int e = 1; e < head_dim; e++) {
if (want[(size_t)h * head_dim + e] > want[(size_t)h * head_dim + wi]) wi = e;
if (got[(size_t)h * head_dim + e] > got[(size_t)h * head_dim + gi]) gi = e;
}
if (wi != gi) {
printf(" head %d argmax: want %d got %d\n", h, wi, gi);
return 0;
}
}
return 1;
}
/* ================================================================
* Fixture
* ================================================================ */
typedef struct attn_fixture {
int n_head, n_head_kv, kv_groups, head_dim, max_seq;
float scale;
fm_buffer *k_cache;
fm_buffer *v_cache;
fm_buffer *q;
fm_buffer *out;
float *k_host; /* mirror of the cache, for the reference */
float *v_host;
float *q_host;
float *want;
} attn_fixture;
static void fixture_free(attn_fixture *f) {
fm_buffer_free(f->k_cache);
fm_buffer_free(f->v_cache);
fm_buffer_free(f->q);
fm_buffer_free(f->out);
free(f->k_host);
free(f->v_host);
free(f->q_host);
free(f->want);
memset(f, 0, sizeof(*f));
}
/* Allocates cache + query buffers and fills them with the deterministic
* stream. `n_rows` is how many query rows the buffers must hold (1 for
* decode). The cache is filled to max_seq so a causality bug reads live
* data, not zeros — zeros would contribute a plausible-looking output and
* could hide the bug. */
static int fixture_open(attn_fixture *f, int n_head, int n_head_kv,
int head_dim, int max_seq, int n_rows, uint32_t seed) {
memset(f, 0, sizeof(*f));
f->n_head = n_head;
f->n_head_kv = n_head_kv;
f->kv_groups = n_head / n_head_kv;
f->head_dim = head_dim;
f->max_seq = max_seq;
f->scale = 1.0f / sqrtf((float)head_dim);
size_t cache_n = (size_t)n_head_kv * max_seq * head_dim;
size_t row_n = (size_t)n_rows * n_head * head_dim;
f->k_cache = fm_buffer_alloc(cache_n * sizeof(float));
f->v_cache = fm_buffer_alloc(cache_n * sizeof(float));
f->q = fm_buffer_alloc(row_n * sizeof(float));
f->out = fm_buffer_alloc(row_n * sizeof(float));
f->k_host = (float *)malloc(cache_n * sizeof(float));
f->v_host = (float *)malloc(cache_n * sizeof(float));
f->q_host = (float *)malloc(row_n * sizeof(float));
f->want = (float *)malloc(row_n * sizeof(float));
if (!f->k_cache || !f->v_cache || !f->q || !f->out ||
!f->k_host || !f->v_host || !f->q_host || !f->want) {
fixture_free(f);
return -1;
}
attn_rng_seed(seed);
attn_fill_random(f->k_host, cache_n);
attn_fill_random(f->v_host, cache_n);
attn_fill_random(f->q_host, row_n);
memcpy(fm_buffer_data(f->k_cache), f->k_host, cache_n * sizeof(float));
memcpy(fm_buffer_data(f->v_cache), f->v_host, cache_n * sizeof(float));
memcpy(fm_buffer_data(f->q), f->q_host, row_n * sizeof(float));
return 0;
}
/* ================================================================
* KV append
* ================================================================ */
/* Every appended element must land at [h][(pos0+t)%max_seq][d] and every
* untouched slot must keep its sentinel. An off-by-one in the position math
* moves a whole row, which both halves catch: the row that should have been
* written still holds the sentinel, and a row that should not have been
* written no longer does. */
static void test_kv_append_lands_at_the_ring_position(void) {
const int n = 5, n_head_kv = 3, head_dim = 32, max_seq = 11, pos0 = 7;
size_t cache_n = (size_t)n_head_kv * max_seq * head_dim;
size_t new_n = (size_t)n * n_head_kv * head_dim;
fm_buffer *k_new = fm_buffer_alloc(new_n * sizeof(float));
fm_buffer *v_new = fm_buffer_alloc(new_n * sizeof(float));
fm_buffer *k_cache = fm_buffer_alloc(cache_n * sizeof(float));
fm_buffer *v_cache = fm_buffer_alloc(cache_n * sizeof(float));
ASSERT_NOT_NULL(k_new);
ASSERT_NOT_NULL(v_new);
ASSERT_NOT_NULL(k_cache);
ASSERT_NOT_NULL(v_cache);
float *kn = (float *)fm_buffer_data(k_new);
float *vn = (float *)fm_buffer_data(v_new);
/* Value encodes (row, head, dim) so a misplaced element is identifiable. */
for (int t = 0; t < n; t++)
for (int h = 0; h < n_head_kv; h++)
for (int d = 0; d < head_dim; d++) {
size_t i = ((size_t)t * n_head_kv + h) * head_dim + d;
kn[i] = (float)(1000 * (t + 1) + 10 * h + d);
vn[i] = -kn[i];
}
const float sentinel = -12345.0f;
for (size_t i = 0; i < cache_n; i++) {
((float *)fm_buffer_data(k_cache))[i] = sentinel;
((float *)fm_buffer_data(v_cache))[i] = sentinel;
}
fm_attn_kv_append(k_new, v_new, k_cache, v_cache, n, n_head_kv, head_dim,
max_seq, pos0);
fm_sync();
const float *kc = (const float *)fm_buffer_data(k_cache);
const float *vc = (const float *)fm_buffer_data(v_cache);
for (int t = 0; t < n; t++) {
int cache_pos = (pos0 + t) % max_seq;
for (int h = 0; h < n_head_kv; h++)
for (int d = 0; d < head_dim; d++) {
size_t src = ((size_t)t * n_head_kv + h) * head_dim + d;
size_t dst = ((size_t)h * max_seq + cache_pos) * head_dim + d;
ASSERT_FLT_NEAR(kn[src], kc[dst], 0.0f);
ASSERT_FLT_NEAR(vn[src], vc[dst], 0.0f);
}
}
/* Slots outside the appended set must be untouched. pos0=7, n=5,
* max_seq=11 wraps: positions 7,8,9,10,0 are written, 1..6 are not. */
for (int h = 0; h < n_head_kv; h++)
for (int p = 1; p <= 6; p++)
for (int d = 0; d < head_dim; d++) {
size_t i = ((size_t)h * max_seq + p) * head_dim + d;
ASSERT_FLT_NEAR(sentinel, kc[i], 0.0f);
ASSERT_FLT_NEAR(sentinel, vc[i], 0.0f);
}
fm_buffer_free(k_new);
fm_buffer_free(v_new);
fm_buffer_free(k_cache);
fm_buffer_free(v_cache);
}
/* n = 0 must write nothing at all. A dispatch that rounds the thread count
* up would clobber cache row pos0. */
static void test_kv_append_of_zero_rows_writes_nothing(void) {
const int n_head_kv = 2, head_dim = 32, max_seq = 4;
size_t cache_n = (size_t)n_head_kv * max_seq * head_dim;
fm_buffer *k_new = fm_buffer_alloc(head_dim * sizeof(float));
fm_buffer *v_new = fm_buffer_alloc(head_dim * sizeof(float));
fm_buffer *k_cache = fm_buffer_alloc(cache_n * sizeof(float));
fm_buffer *v_cache = fm_buffer_alloc(cache_n * sizeof(float));
ASSERT_NOT_NULL(k_cache);
ASSERT_NOT_NULL(v_cache);
float *kc = (float *)fm_buffer_data(k_cache);
float *vc = (float *)fm_buffer_data(v_cache);
for (size_t i = 0; i < cache_n; i++) { kc[i] = 3.5f; vc[i] = 3.5f; }
fm_attn_kv_append(k_new, v_new, k_cache, v_cache, 0, n_head_kv, head_dim,
max_seq, 0);
fm_sync();
for (size_t i = 0; i < cache_n; i++) {
ASSERT_FLT_NEAR(3.5f, kc[i], 0.0f);
ASSERT_FLT_NEAR(3.5f, vc[i], 0.0f);
}
fm_buffer_free(k_new);
fm_buffer_free(v_new);
fm_buffer_free(k_cache);
fm_buffer_free(v_cache);
}
/* ================================================================
* Decode
* ================================================================ */
/* Runs the two-kernel decode path and returns the output pointer. */
static const float *run_decode(attn_fixture *f, int seq_len, int n_splits,
int split_len, fm_buffer *m_buf,
fm_buffer *l_buf, fm_buffer *acc_buf) {
fm_attn_flash_decode(f->q, f->k_cache, f->v_cache, m_buf, l_buf, acc_buf,
f->kv_groups, f->n_head_kv, f->head_dim, f->max_seq,
seq_len, n_splits, split_len, f->scale);
fm_attn_flash_decode_reduce(m_buf, l_buf, acc_buf, f->out, f->n_head,
f->head_dim, n_splits);
fm_sync();
return (const float *)fm_buffer_data(f->out);
}
/* Allocates split-K scratch for the largest plan a test will use. */
static int alloc_scratch(int n_head, int head_dim, int n_splits,
fm_buffer **m, fm_buffer **l, fm_buffer **acc) {
size_t m_floats = 0, acc_floats = 0;
fm_attn_decode_scratch_floats(n_head, head_dim, n_splits, &m_floats,
&acc_floats);
*m = fm_buffer_alloc(m_floats * sizeof(float));
*l = fm_buffer_alloc(m_floats * sizeof(float));
*acc = fm_buffer_alloc(acc_floats * sizeof(float));
return (*m && *l && *acc) ? 0 : -1;
}
/* One workgroup per kv head: the split-K machinery is present but inert, so
* this isolates the tiling and the online softmax from the combine. */
static void case_decode_depths(int n_head, int n_head_kv, int head_dim,
const char *shape) {
char label[96];
for (int d = 0; d < ATTN_N_DEPTHS; d++) {
int seq_len = attn_depths[d];
attn_fixture f;
ASSERT_INT_EQ(0, fixture_open(&f, n_head, n_head_kv, head_dim,
seq_len + 3, 1, 0x1234u + (uint32_t)d));
fm_buffer *m = NULL, *l = NULL, *acc = NULL;
ASSERT_INT_EQ(0, alloc_scratch(n_head, head_dim, 1, &m, &l, &acc));
reference_attention_heads(f.q_host, f.k_host, f.v_host, f.want,
n_head, f.kv_groups, head_dim, f.max_seq,
seq_len, f.scale);
const float *got = run_decode(&f, seq_len, 1, seq_len, m, l, acc);
snprintf(label, sizeof(label), "%s decode depth %d (1 split)", shape,
seq_len);
int ok = case_compare(label, f.want, got,
(size_t)n_head * head_dim);
int argmax_ok = case_argmax_matches(f.want, got, n_head, head_dim);
fm_buffer_free(m);
fm_buffer_free(l);
fm_buffer_free(acc);
fixture_free(&f);
ASSERT_TRUE(ok);
ASSERT_TRUE(argmax_ok);
}
}
static void test_decode_matches_reference_at_every_depth(void) {
case_decode_depths(ATTN_N_HEAD, ATTN_N_HEAD_KV, ATTN_HEAD_DIM, "qwen35");
}
static void test_decode_matches_reference_at_alt_shape(void) {
case_decode_depths(ALT_N_HEAD, ALT_N_HEAD_KV, ALT_HEAD_DIM, "alt");
}
/* SPLIT-K. Forces more than one workgroup per kv head and checks the combined
* result against the CPU reference (the assertion that matters) and against
* the single-workgroup GPU result (secondary — it would stay green if a break
* hit both arms, which is exactly why it is not the primary check).
*
* The rescale in the reduce is what this exercises: with one split it never
* runs, with many splits every split carries a different running max and the
* exp(m_split - M) weights have to put them back on a common scale. */
static void test_decode_split_k_matches_reference_and_single_split(void) {
const int seq_len = 4097;
char label[96];
attn_fixture f;
ASSERT_INT_EQ(0, fixture_open(&f, ATTN_N_HEAD, ATTN_N_HEAD_KV,
ATTN_HEAD_DIM, seq_len + 3, 1, 0x9E37u));
reference_attention_heads(f.q_host, f.k_host, f.v_host, f.want,
ATTN_N_HEAD, f.kv_groups, ATTN_HEAD_DIM,
f.max_seq, seq_len, f.scale);
size_t out_n = (size_t)ATTN_N_HEAD * ATTN_HEAD_DIM;
float *single = (float *)malloc(out_n * sizeof(float));
ASSERT_NOT_NULL(single);
fm_buffer *m = NULL, *l = NULL, *acc = NULL;
ASSERT_INT_EQ(0, alloc_scratch(ATTN_N_HEAD, ATTN_HEAD_DIM,
FM_ATTN_DECODE_TARGET_WORKGROUPS, &m, &l,
&acc));
memcpy(single, run_decode(&f, seq_len, 1, seq_len, m, l, acc),
out_n * sizeof(float));
/* Several plans, so a bug that only shows at one split count cannot hide.
* 6 workgroups gives 3 splits, deliberately not a divisor of anything
* here; 32 is the shipped default. */
const int workgroup_counts[] = { 4, 6, 16, 64 };
for (int s = 0; s < 4; s++) {
int split_len = 0;
int n_splits = fm_attn_decode_plan(seq_len, ATTN_N_HEAD_KV,
workgroup_counts[s], &split_len);
ASSERT_TRUE(n_splits > 1);
const float *got = run_decode(&f, seq_len, n_splits, split_len, m, l,
acc);
snprintf(label, sizeof(label), "decode depth %d (%d splits x %d)",
seq_len, n_splits, split_len);
int ok = case_compare(label, f.want, got, out_n);
int argmax_ok = case_argmax_matches(f.want, got, ATTN_N_HEAD,
ATTN_HEAD_DIM);
snprintf(label, sizeof(label), " ^ vs 1-split GPU (secondary)");
int agree = case_compare(label, single, got, out_n);
if (!ok || !argmax_ok || !agree) {
fm_buffer_free(m); fm_buffer_free(l); fm_buffer_free(acc);
free(single); fixture_free(&f);
ASSERT_TRUE(ok);
ASSERT_TRUE(argmax_ok);
ASSERT_TRUE(agree);
return;
}
}
fm_buffer_free(m);
fm_buffer_free(l);
fm_buffer_free(acc);
free(single);
fixture_free(&f);
}
/* A plan whose splits run past seq_len leaves some workgroups with no keys.
* Those must contribute nothing: m = -inf makes their weight exp(-inf) = 0.
* Getting this wrong yields NaN, not a small error. */
static void test_decode_tolerates_empty_splits(void) {
const int seq_len = 40; /* 3 splits of 16 covers 48 > 40 */
const int n_splits = 8, split_len = 16;
attn_fixture f;
ASSERT_INT_EQ(0, fixture_open(&f, ATTN_N_HEAD, ATTN_N_HEAD_KV,
ATTN_HEAD_DIM, seq_len + 3, 1, 0xBEEFu));
reference_attention_heads(f.q_host, f.k_host, f.v_host, f.want,
ATTN_N_HEAD, f.kv_groups, ATTN_HEAD_DIM,
f.max_seq, seq_len, f.scale);
fm_buffer *m = NULL, *l = NULL, *acc = NULL;
ASSERT_INT_EQ(0, alloc_scratch(ATTN_N_HEAD, ATTN_HEAD_DIM, n_splits, &m,
&l, &acc));
const float *got = run_decode(&f, seq_len, n_splits, split_len, m, l, acc);
size_t out_n = (size_t)ATTN_N_HEAD * ATTN_HEAD_DIM;
int finite = 1;
for (size_t i = 0; i < out_n; i++)
if (!attn_bits_are_finite(got[i])) { finite = 0; break; }
int ok = case_compare("decode depth 40 (5 live + 3 empty splits)", f.want,
got, out_n);
fm_buffer_free(m);
fm_buffer_free(l);
fm_buffer_free(acc);
fixture_free(&f);
ASSERT_TRUE(finite);
ASSERT_TRUE(ok);
}
/* CAUSALITY, decode. The query attends [0, seq_len); rows at and beyond
* seq_len are future. Poison them with a value large enough to dominate any
* softmax it entered and require the output to be BIT-identical. The second
* half proves the poison is reachable at all — the same overwrite, moved
* inside the attended range, must change the output. */
static void test_decode_ignores_keys_beyond_seq_len(void) {
const int seq_len = 100, max_seq = 160;
attn_fixture f;
ASSERT_INT_EQ(0, fixture_open(&f, ATTN_N_HEAD, ATTN_N_HEAD_KV,
ATTN_HEAD_DIM, max_seq, 1, 0x5150u));
fm_buffer *m = NULL, *l = NULL, *acc = NULL;
ASSERT_INT_EQ(0, alloc_scratch(ATTN_N_HEAD, ATTN_HEAD_DIM, 4, &m, &l, &acc));
size_t out_n = (size_t)ATTN_N_HEAD * ATTN_HEAD_DIM;
float *before = (float *)malloc(out_n * sizeof(float));
ASSERT_NOT_NULL(before);
memcpy(before, run_decode(&f, seq_len, 4, 32, m, l, acc),
out_n * sizeof(float));
/* Poison every position >= seq_len. */
float *kc = (float *)fm_buffer_data(f.k_cache);
float *vc = (float *)fm_buffer_data(f.v_cache);
for (int h = 0; h < ATTN_N_HEAD_KV; h++)
for (int p = seq_len; p < max_seq; p++)
for (int d = 0; d < ATTN_HEAD_DIM; d++) {
size_t i = ((size_t)h * max_seq + p) * ATTN_HEAD_DIM + d;
kc[i] = 50.0f;
vc[i] = 777.0f;
}
const float *after = run_decode(&f, seq_len, 4, 32, m, l, acc);
int identical = memcmp(before, after, out_n * sizeof(float)) == 0;
/* Now poison position seq_len-1, which IS attended: the output must move.
* Without this the test above would also pass on a kernel that reads no
* keys at all. */
for (int h = 0; h < ATTN_N_HEAD_KV; h++)
for (int d = 0; d < ATTN_HEAD_DIM; d++) {
size_t i = ((size_t)h * max_seq + (seq_len - 1)) * ATTN_HEAD_DIM + d;
kc[i] = 50.0f;
vc[i] = 777.0f;
}
const float *poisoned = run_decode(&f, seq_len, 4, 32, m, l, acc);
int moved = memcmp(before, poisoned, out_n * sizeof(float)) != 0;
fm_buffer_free(m);
fm_buffer_free(l);
fm_buffer_free(acc);
free(before);
fixture_free(&f);
ASSERT_TRUE(identical);
ASSERT_TRUE(moved);
}
/* ================================================================
* Prefill
* ================================================================ */
/* Compares every query row of a prefill batch against the reference. Query
* row t attends pos0+t+1 keys, so one call sweeps a run of depths and the
* per-row causal bound is checked at every one of them. */
static void case_prefill_batch(const char *shape, int n_head, int n_head_kv,
int head_dim, int n, int pos0) {
char label[96];
attn_fixture f;
ASSERT_INT_EQ(0, fixture_open(&f, n_head, n_head_kv, head_dim,
pos0 + n + 2, n, 0x77u + (uint32_t)pos0));
for (int t = 0; t < n; t++)
reference_attention_heads(f.q_host + (size_t)t * n_head * head_dim,
f.k_host, f.v_host,
f.want + (size_t)t * n_head * head_dim,
n_head, f.kv_groups, head_dim, f.max_seq,
pos0 + t + 1, f.scale);
fm_attn_flash_prefill(f.q, f.k_cache, f.v_cache, f.out, n, f.kv_groups,
n_head_kv, head_dim, f.max_seq, pos0, f.scale);
fm_sync();
const float *got = (const float *)fm_buffer_data(f.out);
snprintf(label, sizeof(label), "%s prefill n=%d pos0=%d (depth %d..%d)",
shape, n, pos0, pos0 + 1, pos0 + n);
int ok = case_compare(label, f.want, got,
(size_t)n * n_head * head_dim);
int argmax_ok = case_argmax_matches(f.want, got, n * n_head, head_dim);
fixture_free(&f);
ASSERT_TRUE(ok);
ASSERT_TRUE(argmax_ok);
}
/* Depths 1..17 in one batch: covers the empty cache (row 0 sees one key),
* exactly one full tile (row 15 sees 16) and a one-key tail (row 16). */
static void test_prefill_matches_reference_at_tile_boundaries(void) {
case_prefill_batch("qwen35", ATTN_N_HEAD, ATTN_N_HEAD_KV, ATTN_HEAD_DIM,
17, 0);
}
/* Deep context, few rows: the O(N^2) cost of a full 4096-row prefill is not
* needed to exercise the depth — pos0 puts the rows at the depth directly. */
static void test_prefill_matches_reference_at_depth_513_and_4097(void) {
case_prefill_batch("qwen35", ATTN_N_HEAD, ATTN_N_HEAD_KV, ATTN_HEAD_DIM,
4, 509);
if (__ft_current_failed) return;
case_prefill_batch("qwen35", ATTN_N_HEAD, ATTN_N_HEAD_KV, ATTN_HEAD_DIM,
4, 4093);
}
static void test_prefill_matches_reference_at_alt_shape(void) {
case_prefill_batch("alt", ALT_N_HEAD, ALT_N_HEAD_KV, ALT_HEAD_DIM, 33, 5);
}
/* CAUSALITY, prefill. Row 0 attends exactly one key (position pos0); every
* later position is future for it. Poisoning positions pos0+1.. must leave
* row 0 bit-identical while moving the last row, which does attend them. */
static void test_prefill_row_zero_ignores_future_positions(void) {
const int n = 24, pos0 = 0, head_dim = ATTN_HEAD_DIM;
attn_fixture f;
ASSERT_INT_EQ(0, fixture_open(&f, ATTN_N_HEAD, ATTN_N_HEAD_KV, head_dim,
n + 2, n, 0xC0DEu));
size_t row_n = (size_t)ATTN_N_HEAD * head_dim;
size_t all_n = (size_t)n * row_n;
float *before = (float *)malloc(all_n * sizeof(float));
ASSERT_NOT_NULL(before);
fm_attn_flash_prefill(f.q, f.k_cache, f.v_cache, f.out, n, f.kv_groups,
ATTN_N_HEAD_KV, head_dim, f.max_seq, pos0, f.scale);
fm_sync();
memcpy(before, fm_buffer_data(f.out), all_n * sizeof(float));
float *kc = (float *)fm_buffer_data(f.k_cache);
float *vc = (float *)fm_buffer_data(f.v_cache);
for (int h = 0; h < ATTN_N_HEAD_KV; h++)
for (int p = pos0 + 1; p < f.max_seq; p++)
for (int d = 0; d < head_dim; d++) {
size_t i = ((size_t)h * f.max_seq + p) * head_dim + d;
kc[i] = 40.0f;
vc[i] = -999.0f;
}
fm_attn_flash_prefill(f.q, f.k_cache, f.v_cache, f.out, n, f.kv_groups,
ATTN_N_HEAD_KV, head_dim, f.max_seq, pos0, f.scale);
fm_sync();
const float *after = (const float *)fm_buffer_data(f.out);
int row0_identical = memcmp(before, after, row_n * sizeof(float)) == 0;
int last_row_moved = memcmp(before + (size_t)(n - 1) * row_n,
after + (size_t)(n - 1) * row_n,
row_n * sizeof(float)) != 0;
free(before);
fixture_free(&f);
ASSERT_TRUE(row0_identical);
ASSERT_TRUE(last_row_moved);
}
/* The append and the read have to agree on the ring rule, or the whole
* attention block is silently reading somebody else's rows. Append a batch
* through fm_attn_kv_append, then prefill over it, and check against a
* reference built from the SOURCE rows rather than from the cache. */
static void test_append_then_prefill_reads_the_appended_rows(void) {
const int n = 20, head_dim = ALT_HEAD_DIM, n_head = ALT_N_HEAD;
const int n_head_kv = ALT_N_HEAD_KV, max_seq = n + 4, pos0 = 0;
const int kv_groups = n_head / n_head_kv;
const float scale = 1.0f / sqrtf((float)head_dim);
size_t cache_n = (size_t)n_head_kv * max_seq * head_dim;
size_t new_n = (size_t)n * n_head_kv * head_dim;
size_t q_n = (size_t)n * n_head * head_dim;
fm_buffer *k_new = fm_buffer_alloc(new_n * sizeof(float));
fm_buffer *v_new = fm_buffer_alloc(new_n * sizeof(float));
fm_buffer *k_cache = fm_buffer_alloc(cache_n * sizeof(float));
fm_buffer *v_cache = fm_buffer_alloc(cache_n * sizeof(float));
fm_buffer *q = fm_buffer_alloc(q_n * sizeof(float));
fm_buffer *out = fm_buffer_alloc(q_n * sizeof(float));
ASSERT_NOT_NULL(k_new);
ASSERT_NOT_NULL(out);
attn_rng_seed(0x4242u);
attn_fill_random((float *)fm_buffer_data(k_new), new_n);
attn_fill_random((float *)fm_buffer_data(v_new), new_n);
attn_fill_random((float *)fm_buffer_data(q), q_n);
memset(fm_buffer_data(k_cache), 0, cache_n * sizeof(float));
memset(fm_buffer_data(v_cache), 0, cache_n * sizeof(float));
/* Reference cache, laid out by the ring rule stated here rather than
* read back from the kernel's output. */
float *k_ref = (float *)calloc(cache_n, sizeof(float));
float *v_ref = (float *)calloc(cache_n, sizeof(float));
ASSERT_NOT_NULL(k_ref);
ASSERT_NOT_NULL(v_ref);
const float *kn = (const float *)fm_buffer_data(k_new);
const float *vn = (const float *)fm_buffer_data(v_new);
for (int t = 0; t < n; t++)
for (int h = 0; h < n_head_kv; h++)
for (int d = 0; d < head_dim; d++) {
size_t src = ((size_t)t * n_head_kv + h) * head_dim + d;
size_t dst = ((size_t)h * max_seq + ((pos0 + t) % max_seq)) *
head_dim + d;
k_ref[dst] = kn[src];
v_ref[dst] = vn[src];
}
float *want = (float *)malloc(q_n * sizeof(float));
ASSERT_NOT_NULL(want);
const float *qh = (const float *)fm_buffer_data(q);
for (int t = 0; t < n; t++)
reference_attention_heads(qh + (size_t)t * n_head * head_dim, k_ref,
v_ref, want + (size_t)t * n_head * head_dim,
n_head, kv_groups, head_dim, max_seq,
pos0 + t + 1, scale);
fm_attn_kv_append(k_new, v_new, k_cache, v_cache, n, n_head_kv, head_dim,
max_seq, pos0);
fm_attn_flash_prefill(q, k_cache, v_cache, out, n, kv_groups, n_head_kv,
head_dim, max_seq, pos0, scale);
fm_sync();
int ok = case_compare("append -> prefill round trip", want,
(const float *)fm_buffer_data(out), q_n);
fm_buffer_free(k_new); fm_buffer_free(v_new);
fm_buffer_free(k_cache); fm_buffer_free(v_cache);
fm_buffer_free(q); fm_buffer_free(out);
free(k_ref); free(v_ref); free(want);
ASSERT_TRUE(ok);
}
/* Decode and prefill must be the same function of the same inputs: a decode
* over seq_len keys is a prefill of one row at pos0 = seq_len-1. Both are
* checked against the reference here, so this catches a divergence between
* the two paths that neither absolute check alone would attribute. */
static void test_decode_and_prefill_agree_with_the_same_reference(void) {
const int seq_len = 300;
attn_fixture f;
ASSERT_INT_EQ(0, fixture_open(&f, ATTN_N_HEAD, ATTN_N_HEAD_KV,
ATTN_HEAD_DIM, seq_len + 2, 1, 0xABCDu));
reference_attention_heads(f.q_host, f.k_host, f.v_host, f.want,
ATTN_N_HEAD, f.kv_groups, ATTN_HEAD_DIM,
f.max_seq, seq_len, f.scale);
size_t out_n = (size_t)ATTN_N_HEAD * ATTN_HEAD_DIM;
fm_buffer *m = NULL, *l = NULL, *acc = NULL;
ASSERT_INT_EQ(0, alloc_scratch(ATTN_N_HEAD, ATTN_HEAD_DIM, 8, &m, &l, &acc));
int split_len = 0;
int n_splits = fm_attn_decode_plan(seq_len, ATTN_N_HEAD_KV, 16, &split_len);
int dec_ok = case_compare("decode depth 300", f.want,
run_decode(&f, seq_len, n_splits, split_len, m,
l, acc), out_n);
fm_attn_flash_prefill(f.q, f.k_cache, f.v_cache, f.out, 1, f.kv_groups,
ATTN_N_HEAD_KV, ATTN_HEAD_DIM, f.max_seq,
seq_len - 1, f.scale);
fm_sync();
int pre_ok = case_compare("prefill depth 300 (1 row)", f.want,
(const float *)fm_buffer_data(f.out), out_n);
fm_buffer_free(m);
fm_buffer_free(l);
fm_buffer_free(acc);
fixture_free(&f);
ASSERT_TRUE(dec_ok);
ASSERT_TRUE(pre_ok);
}
/* THE NORMALIZATION TRAP, and the sharpest assertion in this file.
*
* Make every cached V row identical. The output is then sum_j p_j * v[d] over
* sum_j p_j = v[d] EXACTLY, whatever the scores are — so the expected value is
* known in closed form instead of being a small weighted mean, and the check
* is at full relative precision rather than at the RMS of a deep-context
* average. Anything that scales the result, drops the online-softmax rescale,
* or mismatches the numerator and denominator moves this immediately; the
* random-input comparisons above cannot see a uniform 0.01% scale because
* their outputs are two orders of magnitude smaller than their tolerance.
*
* Run against a MULTI-SPLIT decode and against prefill, since the split
* combine is where the numerator and denominator can drift apart. */
static void test_constant_v_makes_the_output_that_v_exactly(void) {
const int seq_len = 1000, max_seq = 1024;
const int n = 12;
attn_fixture f;
ASSERT_INT_EQ(0, fixture_open(&f, ATTN_N_HEAD, ATTN_N_HEAD_KV,
ATTN_HEAD_DIM, max_seq, n, 0x1F1Fu));
/* v[kvh][any j][d] = 0.5 + d/256 + kvh — distinct per dim and per kv head,
* so a head reading the wrong kv head is caught too. */
float *vc = (float *)fm_buffer_data(f.v_cache);
for (int h = 0; h < ATTN_N_HEAD_KV; h++)
for (int p = 0; p < max_seq; p++)
for (int d = 0; d < ATTN_HEAD_DIM; d++)
vc[((size_t)h * max_seq + p) * ATTN_HEAD_DIM + d] =
0.5f + (float)d / 256.0f + (float)h;
fm_buffer *m = NULL, *l = NULL, *acc = NULL;
ASSERT_INT_EQ(0, alloc_scratch(ATTN_N_HEAD, ATTN_HEAD_DIM, 16, &m, &l, &acc));
int split_len = 0;
int n_splits = fm_attn_decode_plan(seq_len, ATTN_N_HEAD_KV, 32, &split_len);
ASSERT_TRUE(n_splits > 1);
const float *dec = run_decode(&f, seq_len, n_splits, split_len, m, l, acc);
for (int h = 0; h < ATTN_N_HEAD; h++)
for (int d = 0; d < ATTN_HEAD_DIM; d++) {
float want = 0.5f + (float)d / 256.0f +
(float)(h / f.kv_groups);
if (fabsf(want - dec[(size_t)h * ATTN_HEAD_DIM + d]) > 2e-6f)
printf(" decode head %d dim %d\n", h, d);
ASSERT_FLT_NEAR(want, dec[(size_t)h * ATTN_HEAD_DIM + d], 2e-6f);
}
fm_attn_flash_prefill(f.q, f.k_cache, f.v_cache, f.out, n, f.kv_groups,
ATTN_N_HEAD_KV, ATTN_HEAD_DIM, f.max_seq, 0, f.scale);
fm_sync();
const float *pre = (const float *)fm_buffer_data(f.out);
for (int t = 0; t < n; t++)
for (int h = 0; h < ATTN_N_HEAD; h++)
for (int d = 0; d < ATTN_HEAD_DIM; d++) {
size_t i = ((size_t)t * ATTN_N_HEAD + h) * ATTN_HEAD_DIM + d;
float want = 0.5f + (float)d / 256.0f +
(float)(h / f.kv_groups);
if (fabsf(want - pre[i]) > 2e-6f)
printf(" prefill row %d head %d dim %d\n", t, h, d);
ASSERT_FLT_NEAR(want, pre[i], 2e-6f);
}
fm_buffer_free(m);
fm_buffer_free(l);
fm_buffer_free(acc);
fixture_free(&f);
}
/* ================================================================ */
int main(void) {
printf("\n Metal Attention Kernel Tests\n");
printf(" ============================\n\n");
if (fm_init() != 0 || !fm_available()) {
fprintf(stderr, " Metal unavailable — skipping.\n\n");
return 77;
}
RUN_TEST(kv_append_lands_at_the_ring_position);
RUN_TEST(kv_append_of_zero_rows_writes_nothing);
RUN_TEST(decode_matches_reference_at_every_depth);
RUN_TEST(decode_matches_reference_at_alt_shape);
RUN_TEST(decode_split_k_matches_reference_and_single_split);
RUN_TEST(decode_tolerates_empty_splits);
RUN_TEST(decode_ignores_keys_beyond_seq_len);
RUN_TEST(prefill_matches_reference_at_tile_boundaries);
RUN_TEST(prefill_matches_reference_at_depth_513_and_4097);
RUN_TEST(prefill_matches_reference_at_alt_shape);
RUN_TEST(prefill_row_zero_ignores_future_positions);
RUN_TEST(append_then_prefill_reads_the_appended_rows);
RUN_TEST(decode_and_prefill_agree_with_the_same_reference);
RUN_TEST(constant_v_makes_the_output_that_v_exactly);
fm_free();
printf("\n Worst drift vs CPU reference: abs %.3e abs/rms %.3e"
" (bars: abs %.0e, abs/rms %.0e)\n",
attn_worst_abs, attn_worst_ratio,
(double)ATTN_TOL_ABS, (double)ATTN_TOL_RMS);
TEST_REPORT();
return __ft_failed > 0 ? 1 : 0;
}