Foundry/cuda/tests/bench_bandwidth.c

885 lines
33 KiB
C

/* SPDX-License-Identifier: AGPL-3.0-or-later */
/* Copyright (C) 2026 David (Corey) Herrera */
/**
* @file bench_bandwidth.c
* @brief Measures the pure streaming-READ memory ceiling of one GK210 die.
*
* Phase 0 of the quant-layout work. Everything downstream
* of it is scored against "what fraction of the ceiling is this kernel at", so
* the ceiling has to be right.
*
* WHY THIS EXISTS. The number in the spec, 177.5 GB/s, came from
* `bandwidthTest --dtod`, which is a COPY: it alternates reads and writes and
* pays GDDR5 bus turnaround on every switch. Our matvecs are near-pure
* streaming reads with one float written per output row, which normally runs
* closer to the pin rate. 177.5 is therefore a floor on the ceiling, not the
* ceiling. This harness reads and does nothing else.
*
* WHAT IT REPORTS
* Stage A geometry sweep (load width x read path x accumulators in flight x
* block x grid) on a fixed 256 MiB buffer -> the peak geometry.
* Stage B the peak geometry across buffer sizes, 3 reps, spread + SM clock.
* Stage C the matvec-shaped read skeleton (cuda_kernels/bench_bw.cu
* fc_bw_mvskel_r*) at the exact shapes fc_matvec_q3k runs -
* 248320x2048, 8192x2048, 2048x2048 with the 152 B / 256 weight
* device footprint - so the result is directly comparable to
* fc_matvec_q3k's GB/s rather than to an abstract peak.
* Control fc_bw_read_u32_ldg_u4_dead, whose loads nvcc deletes. It must
* print an impossible number. That is the evidence this harness can
* detect the "the compiler optimised my benchmark away" failure,
* which is the only interesting way a bandwidth number lies.
*
* SANITY GATES, applied to every reported figure:
* - > 240.5 GB/s (2505 MHz GDDR5 x 2 x 384 bit / 8) is physically
* impossible on one GK210 die and is flagged IMPOSSIBLE, not reported.
* - the flat read figure should land in [177.5, 240.5): at or above the
* copy figure, below the pin rate. Outside that window it is flagged.
* - per-kernel register count and LOCAL (stack frame) bytes are printed;
* a non-zero stack frame means an accumulator array spilled to local
* memory and the kernel is measuring the wrong thing.
* - byte accounting is exact, never estimated: every thread runs the same
* iteration count and the host derives bytes from it.
* - the SM clock and the sibling die's utilization are sampled DURING every
* timed rep. The two GK210s of ONE K80 board share a
* power budget, so a busy neighbour drags this die off 875 MHz (seen at
* 745-810). A sub-875 reading with the sibling IDLE is different: that is
* our own kernel hitting the die power limit, it is a real property of the
* kernel, and it is accepted with a note. Groups are rejected for a busy
* sibling or a spread over 1%. Foreign resident VRAM is reported too but is
* NOT a gate: the dies have independent memory controllers, and reps taken
* with 7 GB of someone else'"'"'s idle model resident agreed to 0.01%.
*
* BUILD (from foundry/), no CUDA headers needed - the driver API is dlopen'd:
* cd cuda_kernels && make bench_bw.ptx
* gcc -O2 -Wall -I. -o cuda/tests/bench_bandwidth \
* cuda/tests/bench_bandwidth.c -ldl
* RUN (on an otherwise idle board; die 6 shown):
* CUDA_VISIBLE_DEVICES=6 FC_BW_SMI_INDEX=6 \
* ./cuda/tests/bench_bandwidth [--ptx PATH] [--reps N] [--stage abc]
*
* bench_bw.ptx is loaded from disk on purpose. Adding it to the KERNELS list
* in cuda_kernels/Makefile would regenerate ../cuda/fc_cuda_ptx.h, which
* production links against; a benchmark has no business doing that.
*
* @status Phase 0 complete: flat ceiling, per-shape skeleton, elision control.
* @issues Small shapes (2048x2048) include per-launch overhead and partially
* hit the 1.5 MB L2, so their GB/s is not a DRAM figure. Reported as-is with
* the launch count so the effect is visible rather than hidden.
* @todo None
*/
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
/* ================================================================
* Minimal CUDA driver API surface (dlopen'd, as fc_cuda.c does)
* ================================================================ */
typedef int CUresult;
typedef int CUdevice;
typedef unsigned long long CUdeviceptr;
typedef struct CUctx_st *CUcontext;
typedef struct CUmod_st *CUmodule;
typedef struct CUfunc_st *CUfunction;
typedef struct CUstream_st *CUstream;
#define CU_SUCCESS 0
#define CU_FUNC_ATTRIBUTE_MAX_THREADS 0
#define CU_FUNC_ATTRIBUTE_LOCAL_SIZE 3
#define CU_FUNC_ATTRIBUTE_NUM_REGS 4
static void *g_libcuda;
static CUresult (*p_cuInit)(unsigned int);
static CUresult (*p_cuDeviceGetCount)(int *);
static CUresult (*p_cuDeviceGet)(CUdevice *, int);
static CUresult (*p_cuDeviceGetName)(char *, int, CUdevice);
static CUresult (*p_cuCtxCreate)(CUcontext *, unsigned int, CUdevice);
static CUresult (*p_cuCtxDestroy)(CUcontext);
static CUresult (*p_cuCtxSynchronize)(void);
static CUresult (*p_cuMemAlloc)(CUdeviceptr *, size_t);
static CUresult (*p_cuMemFree)(CUdeviceptr);
static CUresult (*p_cuMemGetInfo)(size_t *, size_t *);
static CUresult (*p_cuMemcpyHtoD)(CUdeviceptr, const void *, size_t);
static CUresult (*p_cuMemcpyDtoH)(void *, CUdeviceptr, size_t);
static CUresult (*p_cuMemsetD32)(CUdeviceptr, unsigned int, size_t);
static CUresult (*p_cuModuleLoadData)(CUmodule *, const void *);
static CUresult (*p_cuModuleGetFunction)(CUfunction *, CUmodule, const char *);
static CUresult (*p_cuFuncGetAttribute)(int *, int, CUfunction);
static CUresult (*p_cuLaunchKernel)(CUfunction, unsigned int, unsigned int,
unsigned int, unsigned int, unsigned int,
unsigned int, unsigned int, CUstream,
void **, void **);
static void *sym(const char *name, const char *v2)
{
void *s = v2 ? dlsym(g_libcuda, v2) : NULL;
if (!s)
s = dlsym(g_libcuda, name);
if (!s) {
fprintf(stderr, "[bench_bw] missing driver symbol %s\n", name);
exit(1);
}
return s;
}
static void load_driver(void)
{
g_libcuda = dlopen("libcuda.so.1", RTLD_NOW);
if (!g_libcuda)
g_libcuda = dlopen("libcuda.so", RTLD_NOW);
if (!g_libcuda) {
fprintf(stderr, "[bench_bw] dlopen libcuda failed: %s\n", dlerror());
exit(1);
}
p_cuInit = sym("cuInit", NULL);
p_cuDeviceGetCount = sym("cuDeviceGetCount", NULL);
p_cuDeviceGet = sym("cuDeviceGet", NULL);
p_cuDeviceGetName = sym("cuDeviceGetName", NULL);
p_cuCtxCreate = sym("cuCtxCreate", "cuCtxCreate_v2");
p_cuCtxDestroy = sym("cuCtxDestroy", "cuCtxDestroy_v2");
p_cuCtxSynchronize = sym("cuCtxSynchronize", NULL);
p_cuMemAlloc = sym("cuMemAlloc", "cuMemAlloc_v2");
p_cuMemFree = sym("cuMemFree", "cuMemFree_v2");
p_cuMemGetInfo = sym("cuMemGetInfo", "cuMemGetInfo_v2");
p_cuMemcpyHtoD = sym("cuMemcpyHtoD", "cuMemcpyHtoD_v2");
p_cuMemcpyDtoH = sym("cuMemcpyDtoH", "cuMemcpyDtoH_v2");
p_cuMemsetD32 = sym("cuMemsetD32", "cuMemsetD32_v2");
p_cuModuleLoadData = sym("cuModuleLoadData", NULL);
p_cuModuleGetFunction = sym("cuModuleGetFunction", NULL);
p_cuFuncGetAttribute = sym("cuFuncGetAttribute", NULL);
p_cuLaunchKernel = sym("cuLaunchKernel", NULL);
}
static void cu_ok(CUresult r, const char *what)
{
if (r != CU_SUCCESS) {
fprintf(stderr, "[bench_bw] %s failed: %d\n", what, r);
exit(1);
}
}
/* ================================================================
* Host helpers
* ================================================================ */
#define K80_DIE_PEAK_GBS 240.5 /* 2505 MHz x 2 (DDR) x 384 bit / 8 */
#define K80_DTOD_COPY_GBS 177.5 /* bandwidthTest --dtod, die 6, 2026-08-08 */
#define Q3K_BLOCK_BYTES 152 /* repacked device super-block, 256 wts */
static double now_s(void)
{
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec + (double)ts.tv_nsec * 1e-9;
}
/* One nvidia-smi call sampling BOTH GK210 dies of this K80 board: our target
* die's SM clock and allocated VRAM, plus the sibling die's utilization.
* nvidia-smi ignores CUDA_VISIBLE_DEVICES, so the physical index comes from
* FC_BW_SMI_INDEX; the two dies of a board are consecutive, so the sibling is
* index^1.
*
* WHICH SIGNAL ACTUALLY MATTERS. Resident foreign VRAM is NOT contention: the
* two dies have independent memory controllers, and a neighbour merely
* holding a model consumes no bandwidth. Measured here: three reps taken with
* 7003 MiB of somebody else's model resident on the die agreed to 0.01%. What
* does contend is (a) another process actually issuing work, which shows up
* as a wide spread, and (b) the sibling die under load, which drags the SM
* clock off 875 MHz because both dies share one board power budget - observed
* at 745-810 MHz while the neighbour was busy. So the gates are the CLOCK and
* the SPREAD; foreign MiB and sibling utilization are printed as context for
* why a rep went bad, not as pass/fail on their own. */
struct die_sample {
int clock_mhz;
int mem_mib;
int sibling_util;
};
static void smi_sample(struct die_sample *out)
{
const char *idx_s = getenv("FC_BW_SMI_INDEX");
int idx = idx_s ? atoi(idx_s) : 0;
int sib = idx ^ 1;
char cmd[256], line[128];
FILE *f;
out->clock_mhz = -1;
out->mem_mib = -1;
out->sibling_util = -1;
snprintf(cmd, sizeof(cmd),
"nvidia-smi --query-gpu=index,clocks.sm,memory.used,"
"utilization.gpu --format=csv,noheader,nounits -i %d,%d 2>/dev/null",
idx < sib ? idx : sib, idx < sib ? sib : idx);
f = popen(cmd, "r");
if (!f)
return;
while (fgets(line, sizeof(line), f)) {
int i, clk, mem, util;
if (sscanf(line, "%d, %d, %d, %d", &i, &clk, &mem, &util) != 4)
continue;
if (i == idx) {
out->clock_mhz = clk;
out->mem_mib = mem;
} else if (i == sib) {
out->sibling_util = util;
}
}
pclose(f);
}
static int smi_clock_mhz(void)
{
struct die_sample d;
smi_sample(&d);
return d.clock_mhz;
}
/* Die occupancy once this process has finished allocating and before any
* measurement starts: context + PTX module + our two buffers, plus anything a
* co-tenant already held. */
static int g_baseline_mib;
/* VRAM that appeared on the die after our baseline, i.e. someone else's.
* Context only - see the note above on why this is not a pass/fail gate. */
static int foreign_mib(int total_mib)
{
int f = total_mib - g_baseline_mib;
return (total_mib < 0 || f < 0) ? 0 : f;
}
/* The SM clock every reported figure must have been taken at. */
#define K80_RATED_SM_MHZ 875
static char *read_file(const char *path)
{
FILE *f = fopen(path, "rb");
long n;
char *buf;
if (!f) {
fprintf(stderr, "[bench_bw] cannot open %s\n", path);
exit(1);
}
fseek(f, 0, SEEK_END);
n = ftell(f);
fseek(f, 0, SEEK_SET);
buf = malloc((size_t)n + 1);
if (!buf || fread(buf, 1, (size_t)n, f) != (size_t)n) {
fprintf(stderr, "[bench_bw] cannot read %s\n", path);
exit(1);
}
buf[n] = '\0';
fclose(f);
return buf;
}
/* Verdict string for a bandwidth figure, so an impossible number can never be
* quietly copied into a plan as a result. */
static const char *verdict(double gbs)
{
if (gbs >= K80_DIE_PEAK_GBS)
return "IMPOSSIBLE";
if (gbs > K80_DIE_PEAK_GBS * 0.995)
return "at-pin-rate?";
return "";
}
/* Verdict for a group of reps. Distinguishes the two reasons the SM clock
* leaves 875 MHz, which look identical in the clock column alone:
*
* sibling die busy -> the K80 board's power budget is being shared and the
* measurement is contended. Reject.
* sibling die idle -> the droop is SELF-induced: our own kernel is drawing
* enough power to hit the die's limit. Measured here at
* 758-810 MHz for the matvec skeleton with sibutil 0,
* foreign 0 and a 0.03% spread. That is a real property
* of running this kernel, not an artifact, and the real
* fc_matvec_q3k pays it too. Accept, and say so.
*
* A spread above 1% is rejected regardless: something moved during the group.
* The 2048x2048 shape trips this legitimately, because at 0.03 ms per launch
* the per-launch overhead is a large fraction of the measurement. */
static const char *group_verdict(int sibling_busy, int min_clock, double spread)
{
if (sibling_busy)
return "REJECT: sibling die busy (shared board power)";
if (spread > 1.0)
return "REJECT: spread > 1%";
if (min_clock > 0 && min_clock < K80_RATED_SM_MHZ)
return "accepted (self power-limited, not contended)";
return "accepted";
}
/* ================================================================
* Device state
* ================================================================ */
static CUcontext g_ctx;
static CUmodule g_mod;
static CUdeviceptr g_src; /* the buffer every read kernel streams */
static size_t g_src_bytes;
static CUdeviceptr g_dst; /* per-thread / per-row sink */
static size_t g_dst_bytes;
static CUfunction get_fn(const char *name)
{
CUfunction f = NULL;
cu_ok(p_cuModuleGetFunction(&f, g_mod, name), name);
return f;
}
/* Largest block this kernel can legally launch with. Kepler caps a block's
* register footprint at 64K, so a 74-register kernel tops out at 832 threads,
* not 1024, and asking for more returns CUDA_ERROR_LAUNCH_OUT_OF_RESOURCES
* (701). The geometry sweep must skip those points rather than die on them —
* fc_bw_mvskel_r4 at block=1024 aborted a whole run this way. */
static int fn_max_threads(CUfunction f)
{
int n = 1024;
if (p_cuFuncGetAttribute(&n, CU_FUNC_ATTRIBUTE_MAX_THREADS, f) != CU_SUCCESS)
return 1024;
return n;
}
/* Index-derived fill: word i = i * 2654435761. A constant fill would make the
* XOR checksum zero for any even-sized read and destroy the consumption gate.
* Written in 8 MiB chunks so the host (2 cores / 8 GB) never stages more. */
static void fill_src(void)
{
const size_t chunk_words = 2u << 20; /* 8 MiB */
unsigned int *stage = malloc(chunk_words * sizeof(unsigned int));
size_t done = 0;
if (!stage) {
fprintf(stderr, "[bench_bw] out of host memory for the fill stage\n");
exit(1);
}
while (done < g_src_bytes / 4) {
size_t n = g_src_bytes / 4 - done;
size_t i;
if (n > chunk_words)
n = chunk_words;
for (i = 0; i < n; i++)
stage[i] = (unsigned int)((done + i) * 2654435761u);
cu_ok(p_cuMemcpyHtoD(g_src + done * 4, stage, n * 4), "cuMemcpyHtoD");
done += n;
}
free(stage);
}
/* ================================================================
* Timing
* ================================================================ */
/* Launch `f` back to back until at least target_s of GPU work has been
* enqueued, and return seconds per launch. The SM clock is sampled after the
* launches are queued but before the sync, i.e. while the die is under load —
* only meaningful when target_s is long enough to outlast nvidia-smi's own
* startup, hence the 1.0 s windows used for every reported figure. */
static double time_launches(CUfunction f, unsigned int grid, unsigned int block,
void **args, double target_s,
struct die_sample *smp, int *launches_out)
{
double t0, dt;
int n, i;
cu_ok(p_cuLaunchKernel(f, grid, 1, 1, block, 1, 1, 0, NULL, args, NULL),
"warmup launch");
cu_ok(p_cuCtxSynchronize(), "warmup sync");
t0 = now_s();
cu_ok(p_cuLaunchKernel(f, grid, 1, 1, block, 1, 1, 0, NULL, args, NULL),
"probe launch");
cu_ok(p_cuCtxSynchronize(), "probe sync");
dt = now_s() - t0;
n = (int)(target_s / (dt > 0 ? dt : 1e-6));
if (n < 3)
n = 3;
if (n > 2000)
n = 2000;
t0 = now_s();
for (i = 0; i < n; i++) {
cu_ok(p_cuLaunchKernel(f, grid, 1, 1, block, 1, 1, 0, NULL, args,
NULL), "launch");
/* Sample the clock from the middle of the run: nvidia-smi takes
* ~150 ms to start, so reading it after the sync would report the
* idle clock the die drops back to. The queued work outlasts it. */
if (smp && i == n / 2)
smi_sample(smp);
}
cu_ok(p_cuCtxSynchronize(), "sync");
dt = (now_s() - t0) / n;
if (launches_out)
*launches_out = n;
return dt;
}
/* XOR of the whole sink. Zero means nothing was consumed (or the fill was
* degenerate) and the measurement is void. */
static unsigned int dst_checksum(size_t words)
{
unsigned int *h = malloc(words * sizeof(unsigned int));
unsigned int x = 0;
size_t i;
if (!h)
return 0;
cu_ok(p_cuMemcpyDtoH(h, g_dst, words * 4), "cuMemcpyDtoH");
for (i = 0; i < words; i++)
x ^= h[i];
free(h);
return x;
}
/* ================================================================
* Stage A/B — flat streaming read
* ================================================================ */
struct read_kind {
const char *name;
int elem_bytes;
};
static const struct read_kind KINDS[] = {
{ "u32_ldg", 4 }, { "u32_ld", 4 },
{ "u128_ldg", 16 }, { "u128_ld", 16 },
};
#define N_KINDS ((int)(sizeof(KINDS) / sizeof(KINDS[0])))
static const int UNROLLS[] = { 1, 2, 4, 8 };
static const int BLOCKS[] = { 128, 256, 512, 1024 };
static const int GRIDS[] = { 104, 208, 416, 832, 1664 };
#define N_UNROLLS 4
#define N_BLOCKS 4
#define N_GRIDS 5
struct flat_cfg {
int kind, unroll, block, grid;
double gbs;
};
/* One flat-read configuration. Returns 0 and leaves *gbs untouched when the
* buffer cannot supply at least 8 whole iterations per thread — a config that
* shallow measures launch overhead, not bandwidth. */
static int run_flat(struct flat_cfg *c, size_t bytes, double target_s,
double *gbs, struct die_sample *smp,
unsigned int *sum_out)
{
int esz = KINDS[c->kind].elem_bytes;
long long threads = (long long)c->block * c->grid;
long long elems = (long long)(bytes / (size_t)esz);
long long per_pass = threads * c->unroll;
int iters = (int)(elems / per_pass);
char name[64];
CUfunction f;
void *args[3];
double dt;
size_t read_bytes;
if (iters < 8 || bytes > g_src_bytes)
return 0;
read_bytes = (size_t)iters * (size_t)per_pass * (size_t)esz;
snprintf(name, sizeof(name), "fc_bw_read_%s_u%d", KINDS[c->kind].name,
c->unroll);
f = get_fn(name);
if (c->block > fn_max_threads(f))
return 0;
args[0] = &g_src;
args[1] = &g_dst;
args[2] = &iters;
dt = time_launches(f, (unsigned int)c->grid, (unsigned int)c->block, args,
target_s, smp, NULL);
*gbs = (double)read_bytes / dt / 1e9;
if (sum_out)
*sum_out = dst_checksum((size_t)threads);
return 1;
}
static void print_fn_attrs(const char *name)
{
CUfunction f = get_fn(name);
int regs = -1, local = -1;
p_cuFuncGetAttribute(&regs, CU_FUNC_ATTRIBUTE_NUM_REGS, f);
p_cuFuncGetAttribute(&local, CU_FUNC_ATTRIBUTE_LOCAL_SIZE, f);
printf(" %-28s regs %3d stack frame %d B%s\n", name, regs, local,
local ? " <-- SPILLED, measurement suspect" : "");
}
static struct flat_cfg stage_a_sweep(size_t bytes)
{
struct flat_cfg best = { 0, 0, 0, 0, 0.0 };
int ki, ui, bi, gi;
printf("== Stage A: flat streaming-read geometry sweep (%zu MiB) ==\n",
bytes >> 20);
printf("%-10s %6s %6s %6s %9s %8s\n", "kind", "unroll", "block", "grid",
"GB/s", "note");
for (ki = 0; ki < N_KINDS; ki++)
for (ui = 0; ui < N_UNROLLS; ui++)
for (bi = 0; bi < N_BLOCKS; bi++)
for (gi = 0; gi < N_GRIDS; gi++) {
struct flat_cfg c;
double gbs = 0.0;
c.kind = ki; c.unroll = UNROLLS[ui];
c.block = BLOCKS[bi]; c.grid = GRIDS[gi]; c.gbs = 0.0;
if (!run_flat(&c, bytes, 0.10, &gbs, NULL, NULL))
continue;
printf("%-10s %6d %6d %6d %9.2f %8s\n",
KINDS[ki].name, c.unroll, c.block, c.grid, gbs,
verdict(gbs));
if (gbs > best.gbs) {
c.gbs = gbs;
best = c;
}
}
printf("-- Stage A peak: %s unroll=%d block=%d grid=%d -> %.2f GB/s --\n\n",
KINDS[best.kind].name, best.unroll, best.block, best.grid, best.gbs);
return best;
}
static void stage_b_sizes(struct flat_cfg peak, int reps)
{
static const size_t MIB[] = { 32, 64, 128, 256, 512, 1024 };
int si;
printf("== Stage B: peak geometry across buffer sizes, %d reps ==\n", reps);
printf(" geometry: %s unroll=%d block=%d grid=%d\n",
KINDS[peak.kind].name, peak.unroll, peak.block, peak.grid);
printf("%8s %4s %9s %6s %7s %8s %10s %s\n", "MiB", "rep", "GB/s", "clk",
"sibutil", "foreign", "checksum", "note");
for (si = 0; si < 6; si++) {
double lo = 1e30, hi = 0.0, sum = 0.0, spread;
int got = 0, sibbusy = 0, minclk = 0, r;
for (r = 0; r < reps; r++) {
struct die_sample d;
double gbs = 0.0;
unsigned int cks = 0;
if (!run_flat(&peak, MIB[si] << 20, 1.0, &gbs, &d, &cks))
continue;
sibbusy |= (d.sibling_util > 5);
if (d.clock_mhz > 0 && (!minclk || d.clock_mhz < minclk))
minclk = d.clock_mhz;
printf("%8zu %4d %9.2f %6d %7d %8d %08x %s%s%s\n", MIB[si],
r + 1, gbs, d.clock_mhz, d.sibling_util,
foreign_mib(d.mem_mib), cks, verdict(gbs),
cks == 0 ? " <-- ZERO CHECKSUM, loads not consumed" : "",
(d.clock_mhz > 0 && d.clock_mhz < K80_RATED_SM_MHZ
&& d.sibling_util > 5) ? " <-- SIBLING BUSY" : "");
if (gbs < lo) lo = gbs;
if (gbs > hi) hi = gbs;
sum += gbs;
got++;
}
if (!got)
continue;
spread = 100.0 * (hi - lo) / (sum / got);
printf("%8zu %4s %9.2f %6s %7s %8s %10s spread %.2f%% %s\n", MIB[si],
"avg", sum / got, "", "", "", "", spread,
group_verdict(sibbusy, minclk, spread));
}
printf("\n");
}
/* ================================================================
* Stage C — the matvec's own read pattern
* ================================================================ */
struct mv_shape {
const char *label;
int rows, cols;
};
static const struct mv_shape SHAPES[] = {
{ "lm head 248320x2048", 248320, 2048 },
{ "qkv 8192x2048", 8192, 2048 },
{ "dense 2048x2048", 2048, 2048 },
};
#define N_SHAPES 3
/* One (shape, rows-per-warp, block) point of the read skeleton. Bytes are
* rows * row_stride exactly — the same accounting bench_matvec.c uses for
* fc_matvec_q3k, so the two GB/s columns are the same quantity. */
static int run_mvskel(const struct mv_shape *s, int R, int block,
double target_s, double *ms, double *gbs,
struct die_sample *smp, int *launches)
{
int row_stride = (s->cols / 256) * Q3K_BLOCK_BYTES;
int warps = block / 32;
int rows = s->rows, cols = s->cols;
size_t bytes = (size_t)rows * (size_t)row_stride;
char name[48];
CUfunction f;
void *args[5];
double dt;
if (rows % (warps * R) != 0 || bytes > g_src_bytes)
return 0;
snprintf(name, sizeof(name), "fc_bw_mvskel_r%d", R);
f = get_fn(name);
if (block > fn_max_threads(f))
return 0;
args[0] = &g_src;
args[1] = &g_dst;
args[2] = &rows;
args[3] = &cols;
args[4] = &row_stride;
dt = time_launches(f, (unsigned int)(rows / (warps * R)),
(unsigned int)block, args, target_s, smp, launches);
*ms = dt * 1e3;
*gbs = (double)bytes / dt / 1e9;
return 1;
}
/* fc_matvec_q3k's own launch geometry (fc_cuda.c: 256 threads = 8 warps,
* grid = ceil(rows/16), i.e. 2 output rows per warp). The skeleton at THIS
* point is the number directly comparable to the measured matvec. */
#define Q3K_PROD_R 2
#define Q3K_PROD_BLOCK 256
static const int RS[] = { 1, 2, 4, 8 };
/* Full-rep measurement of one skeleton point, printed with its guard columns.
* Returns the mean GB/s over the reps that ran. */
static double mvskel_confirm(const struct mv_shape *sh, int R, int block,
int reps, const char *tag)
{
double lo = 1e30, hi = 0.0, sum = 0.0, spread;
int warps = block / 32, got = 0, sibbusy = 0, minclk = 0, r;
for (r = 0; r < reps; r++) {
struct die_sample d;
double ms = 0.0, gbs = 0.0;
int nl = 0;
if (!run_mvskel(sh, R, block, 1.0, &ms, &gbs, &d, &nl))
return 0.0; /* shape/geometry not runnable here */
sibbusy |= (d.sibling_util > 5);
if (d.clock_mhz > 0 && (!minclk || d.clock_mhz < minclk))
minclk = d.clock_mhz;
printf("%-20s %3d %6d %6d %9.4f %9.2f %6d %7d %8d %8d %s%s%s\n",
sh->label, R, block, sh->rows / (warps * R), ms, gbs,
d.clock_mhz, d.sibling_util, foreign_mib(d.mem_mib), nl, tag,
verdict(gbs),
(d.clock_mhz > 0 && d.clock_mhz < K80_RATED_SM_MHZ
&& d.sibling_util > 5) ? " <-- SIBLING BUSY" : "");
if (gbs < lo) lo = gbs;
if (gbs > hi) hi = gbs;
sum += gbs;
got++;
}
if (got > 1) {
spread = 100.0 * (hi - lo) / (sum / got);
printf("%-20s %3d %6d %6s %9s %9.2f %6s %7s %8s %8s spread %.2f%% "
"%s\n", " mean", R, block, "", "", sum / got, "", "", "", "",
spread, group_verdict(sibbusy, minclk, spread));
}
return got ? sum / got : 0.0;
}
static void stage_c_shapes(int reps)
{
int si, ri, bi;
printf("== Stage C: matvec-shaped read skeleton (152 B / 256 weights) ==\n");
printf(" Same loads per lane per super-block as fc_q3k_sb_lane_dot, with\n"
" the dequant deleted. R = output rows per warp; fc_matvec_q3k\n"
" ships R=%d block=%d, marked [ship].\n", Q3K_PROD_R,
Q3K_PROD_BLOCK);
printf("%-20s %3s %6s %6s %9s %9s %6s %7s %8s %8s\n", "shape", "R",
"block", "grid", "ms", "GB/s", "clk", "sibutil", "foreign",
"launches");
for (si = 0; si < N_SHAPES; si++) {
int best_r = Q3K_PROD_R, best_b = Q3K_PROD_BLOCK;
double best = 0.0;
/* Cheap single-pass scan for the peak geometry. */
for (ri = 0; ri < 4; ri++)
for (bi = 0; bi < N_BLOCKS; bi++) {
struct die_sample d;
double ms = 0.0, gbs = 0.0;
int nl = 0;
if (!run_mvskel(&SHAPES[si], RS[ri], BLOCKS[bi], 0.25, &ms,
&gbs, &d, &nl))
continue;
printf("%-20s %3d %6d %6d %9.4f %9.2f %6d %7d %8d %8d scan "
"%s\n", SHAPES[si].label, RS[ri], BLOCKS[bi],
SHAPES[si].rows / ((BLOCKS[bi] / 32) * RS[ri]), ms, gbs,
d.clock_mhz, d.sibling_util, foreign_mib(d.mem_mib), nl,
verdict(gbs));
if (gbs > best) {
best = gbs;
best_r = RS[ri];
best_b = BLOCKS[bi];
}
}
/* Then spend the reps where they matter: the shipped geometry, and
* the scan winner if it is a different one. */
mvskel_confirm(&SHAPES[si], Q3K_PROD_R, Q3K_PROD_BLOCK, reps, "[ship] ");
if (best_r != Q3K_PROD_R || best_b != Q3K_PROD_BLOCK)
mvskel_confirm(&SHAPES[si], best_r, best_b, reps, "[peak] ");
printf("\n");
}
}
/* ================================================================
* Elision control
* ================================================================ */
/* Runs the kernel whose loads nvcc is guaranteed to delete. If this does NOT
* print an impossible number, the harness cannot see elision and none of the
* figures above are trustworthy. */
static void elision_control(size_t bytes)
{
int esz = 4, block = 256, grid = 416, unroll = 4;
long long threads = (long long)block * grid;
int iters = (int)((long long)(bytes / (size_t)esz) / (threads * unroll));
CUfunction f = get_fn("fc_bw_read_u32_ldg_u4_dead");
void *args[3];
double dt, gbs;
size_t read_bytes = (size_t)iters * (size_t)(threads * unroll) * 4;
args[0] = &g_src;
args[1] = &g_dst;
args[2] = &iters;
dt = time_launches(f, (unsigned int)grid, (unsigned int)block, args, 0.2,
NULL, NULL);
gbs = (double)read_bytes / dt / 1e9;
printf("== Elision control (loads deliberately dead) ==\n");
printf(" fc_bw_read_u32_ldg_u4_dead: %.1f GB/s %s\n", gbs,
verdict(gbs));
printf(" %s\n\n", gbs >= K80_DIE_PEAK_GBS
? "CONTROL OK - an elided kernel is detectable as impossible."
: "CONTROL FAILED - elision is NOT detectable here; distrust "
"every figure above.");
print_fn_attrs("fc_bw_read_u32_ldg_u4_dead");
}
/* ================================================================
* main
* ================================================================ */
static void print_env(int foreign_at_start)
{
char devname[128] = { 0 };
CUdevice dev = 0;
size_t freeb = 0, totalb = 0;
p_cuDeviceGet(&dev, 0);
p_cuDeviceGetName(devname, sizeof(devname), dev);
p_cuMemGetInfo(&freeb, &totalb);
printf("device: %s VRAM %zu / %zu MiB free SM clock %d MHz (idle)\n",
devname, freeb >> 20, totalb >> 20, smi_clock_mhz());
printf("reference: theoretical %.1f GB/s, bandwidthTest --dtod copy "
"%.1f GB/s\n", K80_DIE_PEAK_GBS, K80_DTOD_COPY_GBS);
if (foreign_at_start > 64)
printf("*** WARNING: %d MiB was already allocated on this die by "
"another process.\n*** This is a SHARED die; nothing below is "
"an exclusive-die ceiling.\n", foreign_at_start);
else
printf("die is exclusive at start (%d MiB foreign)\n",
foreign_at_start);
printf("\n");
}
int main(int argc, char **argv)
{
const char *ptx_path = "cuda_kernels/bench_bw.ptx";
const char *stages = "abc";
size_t buf_mib = 1024;
int reps = 3, i, ndev = 0, foreign_at_start = 0;
char *ptx;
CUdevice dev = 0;
struct flat_cfg peak = { 0, 4, 256, 416, 0.0 };
for (i = 1; i < argc; i++) {
if (!strcmp(argv[i], "--ptx") && i + 1 < argc)
ptx_path = argv[++i];
else if (!strcmp(argv[i], "--reps") && i + 1 < argc)
reps = atoi(argv[++i]);
else if (!strcmp(argv[i], "--stage") && i + 1 < argc)
stages = argv[++i];
else if (!strcmp(argv[i], "--buf-mib") && i + 1 < argc)
buf_mib = (size_t)atoll(argv[++i]);
else {
fprintf(stderr, "usage: %s [--ptx PATH] [--reps N] [--stage abc] "
"[--buf-mib N]\n", argv[0]);
return 2;
}
}
load_driver();
cu_ok(p_cuInit(0), "cuInit");
cu_ok(p_cuDeviceGetCount(&ndev), "cuDeviceGetCount");
if (ndev < 1) {
fprintf(stderr, "[bench_bw] no visible CUDA device\n");
return 1;
}
if (ndev > 1)
fprintf(stderr, "[bench_bw] WARNING: %d devices visible; pin with "
"CUDA_VISIBLE_DEVICES so this cannot touch production\n", ndev);
cu_ok(p_cuDeviceGet(&dev, 0), "cuDeviceGet");
{
struct die_sample d;
smi_sample(&d);
foreign_at_start = d.mem_mib < 0 ? 0 : d.mem_mib;
}
cu_ok(p_cuCtxCreate(&g_ctx, 0, dev), "cuCtxCreate");
print_env(foreign_at_start < 0 ? 0 : foreign_at_start);
ptx = read_file(ptx_path);
cu_ok(p_cuModuleLoadData(&g_mod, ptx), "cuModuleLoadData");
free(ptx);
g_src_bytes = buf_mib << 20;
g_dst_bytes = 8u << 20; /* >= max grid*block words (1664*1024) */
cu_ok(p_cuMemAlloc(&g_src, g_src_bytes), "cuMemAlloc src");
cu_ok(p_cuMemAlloc(&g_dst, g_dst_bytes), "cuMemAlloc dst");
cu_ok(p_cuMemsetD32(g_dst, 0, g_dst_bytes / 4), "cuMemsetD32 dst");
fill_src();
/* Everything we will ever allocate is now resident, so anything that
* appears on the die from here on belongs to someone else. */
{
struct die_sample d;
smi_sample(&d);
g_baseline_mib = d.mem_mib;
}
printf("== kernel resources (stack frame MUST be 0) ==\n");
print_fn_attrs("fc_bw_read_u32_ldg_u4");
print_fn_attrs("fc_bw_read_u128_ldg_u8");
print_fn_attrs("fc_bw_mvskel_r2");
print_fn_attrs("fc_bw_mvskel_r8");
printf("\n");
if (strchr(stages, 'a'))
peak = stage_a_sweep(256u << 20);
if (strchr(stages, 'b'))
stage_b_sizes(peak, reps);
if (strchr(stages, 'c'))
stage_c_shapes(reps);
elision_control(256u << 20);
p_cuMemFree(g_src);
p_cuMemFree(g_dst);
p_cuCtxDestroy(g_ctx);
return 0;
}