641 lines
26 KiB
C
641 lines
26 KiB
C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/**
|
|
* @file test_metal_matvec_q5_k.c
|
|
* @brief Absolute-correctness tests for the Metal Q5_K matvec.
|
|
*
|
|
* The oracle is the CPU dequant+dot in this file, written straight from the
|
|
* standard 176-byte / 256-weight GGUF Q5_K block layout — NOT from the kernel.
|
|
* Every assertion compares the GPU result against that oracle in absolute
|
|
* terms, so a uniform error (a scaled row sum, a wrong scale decode) cannot
|
|
* cancel the way it does in a GPU-against-GPU offset-invariance comparison.
|
|
*
|
|
* The blocks are real: they are extracted from the shipped Q3_K_M GGUF, so a
|
|
* misread of the actual on-disk layout shows up here and not in the logits.
|
|
* The nonzero-region-offset test is an ADDITION to the absolute tests, never
|
|
* a substitute for one.
|
|
*
|
|
* Exits 77 when no Metal device is present so GPU-less CI skips cleanly.
|
|
*
|
|
* @status All tests pass on M4 / macOS 26; each was proven able to fail (scaled
|
|
* row sum, wrong 6-bit scale decode, off-by-one block stride, dropped qh bit,
|
|
* ignored bind offset). The oracle below was itself cross-checked against an
|
|
* independent dequantizer and agreed BIT-IDENTICALLY on 20480 real weights.
|
|
* ragged_row_tail_* covers the hazard the rows-per-threadgroup split adds:
|
|
* the grid rounds up, so the last threadgroup carries rows past the end.
|
|
* @issues None
|
|
* @todo None
|
|
*/
|
|
|
|
#include "lib/test.h"
|
|
#include "metal/fm_metal.h"
|
|
#include "metal/tests/gguf_test_reader.h"
|
|
|
|
#include <fcntl.h>
|
|
#include <math.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <sys/mman.h>
|
|
#include <unistd.h>
|
|
|
|
#define Q5K_BLOCK_BYTES 176u
|
|
#define Q5K_WEIGHTS_PER_BLOCK 256u
|
|
#define Q5K_SUB_BLOCKS 8u /* 8 sub-blocks of 32 weights per block */
|
|
|
|
/* Real Q5_K tensors in the shipped file: the attn_qkv projections (the bulk of
|
|
* the 90) plus a shared-expert FFN weight, which has a different column count
|
|
* and so exercises a different block count per row. */
|
|
static const char *kTensorQkv = "blk.0.attn_qkv.weight";
|
|
static const char *kTensorSharedExpert = "blk.5.ffn_down_shexp.weight";
|
|
|
|
static const char *kDefaultModelPath =
|
|
"models/Nex-N2-mini/nex-agi_Nex-N2-mini-Q3_K_M.gguf";
|
|
|
|
#define SCRATCH_DIR \
|
|
"metal/tests/" /* run from the repo root; test_* is gitignored */
|
|
|
|
static const char *kScratchPath = SCRATCH_DIR "test_q5k_region.bin";
|
|
|
|
/* 32-byte aligned (GGUF's tensor alignment) but deliberately not a multiple of
|
|
* the 16 KiB page — the real case a weight inside an mmap'd model hits. */
|
|
#define REGION_WEIGHT_OFFSET (1024u * 1024u + 32u)
|
|
|
|
static gguf_test_file g_model;
|
|
static int g_model_is_open;
|
|
|
|
/* ================================================================
|
|
* CPU reference — the oracle
|
|
*
|
|
* Standard GGUF Q5_K super-block, 176 bytes per 256 weights:
|
|
*
|
|
* offset 0 : half d super-block scale for the 6-bit scales
|
|
* offset 2 : half dmin super-block scale for the 6-bit mins
|
|
* offset 4 : uchar scales[12] 8 (scale, min) pairs packed 6 bits each
|
|
* offset 16 : uchar qh[32] one high bit per weight
|
|
* offset 48 : uchar qs[128] the low 4 bits, two weights per byte
|
|
*
|
|
* Weight i in 0..255 sits in sub-block s = i / 32 at lane l = i % 32:
|
|
*
|
|
* nibble = (s & 1) ? qs[32 * (s / 2) + l] >> 4 : qs[32 * (s / 2) + l] & 0xF
|
|
* q = nibble + 16 * ((qh[l] >> s) & 1)
|
|
* value = d * scale[s] * q - dmin * min[s]
|
|
* ================================================================ */
|
|
|
|
static float f16_to_f32(uint16_t bits)
|
|
{
|
|
uint32_t sign = (uint32_t)(bits >> 15) << 31;
|
|
uint32_t exponent = (bits >> 10) & 0x1Fu;
|
|
uint32_t mantissa = bits & 0x3FFu;
|
|
uint32_t out;
|
|
|
|
if (exponent == 0) {
|
|
if (mantissa == 0) {
|
|
out = sign;
|
|
} else { /* subnormal: renormalize */
|
|
exponent = 127 - 15 + 1;
|
|
while ((mantissa & 0x400u) == 0) { mantissa <<= 1; exponent--; }
|
|
mantissa &= 0x3FFu;
|
|
out = sign | (exponent << 23) | (mantissa << 13);
|
|
}
|
|
} else if (exponent == 0x1Fu) {
|
|
out = sign | 0x7F800000u | (mantissa << 13);
|
|
} else {
|
|
out = sign | ((exponent + 127 - 15) << 23) | (mantissa << 13);
|
|
}
|
|
|
|
float value;
|
|
memcpy(&value, &out, sizeof(value));
|
|
return value;
|
|
}
|
|
|
|
/* Unpacks the 6-bit scale and min of sub-block `index` from the 12 packed
|
|
* bytes. The first four pairs are stored plainly in the low 6 bits; the last
|
|
* four borrow their top 2 bits from the high bits of the first four. */
|
|
static void q5k_unpack_scale_min(const uint8_t *scales, unsigned index,
|
|
uint8_t *scale_out, uint8_t *min_out)
|
|
{
|
|
if (index < 4) {
|
|
*scale_out = scales[index] & 63u;
|
|
*min_out = scales[index + 4] & 63u;
|
|
} else {
|
|
*scale_out = (uint8_t)((scales[index + 4] & 0xFu) | ((scales[index - 4] >> 6) << 4));
|
|
*min_out = (uint8_t)((scales[index + 4] >> 4) | ((scales[index] >> 6) << 4));
|
|
}
|
|
}
|
|
|
|
/* Dequantizes one 176-byte block into 256 floats. */
|
|
static void q5k_dequantize_block(const uint8_t *block, float *out)
|
|
{
|
|
uint16_t d_bits, dmin_bits;
|
|
memcpy(&d_bits, block + 0, 2);
|
|
memcpy(&dmin_bits, block + 2, 2);
|
|
const float d = f16_to_f32(d_bits);
|
|
const float dmin = f16_to_f32(dmin_bits);
|
|
|
|
const uint8_t *scales = block + 4;
|
|
const uint8_t *qh = block + 16;
|
|
const uint8_t *qs = block + 48;
|
|
|
|
for (unsigned s = 0; s < Q5K_SUB_BLOCKS; s++) {
|
|
uint8_t scale = 0, min = 0;
|
|
q5k_unpack_scale_min(scales, s, &scale, &min);
|
|
const float step = d * (float)scale;
|
|
const float offset = dmin * (float)min;
|
|
for (unsigned l = 0; l < 32; l++) {
|
|
const uint8_t packed = qs[32u * (s / 2u) + l];
|
|
const uint8_t nibble = (s & 1u) ? (uint8_t)(packed >> 4) : (uint8_t)(packed & 0xFu);
|
|
const unsigned high = (qh[l] >> s) & 1u;
|
|
const float quant = (float)(nibble + 16u * high);
|
|
out[s * 32u + l] = step * quant - offset;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* out[r] = sum_c dequant(w)[r][c] * x[c], accumulated in double so the oracle
|
|
* itself is not the thing under suspicion. Also reports the per-row sum of
|
|
* absolute terms, which sets an honest float tolerance for the GPU compare. */
|
|
static void q5k_reference_matvec(const uint8_t *w, int rows, int cols,
|
|
size_t row_stride_bytes, const float *x,
|
|
float *out, float *abs_sum_out)
|
|
{
|
|
const int blocks_per_row = cols / (int)Q5K_WEIGHTS_PER_BLOCK;
|
|
float block[Q5K_WEIGHTS_PER_BLOCK];
|
|
|
|
for (int r = 0; r < rows; r++) {
|
|
double dot = 0.0;
|
|
double abs_sum = 0.0;
|
|
for (int b = 0; b < blocks_per_row; b++) {
|
|
q5k_dequantize_block(w + (size_t)r * row_stride_bytes + (size_t)b * Q5K_BLOCK_BYTES,
|
|
block);
|
|
for (unsigned i = 0; i < Q5K_WEIGHTS_PER_BLOCK; i++) {
|
|
const double term = (double)block[i] * (double)x[b * 256 + (int)i];
|
|
dot += term;
|
|
abs_sum += fabs(term);
|
|
}
|
|
}
|
|
out[r] = (float)dot;
|
|
if (abs_sum_out) abs_sum_out[r] = (float)abs_sum;
|
|
}
|
|
}
|
|
|
|
/* Float tolerance justified by the reordering the GPU does: the kernel splits
|
|
* each row across 32 lanes and reduces with simd_sum, so the summation order
|
|
* differs from the reference's. Drift scales with the row's L1 mass, not with
|
|
* its (possibly cancelling) result.
|
|
*
|
|
* The constant is the MEASURED noise floor with headroom, not a number picked
|
|
* to make the test green: the real-tensor tests print the worst drift they
|
|
* observe (~3e-8 x row L1 on M4 / macOS 26), so this sits ~30x above it and
|
|
* still rejects a 0.01% error in a row sum. */
|
|
#define Q5K_DRIFT_TOLERANCE 1e-6f
|
|
|
|
static float tolerance_for(float abs_sum)
|
|
{
|
|
return Q5K_DRIFT_TOLERANCE * abs_sum + 1e-7f;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Harness helpers
|
|
* ================================================================ */
|
|
|
|
static void fill_probe_vector(float *x, int cols, unsigned seed)
|
|
{
|
|
uint32_t rng = 0x9E3779B9u + seed * 0x85EBCA6Bu;
|
|
for (int i = 0; i < cols; i++) {
|
|
rng ^= rng << 13; rng ^= rng >> 17; rng ^= rng << 5;
|
|
x[i] = (float)((int32_t)(rng & 0xFFFFu) - 32768) / 32768.0f;
|
|
}
|
|
}
|
|
|
|
/* Runs the kernel and copies `rows` floats out of the result binding. */
|
|
static void run_q5k(fm_bufref w, fm_bufref x, fm_bufref out,
|
|
int rows, int cols, int row_stride_bytes, float *host_out)
|
|
{
|
|
char *out_host = (char *)fm_buffer_data(out.buffer) + out.offset;
|
|
memset(out_host, 0, (size_t)rows * sizeof(float));
|
|
|
|
fm_matvec_q5k(w, x, out, rows, cols, row_stride_bytes);
|
|
fm_sync();
|
|
memcpy(host_out, out_host, (size_t)rows * sizeof(float));
|
|
}
|
|
|
|
/* Pulls `rows` consecutive quantized rows of a real Q5_K tensor out of the
|
|
* model file. Reports the tensor's column count. Returns 0 on success. */
|
|
static int load_real_q5k_rows(const char *tensor_name, int rows,
|
|
int *cols_out, uint8_t *dst, size_t dst_capacity)
|
|
{
|
|
gguf_tensor_location location;
|
|
if (gguf_test_find_tensor(&g_model, tensor_name, &location) != 0)
|
|
return -1;
|
|
if (location.ggml_type != GGUF_GGML_TYPE_Q5_K || location.n_dims != 2)
|
|
return -1;
|
|
|
|
const int cols = (int)location.dims[0];
|
|
if (cols % (int)Q5K_WEIGHTS_PER_BLOCK != 0 || (uint64_t)rows > location.dims[1])
|
|
return -1;
|
|
|
|
const size_t row_stride = (size_t)(cols / 256) * Q5K_BLOCK_BYTES;
|
|
const size_t bytes = (size_t)rows * row_stride;
|
|
if (bytes > dst_capacity)
|
|
return -1;
|
|
if (gguf_test_read_at(&g_model, location.file_offset, dst, bytes) != 0)
|
|
return -1;
|
|
|
|
*cols_out = cols;
|
|
return 0;
|
|
}
|
|
|
|
/* Body shared by the two real-tensor tests: extract rows, run, compare
|
|
* absolutely against the CPU oracle. Returns 0 if the tensor loaded. */
|
|
static int check_real_tensor_against_reference(const char *tensor_name, int rows,
|
|
unsigned probe_seed)
|
|
{
|
|
static uint8_t weight_bytes[64 * 1408]; /* 64 rows x 2048 cols, the largest here */
|
|
int cols = 0;
|
|
if (load_real_q5k_rows(tensor_name, rows, &cols, weight_bytes, sizeof(weight_bytes)) != 0)
|
|
return -1;
|
|
|
|
const int row_stride = (cols / 256) * (int)Q5K_BLOCK_BYTES;
|
|
|
|
fm_buffer *w = fm_buffer_alloc((size_t)rows * (size_t)row_stride);
|
|
fm_buffer *x = fm_buffer_alloc((size_t)cols * sizeof(float));
|
|
fm_buffer *out = fm_buffer_alloc((size_t)rows * sizeof(float));
|
|
if (!w || !x || !out) return -1;
|
|
|
|
memcpy(fm_buffer_data(w), weight_bytes, (size_t)rows * (size_t)row_stride);
|
|
float *x_host = (float *)fm_buffer_data(x);
|
|
fill_probe_vector(x_host, cols, probe_seed);
|
|
|
|
float *expected = (float *)malloc((size_t)rows * sizeof(float));
|
|
float *abs_sum = (float *)malloc((size_t)rows * sizeof(float));
|
|
float *actual = (float *)malloc((size_t)rows * sizeof(float));
|
|
q5k_reference_matvec(weight_bytes, rows, cols, (size_t)row_stride, x_host,
|
|
expected, abs_sum);
|
|
run_q5k(fm_bufref_at(w, 0), fm_bufref_at(x, 0), fm_bufref_at(out, 0),
|
|
rows, cols, row_stride, actual);
|
|
|
|
int failures = 0;
|
|
float worst_drift = 0.0f;
|
|
for (int r = 0; r < rows; r++) {
|
|
const float diff = fabsf(expected[r] - actual[r]);
|
|
if (diff > tolerance_for(abs_sum[r])) {
|
|
if (failures < 4)
|
|
printf(" row %d: expected %.8f got %.8f (diff %.3g tol %.3g)\n",
|
|
r, expected[r], actual[r], diff, tolerance_for(abs_sum[r]));
|
|
failures++;
|
|
}
|
|
const float drift = abs_sum[r] > 0.0f ? diff / abs_sum[r] : 0.0f;
|
|
if (drift > worst_drift) worst_drift = drift;
|
|
}
|
|
/* The measured noise floor, so the tolerance above stays honest rather
|
|
* than being whatever number made the test go green. */
|
|
printf(" %-28s worst drift = %.3g x row L1 (tolerance %.3g)\n",
|
|
tensor_name, (double)worst_drift, (double)Q5K_DRIFT_TOLERANCE);
|
|
|
|
/* A kernel that wrote nothing would "agree" with an all-zero reference. */
|
|
float magnitude = 0.0f;
|
|
for (int r = 0; r < rows; r++) magnitude += fabsf(expected[r]);
|
|
|
|
free(expected); free(abs_sum); free(actual);
|
|
fm_buffer_free(w); fm_buffer_free(x); fm_buffer_free(out);
|
|
|
|
if (magnitude <= 1e-3f) return -2;
|
|
return failures == 0 ? 0 : 1;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Absolute correctness against real model blocks
|
|
* ================================================================ */
|
|
|
|
static void test_real_attn_qkv_matches_cpu_reference(void)
|
|
{
|
|
ASSERT_TRUE(g_model_is_open);
|
|
ASSERT_INT_EQ(0, check_real_tensor_against_reference(kTensorQkv, 48, 1));
|
|
}
|
|
|
|
static void test_real_shared_expert_matches_cpu_reference(void)
|
|
{
|
|
ASSERT_TRUE(g_model_is_open);
|
|
ASSERT_INT_EQ(0, check_real_tensor_against_reference(kTensorSharedExpert, 48, 2));
|
|
}
|
|
|
|
/* One-hot x isolates a single weight: out[r] must be exactly dequant(row r)[i].
|
|
* This is the localizing test — it catches a wrong nibble, a dropped high bit,
|
|
* a wrong sub-block scale index and any permutation of the 256 positions,
|
|
* and names the offending element instead of just the row. */
|
|
static void test_one_hot_probe_recovers_every_weight(void)
|
|
{
|
|
ASSERT_TRUE(g_model_is_open);
|
|
|
|
const int rows = 4, cols = 256;
|
|
static uint8_t weight_bytes[4 * Q5K_BLOCK_BYTES];
|
|
int tensor_cols = 0;
|
|
/* Read 4 rows of the real tensor, then use only their first block. */
|
|
static uint8_t full_rows[4 * 1408];
|
|
ASSERT_INT_EQ(0, load_real_q5k_rows(kTensorQkv, rows, &tensor_cols,
|
|
full_rows, sizeof(full_rows)));
|
|
const size_t tensor_stride = (size_t)(tensor_cols / 256) * Q5K_BLOCK_BYTES;
|
|
for (int r = 0; r < rows; r++)
|
|
memcpy(weight_bytes + (size_t)r * Q5K_BLOCK_BYTES,
|
|
full_rows + (size_t)r * tensor_stride, Q5K_BLOCK_BYTES);
|
|
|
|
float expected_block[4][Q5K_WEIGHTS_PER_BLOCK];
|
|
for (int r = 0; r < rows; r++)
|
|
q5k_dequantize_block(weight_bytes + (size_t)r * Q5K_BLOCK_BYTES, expected_block[r]);
|
|
|
|
fm_buffer *w = fm_buffer_alloc(sizeof(weight_bytes));
|
|
fm_buffer *x = fm_buffer_alloc((size_t)cols * sizeof(float));
|
|
fm_buffer *out = fm_buffer_alloc((size_t)rows * sizeof(float));
|
|
ASSERT_NOT_NULL(w); ASSERT_NOT_NULL(x); ASSERT_NOT_NULL(out);
|
|
memcpy(fm_buffer_data(w), weight_bytes, sizeof(weight_bytes));
|
|
float *x_host = (float *)fm_buffer_data(x);
|
|
|
|
int mismatches = 0;
|
|
float nonzero_mass = 0.0f;
|
|
for (int i = 0; i < cols; i++) {
|
|
memset(x_host, 0, (size_t)cols * sizeof(float));
|
|
x_host[i] = 1.0f;
|
|
|
|
float actual[4];
|
|
run_q5k(fm_bufref_at(w, 0), fm_bufref_at(x, 0), fm_bufref_at(out, 0),
|
|
rows, cols, (int)Q5K_BLOCK_BYTES, actual);
|
|
|
|
for (int r = 0; r < rows; r++) {
|
|
const float want = expected_block[r][i];
|
|
nonzero_mass += fabsf(want);
|
|
if (fabsf(want - actual[r]) > 1e-6f * (1.0f + fabsf(want))) {
|
|
if (mismatches < 8)
|
|
printf(" weight[row %d][%d]: expected %.8f got %.8f\n",
|
|
r, i, want, actual[r]);
|
|
mismatches++;
|
|
}
|
|
}
|
|
}
|
|
|
|
fm_buffer_free(w); fm_buffer_free(x); fm_buffer_free(out);
|
|
|
|
ASSERT_TRUE(nonzero_mass > 1e-3f);
|
|
ASSERT_INT_EQ(0, mismatches);
|
|
}
|
|
|
|
/* row_stride_bytes is an independent parameter, not cols/256*176. Padding the
|
|
* stride and poisoning the gap makes an off-by-one in the block or row stride
|
|
* read the poison. */
|
|
static void test_padded_row_stride_is_honored(void)
|
|
{
|
|
ASSERT_TRUE(g_model_is_open);
|
|
|
|
const int rows = 32;
|
|
static uint8_t packed[32 * 1408];
|
|
int cols = 0;
|
|
ASSERT_INT_EQ(0, load_real_q5k_rows(kTensorQkv, rows, &cols, packed, sizeof(packed)));
|
|
|
|
const size_t natural_stride = (size_t)(cols / 256) * Q5K_BLOCK_BYTES;
|
|
const size_t padded_stride = natural_stride + 64;
|
|
|
|
fm_buffer *w = fm_buffer_alloc((size_t)rows * padded_stride);
|
|
fm_buffer *x = fm_buffer_alloc((size_t)cols * sizeof(float));
|
|
fm_buffer *out = fm_buffer_alloc((size_t)rows * sizeof(float));
|
|
ASSERT_NOT_NULL(w); ASSERT_NOT_NULL(x); ASSERT_NOT_NULL(out);
|
|
|
|
uint8_t *w_host = (uint8_t *)fm_buffer_data(w);
|
|
memset(w_host, 0xA5, (size_t)rows * padded_stride); /* poison the gaps */
|
|
for (int r = 0; r < rows; r++)
|
|
memcpy(w_host + (size_t)r * padded_stride,
|
|
packed + (size_t)r * natural_stride, natural_stride);
|
|
|
|
float *x_host = (float *)fm_buffer_data(x);
|
|
fill_probe_vector(x_host, cols, 3);
|
|
|
|
float expected[32], abs_sum[32], actual[32];
|
|
q5k_reference_matvec(w_host, rows, cols, padded_stride, x_host, expected, abs_sum);
|
|
run_q5k(fm_bufref_at(w, 0), fm_bufref_at(x, 0), fm_bufref_at(out, 0),
|
|
rows, cols, (int)padded_stride, actual);
|
|
|
|
for (int r = 0; r < rows; r++)
|
|
ASSERT_FLT_NEAR(expected[r], actual[r], tolerance_for(abs_sum[r]));
|
|
|
|
fm_buffer_free(w); fm_buffer_free(x); fm_buffer_free(out);
|
|
}
|
|
|
|
/* ================================================================
|
|
* Ragged row tail — the hazard the rows-per-threadgroup split introduces
|
|
* ================================================================ */
|
|
|
|
/* Sentinels are finite on purpose: the shader library is built MTLMathModeFast
|
|
* and the host -ffast-math, so -INFINITY is undefined behaviour and isfinite()
|
|
* folds to constant-true. */
|
|
#define TAIL_GUARD_FLOATS 16
|
|
#define TAIL_GUARD_VALUE (-12345.0f)
|
|
|
|
/* Builds a [rows, cols] Q5_K weight out of REAL model bytes by keeping only the
|
|
* first cols/256 super-blocks of each source row, so every column count in the
|
|
* sweep is backed by genuine blocks rather than synthetic ones. */
|
|
static int build_narrowed_weight(const char *tensor_name, int rows, int cols,
|
|
uint8_t *dst, size_t dst_capacity)
|
|
{
|
|
static uint8_t source[40 * 1408]; /* 40 rows x 2048 cols, the widest here */
|
|
int source_cols = 0;
|
|
if (load_real_q5k_rows(tensor_name, rows, &source_cols, source, sizeof(source)) != 0)
|
|
return -1;
|
|
if (cols > source_cols)
|
|
return -1;
|
|
|
|
const size_t source_stride = (size_t)(source_cols / 256) * Q5K_BLOCK_BYTES;
|
|
const size_t stride = (size_t)(cols / 256) * Q5K_BLOCK_BYTES;
|
|
if ((size_t)rows * stride > dst_capacity)
|
|
return -1;
|
|
|
|
for (int r = 0; r < rows; r++)
|
|
memcpy(dst + (size_t)r * stride, source + (size_t)r * source_stride, stride);
|
|
return 0;
|
|
}
|
|
|
|
/* Runs the kernel into an output buffer padded with sentinels past `rows` and
|
|
* compares every live row absolutely against the CPU oracle.
|
|
*
|
|
* @param tail_intact Set nonzero only if every sentinel past `rows` survived
|
|
* @return Rows outside tolerance, or -1 if a buffer could not be
|
|
* allocated
|
|
*/
|
|
static int ragged_tail_failures(const uint8_t *weight, int rows, int cols,
|
|
int row_stride, int *tail_intact)
|
|
{
|
|
fm_buffer *w = fm_buffer_alloc((size_t)rows * (size_t)row_stride);
|
|
fm_buffer *x = fm_buffer_alloc((size_t)cols * sizeof(float));
|
|
fm_buffer *out = fm_buffer_alloc((size_t)(rows + TAIL_GUARD_FLOATS) * sizeof(float));
|
|
float *expected = (float *)malloc((size_t)rows * sizeof(float));
|
|
float *abs_sum = (float *)malloc((size_t)rows * sizeof(float));
|
|
int failures = -1;
|
|
|
|
*tail_intact = 0;
|
|
if (!w || !x || !out || !expected || !abs_sum) goto cleanup;
|
|
|
|
memcpy(fm_buffer_data(w), weight, (size_t)rows * (size_t)row_stride);
|
|
float *x_host = (float *)fm_buffer_data(x);
|
|
fill_probe_vector(x_host, cols, (unsigned)(rows * 31 + cols));
|
|
q5k_reference_matvec(weight, rows, cols, (size_t)row_stride, x_host,
|
|
expected, abs_sum);
|
|
|
|
float *o = (float *)fm_buffer_data(out);
|
|
for (int i = 0; i < rows + TAIL_GUARD_FLOATS; i++)
|
|
o[i] = TAIL_GUARD_VALUE;
|
|
|
|
fm_matvec_q5k(fm_bufref_at(w, 0), fm_bufref_at(x, 0), fm_bufref_at(out, 0),
|
|
rows, cols, row_stride);
|
|
fm_sync();
|
|
|
|
o = (float *)fm_buffer_data(out);
|
|
*tail_intact = 1;
|
|
for (int i = rows; i < rows + TAIL_GUARD_FLOATS; i++)
|
|
if (o[i] != TAIL_GUARD_VALUE) *tail_intact = 0;
|
|
|
|
failures = 0;
|
|
for (int r = 0; r < rows; r++)
|
|
if (fabsf(expected[r] - o[r]) > tolerance_for(abs_sum[r])) failures++;
|
|
|
|
cleanup:
|
|
if (w) fm_buffer_free(w);
|
|
if (x) fm_buffer_free(x);
|
|
if (out) fm_buffer_free(out);
|
|
free(expected);
|
|
free(abs_sum);
|
|
return failures;
|
|
}
|
|
|
|
/* A threadgroup covers 32/(cols/256) output rows, so the grid is rounded up and
|
|
* the last group carries row indices past the end. Those lanes must contribute
|
|
* nothing to a live row's reduction and store nothing — a hazard that did not
|
|
* exist while a row owned the whole SIMD group.
|
|
*
|
|
* Swept over every column count the real tensor can be narrowed to: cols=256
|
|
* puts 32 rows in a group (1 lane each), 512 puts 16, 1024 puts 8 and 2048
|
|
* puts 4, with row counts chosen to leave every kind of remainder including a
|
|
* single row and one short of a full group. */
|
|
static void test_ragged_row_tail_is_exact_and_writes_nothing_past_rows(void)
|
|
{
|
|
static const int shape[][2] = {
|
|
{ 256, 1 }, { 256, 17 }, { 256, 31 }, { 256, 33 },
|
|
{ 512, 1 }, { 512, 15 }, { 512, 17 }, { 512, 33 },
|
|
{ 1024, 1 }, { 1024, 7 }, { 1024, 9 },
|
|
{ 2048, 1 }, { 2048, 3 }, { 2048, 5 },
|
|
};
|
|
static uint8_t weight[40 * 1408];
|
|
|
|
ASSERT_TRUE(g_model_is_open);
|
|
for (size_t i = 0; i < sizeof(shape) / sizeof(shape[0]); i++) {
|
|
const int cols = shape[i][0];
|
|
const int rows = shape[i][1];
|
|
const int row_stride = (cols / 256) * (int)Q5K_BLOCK_BYTES;
|
|
int tail_intact = 0;
|
|
|
|
ASSERT_INT_EQ(0, build_narrowed_weight(kTensorQkv, rows, cols,
|
|
weight, sizeof(weight)));
|
|
const int failures = ragged_tail_failures(weight, rows, cols, row_stride,
|
|
&tail_intact);
|
|
if (failures != 0 || !tail_intact)
|
|
printf(" cols=%d rows=%d: %d bad rows, tail_intact=%d\n",
|
|
cols, rows, failures, tail_intact);
|
|
ASSERT_TRUE(tail_intact);
|
|
ASSERT_INT_EQ(0, failures);
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* The same absolute check, with the weight inside an fm_region at a
|
|
* 32-aligned, non-page-aligned offset. An ADDITION to the tests above.
|
|
* ================================================================ */
|
|
|
|
static void test_region_offset_matches_cpu_reference(void)
|
|
{
|
|
ASSERT_TRUE(g_model_is_open);
|
|
|
|
const int rows = 32;
|
|
static uint8_t packed[32 * 1408];
|
|
int cols = 0;
|
|
ASSERT_INT_EQ(0, load_real_q5k_rows(kTensorQkv, rows, &cols, packed, sizeof(packed)));
|
|
const size_t row_stride = (size_t)(cols / 256) * Q5K_BLOCK_BYTES;
|
|
|
|
const size_t mapped_bytes = 8u * 1024 * 1024;
|
|
int fd = open(kScratchPath, O_RDWR | O_CREAT | O_TRUNC, 0644);
|
|
ASSERT_TRUE(fd >= 0);
|
|
ASSERT_INT_EQ(0, ftruncate(fd, (off_t)mapped_bytes));
|
|
void *base = mmap(NULL, mapped_bytes, PROT_READ | PROT_WRITE, MAP_PRIVATE, fd, 0);
|
|
ASSERT_TRUE(base != MAP_FAILED);
|
|
|
|
/* Decoy at 0: if the binding ignored its offset the kernel would read
|
|
* these bytes and disagree with the oracle for the real weight. */
|
|
memset(base, 0x3C, mapped_bytes);
|
|
memcpy((uint8_t *)base + REGION_WEIGHT_OFFSET, packed, (size_t)rows * row_stride);
|
|
ASSERT_TRUE(REGION_WEIGHT_OFFSET % 32 == 0);
|
|
ASSERT_TRUE(REGION_WEIGHT_OFFSET % 16384 != 0);
|
|
|
|
fm_region *region = fm_region_open(base, mapped_bytes, 0);
|
|
ASSERT_NOT_NULL(region);
|
|
fm_bufref w;
|
|
ASSERT_INT_EQ(0, fm_region_bind(region, REGION_WEIGHT_OFFSET,
|
|
(size_t)rows * row_stride, &w));
|
|
|
|
fm_buffer *x = fm_buffer_alloc((size_t)cols * sizeof(float));
|
|
fm_buffer *out = fm_buffer_alloc((size_t)rows * sizeof(float));
|
|
ASSERT_NOT_NULL(x); ASSERT_NOT_NULL(out);
|
|
float *x_host = (float *)fm_buffer_data(x);
|
|
fill_probe_vector(x_host, cols, 4);
|
|
|
|
float expected[32], abs_sum[32], actual[32];
|
|
q5k_reference_matvec(packed, rows, cols, row_stride, x_host, expected, abs_sum);
|
|
run_q5k(w, fm_bufref_at(x, 0), fm_bufref_at(out, 0),
|
|
rows, cols, (int)row_stride, actual);
|
|
|
|
for (int r = 0; r < rows; r++)
|
|
ASSERT_FLT_NEAR(expected[r], actual[r], tolerance_for(abs_sum[r]));
|
|
|
|
fm_buffer_free(x); fm_buffer_free(out);
|
|
fm_region_close(region);
|
|
munmap(base, mapped_bytes);
|
|
close(fd);
|
|
unlink(kScratchPath);
|
|
}
|
|
|
|
/* ================================================================
|
|
* main
|
|
* ================================================================ */
|
|
|
|
int main(void)
|
|
{
|
|
printf("\n Metal Q5_K Matvec — Absolute Correctness\n");
|
|
printf(" ========================================\n\n");
|
|
|
|
if (fm_init() != 0 || !fm_available()) {
|
|
fprintf(stderr, "Metal unavailable\n");
|
|
return 77;
|
|
}
|
|
|
|
const char *model_path = getenv("FOUNDRY_TEST_GGUF");
|
|
if (!model_path) model_path = kDefaultModelPath;
|
|
g_model_is_open = gguf_test_open(model_path, &g_model) == 0;
|
|
|
|
if (!g_model_is_open) {
|
|
printf(" %sMODEL FILE MISSING — every test below is driven by real\n"
|
|
" Q5_K blocks and cannot run without it:%s\n %s\n\n",
|
|
FT_RED, FT_RESET, model_path);
|
|
fm_free();
|
|
return 1;
|
|
}
|
|
printf(" model = %s\n", model_path);
|
|
printf(" data starts = %llu\n\n", (unsigned long long)g_model.tensor_data_start);
|
|
|
|
RUN_TEST(real_attn_qkv_matches_cpu_reference);
|
|
RUN_TEST(real_shared_expert_matches_cpu_reference);
|
|
RUN_TEST(one_hot_probe_recovers_every_weight);
|
|
RUN_TEST(padded_row_stride_is_honored);
|
|
RUN_TEST(ragged_row_tail_is_exact_and_writes_nothing_past_rows);
|
|
RUN_TEST(region_offset_matches_cpu_reference);
|
|
|
|
gguf_test_close(&g_model);
|
|
fm_free();
|
|
|
|
TEST_REPORT();
|
|
return __ft_failed > 0 ? 1 : 0;
|
|
}
|