278 lines
9.6 KiB
C
278 lines
9.6 KiB
C
/* SPDX-License-Identifier: AGPL-3.0-or-later */
|
|
/* Copyright (C) 2026 David (Corey) Herrera */
|
|
/**
|
|
* @file bench_handoff_cost.c
|
|
* @brief What one die-to-die boundary hand-off actually costs, at prefill size.
|
|
*
|
|
* WHY THIS EXISTS. An N-die layer split pays one activation copy per boundary
|
|
* per chunk. On the reference 8-card host 4 of the 7 hops are device-to-device over a board's
|
|
* PLX switch and 3 cross boards, where cuMemcpyPeer returns success and moves
|
|
* nothing, so they stage through host RAM instead. Any pipelined prefill
|
|
* inherits those 3 host round trips, and the standing question is whether they
|
|
* cap it. The tree's only figure for the host link is a code comment
|
|
* ("0.4 GB/s"), which is not a measurement.
|
|
*
|
|
* This times the two hop shapes at the size prefill actually moves — a
|
|
* [chunk][n_embd] f32 activation block, 1 MiB at the shipped 128-token chunk
|
|
* and 2048-wide residual — and reports microseconds per hop, so the cost can be
|
|
* put directly against a die-segment's compute time.
|
|
*
|
|
* peer : cuMemcpyPeer src -> dst, then sync the receiving context
|
|
* (exactly gpu_handoff_import's device-to-device branch)
|
|
* host : cuMemcpyDtoH into a host buffer then cuMemcpyHtoD into the peer
|
|
* (exactly gpu_handoff_export + import's staged branch)
|
|
*
|
|
* It also VERIFIES the bytes arrive. A cross-board cuMemcpyPeer on a K80
|
|
* reports CUDA_SUCCESS and copies nothing; timing that would report an
|
|
* absurdly cheap hop for a copy that never happened, which is the single most
|
|
* misleading number this file could produce. Pairs whose peer copy does not
|
|
* move the bytes are reported as NO-PEER rather than timed.
|
|
*
|
|
* BUILD (from foundry/), no CUDA headers needed - the driver API is dlopen'd:
|
|
* gcc -O2 -Wall -o cuda/tests/bench_handoff_cost \
|
|
* cuda/tests/bench_handoff_cost.c -ldl
|
|
* RUN:
|
|
* ./cuda/tests/bench_handoff_cost --bytes 1048576 --reps 200 \
|
|
* --pairs 0:1,1:2,2:3,3:4,4:5,5:6,6:7
|
|
*
|
|
* @status Reports per-hop us and GB/s for the peer and host-staged routes.
|
|
* @issues None
|
|
* @todo None
|
|
*/
|
|
|
|
#include <dlfcn.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
typedef int CUresult;
|
|
typedef int CUdevice;
|
|
typedef unsigned long long CUdeviceptr;
|
|
typedef struct CUctx_st *CUcontext;
|
|
|
|
#define CU_SUCCESS 0
|
|
|
|
static void *g_libcuda;
|
|
|
|
static CUresult (*p_cuInit)(unsigned int);
|
|
static CUresult (*p_cuDeviceGetCount)(int *);
|
|
static CUresult (*p_cuDeviceGet)(CUdevice *, int);
|
|
static CUresult (*p_cuCtxCreate)(CUcontext *, unsigned int, CUdevice);
|
|
static CUresult (*p_cuCtxDestroy)(CUcontext);
|
|
static CUresult (*p_cuCtxSetCurrent)(CUcontext);
|
|
static CUresult (*p_cuCtxSynchronize)(void);
|
|
static CUresult (*p_cuCtxEnablePeerAccess)(CUcontext, unsigned int);
|
|
static CUresult (*p_cuMemAlloc)(CUdeviceptr *, size_t);
|
|
static CUresult (*p_cuMemFree)(CUdeviceptr);
|
|
static CUresult (*p_cuMemcpyDtoH)(void *, CUdeviceptr, size_t);
|
|
static CUresult (*p_cuMemcpyHtoD)(CUdeviceptr, const void *, size_t);
|
|
static CUresult (*p_cuMemcpyPeer)(CUdeviceptr, CUcontext, CUdeviceptr,
|
|
CUcontext, size_t);
|
|
static CUresult (*p_cuMemsetD32)(CUdeviceptr, unsigned int, size_t);
|
|
|
|
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, "[handoff] 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, "[handoff] dlopen libcuda failed: %s\n", dlerror());
|
|
exit(1);
|
|
}
|
|
p_cuInit = sym("cuInit", NULL);
|
|
p_cuDeviceGetCount = sym("cuDeviceGetCount", NULL);
|
|
p_cuDeviceGet = sym("cuDeviceGet", NULL);
|
|
p_cuCtxCreate = sym("cuCtxCreate", "cuCtxCreate_v2");
|
|
p_cuCtxDestroy = sym("cuCtxDestroy", "cuCtxDestroy_v2");
|
|
p_cuCtxSetCurrent = sym("cuCtxSetCurrent", NULL);
|
|
p_cuCtxSynchronize = sym("cuCtxSynchronize", NULL);
|
|
p_cuCtxEnablePeerAccess = sym("cuCtxEnablePeerAccess", NULL);
|
|
p_cuMemAlloc = sym("cuMemAlloc", "cuMemAlloc_v2");
|
|
p_cuMemFree = sym("cuMemFree", "cuMemFree_v2");
|
|
p_cuMemcpyDtoH = sym("cuMemcpyDtoH", "cuMemcpyDtoH_v2");
|
|
p_cuMemcpyHtoD = sym("cuMemcpyHtoD", "cuMemcpyHtoD_v2");
|
|
p_cuMemcpyPeer = sym("cuMemcpyPeer", NULL);
|
|
p_cuMemsetD32 = sym("cuMemsetD32", "cuMemsetD32_v2");
|
|
}
|
|
|
|
static double now_s(void)
|
|
{
|
|
struct timespec ts;
|
|
clock_gettime(CLOCK_MONOTONIC, &ts);
|
|
return ts.tv_sec + ts.tv_nsec * 1e-9;
|
|
}
|
|
|
|
/**
|
|
* Does a peer copy between these two contexts actually move bytes?
|
|
*
|
|
* A cross-board K80 pair answers CUDA_SUCCESS and copies nothing, so the only
|
|
* trustworthy check is to poison the destination, copy, and read it back. This
|
|
* mirrors gpu_peer_link_moves_bytes() in the engine.
|
|
*
|
|
* @return 1 if the bytes arrived, 0 otherwise.
|
|
*/
|
|
static int peer_moves_bytes(CUcontext a, CUcontext b, CUdeviceptr src,
|
|
CUdeviceptr dst, size_t bytes)
|
|
{
|
|
unsigned int pattern = 0xA5A5A5A5u;
|
|
size_t probe = bytes < 256 ? bytes : 256;
|
|
|
|
p_cuCtxSetCurrent(a);
|
|
p_cuMemsetD32(src, pattern, probe / 4);
|
|
p_cuCtxSynchronize();
|
|
p_cuCtxSetCurrent(b);
|
|
p_cuMemsetD32(dst, 0xDEADBEEFu, probe / 4);
|
|
p_cuCtxSynchronize();
|
|
|
|
if (p_cuMemcpyPeer(dst, b, src, a, bytes) != CU_SUCCESS)
|
|
return 0;
|
|
p_cuCtxSetCurrent(b);
|
|
p_cuCtxSynchronize();
|
|
|
|
unsigned int *back = malloc(probe);
|
|
if (!back)
|
|
return 0;
|
|
p_cuMemcpyDtoH(back, dst, probe);
|
|
int ok = 1;
|
|
for (size_t i = 0; i < probe / 4; i++)
|
|
if (back[i] != pattern) {
|
|
ok = 0;
|
|
break;
|
|
}
|
|
free(back);
|
|
return ok;
|
|
}
|
|
|
|
int main(int argc, char **argv)
|
|
{
|
|
size_t bytes = 1048576; /* 128 tokens x 2048 embd x f32 */
|
|
int reps = 200;
|
|
const char *pairs = "0:1,1:2,2:3,3:4,4:5,5:6,6:7";
|
|
|
|
for (int i = 1; i < argc; i++) {
|
|
if (!strcmp(argv[i], "--bytes") && i + 1 < argc)
|
|
bytes = (size_t)strtoull(argv[++i], NULL, 10);
|
|
else if (!strcmp(argv[i], "--reps") && i + 1 < argc)
|
|
reps = atoi(argv[++i]);
|
|
else if (!strcmp(argv[i], "--pairs") && i + 1 < argc)
|
|
pairs = argv[++i];
|
|
else {
|
|
printf("usage: bench_handoff_cost [--bytes N] [--reps N] "
|
|
"[--pairs a:b,c:d,...]\n");
|
|
return 1;
|
|
}
|
|
}
|
|
|
|
load_driver();
|
|
if (p_cuInit(0) != CU_SUCCESS) {
|
|
fprintf(stderr, "[handoff] cuInit failed\n");
|
|
return 1;
|
|
}
|
|
int n_dev = 0;
|
|
p_cuDeviceGetCount(&n_dev);
|
|
|
|
void *host = malloc(bytes);
|
|
if (!host) {
|
|
fprintf(stderr, "[handoff] host alloc failed\n");
|
|
return 1;
|
|
}
|
|
memset(host, 0, bytes);
|
|
|
|
printf("hop size %zu bytes (%.2f MiB), %d reps\n", bytes,
|
|
bytes / 1048576.0, reps);
|
|
printf("%-8s %-10s %12s %10s %12s %10s\n", "pair", "peer?", "peer_us",
|
|
"peer_GB/s", "host_us", "host_GB/s");
|
|
|
|
char buf[256];
|
|
snprintf(buf, sizeof(buf), "%s", pairs);
|
|
for (char *tok = strtok(buf, ","); tok; tok = strtok(NULL, ",")) {
|
|
int a = -1, b = -1;
|
|
if (sscanf(tok, "%d:%d", &a, &b) != 2)
|
|
continue;
|
|
if (a < 0 || b < 0 || a >= n_dev || b >= n_dev) {
|
|
printf("%d:%-6d out-of-range\n", a, b);
|
|
continue;
|
|
}
|
|
|
|
CUdevice da, db;
|
|
CUcontext ca, cb;
|
|
CUdeviceptr sa, sb;
|
|
p_cuDeviceGet(&da, a);
|
|
p_cuDeviceGet(&db, b);
|
|
if (p_cuCtxCreate(&ca, 0, da) != CU_SUCCESS) continue;
|
|
if (p_cuCtxCreate(&cb, 0, db) != CU_SUCCESS) { p_cuCtxDestroy(ca); continue; }
|
|
|
|
p_cuCtxSetCurrent(ca);
|
|
p_cuMemAlloc(&sa, bytes);
|
|
p_cuCtxEnablePeerAccess(cb, 0);
|
|
p_cuCtxSetCurrent(cb);
|
|
p_cuMemAlloc(&sb, bytes);
|
|
p_cuCtxEnablePeerAccess(ca, 0);
|
|
|
|
int have_peer = peer_moves_bytes(ca, cb, sa, sb, bytes);
|
|
|
|
double peer_us = 0.0;
|
|
if (have_peer) {
|
|
/* warm */
|
|
for (int r = 0; r < 5; r++) {
|
|
p_cuMemcpyPeer(sb, cb, sa, ca, bytes);
|
|
p_cuCtxSetCurrent(cb);
|
|
p_cuCtxSynchronize();
|
|
}
|
|
double t0 = now_s();
|
|
for (int r = 0; r < reps; r++) {
|
|
p_cuMemcpyPeer(sb, cb, sa, ca, bytes);
|
|
p_cuCtxSetCurrent(cb);
|
|
p_cuCtxSynchronize();
|
|
}
|
|
peer_us = (now_s() - t0) / reps * 1e6;
|
|
}
|
|
|
|
/* Host-staged route: D2H off the sender then H2D into the receiver,
|
|
* both blocking, which is exactly what the engine does. */
|
|
for (int r = 0; r < 5; r++) {
|
|
p_cuCtxSetCurrent(ca);
|
|
p_cuMemcpyDtoH(host, sa, bytes);
|
|
p_cuCtxSetCurrent(cb);
|
|
p_cuMemcpyHtoD(sb, host, bytes);
|
|
}
|
|
double t1 = now_s();
|
|
for (int r = 0; r < reps; r++) {
|
|
p_cuCtxSetCurrent(ca);
|
|
p_cuMemcpyDtoH(host, sa, bytes);
|
|
p_cuCtxSetCurrent(cb);
|
|
p_cuMemcpyHtoD(sb, host, bytes);
|
|
}
|
|
double host_us = (now_s() - t1) / reps * 1e6;
|
|
|
|
printf("%d:%-6d %-10s %12.2f %10.2f %12.2f %10.2f\n", a, b,
|
|
have_peer ? "yes" : "NO-PEER",
|
|
peer_us, have_peer ? bytes / (peer_us * 1e-6) / 1e9 : 0.0,
|
|
host_us, bytes / (host_us * 1e-6) / 1e9);
|
|
fflush(stdout);
|
|
|
|
p_cuCtxSetCurrent(ca);
|
|
p_cuMemFree(sa);
|
|
p_cuCtxSetCurrent(cb);
|
|
p_cuMemFree(sb);
|
|
p_cuCtxDestroy(ca);
|
|
p_cuCtxDestroy(cb);
|
|
}
|
|
|
|
free(host);
|
|
return 0;
|
|
}
|