blob: b5d42ba17ab840b1e581a758108efdc268ad8a29 [file]
// This file is part of Eigen, a lightweight C++ template library
// for linear algebra.
//
// Copyright (C) 2026 Rasmus Munk Larsen <rmlarsen@gmail.com>
//
// This Source Code Form is subject to the terms of the Mozilla
// Public License v. 2.0. If a copy of the MPL was not distributed
// with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
// Micro-benchmarks for per-call overhead in the GPU module's core layer:
// allocation traffic, hidden host synchronization, and host-side copies.
// Each benchmark pairs the current API path against a hand-rolled baseline
// that shows what the same operation costs without the overhead, so the gap
// is directly attributable.
//
// FromHost / FromHostAsync — extra host-side PlainMatrix copy in fromHost()
// GemmFreshDst / PreallocDst — cudaMalloc/cudaFree per GEMM temporary
// DotDeviceScalar / DotRaw — DeviceScalar wrapper cost per reduction
// OneShotLltExpr / CachedLlt / RawPotrs — expression-solve sync + allocs
// CudaMalloc / CudaMallocAsync — stream-ordered allocation as a remedy
// SmallBuffer* / PoolAllocFree — small DeviceBuffer round trip (idle, behind in-flight work) and the pool itself
//
// Build (standalone project, see CMakeLists.txt in this directory):
// cmake -G Ninja -B build-bench-gpu -S contrib/benchmarks/GPU \
// -DCMAKE_CUDA_ARCHITECTURES=89
// cmake --build build-bench-gpu --target bench_overhead
// ./build-bench-gpu/bench_overhead
#ifndef EIGEN_USE_GPU
#define EIGEN_USE_GPU
#endif
#include <Eigen/Cholesky>
#include <Eigen/SparseCore>
#include <contrib/Eigen/GPU>
#include <benchmark/benchmark.h>
#include <cuda_runtime.h>
#include <cusolverDn.h>
#ifndef SCALAR
#define SCALAR double
#endif
using Scalar = SCALAR;
using Eigen::Index;
namespace gpu = Eigen::gpu;
using DeviceMatrix = gpu::DeviceMatrix<Scalar>;
using HostMatrix = Eigen::Matrix<Scalar, Eigen::Dynamic, Eigen::Dynamic>;
namespace {
HostMatrix spdMatrix(Index n) {
HostMatrix A = HostMatrix::Random(n, n);
HostMatrix S = A * A.transpose() + HostMatrix::Identity(n, n) * Scalar(n);
return S;
}
void syncStream(cudaStream_t s) { EIGEN_CUDA_RUNTIME_CHECK(cudaStreamSynchronize(s)); }
} // namespace
// ---------------------------------------------------------------------------
// 1. Host->device upload: fromHost(MatrixBase) evaluates the input into a
// fresh PlainMatrix even when it is already a plain contiguous matrix,
// adding a full host-side copy before the H2D transfer. Baseline uploads
// straight from the existing host buffer.
// ---------------------------------------------------------------------------
static void BM_FromHost(benchmark::State& state) {
const Index n = state.range(0);
HostMatrix A = HostMatrix::Random(n, n);
gpu::Context& ctx = gpu::Context::threadLocal();
for (auto _ : state) {
DeviceMatrix d_A = DeviceMatrix::fromHost(A, ctx.stream());
benchmark::DoNotOptimize(d_A.data());
}
state.SetBytesProcessed(state.iterations() * n * n * sizeof(Scalar));
}
BENCHMARK(BM_FromHost)->Arg(256)->Arg(1024)->Arg(4096)->UseRealTime()->MinWarmUpTime(0.5);
static void BM_FromHostRawPointer(benchmark::State& state) {
const Index n = state.range(0);
HostMatrix A = HostMatrix::Random(n, n);
gpu::Context& ctx = gpu::Context::threadLocal();
for (auto _ : state) {
DeviceMatrix d_A = DeviceMatrix::fromHostAsync(A.data(), n, n, ctx.stream());
syncStream(ctx.stream());
benchmark::DoNotOptimize(d_A.data());
}
state.SetBytesProcessed(state.iterations() * n * n * sizeof(Scalar));
}
BENCHMARK(BM_FromHostRawPointer)->Arg(256)->Arg(1024)->Arg(4096)->UseRealTime()->MinWarmUpTime(0.5);
// ---------------------------------------------------------------------------
// 2. GEMM destination allocation: assigning into a fresh (or wrong-shaped)
// DeviceMatrix triggers cudaMalloc in resize() and cudaFree in the
// destructor on every iteration; a pre-allocated same-shape destination
// skips both.
// ---------------------------------------------------------------------------
static void BM_GemmFreshDst(benchmark::State& state) {
const Index n = state.range(0);
gpu::Context& ctx = gpu::Context::threadLocal();
DeviceMatrix d_A = DeviceMatrix::fromHost(HostMatrix::Random(n, n), ctx.stream());
DeviceMatrix d_B = DeviceMatrix::fromHost(HostMatrix::Random(n, n), ctx.stream());
for (auto _ : state) {
DeviceMatrix d_C; // fresh: resize() -> cudaMalloc; dtor -> cudaFree
d_C.device(ctx) = d_A * d_B;
syncStream(ctx.stream());
}
}
BENCHMARK(BM_GemmFreshDst)->Arg(64)->Arg(256)->Arg(1024)->UseRealTime()->MinWarmUpTime(0.5);
static void BM_GemmPreallocDst(benchmark::State& state) {
const Index n = state.range(0);
gpu::Context& ctx = gpu::Context::threadLocal();
DeviceMatrix d_A = DeviceMatrix::fromHost(HostMatrix::Random(n, n), ctx.stream());
DeviceMatrix d_B = DeviceMatrix::fromHost(HostMatrix::Random(n, n), ctx.stream());
DeviceMatrix d_C(n, n);
for (auto _ : state) {
d_C.device(ctx) = d_A * d_B;
syncStream(ctx.stream());
}
}
BENCHMARK(BM_GemmPreallocDst)->Arg(64)->Arg(256)->Arg(1024)->UseRealTime()->MinWarmUpTime(0.5);
// ---------------------------------------------------------------------------
// 3. BLAS-1 reduction wrapper: dot() constructs a DeviceScalar initialized to
// zero (a small H2D upload that the following cublasXdot immediately
// overwrites) and toggles the cuBLAS pointer mode with three API calls.
// Baseline: cublasDdot into a pre-allocated device slot with pointer mode
// set once. Both enqueue only (no readback); sync once per iteration.
// ---------------------------------------------------------------------------
static void BM_DotDeviceScalar(benchmark::State& state) {
const Index n = state.range(0);
gpu::Context& ctx = gpu::Context::threadLocal();
DeviceMatrix d_x = DeviceMatrix::fromHost(HostMatrix::Random(n, 1), ctx.stream());
DeviceMatrix d_y = DeviceMatrix::fromHost(HostMatrix::Random(n, 1), ctx.stream());
for (auto _ : state) {
gpu::DeviceScalar<Scalar> r = d_x.dot(ctx, d_y);
benchmark::DoNotOptimize(r.devicePtr());
syncStream(ctx.stream());
}
}
BENCHMARK(BM_DotDeviceScalar)->Arg(1 << 12)->Arg(1 << 20)->UseRealTime()->MinWarmUpTime(0.5);
static void BM_DotRawCublas(benchmark::State& state) {
const Index n = state.range(0);
gpu::Context& ctx = gpu::Context::threadLocal();
DeviceMatrix d_x = DeviceMatrix::fromHost(HostMatrix::Random(n, 1), ctx.stream());
DeviceMatrix d_y = DeviceMatrix::fromHost(HostMatrix::Random(n, 1), ctx.stream());
Scalar* d_result = nullptr;
EIGEN_CUDA_RUNTIME_CHECK(cudaMalloc(&d_result, sizeof(Scalar)));
EIGEN_CUBLAS_CHECK(cublasSetPointerMode(ctx.cublasHandle(), CUBLAS_POINTER_MODE_DEVICE));
for (auto _ : state) {
EIGEN_CUBLAS_CHECK(
gpu::internal::cublasXdot(ctx.cublasHandle(), static_cast<int>(n), d_x.data(), 1, d_y.data(), 1, d_result));
syncStream(ctx.stream());
}
EIGEN_CUBLAS_CHECK(cublasSetPointerMode(ctx.cublasHandle(), CUBLAS_POINTER_MODE_HOST));
EIGEN_CUDA_RUNTIME_CHECK(cudaFree(d_result));
}
BENCHMARK(BM_DotRawCublas)->Arg(1 << 12)->Arg(1 << 20)->UseRealTime()->MinWarmUpTime(0.5);
// ---------------------------------------------------------------------------
// 4. Cholesky solve paths.
// OneShotLltExpr: d_X = d_A.llt().solve(d_B) — re-factorizes, allocates
// d_factor/workspace/pinned-info per call, and ends in a mandatory
// cudaStreamSynchronize inside the dispatch.
// CachedLltSolve: gpu::LLT computed once; solve() still allocates a fresh
// RHS DeviceBuffer per call and syncs info on the first call.
// RawPotrs: baseline — potrs into pre-allocated buffers, nothing else.
// The gap between CachedLltSolve and RawPotrs is the per-solve overhead
// attributable to the wrapper (allocation + event bookkeeping).
// ---------------------------------------------------------------------------
static void BM_OneShotLltExpr(benchmark::State& state) {
const Index n = state.range(0);
const Index nrhs = 8;
gpu::Context& ctx = gpu::Context::threadLocal();
DeviceMatrix d_A = DeviceMatrix::fromHost(spdMatrix(n), ctx.stream());
DeviceMatrix d_B = DeviceMatrix::fromHost(HostMatrix::Random(n, nrhs), ctx.stream());
DeviceMatrix d_X(n, nrhs);
for (auto _ : state) {
d_X.device(ctx) = d_A.llt().solve(d_B);
syncStream(ctx.stream());
}
}
BENCHMARK(BM_OneShotLltExpr)->Arg(256)->Arg(1024)->UseRealTime()->MinWarmUpTime(0.5);
static void BM_CachedLltSolve(benchmark::State& state) {
const Index n = state.range(0);
const Index nrhs = 8;
gpu::Context& ctx = gpu::Context::threadLocal();
DeviceMatrix d_A = DeviceMatrix::fromHost(spdMatrix(n), ctx.stream());
DeviceMatrix d_B = DeviceMatrix::fromHost(HostMatrix::Random(n, nrhs), ctx.stream());
gpu::LLT<Scalar> llt;
llt.compute(d_A);
benchmark::DoNotOptimize(llt.info()); // factor + first-solve sync out of the loop
for (auto _ : state) {
DeviceMatrix d_X = llt.solve(d_B);
syncStream(llt.stream());
benchmark::DoNotOptimize(d_X.data());
}
}
BENCHMARK(BM_CachedLltSolve)->Arg(256)->Arg(1024)->UseRealTime()->MinWarmUpTime(0.5);
static void BM_RawPotrs(benchmark::State& state) {
const Index n = state.range(0);
const Index nrhs = 8;
gpu::Context& ctx = gpu::Context::threadLocal();
cusolverDnHandle_t solver = ctx.cusolverHandle();
// Factor once with the module (correctness is not under test here), then
// extract the factor into a raw buffer for repeated potrs calls.
HostMatrix A = spdMatrix(n);
Eigen::LLT<HostMatrix> hostLlt(A);
HostMatrix L = HostMatrix(hostLlt.matrixL());
DeviceMatrix d_L = DeviceMatrix::fromHost(L, ctx.stream());
DeviceMatrix d_B = DeviceMatrix::fromHost(HostMatrix::Random(n, nrhs), ctx.stream());
DeviceMatrix d_X(n, nrhs);
gpu::internal::CusolverParams params;
int* d_info = nullptr;
EIGEN_CUDA_RUNTIME_CHECK(cudaMalloc(&d_info, sizeof(int)));
constexpr cudaDataType_t dtype = gpu::internal::cuda_data_type<Scalar>::value;
for (auto _ : state) {
EIGEN_CUDA_RUNTIME_CHECK(
cudaMemcpyAsync(d_X.data(), d_B.data(), sizeof(Scalar) * n * nrhs, cudaMemcpyDeviceToDevice, ctx.stream()));
EIGEN_CUSOLVER_CHECK(cusolverDnXpotrs(solver, params.p, CUBLAS_FILL_MODE_LOWER, n, nrhs, dtype, d_L.data(), n,
dtype, d_X.data(), n, d_info));
syncStream(ctx.stream());
}
EIGEN_CUDA_RUNTIME_CHECK(cudaFree(d_info));
}
BENCHMARK(BM_RawPotrs)->Arg(256)->Arg(1024)->UseRealTime()->MinWarmUpTime(0.5);
// ---------------------------------------------------------------------------
// 4b. Device-resident SpMV in a loop (the CG hot path): measures per-call
// cuSPARSE descriptor/bufferSize overhead on the cached deviceView path.
// ---------------------------------------------------------------------------
static void BM_DeviceSpMV(benchmark::State& state) {
const Index n = state.range(0);
using SpMat = Eigen::SparseMatrix<Scalar, Eigen::ColMajor, int>;
SpMat A(n, n);
// 1D Laplacian stencil.
A.reserve(Eigen::VectorXi::Constant(n, 3));
for (Index i = 0; i < n; ++i) {
A.insert(i, i) = Scalar(2);
if (i > 0) A.insert(i - 1, i) = Scalar(-1);
if (i + 1 < n) A.insert(i + 1, i) = Scalar(-1);
}
A.makeCompressed();
gpu::Context& gctx = gpu::Context::threadLocal();
gpu::SparseContext<Scalar> ctx(gctx);
auto view = ctx.deviceView(A);
DeviceMatrix d_x = DeviceMatrix::fromHost(HostMatrix::Random(n, 1), gctx.stream());
DeviceMatrix d_y(n, 1);
for (auto _ : state) {
d_y = view * d_x;
syncStream(gctx.stream());
}
}
BENCHMARK(BM_DeviceSpMV)->Arg(1 << 12)->Arg(1 << 20)->UseRealTime()->MinWarmUpTime(0.5);
// ---------------------------------------------------------------------------
// 5. Allocator microbenchmark: cudaMalloc/cudaFree (what DeviceMatrix uses
// today) vs cudaMallocAsync/cudaFreeAsync (stream-ordered pool). Supports
// the proposal to switch DeviceMatrix storage to stream-ordered allocation.
// ---------------------------------------------------------------------------
static void BM_CudaMallocFree(benchmark::State& state) {
const size_t bytes = static_cast<size_t>(state.range(0));
for (auto _ : state) {
void* p = nullptr;
EIGEN_CUDA_RUNTIME_CHECK(cudaMalloc(&p, bytes));
EIGEN_CUDA_RUNTIME_CHECK(cudaFree(p));
}
}
BENCHMARK(BM_CudaMallocFree)->Arg(1 << 10)->Arg(1 << 20)->Arg(1 << 26)->UseRealTime()->MinWarmUpTime(0.5);
static void BM_CudaMallocAsyncFree(benchmark::State& state) {
const size_t bytes = static_cast<size_t>(state.range(0));
gpu::Context& ctx = gpu::Context::threadLocal();
int pools_supported = 0;
int device = 0;
EIGEN_CUDA_RUNTIME_CHECK(cudaGetDevice(&device));
EIGEN_CUDA_RUNTIME_CHECK(cudaDeviceGetAttribute(&pools_supported, cudaDevAttrMemoryPoolsSupported, device));
if (!pools_supported) {
state.SkipWithError("cudaMallocAsync not supported on this device");
return;
}
for (auto _ : state) {
void* p = nullptr;
EIGEN_CUDA_RUNTIME_CHECK(cudaMallocAsync(&p, bytes, ctx.stream()));
EIGEN_CUDA_RUNTIME_CHECK(cudaFreeAsync(p, ctx.stream()));
}
syncStream(ctx.stream());
}
BENCHMARK(BM_CudaMallocAsyncFree)->Arg(1 << 10)->Arg(1 << 20)->Arg(1 << 26)->UseRealTime()->MinWarmUpTime(0.5);
// ---------------------------------------------------------------------------
// 6. Small buffers: a DeviceBuffer below DeviceBufferPool's threshold takes
// the stream-ordered allocator where memory pools exist and the thread-local
// pool otherwise; PoolAllocFree drives the pool directly, paying one event
// record per release and one query per reuse. Behind an in-flight kernel a
// release trails that kernel, so a reuse finds it retired or falls through
// to the allocator (see CudaMallocAsyncFree).
// ---------------------------------------------------------------------------
static void BM_SmallBufferAllocFree(benchmark::State& state) {
const size_t bytes = static_cast<size_t>(state.range(0));
for (auto _ : state) {
gpu::internal::DeviceBuffer b(bytes);
benchmark::DoNotOptimize(b.get());
}
}
BENCHMARK(BM_SmallBufferAllocFree)->Arg(8)->Arg(64)->Arg(256)->UseRealTime()->MinWarmUpTime(0.5);
static void BM_PoolAllocFree(benchmark::State& state) {
const size_t bytes = static_cast<size_t>(state.range(0));
auto& pool = gpu::internal::DeviceBufferPool<>::threadLocal();
for (auto _ : state) {
void* p = pool.allocate(bytes);
benchmark::DoNotOptimize(p);
pool.deallocate(p, bytes);
}
}
BENCHMARK(BM_PoolAllocFree)->Arg(8)->Arg(256)->UseRealTime()->MinWarmUpTime(0.5);
static void BM_SmallBufferAllocFreeBusyStream(benchmark::State& state) {
const size_t bytes = static_cast<size_t>(state.range(0));
gpu::Context& ctx = gpu::Context::threadLocal();
const Index n = Index(1) << 22;
DeviceMatrix d_x = DeviceMatrix::fromHost(HostMatrix::Random(n, 1));
DeviceMatrix d_y = DeviceMatrix::fromHost(HostMatrix::Random(n, 1));
for (auto _ : state) {
// ~1 ms of work ahead of each release, as in an iterative solver loop.
gpu::DeviceScalar<Scalar> r = d_x.dot(ctx, d_y);
gpu::internal::DeviceBuffer b(bytes);
benchmark::DoNotOptimize(b.get());
benchmark::DoNotOptimize(r.devicePtr());
}
syncStream(ctx.stream());
}
BENCHMARK(BM_SmallBufferAllocFreeBusyStream)->Arg(8)->Arg(64)->UseRealTime()->MinWarmUpTime(0.5);