1241 lines
48 KiB
Objective-C
1241 lines
48 KiB
Objective-C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/**
|
|
* @file bench_matvec_bandwidth.m
|
|
* @brief Effective weight-read bandwidth of the four K-quant matvec kernels
|
|
* (Q3_K, Q4_K, Q5_K, Q6_K) across the shapes the Nex-N2-mini GGUF actually
|
|
* dispatches, so Phase 1.5's lane-starvation hypothesis is decided by a
|
|
* sweep rather than by one microbench shape.
|
|
*
|
|
* Wall-clock is not comparable across quants — each reads a different number
|
|
* of bytes for the same shape — so every arm reports GB/s computed from that
|
|
* quant's real block size, alongside the lane occupancy (units_per_row / 32)
|
|
* the hypothesis says drives it.
|
|
*
|
|
* The shape list is not hand-written: the GGUF tensor table is parsed and the
|
|
* distinct (cols, rows) of every quantized weight that decode dispatches is
|
|
* used, plus row-scaled variants that force the small ones out of cache.
|
|
*
|
|
* Rigour guards, in the order they run:
|
|
* - Every arm streams distinct weight bytes (the bind offset walks the
|
|
* buffer between repetitions), so a repeated arm is not measuring cache.
|
|
* Explicit cache-resident twins for the small real shapes make the
|
|
* DRAM-vs-issue question visible instead of assumed.
|
|
* - Elision defence: the output is read back and must change when the
|
|
* activation changes, when the LAST super-block of row 0 changes, and
|
|
* when the last row's bytes change — so no prefix of the row and no
|
|
* suffix of the grid can have been skipped.
|
|
* - `report_elision_guard_can_fail` mutates the weights and then does NOT
|
|
* dispatch; the same comparator must report "unchanged". A green that was
|
|
* never shown to fail is not a gate.
|
|
* - Replica arms give the noise floor on the reported statistic; nothing
|
|
* smaller than it is interpreted.
|
|
* - Arms are interleaved and rotated between rounds, and the pooled MINIMUM
|
|
* is reported — this machine shares its GPU with the window server, so
|
|
* the mean tracks desktop contention rather than kernel cost. p50/p95 are
|
|
* printed beside it to keep the contention band visible.
|
|
*
|
|
* Results and verdict: metal/tests/RESULTS_matvec_bandwidth.md
|
|
*
|
|
* @status Complete and run; verdict recorded in RESULTS_matvec_bandwidth.md.
|
|
* @issues None
|
|
* @todo
|
|
* - [ ] Add the `bench-matvec-bandwidth` Makefile stanza. It is not added
|
|
* here because concurrent work owns the Makefile; the exact stanza and
|
|
* the standalone compile line are in RESULTS_matvec_bandwidth.md.
|
|
*/
|
|
|
|
#import <Foundation/Foundation.h>
|
|
|
|
#include "metal/fm_metal.h"
|
|
#include "metal/fm_metal_internal.h"
|
|
#include "metal/tests/gguf_test_reader.h"
|
|
|
|
#include <math.h>
|
|
#include <stdbool.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
/* ================================================================
|
|
* Configuration
|
|
* ================================================================ */
|
|
|
|
#define DEFAULT_MODEL_PATH \
|
|
"models/Nex-N2-mini/nex-agi_Nex-N2-mini-Q3_K_M.gguf"
|
|
|
|
/* Every matvec here runs one SIMD group per output row. */
|
|
#define SIMD_LANES_PER_THREADGROUP 32
|
|
|
|
/* ggml type ids for the quants this benchmark covers. */
|
|
#define GGML_TYPE_Q3_K 11
|
|
#define GGML_TYPE_Q4_K 12
|
|
#define GGML_TYPE_Q5_K 13
|
|
#define GGML_TYPE_Q6_K 14
|
|
#define GGML_TYPE_Q8_0 8
|
|
|
|
/* Sampling. Rounds are interleaved and rotated; all samples pool. */
|
|
#define MEASUREMENT_ROUND_COUNT 5
|
|
#define SAMPLES_PER_ROUND 10
|
|
#define WARMUP_SAMPLES_PER_ARM 2
|
|
#define MAX_POOLED_SAMPLE_COUNT (MEASUREMENT_ROUND_COUNT * SAMPLES_PER_ROUND)
|
|
|
|
/* Coprime with the arm count, so each arm follows a different predecessor in
|
|
* every round instead of one arm always inheriting the heaviest one's wake. */
|
|
#define ARM_ORDER_ROTATION_STRIDE 7
|
|
|
|
/* Weight bytes one timed sample should move, by repeating the dispatch over
|
|
* successive slabs of the weight buffer. Large enough that per-commit overhead
|
|
* is a rounding error, small enough that a full sweep stays under a minute. */
|
|
#define TARGET_BYTES_PER_SAMPLE (64u * 1024u * 1024u)
|
|
#define MAX_REPETITIONS_PER_SAMPLE 256
|
|
|
|
/* Row counts used by the row-scaled arms, chosen so the smallest quant (Q3_K)
|
|
* still spans >= 128 MB and cannot sit in any level of cache. */
|
|
#define SCALED_ROWS_AT_512_COLS 589824
|
|
#define SCALED_ROWS_AT_4096_COLS 73728
|
|
|
|
/* Footprint under which an arm's weights plausibly live in cache. */
|
|
#define CACHE_RESIDENT_FOOTPRINT_LIMIT (16u * 1024u * 1024u)
|
|
|
|
#define MAX_CENSUS_ENTRIES 32
|
|
#define MAX_SHAPES 16
|
|
#define MAX_ARMS 128
|
|
|
|
/* ================================================================
|
|
* Types
|
|
* ================================================================ */
|
|
|
|
typedef void (*matvec_dispatch_fn)(fm_bufref w, fm_bufref x, fm_bufref out,
|
|
int rows, int cols, int row_stride_bytes);
|
|
|
|
/* One quant's kernel plus the buffer of real blocks it reads. */
|
|
typedef struct {
|
|
const char *name;
|
|
uint32_t ggml_type;
|
|
int block_bytes; /* bytes per 256-weight super-block */
|
|
int lane_unit_weights; /* weights one lane owns per step */
|
|
matvec_dispatch_fn dispatch;
|
|
fm_buffer *weights;
|
|
size_t weight_bytes;
|
|
char source_tensor[GGUF_MAX_TENSOR_NAME];
|
|
} quant_matvec_kernel;
|
|
|
|
/* A weight shape a matvec is asked to run. */
|
|
typedef struct {
|
|
int cols;
|
|
int rows;
|
|
const char *origin; /* where in the model this shape comes from */
|
|
bool from_model; /* false for the row-scaled residency arms */
|
|
} matvec_shape;
|
|
|
|
typedef struct {
|
|
double mean_ms;
|
|
double min_ms;
|
|
double median_ms;
|
|
double p95_ms;
|
|
} timing_statistics;
|
|
|
|
/* One measured configuration: a kernel at a shape, cached or streaming. */
|
|
typedef struct {
|
|
quant_matvec_kernel *kernel;
|
|
matvec_shape shape;
|
|
bool stream_distinct_slabs;
|
|
bool is_noise_replica;
|
|
int repetitions;
|
|
size_t slab_stride_bytes;
|
|
int slab_count;
|
|
double pooled_samples[MAX_POOLED_SAMPLE_COUNT];
|
|
int pooled_count;
|
|
} measurement_arm;
|
|
|
|
/* Distinct (type, cols, rows) seen in the model's tensor table. */
|
|
typedef struct {
|
|
uint32_t ggml_type;
|
|
int cols;
|
|
int rows;
|
|
int tensor_count;
|
|
long matrix_count; /* tensors x expert slots */
|
|
char example[GGUF_MAX_TENSOR_NAME];
|
|
} census_entry;
|
|
|
|
/* ================================================================
|
|
* Shared benchmark buffers
|
|
* ================================================================ */
|
|
|
|
static fm_buffer *activation_a; /* two distinct activations, so a repeat */
|
|
static fm_buffer *activation_b; /* dispatch is never bit-identical */
|
|
static fm_buffer *output_rows;
|
|
static int max_rows_allocated;
|
|
static int max_cols_allocated;
|
|
|
|
/* ================================================================
|
|
* Timing and statistics
|
|
* ================================================================ */
|
|
|
|
static double monotonic_milliseconds(void)
|
|
{
|
|
struct timespec now;
|
|
clock_gettime(CLOCK_MONOTONIC, &now);
|
|
return (double)now.tv_sec * 1000.0 + (double)now.tv_nsec / 1.0e6;
|
|
}
|
|
|
|
static int compare_doubles_ascending(const void *left, const void *right)
|
|
{
|
|
double a = *(const double *)left;
|
|
double b = *(const double *)right;
|
|
if (a < b)
|
|
return -1;
|
|
if (a > b)
|
|
return 1;
|
|
return 0;
|
|
}
|
|
|
|
/**
|
|
* Summarises latency samples. Sorts `samples` in place.
|
|
*
|
|
* @param samples Per-sample wall times in milliseconds
|
|
* @param count Sample count, must be > 0
|
|
* @return Mean, min, median and p95
|
|
*/
|
|
static timing_statistics summarise_latency_samples(double *samples, int count)
|
|
{
|
|
timing_statistics stats = {0};
|
|
double sum = 0.0;
|
|
|
|
for (int i = 0; i < count; i++)
|
|
sum += samples[i];
|
|
stats.mean_ms = sum / (double)count;
|
|
|
|
qsort(samples, (size_t)count, sizeof(*samples), compare_doubles_ascending);
|
|
stats.min_ms = samples[0];
|
|
stats.median_ms = samples[count / 2];
|
|
stats.p95_ms = samples[(int)((double)(count - 1) * 0.95)];
|
|
return stats;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Shape arithmetic — the axis the hypothesis is about
|
|
* ================================================================ */
|
|
|
|
/* Every K-quant here packs 256 weights per super-block, whatever its size. */
|
|
static int blocks_per_row(int cols)
|
|
{
|
|
return cols / 256;
|
|
}
|
|
|
|
static int row_stride_bytes_for(const quant_matvec_kernel *kernel, int cols)
|
|
{
|
|
return blocks_per_row(cols) * kernel->block_bytes;
|
|
}
|
|
|
|
static size_t weight_bytes_for(const quant_matvec_kernel *kernel, int cols, int rows)
|
|
{
|
|
return (size_t)rows * (size_t)row_stride_bytes_for(kernel, cols);
|
|
}
|
|
|
|
/** Lane-sized work units in one row: what the hypothesis says starves lanes. */
|
|
static int units_per_row(const quant_matvec_kernel *kernel, int cols)
|
|
{
|
|
return cols / kernel->lane_unit_weights;
|
|
}
|
|
|
|
/** Fraction of a SIMD group's 32 lanes that own work; 1.0 once units >= 32. */
|
|
static double lane_occupancy(const quant_matvec_kernel *kernel, int cols)
|
|
{
|
|
int units = units_per_row(kernel, cols);
|
|
return units >= SIMD_LANES_PER_THREADGROUP
|
|
? 1.0 : (double)units / (double)SIMD_LANES_PER_THREADGROUP;
|
|
}
|
|
|
|
/* ================================================================
|
|
* GGUF census — the shapes decode actually dispatches
|
|
* ================================================================ */
|
|
|
|
static bool ggml_type_is_quantized_matvec(uint32_t ggml_type)
|
|
{
|
|
return ggml_type == GGML_TYPE_Q3_K || ggml_type == GGML_TYPE_Q4_K ||
|
|
ggml_type == GGML_TYPE_Q5_K || ggml_type == GGML_TYPE_Q6_K ||
|
|
ggml_type == GGML_TYPE_Q8_0;
|
|
}
|
|
|
|
/* token_embd is Q3_K but is dequantized at load and never dispatched, so its
|
|
* shape must not enter the sweep. Everything else quantized is a matvec. */
|
|
static bool tensor_is_dispatched_weight(const char *name, uint32_t ggml_type,
|
|
uint32_t n_dims)
|
|
{
|
|
return ggml_type_is_quantized_matvec(ggml_type) && n_dims >= 2 &&
|
|
strstr(name, "token_embd") == NULL;
|
|
}
|
|
|
|
static int find_census_slot(census_entry *entries, int count,
|
|
uint32_t ggml_type, int cols, int rows)
|
|
{
|
|
for (int i = 0; i < count; i++) {
|
|
if (entries[i].ggml_type == ggml_type && entries[i].cols == cols &&
|
|
entries[i].rows == rows)
|
|
return i;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/**
|
|
* Walks the tensor table and aggregates every dispatched quantized weight by
|
|
* (type, cols, rows). GGUF stores dims fastest-varying first, so dims[0] is the
|
|
* column count, dims[1] the row count, and dims[2] (experts) multiplies how
|
|
* many matrices of that shape exist.
|
|
*
|
|
* @param file Handle from gguf_test_open
|
|
* @param entries Receives the distinct (type, shape) rows
|
|
* @param capacity Capacity of `entries`
|
|
* @return Number of entries filled, or -1 on a malformed table
|
|
*/
|
|
static int collect_dispatched_weight_census(gguf_test_file *file,
|
|
census_entry *entries, int capacity)
|
|
{
|
|
if (fseeko(file->fp, (off_t)file->tensor_info_start, SEEK_SET) != 0)
|
|
return -1;
|
|
|
|
int count = 0;
|
|
for (uint64_t i = 0; i < file->tensor_count; i++) {
|
|
char name[GGUF_MAX_TENSOR_NAME];
|
|
gguf_tensor_location location;
|
|
if (gguf_read_tensor_info(file->fp, name, sizeof(name), &location) != 0)
|
|
return -1;
|
|
if (!tensor_is_dispatched_weight(name, location.ggml_type, location.n_dims))
|
|
continue;
|
|
|
|
int cols = (int)location.dims[0];
|
|
int rows = (int)location.dims[1];
|
|
long experts = location.n_dims >= 3 ? (long)location.dims[2] : 1;
|
|
int slot = find_census_slot(entries, count, location.ggml_type, cols, rows);
|
|
if (slot < 0) {
|
|
if (count >= capacity)
|
|
continue;
|
|
slot = count++;
|
|
memset(&entries[slot], 0, sizeof(entries[slot]));
|
|
entries[slot].ggml_type = location.ggml_type;
|
|
entries[slot].cols = cols;
|
|
entries[slot].rows = rows;
|
|
snprintf(entries[slot].example, sizeof(entries[slot].example), "%s", name);
|
|
}
|
|
entries[slot].tensor_count++;
|
|
entries[slot].matrix_count += experts;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
static const char *ggml_type_name(uint32_t ggml_type)
|
|
{
|
|
switch (ggml_type) {
|
|
case GGML_TYPE_Q3_K: return "Q3_K";
|
|
case GGML_TYPE_Q4_K: return "Q4_K";
|
|
case GGML_TYPE_Q5_K: return "Q5_K";
|
|
case GGML_TYPE_Q6_K: return "Q6_K";
|
|
case GGML_TYPE_Q8_0: return "Q8_0";
|
|
default: return "?";
|
|
}
|
|
}
|
|
|
|
static void print_census(const census_entry *entries, int count)
|
|
{
|
|
printf("\nDispatched quantized weights in the model (token_embd excluded — "
|
|
"dequantized at load)\n");
|
|
printf("%-6s %7s %8s %9s %10s %s\n",
|
|
"type", "cols", "rows", "tensors", "matrices", "example");
|
|
printf("%-6s %7s %8s %9s %10s %s\n",
|
|
"------", "-------", "--------", "---------", "----------",
|
|
"------------------------------");
|
|
for (int i = 0; i < count; i++) {
|
|
printf("%-6s %7d %8d %9d %10ld %s\n",
|
|
ggml_type_name(entries[i].ggml_type), entries[i].cols,
|
|
entries[i].rows, entries[i].tensor_count,
|
|
entries[i].matrix_count, entries[i].example);
|
|
}
|
|
}
|
|
|
|
/** Distinct (cols, rows) across the census, in first-seen order. */
|
|
static int collect_distinct_shapes(const census_entry *entries, int count,
|
|
matvec_shape *shapes, int capacity)
|
|
{
|
|
int shape_count = 0;
|
|
|
|
for (int i = 0; i < count; i++) {
|
|
bool seen = false;
|
|
for (int s = 0; s < shape_count; s++) {
|
|
if (shapes[s].cols == entries[i].cols && shapes[s].rows == entries[i].rows)
|
|
seen = true;
|
|
}
|
|
if (seen || shape_count >= capacity)
|
|
continue;
|
|
if (entries[i].cols % 256 != 0)
|
|
continue;
|
|
shapes[shape_count].cols = entries[i].cols;
|
|
shapes[shape_count].rows = entries[i].rows;
|
|
shapes[shape_count].origin = entries[i].example;
|
|
shapes[shape_count].from_model = true;
|
|
shape_count++;
|
|
}
|
|
return shape_count;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Real weight bytes
|
|
* ================================================================ */
|
|
|
|
/* Largest chunk of a real tensor copied out of the model file and tiled to
|
|
* fill a weight buffer. Real blocks matter: random bytes put NaN in the half
|
|
* scales, which would make every integrity check vacuous. */
|
|
#define WEIGHT_SOURCE_CHUNK_LIMIT (8u * 1024u * 1024u)
|
|
|
|
/** Stored bytes of a quantized tensor, expert dimensions included. */
|
|
static size_t quantized_tensor_bytes(const gguf_tensor_location *location,
|
|
int block_bytes)
|
|
{
|
|
size_t bytes = (size_t)location->dims[1] *
|
|
((size_t)location->dims[0] / 256) * (size_t)block_bytes;
|
|
|
|
for (uint32_t d = 2; d < location->n_dims; d++)
|
|
bytes *= (size_t)location->dims[d];
|
|
return bytes;
|
|
}
|
|
|
|
/** Largest tensor of `ggml_type` in the file, so the tile period is as long
|
|
* as possible; reports its absolute offset and stored size. */
|
|
static int find_weight_source_tensor(gguf_test_file *file, uint32_t ggml_type,
|
|
int block_bytes, gguf_tensor_location *out,
|
|
size_t *bytes_out, char *name_out,
|
|
size_t name_out_size)
|
|
{
|
|
size_t best_bytes = 0;
|
|
|
|
if (fseeko(file->fp, (off_t)file->tensor_info_start, SEEK_SET) != 0)
|
|
return -1;
|
|
|
|
for (uint64_t i = 0; i < file->tensor_count; i++) {
|
|
char name[GGUF_MAX_TENSOR_NAME];
|
|
gguf_tensor_location location;
|
|
if (gguf_read_tensor_info(file->fp, name, sizeof(name), &location) != 0)
|
|
return -1;
|
|
if (location.ggml_type != ggml_type || location.n_dims < 2)
|
|
continue;
|
|
|
|
size_t bytes = quantized_tensor_bytes(&location, block_bytes);
|
|
if (bytes <= best_bytes)
|
|
continue;
|
|
|
|
best_bytes = bytes;
|
|
location.file_offset += file->tensor_data_start;
|
|
*out = location;
|
|
*bytes_out = bytes;
|
|
snprintf(name_out, name_out_size, "%s", name);
|
|
}
|
|
return best_bytes > 0 ? 0 : -1;
|
|
}
|
|
|
|
/**
|
|
* Fills a kernel's weight buffer by tiling a chunk of one real tensor. The
|
|
* chunk is a whole number of super-blocks so every tile boundary lands on a
|
|
* block boundary and the kernel decodes valid scales everywhere.
|
|
*
|
|
* @return 0 on success, -1 if no suitable tensor exists or the read failed
|
|
*/
|
|
static int fill_weight_buffer_from_model(gguf_test_file *file,
|
|
quant_matvec_kernel *kernel)
|
|
{
|
|
gguf_tensor_location location;
|
|
size_t available = 0;
|
|
|
|
if (find_weight_source_tensor(file, kernel->ggml_type, kernel->block_bytes,
|
|
&location, &available, kernel->source_tensor,
|
|
sizeof(kernel->source_tensor)) != 0)
|
|
return -1;
|
|
|
|
size_t budget = available < WEIGHT_SOURCE_CHUNK_LIMIT
|
|
? available : WEIGHT_SOURCE_CHUNK_LIMIT;
|
|
size_t chunk_blocks = budget / (size_t)kernel->block_bytes;
|
|
size_t chunk_bytes = chunk_blocks * (size_t)kernel->block_bytes;
|
|
|
|
if (chunk_bytes == 0)
|
|
return -1;
|
|
|
|
unsigned char *chunk = (unsigned char *)malloc(chunk_bytes);
|
|
if (!chunk)
|
|
return -1;
|
|
if (gguf_test_read_at(file, location.file_offset, chunk, chunk_bytes) != 0) {
|
|
free(chunk);
|
|
return -1;
|
|
}
|
|
|
|
unsigned char *dst = (unsigned char *)fm_buffer_data(kernel->weights);
|
|
for (size_t written = 0; written < kernel->weight_bytes; written += chunk_bytes) {
|
|
size_t span = kernel->weight_bytes - written;
|
|
if (span > chunk_bytes)
|
|
span = chunk_bytes;
|
|
memcpy(dst + written, chunk, span);
|
|
}
|
|
free(chunk);
|
|
return 0;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Slab walking — keeps a repeated dispatch off the cache it just filled
|
|
* ================================================================ */
|
|
|
|
static size_t greatest_common_divisor(size_t a, size_t b)
|
|
{
|
|
while (b != 0) {
|
|
size_t remainder = a % b;
|
|
a = b;
|
|
b = remainder;
|
|
}
|
|
return a;
|
|
}
|
|
|
|
/**
|
|
* Byte stride between successive repetitions of one arm: the arm's own weight
|
|
* footprint rounded up to a multiple of both the bind alignment and the block
|
|
* size, so every repetition starts on a real super-block at a legal offset.
|
|
*/
|
|
static size_t slab_stride_for(const quant_matvec_kernel *kernel, int cols, int rows)
|
|
{
|
|
size_t alignment = fm_buffer_offset_alignment();
|
|
size_t block = (size_t)kernel->block_bytes;
|
|
size_t granule = alignment / greatest_common_divisor(alignment, block) * block;
|
|
size_t footprint = weight_bytes_for(kernel, cols, rows);
|
|
|
|
return (footprint + granule - 1) / granule * granule;
|
|
}
|
|
|
|
/** How many distinct slabs of this shape the kernel's buffer holds. */
|
|
static int slab_count_for(const quant_matvec_kernel *kernel, int cols, int rows,
|
|
size_t slab_stride)
|
|
{
|
|
size_t footprint = weight_bytes_for(kernel, cols, rows);
|
|
|
|
if (kernel->weight_bytes < footprint || slab_stride == 0)
|
|
return 1;
|
|
return (int)((kernel->weight_bytes - footprint) / slab_stride) + 1;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Measurement
|
|
* ================================================================ */
|
|
|
|
/** Encodes `repetitions` dispatches into one command buffer and waits. */
|
|
static double time_one_sample(const measurement_arm *arm)
|
|
{
|
|
quant_matvec_kernel *kernel = arm->kernel;
|
|
int stride = row_stride_bytes_for(kernel, arm->shape.cols);
|
|
double started;
|
|
|
|
fm_batch_begin();
|
|
started = monotonic_milliseconds();
|
|
for (int r = 0; r < arm->repetitions; r++) {
|
|
size_t offset = arm->stream_distinct_slabs
|
|
? (size_t)(r % arm->slab_count) * arm->slab_stride_bytes : 0;
|
|
fm_buffer *x = (r & 1) ? activation_b : activation_a;
|
|
kernel->dispatch(fm_bufref_at(kernel->weights, offset),
|
|
fm_bufref_at(x, 0), fm_bufref_at(output_rows, 0),
|
|
arm->shape.rows, arm->shape.cols, stride);
|
|
}
|
|
fm_batch_end();
|
|
return monotonic_milliseconds() - started;
|
|
}
|
|
|
|
static void run_measurement_arms(measurement_arm *arms, int count)
|
|
{
|
|
for (int arm = 0; arm < count; arm++) {
|
|
for (int w = 0; w < WARMUP_SAMPLES_PER_ARM; w++)
|
|
(void)time_one_sample(&arms[arm]);
|
|
}
|
|
|
|
for (int round = 0; round < MEASUREMENT_ROUND_COUNT; round++) {
|
|
for (int slot = 0; slot < count; slot++) {
|
|
int index = (slot + round * ARM_ORDER_ROTATION_STRIDE) % count;
|
|
measurement_arm *arm = &arms[index];
|
|
for (int s = 0; s < SAMPLES_PER_ROUND; s++)
|
|
arm->pooled_samples[arm->pooled_count++] = time_one_sample(arm);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Weight bytes one timed sample moves. */
|
|
static double sample_weight_bytes(const measurement_arm *arm)
|
|
{
|
|
return (double)arm->repetitions *
|
|
(double)weight_bytes_for(arm->kernel, arm->shape.cols, arm->shape.rows);
|
|
}
|
|
|
|
static double effective_gigabytes_per_second(const measurement_arm *arm,
|
|
double milliseconds)
|
|
{
|
|
if (milliseconds <= 0.0)
|
|
return 0.0;
|
|
return sample_weight_bytes(arm) / (milliseconds / 1000.0) / 1.0e9;
|
|
}
|
|
|
|
/** Milliseconds one single dispatch of this arm's shape costs. */
|
|
static double milliseconds_per_dispatch(const measurement_arm *arm,
|
|
double sample_milliseconds)
|
|
{
|
|
return sample_milliseconds / (double)arm->repetitions;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Elision and dependence guards
|
|
* ================================================================ */
|
|
|
|
/* Shape used for the integrity probes: big enough that a skipped tail would be
|
|
* a large fraction of the work, small enough to check quickly. */
|
|
#define GUARD_COLS 2048
|
|
#define GUARD_ROWS 4096
|
|
|
|
static void dispatch_once_and_capture(quant_matvec_kernel *kernel, fm_buffer *x,
|
|
int rows, int cols, float *snapshot)
|
|
{
|
|
memset(fm_buffer_data(output_rows), 0, (size_t)rows * sizeof(float));
|
|
fm_batch_begin();
|
|
kernel->dispatch(fm_bufref_at(kernel->weights, 0), fm_bufref_at(x, 0),
|
|
fm_bufref_at(output_rows, 0), rows, cols,
|
|
row_stride_bytes_for(kernel, cols));
|
|
fm_batch_end();
|
|
memcpy(snapshot, fm_buffer_data(output_rows), (size_t)rows * sizeof(float));
|
|
}
|
|
|
|
/** True if the two snapshots differ anywhere outside float noise. */
|
|
static bool snapshots_differ(const float *left, const float *right, int count)
|
|
{
|
|
for (int i = 0; i < count; i++) {
|
|
double a = left[i];
|
|
double b = right[i];
|
|
double scale = fabs(a) > fabs(b) ? fabs(a) : fabs(b);
|
|
if (fabs(a - b) > 1.0e-6 * (scale > 1.0 ? scale : 1.0))
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
static bool snapshot_is_live(const float *values, int count)
|
|
{
|
|
int nonzero = 0;
|
|
|
|
for (int i = 0; i < count; i++) {
|
|
if (!isfinite(values[i]))
|
|
return false;
|
|
if (values[i] != 0.0f)
|
|
nonzero++;
|
|
}
|
|
return nonzero > count / 2;
|
|
}
|
|
|
|
/**
|
|
* Proves the timed kernel really reads every weight byte it is credited with.
|
|
* Three dependences are required, each of which a partially-elided or
|
|
* early-exiting kernel would fail: the activation, the LAST super-block of
|
|
* row 0, and the last row's bytes.
|
|
*
|
|
* @param kernel Kernel under test (its weight buffer is mutated and restored)
|
|
* @param scratch_a Snapshot buffer, >= GUARD_ROWS floats
|
|
* @param scratch_b Second snapshot buffer
|
|
* @return true if every dependence held
|
|
*/
|
|
static bool probe_full_read_dependence(quant_matvec_kernel *kernel,
|
|
float *scratch_a, float *scratch_b)
|
|
{
|
|
unsigned char *weights = (unsigned char *)fm_buffer_data(kernel->weights);
|
|
int stride = row_stride_bytes_for(kernel, GUARD_COLS);
|
|
size_t last_block_of_row0 = (size_t)stride - (size_t)kernel->block_bytes;
|
|
size_t last_row_first_byte = (size_t)(GUARD_ROWS - 1) * (size_t)stride;
|
|
bool ok = true;
|
|
|
|
dispatch_once_and_capture(kernel, activation_a, GUARD_ROWS, GUARD_COLS, scratch_a);
|
|
if (!snapshot_is_live(scratch_a, GUARD_ROWS)) {
|
|
printf(" %-5s FAIL output is not finite/non-trivial\n", kernel->name);
|
|
return false;
|
|
}
|
|
|
|
dispatch_once_and_capture(kernel, activation_b, GUARD_ROWS, GUARD_COLS, scratch_b);
|
|
if (!snapshots_differ(scratch_a, scratch_b, GUARD_ROWS)) {
|
|
printf(" %-5s FAIL output does not depend on the activation\n", kernel->name);
|
|
ok = false;
|
|
}
|
|
|
|
weights[last_block_of_row0] ^= 0xFFu;
|
|
dispatch_once_and_capture(kernel, activation_a, GUARD_ROWS, GUARD_COLS, scratch_b);
|
|
weights[last_block_of_row0] ^= 0xFFu;
|
|
if (!snapshots_differ(scratch_a, scratch_b, 1)) {
|
|
printf(" %-5s FAIL row 0 ignores its LAST super-block\n", kernel->name);
|
|
ok = false;
|
|
}
|
|
|
|
weights[last_row_first_byte] ^= 0xFFu;
|
|
dispatch_once_and_capture(kernel, activation_a, GUARD_ROWS, GUARD_COLS, scratch_b);
|
|
weights[last_row_first_byte] ^= 0xFFu;
|
|
if (!snapshots_differ(&scratch_a[GUARD_ROWS - 1], &scratch_b[GUARD_ROWS - 1], 1)) {
|
|
printf(" %-5s FAIL the last row was never dispatched\n", kernel->name);
|
|
ok = false;
|
|
}
|
|
|
|
if (ok)
|
|
printf(" %-5s pass activation, last super-block of row 0, last row\n",
|
|
kernel->name);
|
|
return ok;
|
|
}
|
|
|
|
/**
|
|
* Shows the dependence comparator can report red: mutate the weights and then
|
|
* skip the dispatch. The snapshot must be unchanged, which is exactly what a
|
|
* silently-elided dispatch would look like.
|
|
*
|
|
* @return true if the comparator correctly reported "unchanged"
|
|
*/
|
|
static bool report_elision_guard_can_fail(quant_matvec_kernel *kernel,
|
|
float *scratch_a, float *scratch_b)
|
|
{
|
|
unsigned char *weights = (unsigned char *)fm_buffer_data(kernel->weights);
|
|
int stride = row_stride_bytes_for(kernel, GUARD_COLS);
|
|
size_t last_block_of_row0 = (size_t)stride - (size_t)kernel->block_bytes;
|
|
bool detected;
|
|
|
|
dispatch_once_and_capture(kernel, activation_a, GUARD_ROWS, GUARD_COLS, scratch_a);
|
|
memcpy(scratch_b, scratch_a, (size_t)GUARD_ROWS * sizeof(float));
|
|
|
|
weights[last_block_of_row0] ^= 0xFFu; /* mutate, then deliberately do not dispatch */
|
|
detected = snapshots_differ(scratch_a, scratch_b, GUARD_ROWS);
|
|
weights[last_block_of_row0] ^= 0xFFu;
|
|
|
|
printf(" %-5s %s comparator on a mutation with NO dispatch reported %s\n",
|
|
kernel->name, detected ? "FAIL" : "pass",
|
|
detected ? "changed (bogus)" : "unchanged");
|
|
return !detected;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Arm construction
|
|
* ================================================================ */
|
|
|
|
static int repetitions_for(const quant_matvec_kernel *kernel, int cols, int rows)
|
|
{
|
|
size_t footprint = weight_bytes_for(kernel, cols, rows);
|
|
size_t wanted = TARGET_BYTES_PER_SAMPLE / (footprint > 0 ? footprint : 1);
|
|
|
|
if (wanted < 1)
|
|
wanted = 1;
|
|
if (wanted > MAX_REPETITIONS_PER_SAMPLE)
|
|
wanted = MAX_REPETITIONS_PER_SAMPLE;
|
|
return (int)wanted;
|
|
}
|
|
|
|
static void configure_arm(measurement_arm *arm, quant_matvec_kernel *kernel,
|
|
matvec_shape shape, bool stream_distinct_slabs)
|
|
{
|
|
memset(arm, 0, sizeof(*arm));
|
|
arm->kernel = kernel;
|
|
arm->shape = shape;
|
|
arm->stream_distinct_slabs = stream_distinct_slabs;
|
|
arm->repetitions = repetitions_for(kernel, shape.cols, shape.rows);
|
|
arm->slab_stride_bytes = slab_stride_for(kernel, shape.cols, shape.rows);
|
|
arm->slab_count = slab_count_for(kernel, shape.cols, shape.rows,
|
|
arm->slab_stride_bytes);
|
|
}
|
|
|
|
/**
|
|
* Builds the sweep: every kernel at every shape, streaming distinct slabs, plus
|
|
* a cache-resident twin wherever the shape's footprint is small enough for the
|
|
* distinction to mean anything.
|
|
*/
|
|
static int build_sweep_arms(measurement_arm *arms, int capacity,
|
|
quant_matvec_kernel *kernels, int kernel_count,
|
|
const matvec_shape *shapes, int shape_count)
|
|
{
|
|
int count = 0;
|
|
|
|
for (int s = 0; s < shape_count; s++) {
|
|
for (int k = 0; k < kernel_count; k++) {
|
|
if (count >= capacity)
|
|
return count;
|
|
configure_arm(&arms[count++], &kernels[k], shapes[s], true);
|
|
}
|
|
}
|
|
|
|
for (int s = 0; s < shape_count; s++) {
|
|
size_t smallest = weight_bytes_for(&kernels[0], shapes[s].cols, shapes[s].rows);
|
|
if (smallest >= CACHE_RESIDENT_FOOTPRINT_LIMIT)
|
|
continue;
|
|
for (int k = 0; k < kernel_count; k++) {
|
|
if (count >= capacity)
|
|
return count;
|
|
configure_arm(&arms[count++], &kernels[k], shapes[s], false);
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/* Indices replicated byte-for-byte to establish the noise floor. A replica
|
|
* runs through the same interleaved rounds as its original, so the gap between
|
|
* the two is pure measurement noise on the statistic actually reported. */
|
|
static const int NOISE_FLOOR_REPLICATED_ARMS[] = { 0, 5 };
|
|
#define NOISE_FLOOR_REPLICA_COUNT \
|
|
((int)(sizeof(NOISE_FLOOR_REPLICATED_ARMS) / sizeof(NOISE_FLOOR_REPLICATED_ARMS[0])))
|
|
|
|
static int append_noise_floor_replicas(measurement_arm *arms, int count, int capacity)
|
|
{
|
|
for (int i = 0; i < NOISE_FLOOR_REPLICA_COUNT; i++) {
|
|
int source = NOISE_FLOOR_REPLICATED_ARMS[i];
|
|
if (source >= count || count >= capacity)
|
|
continue;
|
|
arms[count] = arms[source];
|
|
arms[count].is_noise_replica = true;
|
|
arms[count].pooled_count = 0;
|
|
count++;
|
|
}
|
|
return count;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Reporting
|
|
* ================================================================ */
|
|
|
|
static void print_results_header(const char *title)
|
|
{
|
|
printf("\n%s\n", title);
|
|
printf(" (every kernel dispatches ONE threadgroup of %d threads per output "
|
|
"row, so threadgroups == rows)\n", SIMD_LANES_PER_THREADGROUP);
|
|
printf("%-5s %6s %8s %9s %6s %5s %10s %9s %9s %8s %8s\n",
|
|
"quant", "cols", "rows/tg", "footprnt", "units", "lane%", "resident",
|
|
"min ms/d", "p50 ms/d", "GB/s", "p50GB/s");
|
|
printf("%-5s %6s %8s %9s %6s %5s %10s %9s %9s %8s %8s\n",
|
|
"-----", "------", "--------", "---------", "------", "-----",
|
|
"----------", "---------", "---------", "--------", "--------");
|
|
}
|
|
|
|
static void print_arm_row(measurement_arm *arm)
|
|
{
|
|
timing_statistics stats = summarise_latency_samples(arm->pooled_samples,
|
|
arm->pooled_count);
|
|
size_t footprint = weight_bytes_for(arm->kernel, arm->shape.cols, arm->shape.rows);
|
|
|
|
printf("%-5s %6d %8d %8.1fM %6d %4.0f%% %10s %9.3f %9.3f %8.2f %8.2f%s\n",
|
|
arm->kernel->name, arm->shape.cols, arm->shape.rows,
|
|
(double)footprint / (1024.0 * 1024.0),
|
|
units_per_row(arm->kernel, arm->shape.cols),
|
|
100.0 * lane_occupancy(arm->kernel, arm->shape.cols),
|
|
arm->stream_distinct_slabs ? "streaming" : "cache-hot",
|
|
milliseconds_per_dispatch(arm, stats.min_ms),
|
|
milliseconds_per_dispatch(arm, stats.median_ms),
|
|
effective_gigabytes_per_second(arm, stats.min_ms),
|
|
effective_gigabytes_per_second(arm, stats.median_ms),
|
|
arm->is_noise_replica ? " (noise replica)" : "");
|
|
}
|
|
|
|
/**
|
|
* Reports the noise floor as the GB/s gap between each replicated arm and its
|
|
* byte-identical replica. Nothing smaller than this is a difference.
|
|
*
|
|
* @return The largest observed replica gap, as a fraction of the arm's GB/s
|
|
*/
|
|
static double report_noise_floor(measurement_arm *arms, int replica_start)
|
|
{
|
|
double worst = 0.0;
|
|
|
|
printf("\nNoise floor — byte-identical replica arms, same interleaved rounds\n");
|
|
printf("%-5s %6s %8s %12s %12s %10s\n",
|
|
"quant", "cols", "rows", "GB/s", "replica GB/s", "gap");
|
|
for (int i = 0; i < NOISE_FLOOR_REPLICA_COUNT; i++) {
|
|
measurement_arm *original = &arms[NOISE_FLOOR_REPLICATED_ARMS[i]];
|
|
measurement_arm *replica = &arms[replica_start + i];
|
|
timing_statistics a = summarise_latency_samples(original->pooled_samples,
|
|
original->pooled_count);
|
|
timing_statistics b = summarise_latency_samples(replica->pooled_samples,
|
|
replica->pooled_count);
|
|
double left = effective_gigabytes_per_second(original, a.min_ms);
|
|
double right = effective_gigabytes_per_second(replica, b.min_ms);
|
|
double gap = left > 0.0 ? fabs(left - right) / left : 0.0;
|
|
|
|
if (gap > worst)
|
|
worst = gap;
|
|
printf("%-5s %6d %8d %12.2f %12.2f %9.2f%%\n",
|
|
original->kernel->name, original->shape.cols, original->shape.rows,
|
|
left, right, 100.0 * gap);
|
|
}
|
|
printf("\nNOISE FLOOR: %.2f%% of GB/s. No difference below this is real.\n",
|
|
100.0 * worst);
|
|
return worst;
|
|
}
|
|
|
|
/* ================================================================
|
|
* The kernels under test
|
|
*
|
|
* lane_unit_weights is the axis Phase 1.5 is about: q3_k and q4_k give one
|
|
* lane a whole 256-weight super-block, q5_k and q6_k give it a 32-weight
|
|
* sub-block, so at the same shape the latter two have 8x the work units to
|
|
* spread over the same 32 lanes.
|
|
* ================================================================ */
|
|
|
|
static quant_matvec_kernel KERNELS[] = {
|
|
{ "q3_k", GGML_TYPE_Q3_K, 110, 256, fm_matvec_q3k_at, NULL, 0, {0} },
|
|
{ "q4_k", GGML_TYPE_Q4_K, 144, 256, fm_matvec_q4_k_at, NULL, 0, {0} },
|
|
{ "q5_k", GGML_TYPE_Q5_K, 176, 32, fm_matvec_q5k, NULL, 0, {0} },
|
|
{ "q6_k", GGML_TYPE_Q6_K, 210, 32, fm_matvec_q6k, NULL, 0, {0} },
|
|
};
|
|
#define KERNEL_COUNT ((int)(sizeof(KERNELS) / sizeof(KERNELS[0])))
|
|
|
|
/* ================================================================
|
|
* Commit cost — what a one-dispatch-per-commit measurement really times
|
|
*
|
|
* Phase 1.5 was raised on a single-shape number of 1.04 ms (Q3_K) / 0.97 ms
|
|
* (Q4_K) at rows=4096, cols=2048. Every arm above amortizes the command-buffer
|
|
* commit across many dispatches, so this stage re-measures the same shape with
|
|
* exactly one dispatch per commit-and-wait and prints both, making visible how
|
|
* much of that original number was the kernel and how much was the round trip.
|
|
* ================================================================ */
|
|
|
|
#define COMMIT_COST_COLS 2048
|
|
#define COMMIT_COST_ROWS 4096
|
|
|
|
static void report_commit_cost_at_microbench_shape(measurement_arm *scratch)
|
|
{
|
|
matvec_shape shape = { COMMIT_COST_COLS, COMMIT_COST_ROWS,
|
|
"Phase 1.5 microbench shape", true };
|
|
|
|
printf("\nCommit cost at the Phase 1.5 microbench shape "
|
|
"(rows=%d, cols=%d)\n", COMMIT_COST_ROWS, COMMIT_COST_COLS);
|
|
printf("%-5s %12s %14s %14s %12s\n", "quant", "amortized ms",
|
|
"1-per-commit ms", "commit ms", "amortized GB/s");
|
|
for (int k = 0; k < KERNEL_COUNT; k++) {
|
|
configure_arm(&scratch[0], &KERNELS[k], shape, true);
|
|
configure_arm(&scratch[1], &KERNELS[k], shape, true);
|
|
scratch[1].repetitions = 1;
|
|
run_measurement_arms(scratch, 2);
|
|
|
|
timing_statistics amortized =
|
|
summarise_latency_samples(scratch[0].pooled_samples, scratch[0].pooled_count);
|
|
timing_statistics single =
|
|
summarise_latency_samples(scratch[1].pooled_samples, scratch[1].pooled_count);
|
|
double per_dispatch = milliseconds_per_dispatch(&scratch[0], amortized.min_ms);
|
|
|
|
printf("%-5s %12.3f %14.3f %14.3f %12.2f\n", KERNELS[k].name, per_dispatch,
|
|
single.min_ms, single.min_ms - per_dispatch,
|
|
effective_gigabytes_per_second(&scratch[0], amortized.min_ms));
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Threads-per-threadgroup probe — the OTHER occupancy axis
|
|
*
|
|
* Lane occupancy (units per row vs 32 lanes) and core occupancy (how the grid
|
|
* maps onto the M4's 10 cores) are independent, and every matvec here is
|
|
* pinned at 32 threads per threadgroup because it reduces with simd_sum. That
|
|
* pin cannot be varied without rewriting the kernels, so it is measured on a
|
|
* kernel whose geometry IS free: elementwise_add, dispatched through the same
|
|
* threadgroup API at a range of group sizes over a fixed, DRAM-sized vector.
|
|
*
|
|
* If a 32-thread group reaches the same bandwidth as a 256-thread group, then
|
|
* 32-wide threadgroups are not what holds the matvecs down and the remaining
|
|
* suspect is inside the threadgroup. If it does not, the matvecs inherit a
|
|
* ceiling that no lane-unit rework can lift.
|
|
* ================================================================ */
|
|
|
|
/* 16M floats per operand: 192 MB touched per pass, far past any cache. */
|
|
#define ROOF_ELEMENT_COUNT (16 * 1024 * 1024)
|
|
#define ROOF_BYTES_PER_ELEMENT 12 /* two reads + one write */
|
|
#define ROOF_SAMPLES_PER_ROUND 4
|
|
#define ROOF_ROUND_COUNT 5
|
|
#define ROOF_MAX_SAMPLES (ROOF_SAMPLES_PER_ROUND * ROOF_ROUND_COUNT)
|
|
|
|
static void fill_activation(fm_buffer *buffer, int cols, unsigned seed);
|
|
|
|
static const int ROOF_THREADS_PER_GROUP[] = { 32, 64, 128, 256, 512, 1024 };
|
|
#define ROOF_ARM_COUNT \
|
|
((int)(sizeof(ROOF_THREADS_PER_GROUP) / sizeof(ROOF_THREADS_PER_GROUP[0])))
|
|
|
|
typedef struct {
|
|
int threads_per_group;
|
|
int threadgroups;
|
|
double samples[ROOF_MAX_SAMPLES];
|
|
int sample_count;
|
|
} threadgroup_size_arm;
|
|
|
|
static double time_elementwise_add(fm_buffer *a, fm_buffer *b, fm_buffer *out,
|
|
int threads_per_group)
|
|
{
|
|
fm_bufref refs[] = { fm_bufref_at(a, 0), fm_bufref_at(b, 0),
|
|
fm_bufref_at(out, 0) };
|
|
uint32_t consts[] = { (uint32_t)ROOF_ELEMENT_COUNT };
|
|
int groups = (ROOF_ELEMENT_COUNT + threads_per_group - 1) / threads_per_group;
|
|
double started;
|
|
|
|
fm_batch_begin();
|
|
started = monotonic_milliseconds();
|
|
fm_dispatch_threadgroups_refs(@"elementwise_add", NULL, refs, 3, consts, 1,
|
|
groups, threads_per_group);
|
|
fm_batch_end();
|
|
return monotonic_milliseconds() - started;
|
|
}
|
|
|
|
/**
|
|
* Sweeps threads-per-threadgroup on elementwise_add and prints the achieved
|
|
* bandwidth, establishing both the machine's memory roof and whether a
|
|
* 32-thread group — the shape every matvec is pinned to — costs anything.
|
|
*
|
|
* @return The best GB/s observed, i.e. the measured memory roof
|
|
*/
|
|
static double report_threadgroup_size_roof(void)
|
|
{
|
|
size_t bytes = (size_t)ROOF_ELEMENT_COUNT * sizeof(float);
|
|
fm_buffer *a = fm_buffer_alloc(bytes);
|
|
fm_buffer *b = fm_buffer_alloc(bytes);
|
|
fm_buffer *out = fm_buffer_alloc(bytes);
|
|
threadgroup_size_arm arms[ROOF_ARM_COUNT] = {{0, 0, {0}, 0}};
|
|
double roof = 0.0;
|
|
|
|
if (!a || !b || !out) {
|
|
printf(" (skipped: could not allocate %.0f MB x 3)\n",
|
|
(double)bytes / (1024.0 * 1024.0));
|
|
return 0.0;
|
|
}
|
|
fill_activation(a, ROOF_ELEMENT_COUNT, 4242u);
|
|
fill_activation(b, ROOF_ELEMENT_COUNT, 777u);
|
|
|
|
for (int i = 0; i < ROOF_ARM_COUNT; i++) {
|
|
arms[i].threads_per_group = ROOF_THREADS_PER_GROUP[i];
|
|
arms[i].threadgroups = (ROOF_ELEMENT_COUNT + arms[i].threads_per_group - 1) /
|
|
arms[i].threads_per_group;
|
|
(void)time_elementwise_add(a, b, out, arms[i].threads_per_group);
|
|
}
|
|
for (int round = 0; round < ROOF_ROUND_COUNT; round++) {
|
|
for (int slot = 0; slot < ROOF_ARM_COUNT; slot++) {
|
|
int i = (slot + round) % ROOF_ARM_COUNT;
|
|
for (int s = 0; s < ROOF_SAMPLES_PER_ROUND; s++)
|
|
arms[i].samples[arms[i].sample_count++] =
|
|
time_elementwise_add(a, b, out, arms[i].threads_per_group);
|
|
}
|
|
}
|
|
|
|
printf("\nThreads-per-threadgroup roof — elementwise_add, n=%d (%d MB/pass)\n",
|
|
ROOF_ELEMENT_COUNT,
|
|
(int)((size_t)ROOF_ELEMENT_COUNT * ROOF_BYTES_PER_ELEMENT / (1024 * 1024)));
|
|
printf("%10s %13s %10s %10s\n", "threads/tg", "threadgroups", "min ms", "GB/s");
|
|
for (int i = 0; i < ROOF_ARM_COUNT; i++) {
|
|
timing_statistics stats = summarise_latency_samples(arms[i].samples,
|
|
arms[i].sample_count);
|
|
double gbps = (double)ROOF_ELEMENT_COUNT * ROOF_BYTES_PER_ELEMENT /
|
|
(stats.min_ms / 1000.0) / 1.0e9;
|
|
if (gbps > roof)
|
|
roof = gbps;
|
|
printf("%10d %13d %10.3f %10.2f\n", arms[i].threads_per_group,
|
|
arms[i].threadgroups, stats.min_ms, gbps);
|
|
}
|
|
|
|
fm_buffer_free(a);
|
|
fm_buffer_free(b);
|
|
fm_buffer_free(out);
|
|
return roof;
|
|
}
|
|
|
|
/* ================================================================
|
|
* Setup
|
|
* ================================================================ */
|
|
|
|
static void fill_activation(fm_buffer *buffer, int cols, unsigned seed)
|
|
{
|
|
float *values = (float *)fm_buffer_data(buffer);
|
|
unsigned state = seed;
|
|
|
|
for (int i = 0; i < cols; i++) {
|
|
state = state * 1664525u + 1013904223u;
|
|
values[i] = ((float)(state >> 8) / 8388608.0f - 1.0f) * 0.05f;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Allocates one weight buffer per quant, large enough for the largest arm, and
|
|
* fills it with tiled real blocks of that quant from the model file.
|
|
*
|
|
* @return 0 on success, -1 if allocation or the model read failed
|
|
*/
|
|
static int allocate_kernel_weight_buffers(gguf_test_file *file,
|
|
quant_matvec_kernel *kernels,
|
|
int kernel_count,
|
|
const matvec_shape *shapes,
|
|
int shape_count)
|
|
{
|
|
for (int k = 0; k < kernel_count; k++) {
|
|
size_t largest = 0;
|
|
for (int s = 0; s < shape_count; s++) {
|
|
size_t bytes = weight_bytes_for(&kernels[k], shapes[s].cols, shapes[s].rows);
|
|
if (bytes > largest)
|
|
largest = bytes;
|
|
}
|
|
kernels[k].weight_bytes = largest;
|
|
kernels[k].weights = fm_buffer_alloc(largest);
|
|
if (!kernels[k].weights) {
|
|
printf("FAIL: could not allocate %.1f MB for %s\n",
|
|
(double)largest / (1024.0 * 1024.0), kernels[k].name);
|
|
return -1;
|
|
}
|
|
if (fill_weight_buffer_from_model(file, &kernels[k]) != 0) {
|
|
printf("FAIL: no usable %s tensor in the model file\n", kernels[k].name);
|
|
return -1;
|
|
}
|
|
printf(" %-5s %8.1f MB weight buffer, blocks tiled from %s\n",
|
|
kernels[k].name, (double)largest / (1024.0 * 1024.0),
|
|
kernels[k].source_tensor);
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
static int allocate_shared_buffers(const matvec_shape *shapes, int shape_count)
|
|
{
|
|
for (int s = 0; s < shape_count; s++) {
|
|
if (shapes[s].rows > max_rows_allocated)
|
|
max_rows_allocated = shapes[s].rows;
|
|
if (shapes[s].cols > max_cols_allocated)
|
|
max_cols_allocated = shapes[s].cols;
|
|
}
|
|
|
|
activation_a = fm_buffer_alloc((size_t)max_cols_allocated * sizeof(float));
|
|
activation_b = fm_buffer_alloc((size_t)max_cols_allocated * sizeof(float));
|
|
output_rows = fm_buffer_alloc((size_t)max_rows_allocated * sizeof(float));
|
|
if (!activation_a || !activation_b || !output_rows)
|
|
return -1;
|
|
|
|
fill_activation(activation_a, max_cols_allocated, 12345u);
|
|
fill_activation(activation_b, max_cols_allocated, 987654321u);
|
|
return 0;
|
|
}
|
|
|
|
/** Appends the row-scaled shapes that force the small real ones out of cache. */
|
|
static int append_residency_shapes(matvec_shape *shapes, int count, int capacity)
|
|
{
|
|
const matvec_shape scaled[] = {
|
|
{ 512, SCALED_ROWS_AT_512_COLS, "row-scaled cols=512 (DRAM-resident)", false },
|
|
{ 4096, SCALED_ROWS_AT_4096_COLS, "row-scaled cols=4096 (DRAM-resident)", false },
|
|
};
|
|
|
|
for (int i = 0; i < (int)(sizeof(scaled) / sizeof(scaled[0])); i++) {
|
|
if (count >= capacity)
|
|
break;
|
|
shapes[count++] = scaled[i];
|
|
}
|
|
return count;
|
|
}
|
|
|
|
static void describe_shapes(const matvec_shape *shapes, int count)
|
|
{
|
|
printf("\nShapes measured (%d)\n", count);
|
|
for (int i = 0; i < count; i++) {
|
|
printf(" cols=%-5d rows=%-7d %-8s %s\n", shapes[i].cols, shapes[i].rows,
|
|
shapes[i].from_model ? "model" : "scaled", shapes[i].origin);
|
|
}
|
|
}
|
|
|
|
/* ================================================================
|
|
* Entry point
|
|
* ================================================================ */
|
|
|
|
static int run_integrity_guards(void)
|
|
{
|
|
float *scratch_a = (float *)malloc((size_t)GUARD_ROWS * sizeof(float));
|
|
float *scratch_b = (float *)malloc((size_t)GUARD_ROWS * sizeof(float));
|
|
int failures = 0;
|
|
|
|
if (!scratch_a || !scratch_b) {
|
|
free(scratch_a);
|
|
free(scratch_b);
|
|
return 1;
|
|
}
|
|
|
|
printf("\nElision guard — the timed kernel must read every byte it is credited with\n");
|
|
for (int k = 0; k < KERNEL_COUNT; k++) {
|
|
if (!probe_full_read_dependence(&KERNELS[k], scratch_a, scratch_b))
|
|
failures++;
|
|
}
|
|
|
|
printf("\nCan-fail demonstration — the same comparator, mutation without dispatch\n");
|
|
for (int k = 0; k < KERNEL_COUNT; k++) {
|
|
if (!report_elision_guard_can_fail(&KERNELS[k], scratch_a, scratch_b))
|
|
failures++;
|
|
}
|
|
|
|
free(scratch_a);
|
|
free(scratch_b);
|
|
return failures;
|
|
}
|
|
|
|
int main(int argc, char **argv)
|
|
{
|
|
const char *model_path = argc > 1 ? argv[1] : DEFAULT_MODEL_PATH;
|
|
gguf_test_file model;
|
|
census_entry census[MAX_CENSUS_ENTRIES];
|
|
matvec_shape shapes[MAX_SHAPES];
|
|
static measurement_arm arms[MAX_ARMS];
|
|
int census_count, shape_count, arm_count, replica_start, failures;
|
|
|
|
printf("=== K-quant matvec effective bandwidth ===\n");
|
|
printf(" model : %s\n", model_path);
|
|
printf(" rounds x samples : %d x %d (%d pooled per arm)\n",
|
|
MEASUREMENT_ROUND_COUNT, SAMPLES_PER_ROUND, MAX_POOLED_SAMPLE_COUNT);
|
|
printf(" statistic : pooled MIN (p50 shown for the contention band)\n");
|
|
printf(" target bytes/sample : %u MB\n", TARGET_BYTES_PER_SAMPLE / (1024u * 1024u));
|
|
|
|
if (fm_init() != 0 || !fm_available()) {
|
|
printf("FAIL: Metal unavailable\n");
|
|
return 1;
|
|
}
|
|
if (gguf_test_open(model_path, &model) != 0) {
|
|
printf("FAIL: cannot open %s\n", model_path);
|
|
return 1;
|
|
}
|
|
|
|
census_count = collect_dispatched_weight_census(&model, census, MAX_CENSUS_ENTRIES);
|
|
if (census_count <= 0) {
|
|
printf("FAIL: could not read the tensor table\n");
|
|
gguf_test_close(&model);
|
|
return 1;
|
|
}
|
|
print_census(census, census_count);
|
|
|
|
shape_count = collect_distinct_shapes(census, census_count, shapes, MAX_SHAPES);
|
|
shape_count = append_residency_shapes(shapes, shape_count, MAX_SHAPES);
|
|
describe_shapes(shapes, shape_count);
|
|
|
|
printf("\nBuffers\n");
|
|
if (allocate_shared_buffers(shapes, shape_count) != 0 ||
|
|
allocate_kernel_weight_buffers(&model, KERNELS, KERNEL_COUNT,
|
|
shapes, shape_count) != 0) {
|
|
gguf_test_close(&model);
|
|
return 1;
|
|
}
|
|
gguf_test_close(&model);
|
|
|
|
failures = run_integrity_guards();
|
|
|
|
arm_count = build_sweep_arms(arms, MAX_ARMS, KERNELS, KERNEL_COUNT,
|
|
shapes, shape_count);
|
|
replica_start = arm_count;
|
|
arm_count = append_noise_floor_replicas(arms, arm_count, MAX_ARMS);
|
|
printf("\nMeasuring %d arms...\n", arm_count);
|
|
run_measurement_arms(arms, arm_count);
|
|
|
|
print_results_header("Effective weight-read bandwidth (weight bytes only; "
|
|
"x and out are <2% of traffic)");
|
|
for (int i = 0; i < arm_count; i++)
|
|
print_arm_row(&arms[i]);
|
|
report_noise_floor(arms, replica_start);
|
|
|
|
static measurement_arm commit_cost_arms[2];
|
|
report_commit_cost_at_microbench_shape(commit_cost_arms);
|
|
|
|
double roof = report_threadgroup_size_roof();
|
|
printf("\nMeasured memory roof on this machine: %.1f GB/s "
|
|
"(elementwise_add, best threadgroup size).\n", roof);
|
|
|
|
printf("\n%s\n", failures == 0
|
|
? "Integrity guards: all green (and shown able to fail)."
|
|
: "Integrity guards: FAILURES — the timings above are not trustworthy.");
|
|
return failures == 0 ? 0 : 1;
|
|
}
|