Foundry/cuda/tests/bench_split_scaling.c

798 lines
32 KiB
C

/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* Copyright (C) 2026 David (Corey) Herrera */
/**
* @file bench_split_scaling.c
* @brief Prices tensor-parallelism on one K80 die: does halving a kernel's work halve
* its time? Times every hot saber decode kernel at FULL width and at the width a
* 2-die tensor-parallel split would give it, and reports eta = t_full / t_half.
*
* WHY THIS IS THE WHOLE TP QUESTION, ON ONE DIE
* ---------------------------------------------
* saber is layer-split at 20: die A runs layers 0-19, then die B runs 20-39, so the
* two dies together spend ~52.6 ms on 40 layers at full width. Under tensor
* parallelism both dies run ALL 40 layers at half width, concurrently:
*
* t_TP = 40 * t_half_layer
* t_today = 40 * t_full_layer = 52.6 ms
* speedup = t_full / t_half = eta
*
* So eta IS the TP speedup, before any cross-die barrier is paid. eta = 2 means the
* kernels split cleanly and TP is a 2x lever; eta = 1 means splitting the tensor buys
* nothing and TP is dead with no two-die measurement needed. Auto-memory
* `k80-work-reduction-doesnt-convert` (a 4.15x work cut returning 1.097x, bound by
* BLOCK COUNT) says the prior is bad -- a row split halves block count.
*
* The MoE rows additionally price EXPERT parallelism (halve k, the number of routed
* experts per launch) separately from tensor parallelism (halve ff, the expert
* intermediate width). They are different splits with different costs.
*
* PROVING THE HARNESS CAN FAIL (six green-but-vacuous gates shipped here in 48 hours)
* ---------------------------------------------------------------------------------
* Two controls run first and their expected values are asserted, not eyeballed:
* NULL control -- the same shape in both arms; eta MUST land in [0.97, 1.03].
* If a "half" arm is silently running the full shape, this is
* the only thing that catches it.
* QUARTER control -- rows/4 in the half arm; eta MUST exceed the rows/2 eta.
* If the kernel is fully latency-bound, both collapse to ~1 and
* this reports that honestly rather than hiding it.
* A control failure sets the exit code; the timing table is still printed.
*
* Arms ALTERNATE full/half/full/half within a rep and the table reports the MEDIAN of
* REPS reps, because this board's clock moves under load (an
* unpinned clock drifts between arms). Pin the clock before trusting any number:
* sudo nvidia-smi -i <die> -ac 2505,666 (and trap-restore 2505,875)
*
* No model is loaded and no correctness is claimed: weights are random quant bytes
* with sane fp16 block scales. bench_matvec.c already gates these kernels bit-exactly
* against CPU replicas; this file only times them.
*
* Build (from foundry/):
* gcc -O2 -I. -o cuda/tests/bench_split_scaling cuda/tests/bench_split_scaling.c \
* cuda/fc_cuda.c -lm -ldl
* Run (ONE die, never a die another agent is using):
* CUDA_VISIBLE_DEVICES=7 ./cuda/tests/bench_split_scaling
* Env:
* SPLIT_REPS number of alternating reps (default 5)
* SPLIT_ITERS launches timed per arm (default 200; LM head uses 20)
*
* @status None
* @issues None
* @todo None
*/
#include "cuda/fc_cuda.h"
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
/* ------------------------------------------------------------------
* saber (Qwen3.6-35B-A3B-NSC-ACE-SABER) decode geometry.
* Dense shapes from a census of the model's GGUF; SSM/attention dims from
* Pantheon's qwen35moe model definition.
* ------------------------------------------------------------------ */
#define SB_N_EMBD 2048
#define SB_N_VOCAB 248320
#define SB_N_EXPERT 256
#define SB_EXPERT_FF 512
#define SB_TOPK 8
#define SB_SSM_STATE 128 /* per-head state matrix is 128 x 128 */
#define SB_SSM_V_HEADS 32
#define SB_SSM_K_HEADS 16
#define SB_CONV_DIM 8192 /* key*2 + value */
#define SB_CONV_KERNEL 4
#define SB_ATTN_HEADS 16
#define SB_HEAD_DIM 256
#define SB_ATTN_KV_HEADS 2 /* GQA 8:1 -- and the CEILING on TP width */
/* Decode flash split plan, mirroring qwen35moe_forward_cuda.c:114-115. */
#define SB_FLASH_KPS 256
#define SB_FLASH_SPLIT_MAX 32
/* GGUF quant type ids used below. */
#define Q3K 11
#define Q4K 12
#define Q6K 14
static double now_s(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
}
static int env_int(const char *name, int dflt)
{
const char *e = getenv(name);
return (e && *e) ? atoi(e) : dflt;
}
/* ------------------------------------------------------------------
* Random weights (mirrors bench_matvec.c: fp16 block scales clamped to a
* sane exponent so no arm ever hits a denormal/inf slow path -- an arm that
* silently ran on inf data would be a timing artefact, not a measurement).
* ------------------------------------------------------------------ */
static size_t raw_block_bytes(int t)
{
switch (t) {
case 8: return 34;
case 11: return 110;
case 12: return 144;
case 13: return 176;
case 14: return 210;
default: return 0;
}
}
static void sanitize_quant(int t, unsigned char *p, size_t bytes)
{
size_t bs = raw_block_bytes(t);
size_t off;
for (off = 0; bs && off + bs <= bytes; off += bs) {
unsigned char *b = p + off;
unsigned short h = (unsigned short)(0x2C00 | (rand() & 0x3FF));
unsigned short h2 = (unsigned short)(0x2C00 | (rand() & 0x3FF));
switch (t) {
case 11: b[108] = h & 0xFF; b[109] = h >> 8; break;
case 14: b[208] = h & 0xFF; b[209] = h >> 8; break;
case 12: case 13:
b[0] = h & 0xFF; b[1] = h >> 8;
b[2] = h2 & 0xFF; b[3] = h2 >> 8;
break;
case 8: b[0] = h & 0xFF; b[1] = h >> 8; break;
}
}
}
static float *rand_floats(size_t n)
{
float *p = malloc(n * sizeof(float));
size_t i;
if (!p)
return NULL;
for (i = 0; i < n; i++)
p[i] = (float)rand() / (float)RAND_MAX - 0.5f;
return p;
}
/* Upload `rows` x `cols` of random quant bytes, repacked exactly as a model
* upload does (fc_repack_weight). Writes the device row stride. */
static fc_buffer *make_weight(int t, long rows, int cols, int *dev_stride)
{
size_t raw_stride = raw_block_bytes(t) * (size_t)((t == 8) ? cols / 32
: cols / 256);
size_t rs = fc_repack_row_bytes(t, cols);
unsigned char *raw = NULL, *packed = NULL;
fc_buffer *b = NULL;
size_t i;
raw = malloc(raw_stride * (size_t)rows);
packed = malloc(rs * (size_t)rows);
if (!raw || !packed)
goto out;
for (i = 0; i < raw_stride * (size_t)rows; i++)
raw[i] = (unsigned char)(rand() & 0xFF);
sanitize_quant(t, raw, raw_stride * (size_t)rows);
if (fc_repack_weight(t, raw, packed, rows, cols) != 0)
goto out;
b = fc_buffer_from_ptr(packed, rs * (size_t)rows);
*dev_stride = (int)rs;
out:
free(raw);
free(packed);
return b;
}
static fc_buffer *make_floats(size_t n)
{
float *h = rand_floats(n);
fc_buffer *b = h ? fc_buffer_from_ptr(h, n * sizeof(float)) : NULL;
free(h);
return b;
}
/* ------------------------------------------------------------------
* The arm abstraction: one timed kernel configuration.
*
* Each row of the table is a pair of arms (full, half) sharing one prepared
* workload. `run` issues ONE launch of that arm; the harness handles warmup,
* iteration and alternation so both arms see identical treatment.
* ------------------------------------------------------------------ */
typedef void (*arm_run_fn)(void *ctx, int half);
typedef struct split_case {
const char *name; /* kernel + split axis, e.g. "attn_qg row-split" */
const char *full_shape;
const char *half_shape;
arm_run_fn run;
void *ctx;
int iters;
double t_full_ms; /* median over reps */
double t_half_ms;
double eta; /* t_full / t_half */
} split_case;
static int cmp_double(const void *a, const void *b)
{
double x = *(const double *)a, y = *(const double *)b;
return (x > y) - (x < y);
}
/* Time `iters` launches of one arm, returning ms per launch. */
static double time_arm(split_case *c, int half)
{
int i;
double t0;
c->run(c->ctx, half); /* warm up outside the timed region */
fc_sync();
t0 = now_s();
for (i = 0; i < c->iters; i++)
c->run(c->ctx, half);
fc_sync();
return (now_s() - t0) * 1e3 / c->iters;
}
/* Run one case for `reps` alternating full/half rounds and keep the medians.
* Alternating (not blocked) is what keeps a DVFS drift from landing entirely
* on one arm -- the board's clock moves ~2% under load. */
static void measure_case(split_case *c, int reps)
{
double *full = malloc((size_t)reps * sizeof(double));
double *half = malloc((size_t)reps * sizeof(double));
int r;
if (!full || !half) {
free(full);
free(half);
return;
}
for (r = 0; r < reps; r++) {
full[r] = time_arm(c, 0);
half[r] = time_arm(c, 1);
}
qsort(full, (size_t)reps, sizeof(double), cmp_double);
qsort(half, (size_t)reps, sizeof(double), cmp_double);
c->t_full_ms = full[reps / 2];
c->t_half_ms = half[reps / 2];
c->eta = (c->t_half_ms > 0.0) ? c->t_full_ms / c->t_half_ms : 0.0;
free(full);
free(half);
}
/* ------------------------------------------------------------------
* Case 1 -- dense quant matvec, split by output ROWS or by input COLS.
*
* Row split = Megatron column-parallel: each die owns half the output rows,
* reads the whole activation, produces a disjoint half (concat).
* Col split = Megatron row-parallel: each die owns half the input columns,
* produces a full-length PARTIAL that must be summed (all-reduce).
* Both are modelled here purely as a smaller launch; the reduce is priced
* separately by bench_cross_die.
* ------------------------------------------------------------------ */
typedef struct mv_ctx {
int quant;
fc_buffer *w_full, *w_half;
fc_buffer *x_full, *x_half;
fc_buffer *y;
int rows_full, cols_full, stride_full;
int rows_half, cols_half, stride_half;
} mv_ctx;
static void mv_dispatch(fc_cuda_ctx *ctx, int quant, fc_buffer *w, fc_buffer *x,
fc_buffer *y, int rows, int cols, int stride)
{
switch (quant) {
case Q3K: fc_matvec_q3k_ctx(ctx, w, x, y, rows, cols, stride); break;
case Q4K: fc_matvec_q4k_ctx(ctx, w, x, y, rows, cols, stride); break;
case Q6K: fc_matvec_q6k_ctx(ctx, w, x, y, rows, cols, stride); break;
default: break;
}
}
static void mv_run(void *vc, int half)
{
mv_ctx *m = (mv_ctx *)vc;
if (half)
mv_dispatch(fc_default_ctx(), m->quant, m->w_half, m->x_half, m->y,
m->rows_half, m->cols_half, m->stride_half);
else
mv_dispatch(fc_default_ctx(), m->quant, m->w_full, m->x_full, m->y,
m->rows_full, m->cols_full, m->stride_full);
}
/* Build a matvec case. rows_half/cols_half give the post-split shape; passing
* the full shape for both is the NULL control. */
static int mv_case(split_case *c, const char *name, int quant,
int rows_full, int cols_full, int rows_half, int cols_half,
int iters, char *shape_a, char *shape_b)
{
mv_ctx *m = calloc(1, sizeof(*m));
if (!m)
return -1;
m->quant = quant;
m->rows_full = rows_full; m->cols_full = cols_full;
m->rows_half = rows_half; m->cols_half = cols_half;
m->w_full = make_weight(quant, rows_full, cols_full, &m->stride_full);
m->w_half = make_weight(quant, rows_half, cols_half, &m->stride_half);
m->x_full = make_floats((size_t)cols_full);
m->x_half = make_floats((size_t)cols_half);
m->y = fc_buffer_alloc((size_t)rows_full * sizeof(float));
if (!m->w_full || !m->w_half || !m->x_full || !m->x_half || !m->y) {
fprintf(stderr, "%s: allocation failed (VRAM?)\n", name);
free(m);
return -1;
}
sprintf(shape_a, "%dx%d", rows_full, cols_full);
sprintf(shape_b, "%dx%d", rows_half, cols_half);
c->name = name;
c->full_shape = shape_a;
c->half_shape = shape_b;
c->run = mv_run;
c->ctx = m;
c->iters = iters;
return 0;
}
/* ------------------------------------------------------------------
* Case 2 -- grouped MoE gate/up and down.
*
* Two independent split axes, and they are NOT interchangeable:
* TP (halve ff) -- both dies see all 8 routed experts, each computing half
* the expert intermediate. Perfectly balanced, needs an
* all-reduce after `down`.
* EP (halve k) -- each die owns 128 of the 256 experts and computes only
* the ~4 that routed to it. One reduce, but the split is
* data-dependent and therefore imbalanced (see the doc's
* E[max] = 5.09/8 argument).
* ------------------------------------------------------------------ */
typedef struct moe_ctx {
fc_buffer *gate, *up, *down;
fc_buffer *x, *hid, *out, *top_idx, *top_wt;
int gu_stride, dn_stride;
int k_full, k_half;
int ff_full, ff_half;
int rows; /* down output rows == n_embd */
int is_down;
} moe_ctx;
static void moe_run(void *vc, int half)
{
moe_ctx *m = (moe_ctx *)vc;
int k = half ? m->k_half : m->k_full;
int ff = half ? m->ff_half : m->ff_full;
if (m->is_down)
fc_moe_down_accum_ctx(fc_default_ctx(), Q3K, m->down, m->hid,
m->top_idx, m->top_wt, m->out, k, m->rows, ff,
m->dn_stride);
else
fc_moe_gateup_q3k_ctx(fc_default_ctx(), m->gate, m->up, m->x,
m->top_idx, m->hid, k, ff, SB_N_EMBD,
m->gu_stride);
}
/* Shared expert-weight pool: allocating all 256 experts of gate/up/down at
* Q3_K is ~1.1 GB, so the pool is built ONCE and every MoE case points at it.
* Only the k selected experts are read per launch, so the pool's size does not
* affect the timing -- but it must be real memory, not a small buffer indexed
* out of bounds, or the "half" arm would get a hotter cache than the full one. */
typedef struct moe_pool {
fc_buffer *gate, *up, *down;
fc_buffer *x, *hid, *out, *top_idx, *top_wt;
int gu_stride, dn_stride;
} moe_pool;
static int moe_pool_init(moe_pool *p)
{
int idx[SB_TOPK];
float wt[SB_TOPK];
int i;
memset(p, 0, sizeof(*p));
p->gate = make_weight(Q3K, (long)SB_N_EXPERT * SB_EXPERT_FF, SB_N_EMBD,
&p->gu_stride);
p->up = make_weight(Q3K, (long)SB_N_EXPERT * SB_EXPERT_FF, SB_N_EMBD,
&p->gu_stride);
p->down = make_weight(Q3K, (long)SB_N_EXPERT * SB_N_EMBD, SB_EXPERT_FF,
&p->dn_stride);
p->x = make_floats(SB_N_EMBD);
p->hid = fc_buffer_alloc((size_t)SB_TOPK * SB_EXPERT_FF * sizeof(float));
p->out = fc_buffer_alloc((size_t)SB_N_EMBD * sizeof(float));
/* Spread the selected experts across the whole pool so neither arm gets a
* cache-friendly contiguous run. */
for (i = 0; i < SB_TOPK; i++) {
idx[i] = (i * (SB_N_EXPERT / SB_TOPK)) + 3;
wt[i] = 1.0f / (float)SB_TOPK;
}
p->top_idx = fc_buffer_from_ptr(idx, sizeof(idx));
p->top_wt = fc_buffer_from_ptr(wt, sizeof(wt));
if (!p->gate || !p->up || !p->down || !p->x || !p->hid || !p->out ||
!p->top_idx || !p->top_wt) {
fprintf(stderr, "moe pool: allocation failed (VRAM?)\n");
return -1;
}
return 0;
}
static int moe_case(split_case *c, const char *name, const moe_pool *p,
int is_down, int k_full, int k_half, int ff_full,
int ff_half, int iters, char *shape_a, char *shape_b)
{
moe_ctx *m = calloc(1, sizeof(*m));
if (!m)
return -1;
m->gate = p->gate; m->up = p->up; m->down = p->down;
m->x = p->x; m->hid = p->hid; m->out = p->out;
m->top_idx = p->top_idx; m->top_wt = p->top_wt;
m->gu_stride = p->gu_stride; m->dn_stride = p->dn_stride;
m->k_full = k_full; m->k_half = k_half;
m->ff_full = ff_full; m->ff_half = ff_half;
m->rows = SB_N_EMBD;
m->is_down = is_down;
sprintf(shape_a, "k=%d ff=%d", k_full, ff_full);
sprintf(shape_b, "k=%d ff=%d", k_half, ff_half);
c->name = name;
c->full_shape = shape_a;
c->half_shape = shape_b;
c->run = moe_run;
c->ctx = m;
c->iters = iters;
return 0;
}
/* ------------------------------------------------------------------
* Case 3 -- the SSM block (Gated Delta Net), split by HEADS.
*
* A head split is the natural TP decomposition for a recurrent block: each die
* owns half the value heads and their state matrices, and the recurrence never
* crosses heads, so NO cross-die communication is needed inside the SSM at all
* -- only at the block's input and output projections. That makes the SSM the
* cheapest part of a TP scheme to communicate and, at 30 of saber's 40 layers,
* the most important part to scale.
* ------------------------------------------------------------------ */
typedef struct ssm_ctx {
fc_buffer *s, *q, *k, *v, *g, *beta, *y; /* recurrence */
fc_buffer *qkv, *cw, *cs, *cout; /* conv1d */
fc_buffer *gn_y, *gn_w, *gn_z, *gn_out; /* gated norm */
int heads_full, heads_half;
int conv_full, conv_half;
int which; /* 0 recur, 1 conv, 2 gnorm */
} ssm_ctx;
static void ssm_run(void *vc, int half)
{
ssm_ctx *s = (ssm_ctx *)vc;
fc_cuda_ctx *ctx = fc_default_ctx();
int heads = half ? s->heads_half : s->heads_full;
switch (s->which) {
case 0:
fc_ssm_recurrence_ctx(ctx, s->s, s->q, s->k, s->v, s->g, s->beta, s->y,
heads, SB_SSM_STATE,
1.0f / sqrtf((float)SB_SSM_STATE));
break;
case 1:
fc_ssm_conv1d_silu_ctx(ctx, s->qkv, s->cw, s->cs, s->cout,
half ? s->conv_half : s->conv_full,
SB_CONV_KERNEL);
break;
default:
fc_ssm_gated_norm_ctx(ctx, s->gn_y, s->gn_w, s->gn_z, s->gn_out,
heads, SB_SSM_STATE, 1e-6f);
break;
}
}
static int ssm_pool_init(ssm_ctx *s)
{
size_t hs = (size_t)SB_SSM_V_HEADS * SB_SSM_STATE;
memset(s, 0, sizeof(*s));
s->heads_full = SB_SSM_V_HEADS;
s->heads_half = SB_SSM_V_HEADS / 2;
s->conv_full = SB_CONV_DIM;
s->conv_half = SB_CONV_DIM / 2;
s->s = make_floats(hs * SB_SSM_STATE);
s->q = make_floats(hs);
s->k = make_floats(hs);
s->v = make_floats(hs);
s->g = make_floats(SB_SSM_V_HEADS);
s->beta = make_floats(SB_SSM_V_HEADS);
s->y = fc_buffer_alloc(hs * sizeof(float));
s->qkv = make_floats(SB_CONV_DIM);
s->cw = make_floats((size_t)SB_CONV_DIM * SB_CONV_KERNEL);
s->cs = make_floats((size_t)SB_CONV_DIM * (SB_CONV_KERNEL - 1));
s->cout = fc_buffer_alloc((size_t)SB_CONV_DIM * sizeof(float));
s->gn_y = make_floats(hs);
s->gn_w = make_floats(SB_SSM_STATE);
s->gn_z = make_floats(hs);
s->gn_out = fc_buffer_alloc(hs * sizeof(float));
if (!s->s || !s->q || !s->k || !s->v || !s->g || !s->beta || !s->y ||
!s->qkv || !s->cw || !s->cs || !s->cout || !s->gn_y || !s->gn_w ||
!s->gn_z || !s->gn_out) {
fprintf(stderr, "ssm pool: allocation failed\n");
return -1;
}
return 0;
}
/* ------------------------------------------------------------------
* Case 4 -- decode flash attention, split by HEAD.
*
* The most favourable phase in the whole model for TP: heads are independent,
* each die would own 8 of the 16 q-heads and 1 of the 2 kv-heads, and NO
* cross-die communication is needed -- the split lives entirely between the
* (already-split) input and output projections. It is also 13.7% of the step.
*
* But the flash decode grid is (n_splits, n_head_kv), and n_splits is
* ceil(seq_len/256) capped at 32. At short context that is a 2-block launch on
* a 13-SM die, and halving 2 blocks to 1 cannot make anything faster. So eta
* here is CONTEXT-DEPENDENT and the sweep below is the point of this case:
* seq_len 512 -> n_splits 2 -> grid 4 blocks (full) / 2 (half)
* seq_len 4096 -> n_splits 16 -> grid 32 blocks (full) / 16 (half)
* seq_len 32768 -> n_splits 32 -> grid 64 blocks (full) / 32 (half)
* Anything that reports eta ~ 1.0 at 512 and ~ 2.0 at 32768 is telling us TP's
* attention win only exists at depth -- which is where the serve actually runs.
*
* fp32 KV, not the q4 path the deployment runs (QWEN35_KV_Q4=1). The q4 kernel adds sink
* and window shadow buffers; the head split is the same shape either way, but
* the absolute ms here are NOT the deployment's.
* ------------------------------------------------------------------ */
typedef struct flash_ctx {
fc_buffer *q_full, *q_half;
fc_buffer *k_cache, *v_cache; /* [n_head_kv][max_seq][head_dim] */
fc_buffer *m_scr, *l_scr, *acc_scr, *out;
int seq_len, max_seq;
int n_splits, split_len;
} flash_ctx;
/* The engine's plan, verbatim (qwen35moe_forward_cuda.c:4592-4602). */
static void flash_plan(int seq_len, int *n_splits, int *split_len)
{
int ns = (seq_len + SB_FLASH_KPS - 1) / SB_FLASH_KPS;
int sl;
if (ns < 1)
ns = 1;
if (ns > SB_FLASH_SPLIT_MAX)
ns = SB_FLASH_SPLIT_MAX;
sl = (seq_len + ns - 1) / ns;
sl = (sl + 15) & ~15;
*split_len = sl;
*n_splits = (seq_len + sl - 1) / sl;
}
static void flash_run(void *vc, int half)
{
flash_ctx *f = (flash_ctx *)vc;
int nh = half ? SB_ATTN_HEADS / 2 : SB_ATTN_HEADS;
int nkv = half ? SB_ATTN_KV_HEADS / 2 : SB_ATTN_KV_HEADS;
fc_attn_flash_decode_ctx(fc_default_ctx(), half ? f->q_half : f->q_full,
f->k_cache, f->v_cache, f->m_scr, f->l_scr,
f->acc_scr, f->out, nh, nkv, SB_HEAD_DIM,
f->max_seq, f->seq_len, f->n_splits, f->split_len,
0, 0, 1.0f / 16.0f);
}
/* Build one flash case at `seq_len`. Returns -1 if the kernels are absent from
* this PTX blob (they are optional) so the row is skipped rather than faked. */
static int flash_case(split_case *c, const char *name, int seq_len, int iters,
char *shape_a, char *shape_b)
{
flash_ctx *f = calloc(1, sizeof(*f));
size_t ring = (size_t)SB_ATTN_KV_HEADS * seq_len * SB_HEAD_DIM;
size_t np = (size_t)SB_ATTN_HEADS * SB_FLASH_SPLIT_MAX;
if (!f)
return -1;
if (!fc_attn_flash_decode_available_ctx(fc_default_ctx())) {
free(f);
return -1;
}
f->seq_len = seq_len;
f->max_seq = seq_len;
flash_plan(seq_len, &f->n_splits, &f->split_len);
f->q_full = make_floats((size_t)SB_ATTN_HEADS * SB_HEAD_DIM);
f->q_half = make_floats((size_t)(SB_ATTN_HEADS / 2) * SB_HEAD_DIM);
f->k_cache = make_floats(ring);
f->v_cache = make_floats(ring);
f->m_scr = fc_buffer_alloc(np * sizeof(float));
f->l_scr = fc_buffer_alloc(np * sizeof(float));
f->acc_scr = fc_buffer_alloc(np * SB_HEAD_DIM * sizeof(float));
f->out = fc_buffer_alloc((size_t)SB_ATTN_HEADS * SB_HEAD_DIM *
sizeof(float));
if (!f->q_full || !f->q_half || !f->k_cache || !f->v_cache || !f->m_scr ||
!f->l_scr || !f->acc_scr || !f->out) {
fprintf(stderr, "%s: allocation failed (VRAM?)\n", name);
free(f);
return -1;
}
sprintf(shape_a, "16h/2kv s=%d", seq_len);
sprintf(shape_b, "8h/1kv s=%d", seq_len);
c->name = name;
c->full_shape = shape_a;
c->half_shape = shape_b;
c->run = flash_run;
c->ctx = f;
c->iters = iters;
return 0;
}
/* ------------------------------------------------------------------
* Reporting
* ------------------------------------------------------------------ */
static void print_header(void)
{
printf("\n%-34s %-14s %-14s %10s %10s %7s\n", "kernel / split axis",
"full", "half", "t_full ms", "t_half ms", "eta");
printf("%-34s %-14s %-14s %10s %10s %7s\n", "----------------------------------",
"--------------", "--------------", "----------", "----------",
"-------");
}
static void print_case(const split_case *c)
{
printf("%-34s %-14s %-14s %10.4f %10.4f %6.3fx\n", c->name, c->full_shape,
c->half_shape, c->t_full_ms, c->t_half_ms, c->eta);
}
int main(void)
{
/* Shape strings are held by the cases, so they must outlive the table. */
static char shp[64][2][32];
static split_case cases[64];
moe_pool mp;
ssm_ctx ssm_r, ssm_c, ssm_g;
int reps = env_int("SPLIT_REPS", 5);
int iters = env_int("SPLIT_ITERS", 200);
int n = 0, i, control_fail = 0;
double null_eta = 0.0, half_eta = 0.0, quarter_eta = 0.0;
if (fc_init() != 0) {
fprintf(stderr, "no CUDA driver / no visible die\n");
return 1;
}
srand(42);
printf("bench_split_scaling -- does halving the work halve the time?\n");
printf("device %d, reps=%d iters=%d\n", fc_cuda_ctx_device_id(fc_default_ctx()),
reps, iters);
/* ---- controls (see the file header: these are what make the table
* trustworthy; a NULL control off 1.00 means an arm is not what it says) */
if (mv_case(&cases[n], "CONTROL null (same shape both arms)", Q4K,
8192, SB_N_EMBD, 8192, SB_N_EMBD, iters,
shp[n][0], shp[n][1]) == 0) n++;
if (mv_case(&cases[n], "CONTROL quarter (rows/4)", Q4K,
8192, SB_N_EMBD, 2048, SB_N_EMBD, iters,
shp[n][0], shp[n][1]) == 0) n++;
/* ---- dense projections, decode width (n = 1) ---- */
/* SSM in_proj / attention Q+gate: 8192 rows -> 4096 (column-parallel). */
if (mv_case(&cases[n], "ssm_in_proj / attn_qg ROW", Q4K,
SB_CONV_DIM, SB_N_EMBD, SB_CONV_DIM / 2, SB_N_EMBD, iters,
shp[n][0], shp[n][1]) == 0) n++;
/* attn_k / attn_v: 512 rows -> 256. Already tiny; this is where a row
* split is most likely to hit the block-count floor. */
if (mv_case(&cases[n], "attn_k / attn_v ROW", Q4K,
512, SB_N_EMBD, 256, SB_N_EMBD, iters,
shp[n][0], shp[n][1]) == 0) n++;
/* attn_output: row-parallel, so the CONTRACTION halves, not the rows. */
if (mv_case(&cases[n], "attn_output COL", Q4K,
SB_N_EMBD, 4096, SB_N_EMBD, 2048, iters,
shp[n][0], shp[n][1]) == 0) n++;
/* shared-expert gate+up (column-parallel) and down (row-parallel). */
if (mv_case(&cases[n], "shexp_gate_up ROW", Q3K,
2 * SB_EXPERT_FF, SB_N_EMBD, SB_EXPERT_FF, SB_N_EMBD, iters,
shp[n][0], shp[n][1]) == 0) n++;
if (mv_case(&cases[n], "shexp_down COL", Q4K,
SB_N_EMBD, SB_EXPERT_FF, SB_N_EMBD, SB_EXPERT_FF / 2, iters,
shp[n][0], shp[n][1]) == 0) n++;
/* LM head: the shipped T3 vocab split. This row is the cross-check against
* the live +1.01 ms/step that QWEN35_VOCAB_SPLIT measured on the serve. */
if (mv_case(&cases[n], "lm_head ROW (= shipped T3)", Q6K,
SB_N_VOCAB, SB_N_EMBD, SB_N_VOCAB / 2, SB_N_EMBD, 20,
shp[n][0], shp[n][1]) == 0) n++;
/* ---- grouped MoE ---- */
if (moe_pool_init(&mp) == 0) {
if (moe_case(&cases[n], "moe_gateup TP (halve ff)", &mp, 0,
SB_TOPK, SB_TOPK, SB_EXPERT_FF, SB_EXPERT_FF / 2, iters,
shp[n][0], shp[n][1]) == 0) n++;
if (moe_case(&cases[n], "moe_gateup EP (halve k)", &mp, 0,
SB_TOPK, SB_TOPK / 2, SB_EXPERT_FF, SB_EXPERT_FF, iters,
shp[n][0], shp[n][1]) == 0) n++;
if (moe_case(&cases[n], "moe_down TP (halve ff)", &mp, 1,
SB_TOPK, SB_TOPK, SB_EXPERT_FF, SB_EXPERT_FF / 2, iters,
shp[n][0], shp[n][1]) == 0) n++;
if (moe_case(&cases[n], "moe_down EP (halve k)", &mp, 1,
SB_TOPK, SB_TOPK / 2, SB_EXPERT_FF, SB_EXPERT_FF, iters,
shp[n][0], shp[n][1]) == 0) n++;
/* EP at the WORST routed split the doc's binomial allows: 7 of 8
* experts on one die. The busy die sets the critical path, so this is
* the number an EP scheme actually pays on its unlucky tokens. */
if (moe_case(&cases[n], "moe_gateup EP worst (7 of 8)", &mp, 0,
SB_TOPK, 7, SB_EXPERT_FF, SB_EXPERT_FF, iters,
shp[n][0], shp[n][1]) == 0) n++;
}
/* ---- decode flash attention, head split, swept over context depth ----
* The grid is (n_splits, n_head_kv) and n_splits comes from seq_len, so a
* head split has almost no blocks to take away at short context and a
* useful number at depth. Sweeping is the only honest way to report it. */
{
static const int depths[] = { 512, 4096, 16384, 32768 };
static char fname[4][40];
size_t d;
for (d = 0; d < sizeof(depths) / sizeof(depths[0]); d++) {
sprintf(fname[d], "attn_flash HEAD @ %d ctx", depths[d]);
if (flash_case(&cases[n], fname[d], depths[d], iters / 4 + 1,
shp[n][0], shp[n][1]) == 0)
n++;
}
}
/* ---- SSM block, head split ---- */
if (ssm_pool_init(&ssm_r) == 0) {
memcpy(&ssm_c, &ssm_r, sizeof(ssm_c));
memcpy(&ssm_g, &ssm_r, sizeof(ssm_g));
ssm_r.which = 0; ssm_c.which = 1; ssm_g.which = 2;
cases[n].name = "ssm_recurrence HEAD";
cases[n].full_shape = "32 heads"; cases[n].half_shape = "16 heads";
cases[n].run = ssm_run; cases[n].ctx = &ssm_r; cases[n].iters = iters; n++;
cases[n].name = "ssm_conv1d_silu HEAD";
cases[n].full_shape = "conv 8192"; cases[n].half_shape = "conv 4096";
cases[n].run = ssm_run; cases[n].ctx = &ssm_c; cases[n].iters = iters; n++;
cases[n].name = "ssm_gated_norm HEAD";
cases[n].full_shape = "32 heads"; cases[n].half_shape = "16 heads";
cases[n].run = ssm_run; cases[n].ctx = &ssm_g; cases[n].iters = iters; n++;
}
print_header();
for (i = 0; i < n; i++) {
measure_case(&cases[i], reps);
print_case(&cases[i]);
fflush(stdout);
}
/* ---- control verdict ---- */
null_eta = cases[0].eta;
quarter_eta = cases[1].eta;
for (i = 0; i < n; i++)
if (strstr(cases[i].name, "ssm_in_proj"))
half_eta = cases[i].eta;
printf("\n== controls ==\n");
if (null_eta < 0.97 || null_eta > 1.03) {
printf(" FAIL null control eta = %.3f, expected 1.00 +/- 0.03 --"
" the two arms are NOT running the same shape; the table above"
" is not trustworthy\n", null_eta);
control_fail = 1;
} else {
printf(" pass null control eta = %.3f (arms are comparable)\n", null_eta);
}
if (quarter_eta <= half_eta) {
printf(" NOTE quarter control eta = %.3f is NOT above the rows/2 eta"
" %.3f -- this kernel does not scale with work at all at these"
" sizes (a real finding, not a harness fault)\n",
quarter_eta, half_eta);
} else {
printf(" pass quarter control eta = %.3f > rows/2 eta %.3f"
" (the harness can see work reduction)\n", quarter_eta, half_eta);
}
printf("\neta is the tensor-parallel speedup ceiling before any cross-die"
" barrier.\n2.00 = perfect split, 1.00 = splitting buys nothing.\n");
fc_free();
return control_fail;
}