Foundry/metal/tests/bench_dispatch.m

1054 lines
43 KiB
Objective-C

/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* Copyright (C) 2026 David (Corey) Herrera */
/**
* @file bench_dispatch.m
* @brief Phase-0 dispatch-overhead probe for the qwen35moe Metal backend.
* Encodes a decode step's worth of trivial kernel dispatches (40 layers x K
* dispatches) and measures the tok/s ceiling that dispatch + encode + commit
* cost imposes, independent of any real compute.
*
* Measurement paths:
* 1. The `fm_` API (fm_add / fm_mul / fm_silu) under four batching
* strategies — this is what the planned forward would actually pay.
* 2. A raw-Metal reference on a private device/queue that isolates
* *encoder creation* and *pipeline switching* from *dispatch*, because
* `fm_metal.m` opens a new MTLComputeCommandEncoder per dispatch.
*
* Rigour guards, in the order they run:
* - Elision defence: every dispatch is a link in a data-dependent chain
* (`accumulator += ones`, in place), and the final value must equal the
* dispatch count predicted from the CONFIGURATION, not from what ran.
* - `report_can_fail_demonstration` drops every 7th dispatch and requires
* the elision check to report a mismatch — a green that was never shown
* to fail is not a gate.
* - `report_batch_begin_sync_pairing` checks whether fm_sync() commits a
* fm_batch_begin() batch (it does not — see RESULTS_dispatch_probe.md).
* - Replica arms give a noise floor on the reported statistic; the
* compute-sensitivity sweep proves the kernels are overhead, not work.
*
* Results and verdict: metal/tests/RESULTS_dispatch_probe.md
*
* @status Complete. Run: make USE_METAL=1 bench-dispatch
* @issues Reports the pooled MINIMUM: this machine shares its GPU with the
* window server, so the mean and p50 track desktop contention rather than
* dispatch cost. p50/p95 are printed alongside to keep that visible.
* @todo None
*/
#import <Foundation/Foundation.h>
#import <Metal/Metal.h>
#include "metal/fm_metal.h"
#include <math.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
/* ================================================================
* Probe configuration
* ================================================================ */
/* The planned qwen35moe forward is 40 transformer layers per decoded token. */
#define TRANSFORMER_LAYER_COUNT 40
/* Buffers are allocated at the largest size the probe ever uses; each arm
* dispatches over `element_count` of them. The default is deliberately small so
* the kernels are pure overhead — but that is an assumption, not a fact, so the
* compute-sensitivity stage sweeps the size and proves the default sits on the
* flat part of the curve. Never assume a kernel is free; show it. */
#define MAX_PROBE_ELEMENT_COUNT 65536
#define PROBE_ELEMENT_COUNT 4096
/* Element counts swept to prove the timing is dispatch-bound, not compute-bound. */
static const int ELEMENT_COUNT_SWEEP[] = { 256, 1024, 4096, 16384, 65536 };
#define ELEMENT_COUNT_SWEEP_COUNT \
((int)(sizeof(ELEMENT_COUNT_SWEEP) / sizeof(ELEMENT_COUNT_SWEEP[0])))
/* Per-token samples collected for each configuration, plus discarded warmup. */
#define WARMUP_TOKEN_COUNT 4
#define MEASURED_TOKEN_COUNT 25
/* Seed for the repeatedly-applied silu in the kernel-mix arm. */
#define SILU_STABLE_OPERAND 20.0f
/* Dispatches-per-layer values swept. 0 measures the fixed per-token cost. */
static const int DISPATCHES_PER_LAYER_SWEEP[] = { 0, 5, 10, 20, 40 };
#define DISPATCHES_PER_LAYER_SWEEP_COUNT \
((int)(sizeof(DISPATCHES_PER_LAYER_SWEEP) / sizeof(DISPATCHES_PER_LAYER_SWEEP[0])))
/* ================================================================
* Types
* ================================================================ */
typedef enum {
BATCH_STRATEGY_PER_TOKEN, /* fm_batch_begin .. fm_batch_end once per token */
BATCH_STRATEGY_PER_LAYER, /* fm_batch_begin .. fm_batch_end once per layer */
BATCH_STRATEGY_AUTO_SYNC, /* fm_set_auto_batch(true), fm_sync() once per token */
BATCH_STRATEGY_NONE /* auto-batch off: every dispatch commits and waits */
} fm_batch_strategy;
typedef enum {
OP_MIX_ADD_CHAIN, /* every dispatch is fm_add — count-verifiable */
OP_MIX_ROUND_ROBIN /* fm_add / fm_mul / fm_silu round-robin */
} fm_op_mix;
typedef struct {
int dispatches_per_layer;
int element_count; /* elements each kernel touches */
fm_batch_strategy strategy;
fm_op_mix op_mix;
int dropped_dispatch_period; /* 0 = none; else drop every Nth */
} dispatch_probe_config;
typedef struct {
double mean_ms;
double stddev_ms;
double min_ms;
double median_ms;
double p95_ms;
} timing_statistics;
typedef struct {
timing_statistics timing;
bool work_verified;
double observed_accumulator;
double expected_accumulator;
} probe_result;
/* ================================================================
* Timing and statistics helpers
* ================================================================ */
/** Monotonic wall clock in milliseconds. Immune to wall-clock adjustment. */
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 per-token latency samples.
*
* @param samples Latency samples in milliseconds (reordered in place)
* @param count Number of samples, must be > 0
* @return Mean, sample stddev, min, median and p95
*/
static timing_statistics summarise_latency_samples(double *samples, int count)
{
timing_statistics stats = {0};
double sum = 0.0;
double sum_squared_deviation = 0.0;
for (int i = 0; i < count; i++)
sum += samples[i];
stats.mean_ms = sum / (double)count;
for (int i = 0; i < count; i++) {
double deviation = samples[i] - stats.mean_ms;
sum_squared_deviation += deviation * deviation;
}
stats.stddev_ms = count > 1 ? sqrt(sum_squared_deviation / (double)(count - 1)) : 0.0;
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;
}
/* Ceiling reported for configurations that do no GPU work at all (K=0 with no
* batching): the wall time is pure loop overhead, so the ratio is meaningless.
* -ffast-math is on, so INFINITY is not usable as a sentinel. */
#define UNBOUNDED_TOKENS_PER_SECOND (-1.0)
/** Implied decode ceiling in tokens/second from a per-token wall time. */
static double implied_tokens_per_second(double milliseconds_per_token)
{
if (milliseconds_per_token <= 1.0e-3)
return UNBOUNDED_TOKENS_PER_SECOND;
return 1000.0 / milliseconds_per_token;
}
/* ================================================================
* fm_ path — probe buffers
* ================================================================ */
typedef struct {
fm_buffer *accumulator; /* chain target: accumulator += ones, in place */
fm_buffer *ones; /* all 1.0f */
fm_buffer *silu_scratch; /* target for the round-robin fm_silu dispatches */
} fm_probe_buffers;
static bool allocate_fm_probe_buffers(fm_probe_buffers *buffers)
{
size_t bytes = (size_t)MAX_PROBE_ELEMENT_COUNT * sizeof(float);
buffers->accumulator = fm_buffer_alloc(bytes);
buffers->ones = fm_buffer_alloc(bytes);
buffers->silu_scratch = fm_buffer_alloc(bytes);
if (!buffers->accumulator || !buffers->ones || !buffers->silu_scratch)
return false;
float *ones_data = (float *)fm_buffer_data(buffers->ones);
float *scratch_data = (float *)fm_buffer_data(buffers->silu_scratch);
for (int i = 0; i < MAX_PROBE_ELEMENT_COUNT; i++) {
ones_data[i] = 1.0f;
/* Near-fixed-point of silu: x*sigmoid(x) ~= x for x >> 0, so repeated
* application keeps the operand in a normal float range. Seeding with
* 1.0 instead halves the value every pass and reaches denormals within
* ~150 dispatches, which would time denormal handling, not dispatch. */
scratch_data[i] = SILU_STABLE_OPERAND;
}
memset(fm_buffer_data(buffers->accumulator), 0, bytes);
return true;
}
static void free_fm_probe_buffers(fm_probe_buffers *buffers)
{
fm_buffer_free(buffers->accumulator);
fm_buffer_free(buffers->ones);
fm_buffer_free(buffers->silu_scratch);
memset(buffers, 0, sizeof(*buffers));
}
/** Zeroes the chain accumulator. Caller must guarantee the GPU is idle. */
static void reset_fm_accumulator(fm_probe_buffers *buffers)
{
fm_sync();
memset(fm_buffer_data(buffers->accumulator), 0,
(size_t)MAX_PROBE_ELEMENT_COUNT * sizeof(float));
}
/**
* Number of accumulator-incrementing dispatches one simulated token should
* issue, derived from the configuration alone — never from what actually ran.
* This is what makes the elision check meaningful.
*/
static long expected_increments_per_token(const dispatch_probe_config *config)
{
long per_layer = 0;
for (int index = 0; index < config->dispatches_per_layer; index++) {
if (config->op_mix == OP_MIX_ADD_CHAIN || index % 3 == 0)
per_layer++;
}
return per_layer * TRANSFORMER_LAYER_COUNT;
}
/* ================================================================
* fm_ path — encoding one simulated token
* ================================================================ */
/** Issues one dispatch of the configured op mix. Returns false if dropped. */
static bool issue_fm_dispatch(fm_probe_buffers *buffers,
const dispatch_probe_config *config,
int index_within_layer, long global_index)
{
if (config->dropped_dispatch_period > 0 &&
global_index % config->dropped_dispatch_period == 0)
return false;
if (config->op_mix == OP_MIX_ADD_CHAIN || index_within_layer % 3 == 0) {
fm_add(buffers->accumulator, buffers->ones, buffers->accumulator,
config->element_count);
} else if (index_within_layer % 3 == 1) {
/* Multiply by ones: keeps the chain dependency without changing value. */
fm_mul(buffers->accumulator, buffers->ones, buffers->accumulator,
config->element_count);
} else {
fm_silu(buffers->silu_scratch, buffers->silu_scratch, config->element_count);
}
return true;
}
/**
* Encodes and completes one simulated decode token through the fm_ API.
*
* @param buffers Probe buffers
* @param config Layer count is fixed; K, strategy, op mix, fault period
* @param global_index Running dispatch counter, advanced in place
*/
static void run_fm_simulated_token(fm_probe_buffers *buffers,
const dispatch_probe_config *config,
long *global_index)
{
if (config->strategy == BATCH_STRATEGY_PER_TOKEN)
fm_batch_begin();
for (int layer = 0; layer < TRANSFORMER_LAYER_COUNT; layer++) {
if (config->strategy == BATCH_STRATEGY_PER_LAYER)
fm_batch_begin();
for (int index = 0; index < config->dispatches_per_layer; index++) {
issue_fm_dispatch(buffers, config, index, *global_index);
(*global_index)++;
}
if (config->strategy == BATCH_STRATEGY_PER_LAYER)
fm_batch_end();
}
if (config->strategy == BATCH_STRATEGY_PER_TOKEN)
fm_batch_end();
else if (config->strategy == BATCH_STRATEGY_AUTO_SYNC)
fm_sync();
}
/**
* Reads back the accumulator and checks every touched element equals
* `expected`. A single short element means at least one dispatch never reached
* the GPU.
*
* @param accumulator Chain target buffer
* @param element_count Elements the arm's dispatches actually covered
* @param expected Analytically predicted increment total
* @param observed_out Receives the first mismatching value, or element 0
*/
static bool verify_accumulator(fm_buffer *accumulator, int element_count,
double expected, double *observed_out)
{
const float *data = (const float *)fm_buffer_data(accumulator);
bool matched = true;
*observed_out = (double)data[0];
for (int i = 0; i < element_count; i++) {
if ((double)data[i] != expected) {
matched = false;
*observed_out = (double)data[i];
break;
}
}
return matched;
}
/**
* Runs warmup + measured tokens for one fm_ configuration.
*
* @param buffers Probe buffers
* @param config Configuration to measure
* @param samples_out Receives MEASURED_TOKEN_COUNT per-token times, or NULL
*/
static probe_result measure_fm_configuration(fm_probe_buffers *buffers,
const dispatch_probe_config *config,
double *samples_out)
{
double samples[MEASURED_TOKEN_COUNT];
probe_result result = {0};
long global_index = 0;
fm_set_auto_batch(config->strategy == BATCH_STRATEGY_AUTO_SYNC);
reset_fm_accumulator(buffers);
for (int token = 0; token < WARMUP_TOKEN_COUNT; token++)
run_fm_simulated_token(buffers, config, &global_index);
/* Warmup adds count toward the expected total: the chain is never reset
* mid-configuration, so pipeline creation cost is excluded from timing but
* not from the correctness ledger. */
for (int token = 0; token < MEASURED_TOKEN_COUNT; token++) {
double started_at = monotonic_milliseconds();
run_fm_simulated_token(buffers, config, &global_index);
samples[token] = monotonic_milliseconds() - started_at;
}
fm_set_auto_batch(false);
fm_sync();
if (samples_out)
memcpy(samples_out, samples, sizeof(samples));
result.timing = summarise_latency_samples(samples, MEASURED_TOKEN_COUNT);
result.expected_accumulator =
(double)expected_increments_per_token(config) *
(double)(WARMUP_TOKEN_COUNT + MEASURED_TOKEN_COUNT);
result.work_verified = verify_accumulator(buffers->accumulator,
config->element_count,
result.expected_accumulator,
&result.observed_accumulator);
return result;
}
/* ================================================================
* Raw-Metal reference path
*
* Isolates encoder creation from dispatch. `fm_metal.m` opens one
* MTLComputeCommandEncoder per dispatch (fm_metal.m:388, 417, 447); a real
* forward would encode many dispatches into a single encoder. Two identical
* kernels are alternated so pipeline switching is exercised for real.
* ================================================================ */
/* The two kernels are byte-identical, so alternating them changes only the
* MTLComputePipelineState object bound before each dispatch. Comparing the
* alternating arm with the one-pipeline arm isolates pipeline-state switching
* from every other per-dispatch cost. */
typedef enum {
RAW_ENCODING_ENCODER_PER_DISPATCH, /* mirrors today's fm_metal.m */
RAW_ENCODING_SINGLE_ENCODER, /* one encoder per token */
RAW_ENCODING_SINGLE_PIPELINE /* one encoder, no pipeline switching */
} raw_encoding_mode;
static NSString *const RAW_KERNEL_SOURCE = @
"#include <metal_stdlib>\n"
"using namespace metal;\n"
"kernel void bench_accumulate_a(device float* accumulator [[buffer(0)]],\n"
" device const float* ones [[buffer(1)]],\n"
" constant uint& count [[buffer(2)]],\n"
" uint idx [[thread_position_in_grid]]) {\n"
" if (idx >= count) return;\n"
" accumulator[idx] = accumulator[idx] + ones[idx];\n"
"}\n"
"kernel void bench_accumulate_b(device float* accumulator [[buffer(0)]],\n"
" device const float* ones [[buffer(1)]],\n"
" constant uint& count [[buffer(2)]],\n"
" uint idx [[thread_position_in_grid]]) {\n"
" if (idx >= count) return;\n"
" accumulator[idx] = accumulator[idx] + ones[idx];\n"
"}\n";
static id<MTLDevice> raw_device;
static id<MTLCommandQueue> raw_queue;
static id<MTLComputePipelineState> raw_pipelines[2];
static id<MTLBuffer> raw_accumulator;
static id<MTLBuffer> raw_ones;
static bool initialise_raw_metal_probe(void)
{
NSError *error = nil;
size_t bytes = (size_t)PROBE_ELEMENT_COUNT * sizeof(float);
raw_device = MTLCreateSystemDefaultDevice();
if (!raw_device)
return false;
raw_queue = [raw_device newCommandQueue];
id<MTLLibrary> library = [raw_device newLibraryWithSource:RAW_KERNEL_SOURCE
options:nil
error:&error];
if (!library) {
fprintf(stderr, "raw probe: shader compile failed: %s\n",
error.localizedDescription.UTF8String);
return false;
}
NSArray<NSString *> *names = @[ @"bench_accumulate_a", @"bench_accumulate_b" ];
for (int i = 0; i < 2; i++) {
id<MTLFunction> function = [library newFunctionWithName:names[(NSUInteger)i]];
raw_pipelines[i] = [raw_device newComputePipelineStateWithFunction:function
error:&error];
if (!raw_pipelines[i]) {
fprintf(stderr, "raw probe: pipeline failed: %s\n",
error.localizedDescription.UTF8String);
return false;
}
}
raw_accumulator = [raw_device newBufferWithLength:bytes
options:MTLResourceStorageModeShared];
raw_ones = [raw_device newBufferWithLength:bytes
options:MTLResourceStorageModeShared];
float *ones_data = (float *)raw_ones.contents;
for (int i = 0; i < PROBE_ELEMENT_COUNT; i++)
ones_data[i] = 1.0f;
memset(raw_accumulator.contents, 0, bytes);
return true;
}
/** Binds and dispatches one accumulate kernel on an existing encoder. */
static void encode_raw_dispatch(id<MTLComputeCommandEncoder> encoder,
long pipeline_index)
{
id<MTLComputePipelineState> pipeline = raw_pipelines[pipeline_index & 1];
uint32_t count = (uint32_t)PROBE_ELEMENT_COUNT;
NSUInteger threads_per_group = MIN((NSUInteger)PROBE_ELEMENT_COUNT,
pipeline.maxTotalThreadsPerThreadgroup);
[encoder setComputePipelineState:pipeline];
[encoder setBuffer:raw_accumulator offset:0 atIndex:0];
[encoder setBuffer:raw_ones offset:0 atIndex:1];
[encoder setBytes:&count length:sizeof(count) atIndex:2];
[encoder dispatchThreads:MTLSizeMake(PROBE_ELEMENT_COUNT, 1, 1)
threadsPerThreadgroup:MTLSizeMake(threads_per_group, 1, 1)];
}
/** One simulated token on the raw path: one command buffer, commit and wait. */
static void run_raw_simulated_token(int dispatches_per_layer,
raw_encoding_mode mode, long *global_index)
{
@autoreleasepool {
id<MTLCommandBuffer> command_buffer = [raw_queue commandBuffer];
id<MTLComputeCommandEncoder> shared_encoder = nil;
if (mode != RAW_ENCODING_ENCODER_PER_DISPATCH)
shared_encoder = [command_buffer computeCommandEncoder];
for (int layer = 0; layer < TRANSFORMER_LAYER_COUNT; layer++) {
for (int index = 0; index < dispatches_per_layer; index++) {
long pipeline_index =
mode == RAW_ENCODING_SINGLE_PIPELINE ? 0 : *global_index;
if (shared_encoder) {
encode_raw_dispatch(shared_encoder, pipeline_index);
} else {
id<MTLComputeCommandEncoder> encoder =
[command_buffer computeCommandEncoder];
encode_raw_dispatch(encoder, pipeline_index);
[encoder endEncoding];
}
(*global_index)++;
}
}
if (shared_encoder)
[shared_encoder endEncoding];
[command_buffer commit];
[command_buffer waitUntilCompleted];
}
}
/**
* Runs warmup + measured tokens on the raw-Metal reference path.
*
* @param dispatches_per_layer K
* @param mode Encoder granularity under test
* @param samples_out Receives MEASURED_TOKEN_COUNT times, or NULL
*/
static probe_result measure_raw_configuration(int dispatches_per_layer,
raw_encoding_mode mode,
double *samples_out)
{
double samples[MEASURED_TOKEN_COUNT];
probe_result result = {0};
long global_index = 0;
memset(raw_accumulator.contents, 0,
(size_t)PROBE_ELEMENT_COUNT * sizeof(float));
for (int token = 0; token < WARMUP_TOKEN_COUNT; token++)
run_raw_simulated_token(dispatches_per_layer, mode, &global_index);
for (int token = 0; token < MEASURED_TOKEN_COUNT; token++) {
double started_at = monotonic_milliseconds();
run_raw_simulated_token(dispatches_per_layer, mode, &global_index);
samples[token] = monotonic_milliseconds() - started_at;
}
if (samples_out)
memcpy(samples_out, samples, sizeof(samples));
result.timing = summarise_latency_samples(samples, MEASURED_TOKEN_COUNT);
result.expected_accumulator =
(double)dispatches_per_layer * TRANSFORMER_LAYER_COUNT *
(double)(WARMUP_TOKEN_COUNT + MEASURED_TOKEN_COUNT);
const float *data = (const float *)raw_accumulator.contents;
result.work_verified = true;
result.observed_accumulator = (double)data[0];
for (int i = 0; i < PROBE_ELEMENT_COUNT; i++) {
if ((double)data[i] != result.expected_accumulator) {
result.work_verified = false;
result.observed_accumulator = (double)data[i];
break;
}
}
return result;
}
/* ================================================================
* Measurement arms
*
* Every configuration is measured once per round and the rounds are
* interleaved, so no configuration is penalised for running immediately after
* a heavy one (an earlier revision of this probe reported a bogus 3x penalty
* on the arm that happened to follow the no-batching sweep).
*
* All per-token samples from all rounds are pooled. The reported ceiling comes
* from the pool minimum — the cost when the GPU is momentarily uncontended,
* which is what "dispatch overhead" means. Median and p95 of the same pool show
* what a desktop with a browser open actually delivers, and the gap between
* them is the contention band rather than something hidden inside a mean.
* ================================================================ */
#define MEASUREMENT_ROUND_COUNT 6
#define MAX_POOLED_SAMPLE_COUNT (MEASUREMENT_ROUND_COUNT * MEASURED_TOKEN_COUNT)
typedef struct {
const char *label;
dispatch_probe_config config; /* fm arms only */
int raw_dispatches_per_layer;
raw_encoding_mode raw_mode;
bool is_raw_arm;
double pooled_samples[MAX_POOLED_SAMPLE_COUNT];
int pooled_count;
bool work_verified;
} measurement_arm;
/* Stride used to rotate arm order between rounds. Coprime with any realistic
* arm count, so every arm follows a different predecessor each round — the GPU
* is left in a different state before each measurement instead of one arm
* always inheriting the wake of the heaviest one. */
#define ARM_ORDER_ROTATION_STRIDE 7
/** Runs every arm once per round, rotating order, pooling all samples. */
static void run_measurement_arms(fm_probe_buffers *buffers,
measurement_arm *arms, int count)
{
for (int round = 0; round < MEASUREMENT_ROUND_COUNT; round++) {
for (int slot = 0; slot < count; slot++) {
int i = (slot + round * ARM_ORDER_ROTATION_STRIDE) % count;
measurement_arm *arm = &arms[i];
double *sample_slot = &arm->pooled_samples[arm->pooled_count];
probe_result result;
if (arm->is_raw_arm) {
result = measure_raw_configuration(arm->raw_dispatches_per_layer,
arm->raw_mode, sample_slot);
} else {
result = measure_fm_configuration(buffers, &arm->config,
sample_slot);
}
arm->pooled_count += MEASURED_TOKEN_COUNT;
arm->work_verified = round == 0 ? result.work_verified
: (arm->work_verified &&
result.work_verified);
}
}
}
/** Summary of an arm's pooled samples. Sorts the pool in place. */
static timing_statistics summarise_arm(measurement_arm *arm)
{
return summarise_latency_samples(arm->pooled_samples, arm->pooled_count);
}
static int arm_dispatches_per_layer(const measurement_arm *arm)
{
return arm->is_raw_arm ? arm->raw_dispatches_per_layer
: arm->config.dispatches_per_layer;
}
/* ================================================================
* Reporting
* ================================================================ */
static const char *batch_strategy_name(fm_batch_strategy strategy)
{
switch (strategy) {
case BATCH_STRATEGY_PER_TOKEN: return "per-token (begin/end x1)";
case BATCH_STRATEGY_PER_LAYER: return "per-layer (begin/end x40)";
case BATCH_STRATEGY_AUTO_SYNC: return "auto-batch (fm_sync x1)";
case BATCH_STRATEGY_NONE: return "none (commit+wait each)";
}
return "unknown";
}
static void print_table_header(const char *title)
{
printf("\n%s\n", title);
printf("%-32s %4s %9s %9s %9s %9s %11s %11s\n",
"strategy", "K", "dispatch", "min ms", "p50 ms", "p95 ms",
"tok/s @min", "tok/s @p50");
printf("%-32s %4s %9s %9s %9s %9s %11s %11s\n",
"--------------------------------", "----", "---------", "---------",
"---------", "---------", "-----------", "-----------");
}
static void format_ceiling(char *out, size_t out_len, double milliseconds)
{
double ceiling = implied_tokens_per_second(milliseconds);
if (ceiling == UNBOUNDED_TOKENS_PER_SECOND)
snprintf(out, out_len, "%s", "n/a");
else
snprintf(out, out_len, "%.1f", ceiling);
}
static void print_arm_row(measurement_arm *arm)
{
timing_statistics stats = summarise_arm(arm);
int dispatches_per_layer = arm_dispatches_per_layer(arm);
char ceiling_at_min[16];
char ceiling_at_median[16];
format_ceiling(ceiling_at_min, sizeof(ceiling_at_min), stats.min_ms);
format_ceiling(ceiling_at_median, sizeof(ceiling_at_median), stats.median_ms);
printf("%-32s %4d %9d %9.3f %9.3f %9.3f %11s %11s%s\n",
arm->label, dispatches_per_layer,
dispatches_per_layer * TRANSFORMER_LAYER_COUNT,
stats.min_ms, stats.median_ms, stats.p95_ms,
ceiling_at_min, ceiling_at_median,
arm->work_verified ? "" : " <-- WORK CHECK FAILED");
}
static void print_arm_table(const char *title, measurement_arm *arms, int count)
{
print_table_header(title);
for (int i = 0; i < count; i++)
print_arm_row(&arms[i]);
}
/* ================================================================
* Arm construction
* ================================================================ */
/** Fills the fm_ sweep arms: every batching strategy x every K. */
static int build_fm_sweep_arms(measurement_arm *arms)
{
const fm_batch_strategy strategies[] = {
BATCH_STRATEGY_PER_TOKEN, BATCH_STRATEGY_PER_LAYER,
BATCH_STRATEGY_AUTO_SYNC, BATCH_STRATEGY_NONE
};
int count = 0;
for (int s = 0; s < 4; s++) {
for (int k = 0; k < DISPATCHES_PER_LAYER_SWEEP_COUNT; k++) {
arms[count].label = batch_strategy_name(strategies[s]);
arms[count].config.dispatches_per_layer = DISPATCHES_PER_LAYER_SWEEP[k];
arms[count].config.strategy = strategies[s];
arms[count].config.op_mix = OP_MIX_ADD_CHAIN;
arms[count].config.element_count = PROBE_ELEMENT_COUNT;
arms[count].config.dropped_dispatch_period = 0;
count++;
}
}
return count;
}
/* Sweep indices replicated for the noise floor: per-token K=10 and K=40. */
static const int NOISE_FLOOR_REPLICATED_ARMS[] = { 2, 4 };
#define NOISE_FLOOR_REPLICA_COUNT \
((int)(sizeof(NOISE_FLOOR_REPLICATED_ARMS) / sizeof(NOISE_FLOOR_REPLICATED_ARMS[0])))
/**
* Appends byte-identical copies of two per-token arms. Because a replica runs
* through exactly the same interleaved rounds as its original, the gap between
* the two is pure measurement noise on the statistic actually reported — a
* better floor than repeating one configuration in a separate stage under
* different machine conditions.
*
* @param arms Arm array already holding the sweep
* @param count Arms currently present
* @return New arm count
*/
static int build_noise_floor_replica_arms(measurement_arm *arms, int count)
{
for (int i = 0; i < NOISE_FLOOR_REPLICA_COUNT; i++) {
arms[count] = arms[NOISE_FLOOR_REPLICATED_ARMS[i]];
arms[count].label = "per-token REPLICA (noise floor)";
arms[count].pooled_count = 0;
count++;
}
return count;
}
/**
* Kernel-mix cross-check arms: an add/mul/silu round-robin should cost the same
* as the pure-add chain if cost tracks dispatch count, not kernel identity.
*/
static int build_kernel_mix_arms(measurement_arm *arms)
{
int count = 0;
for (int k = 1; k < DISPATCHES_PER_LAYER_SWEEP_COUNT; k++) {
arms[count].label = "add/mul/silu round-robin";
arms[count].config.dispatches_per_layer = DISPATCHES_PER_LAYER_SWEEP[k];
arms[count].config.strategy = BATCH_STRATEGY_PER_TOKEN;
arms[count].config.op_mix = OP_MIX_ROUND_ROBIN;
arms[count].config.element_count = PROBE_ELEMENT_COUNT;
arms[count].config.dropped_dispatch_period = 0;
count++;
}
return count;
}
/**
* Compute-sensitivity arms: the same 1600 dispatches over buffers from 1 KiB to
* 256 KiB. If the probe is measuring dispatch overhead, these are flat; where
* they start to rise, the kernels have become real work and any ceiling read off
* that row is a compute number wearing a dispatch number's clothes.
*/
static int build_compute_sensitivity_arms(measurement_arm *arms)
{
const int k_index = DISPATCHES_PER_LAYER_SWEEP_COUNT - 1; /* K = 40 */
static char labels[ELEMENT_COUNT_SWEEP_COUNT][32];
int count = 0;
for (int e = 0; e < ELEMENT_COUNT_SWEEP_COUNT; e++) {
snprintf(labels[e], sizeof(labels[e]), "elements = %d (%d KiB)",
ELEMENT_COUNT_SWEEP[e],
(int)((size_t)ELEMENT_COUNT_SWEEP[e] * sizeof(float) / 1024));
arms[count].label = labels[e];
arms[count].config.dispatches_per_layer = DISPATCHES_PER_LAYER_SWEEP[k_index];
arms[count].config.strategy = BATCH_STRATEGY_PER_TOKEN;
arms[count].config.op_mix = OP_MIX_ADD_CHAIN;
arms[count].config.element_count = ELEMENT_COUNT_SWEEP[e];
arms[count].config.dropped_dispatch_period = 0;
count++;
}
return count;
}
/** Raw-Metal arms isolating encoder creation from dispatch. */
static int build_raw_metal_arms(measurement_arm *arms)
{
const raw_encoding_mode modes[] = { RAW_ENCODING_ENCODER_PER_DISPATCH,
RAW_ENCODING_SINGLE_ENCODER,
RAW_ENCODING_SINGLE_PIPELINE };
const char *labels[] = { "raw: encoder/dispatch, 2 pipes",
"raw: 1 encoder, 2 pipelines",
"raw: 1 encoder, 1 pipeline" };
int count = 0;
for (int m = 0; m < 3; m++) {
for (int k = 0; k < DISPATCHES_PER_LAYER_SWEEP_COUNT; k++) {
arms[count].label = labels[m];
arms[count].is_raw_arm = true;
arms[count].raw_mode = modes[m];
arms[count].raw_dispatches_per_layer = DISPATCHES_PER_LAYER_SWEEP[k];
count++;
}
}
return count;
}
/* ================================================================
* Analysis
* ================================================================ */
/**
* Reports the noise floor as the gap between each per-token arm and its
* byte-identical replica. Nothing smaller than this may be called a difference.
*
* @return The largest replica gap on the min statistic, in milliseconds
*/
static double report_noise_floor(measurement_arm *arms, int replica_start)
{
double largest_min_gap_ms = 0.0;
printf("\n== Noise floor: identical arms measured twice, interleaved ==\n");
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 original_stats = summarise_arm(original);
timing_statistics replica_stats = summarise_arm(replica);
double min_gap = fabs(original_stats.min_ms - replica_stats.min_ms);
double median_gap = fabs(original_stats.median_ms - replica_stats.median_ms);
if (min_gap > largest_min_gap_ms)
largest_min_gap_ms = min_gap;
printf(" per-token K=%d: min %.3f vs %.3f ms (gap %.3f, %.2f%%) "
"p50 %.3f vs %.3f ms (gap %.3f, %.2f%%)\n",
original->config.dispatches_per_layer,
original_stats.min_ms, replica_stats.min_ms, min_gap,
100.0 * min_gap / original_stats.min_ms,
original_stats.median_ms, replica_stats.median_ms, median_gap,
100.0 * median_gap / original_stats.median_ms);
}
printf(" NOISE FLOOR (min statistic): %.3f ms per token. Differences "
"below this are not reportable.\n", largest_min_gap_ms);
return largest_min_gap_ms;
}
/**
* Turns the sweep into the numbers the kernel design actually needs: marginal
* cost per dispatch, fixed cost per command-buffer round trip, and the
* dispatch budget a 25 tok/s decode target allows.
*
* @param sweep The fm_ sweep arms, strategy-major then K-ascending
*/
static void report_derived_costs(measurement_arm *sweep)
{
const int per_token_base = 0 * DISPATCHES_PER_LAYER_SWEEP_COUNT;
const int no_batch_base = 3 * DISPATCHES_PER_LAYER_SWEEP_COUNT;
const int low = 1; /* K = 5 -> 200 */
const int high = DISPATCHES_PER_LAYER_SWEEP_COUNT - 1; /* K = 40 -> 1600 */
double dispatch_delta = (double)(DISPATCHES_PER_LAYER_SWEEP[high] -
DISPATCHES_PER_LAYER_SWEEP[low]) *
TRANSFORMER_LAYER_COUNT;
double per_token_high = summarise_arm(&sweep[per_token_base + high]).min_ms;
double per_token_low = summarise_arm(&sweep[per_token_base + low]).min_ms;
double per_token_zero = summarise_arm(&sweep[per_token_base]).min_ms;
double no_batch_high = summarise_arm(&sweep[no_batch_base + high]).min_ms;
double no_batch_low = summarise_arm(&sweep[no_batch_base + low]).min_ms;
double per_dispatch_us = 1000.0 * (per_token_high - per_token_low) / dispatch_delta;
double fixed_per_token_us = 1000.0 * per_token_zero;
double per_commit_us = 1000.0 * (no_batch_high - no_batch_low) / dispatch_delta;
double budget_us = 1000.0 * 1000.0 / 25.0; /* 25 tok/s => 40 ms per token */
double affordable = (budget_us - fixed_per_token_us) / per_dispatch_us;
printf("\n== Derived costs (pool minimum, one command buffer per token) ==\n");
printf(" marginal cost per dispatch : %.3f us\n", per_dispatch_us);
printf(" fixed cost per token (K=0, 1 commit) : %.1f us\n", fixed_per_token_us);
printf(" cost per command-buffer round trip : %.1f us "
"(from the no-batching arm)\n", per_commit_us);
printf(" 25 tok/s budget : %.0f us per token\n", budget_us);
printf(" dispatch budget at 25 tok/s : %.0f total = %.0f per layer "
"across %d layers\n", affordable, affordable / TRANSFORMER_LAYER_COUNT,
TRANSFORMER_LAYER_COUNT);
printf(" planned granularity (K=40, 1600 disp): %.2f ms per token = "
"%.1f tok/s ceiling\n", per_token_high,
implied_tokens_per_second(per_token_high));
printf(" dispatch overhead as a share of a 40 ms token budget: %.1f%%\n",
100.0 * per_token_high / 40.0);
}
/**
* Checks whether `fm_sync()` commits work encoded inside a `fm_batch_begin()`
* batch. The plan's Phase 5 specifies "one fm_batch_begin() per token, single
* fm_sync() before reading logits", so whether that pairing actually flushes
* decides whether the planned forward would ever produce logits.
*
* @return 0 if the pairing works, 1 if fm_sync leaves the batch uncommitted
*/
static int report_batch_begin_sync_pairing(fm_probe_buffers *buffers)
{
const int dispatch_count = 8;
const float *data = (const float *)fm_buffer_data(buffers->accumulator);
double after_sync = 0.0;
double after_end = 0.0;
reset_fm_accumulator(buffers);
fm_batch_begin();
for (int i = 0; i < dispatch_count; i++)
fm_add(buffers->accumulator, buffers->ones, buffers->accumulator,
PROBE_ELEMENT_COUNT);
fm_sync();
after_sync = (double)data[0];
fm_batch_end();
after_end = (double)data[0];
printf("\n== fm_batch_begin + fm_sync pairing (plan Phase 5) ==\n");
printf(" %d dispatches encoded; after fm_sync() accumulator = %.0f, "
"after fm_batch_end() = %.0f (expected %d)\n",
dispatch_count, after_sync, after_end, dispatch_count);
if (after_sync == (double)dispatch_count)
return 0;
printf(" DEFECT: fm_sync() does NOT commit a fm_batch_begin() batch — it "
"only flushes the auto-batch buffer (fm_metal.m:667-677), and\n"
" fm_submit_if_not_batching() returns early while batching so "
"_fm_pending_ops stays 0. Phase 5 must call fm_batch_end(),\n"
" or use fm_set_auto_batch(true) + fm_sync(), but not "
"fm_batch_begin() + fm_sync().\n");
return 1;
}
/**
* Demonstrates the elision check can fail: drops every 7th dispatch and
* confirms the accumulator verification reports a mismatch. Per the harness
* rule, a green check that was never shown to fail is not a gate.
*/
static int report_can_fail_demonstration(fm_probe_buffers *buffers)
{
dispatch_probe_config honest = { 10, PROBE_ELEMENT_COUNT,
BATCH_STRATEGY_PER_TOKEN,
OP_MIX_ADD_CHAIN, 0 };
dispatch_probe_config faulty = { 10, PROBE_ELEMENT_COUNT,
BATCH_STRATEGY_PER_TOKEN,
OP_MIX_ADD_CHAIN, 7 };
probe_result honest_result = measure_fm_configuration(buffers, &honest, NULL);
probe_result faulty_result = measure_fm_configuration(buffers, &faulty, NULL);
printf("\n== Can-fail demonstration (harness rule) ==\n");
printf(" honest run: expected %.0f, observed %.0f -> %s\n",
honest_result.expected_accumulator, honest_result.observed_accumulator,
honest_result.work_verified ? "verified" : "MISMATCH");
printf(" 1-in-7 dispatches dropped: expected %.0f, observed %.0f -> %s\n",
faulty_result.expected_accumulator, faulty_result.observed_accumulator,
faulty_result.work_verified ? "verified" : "MISMATCH");
if (honest_result.work_verified && !faulty_result.work_verified) {
printf(" RESULT: the work check detects missing dispatches. "
"Its green is trustworthy.\n");
return 0;
}
printf(" RESULT: the work check is NOT a gate — it did not behave as "
"required.\n");
return 1;
}
/* ================================================================
* Entry point
* ================================================================ */
#define MAX_ARM_COUNT 32
static void run_full_probe(fm_probe_buffers *buffers)
{
static measurement_arm sweep_arms[MAX_ARM_COUNT];
static measurement_arm mix_arms[MAX_ARM_COUNT];
static measurement_arm raw_arms[MAX_ARM_COUNT];
int sweep_count = build_fm_sweep_arms(sweep_arms);
int replica_start = sweep_count;
sweep_count = build_noise_floor_replica_arms(sweep_arms, sweep_count);
run_measurement_arms(buffers, sweep_arms, sweep_count);
int mix_count = build_kernel_mix_arms(mix_arms);
mix_count += build_compute_sensitivity_arms(&mix_arms[mix_count]);
run_measurement_arms(buffers, mix_arms, mix_count);
report_noise_floor(sweep_arms, replica_start);
print_arm_table("== fm_ API sweep: 40 layers x K dispatches per simulated "
"token (fm_add chain) ==", sweep_arms, sweep_count);
print_arm_table("== Kernel-mix and compute-sensitivity cross-checks "
"(per-token batching) ==", mix_arms, mix_count);
if (initialise_raw_metal_probe()) {
int raw_count = build_raw_metal_arms(raw_arms);
run_measurement_arms(buffers, raw_arms, raw_count);
print_arm_table("== Raw-Metal reference: one command buffer per token, "
"encoder count varied ==", raw_arms, raw_count);
} else {
fprintf(stderr, "raw-Metal reference unavailable, skipped.\n");
}
report_derived_costs(sweep_arms);
}
int main(int argc, char **argv)
{
fm_probe_buffers buffers = {0};
bool prove_can_fail_only = argc > 1 && strcmp(argv[1], "--prove-can-fail") == 0;
int exit_code = 0;
if (fm_init() != 0 || !fm_available()) {
fprintf(stderr, "Metal unavailable — cannot run the dispatch probe.\n");
return 1;
}
if (!allocate_fm_probe_buffers(&buffers)) {
fprintf(stderr, "Failed to allocate probe buffers.\n");
fm_free();
return 1;
}
printf("qwen35moe Metal dispatch-overhead probe\n");
printf(" layers per simulated token : %d\n", TRANSFORMER_LAYER_COUNT);
printf(" elements per buffer : %d (%zu KiB)\n", PROBE_ELEMENT_COUNT,
(size_t)PROBE_ELEMENT_COUNT * sizeof(float) / 1024);
printf(" warmup / measured tokens : %d / %d per round\n",
WARMUP_TOKEN_COUNT, MEASURED_TOKEN_COUNT);
printf(" interleaved rounds per arm : %d (%d pooled samples)\n",
MEASUREMENT_ROUND_COUNT, MAX_POOLED_SAMPLE_COUNT);
exit_code = report_can_fail_demonstration(&buffers);
report_batch_begin_sync_pairing(&buffers);
if (!prove_can_fail_only)
run_full_probe(&buffers);
free_fm_probe_buffers(&buffers);
fm_free();
return exit_code;
}