| // 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 |
| |
| // Compares the host-to-device storage strategies available to DeviceMatrix on |
| // an integrated GPU, where the "copy" moves bytes from one region of DRAM to |
| // another region of the same DRAM. |
| // |
| // DeviceMatrix currently always allocates with cudaMalloc and reaches host data |
| // through cudaMemcpy. On a discrete GPU that is the only option. On a device |
| // reporting cudaDeviceProp::integrated the copy is avoidable, but only if the |
| // alternative storage is also fast to compute on -- mapped host memory is not |
| // cached in the GPU's L2 on Tegra, so a cheaper upload can cost more than it |
| // saves once a kernel reads the data. Every strategy is therefore measured |
| // twice: what it costs to get the data in place, and what a GEMM reading it |
| // then achieves. |
| // |
| // Upload_* cost to make an existing host matrix readable by cuBLAS |
| // Gemm_* GEMM throughput with both operands in that storage |
| // Download_* cost to make a device-written result readable by the host |
| // RoundTrip_* upload + GEMM + download, the figure that decides the design |
| // |
| // Strategies: |
| // DeviceMalloc cudaMalloc + cudaMemcpy from pageable host memory (today) |
| // Pinned cudaMalloc + cudaMemcpy staged through cudaMallocHost |
| // Managed cudaMallocManaged + host memcpy |
| // ManagedInPlace cudaMallocManaged, matrix built in place -- no copy at all |
| // Mapped cudaHostAlloc(cudaHostAllocMapped) + memcpy, zero-copy |
| // Registered cudaHostRegister over the host matrix's own storage |
| // |
| // Build (standalone project, see CMakeLists.txt in this directory): |
| // cmake -G Ninja -B build-bench-gpu -S contrib/benchmarks/GPU \ |
| // -DCMAKE_CUDA_ARCHITECTURES=87 |
| // cmake --build build-bench-gpu --target bench_unified_memory |
| // ./build-bench-gpu/bench_unified_memory |
| |
| #ifndef EIGEN_USE_GPU |
| #define EIGEN_USE_GPU |
| #endif |
| #include <Eigen/Core> |
| #include <contrib/Eigen/GPU> |
| |
| #include <benchmark/benchmark.h> |
| #include <cublas_v2.h> |
| #include <cuda_runtime.h> |
| |
| #include <cstring> |
| |
| #ifndef SCALAR |
| #define SCALAR double |
| #endif |
| using Scalar = SCALAR; |
| |
| using Eigen::Index; |
| namespace gpu = Eigen::gpu; |
| using HostMatrix = Eigen::Matrix<Scalar, Eigen::Dynamic, Eigen::Dynamic>; |
| using HostMap = Eigen::Map<HostMatrix>; |
| |
| namespace { |
| |
| // cuBLAS handle and stream shared by every case, created once. |
| struct Blas { |
| Blas() { |
| EIGEN_CUDA_RUNTIME_CHECK(cudaStreamCreate(&stream)); |
| cublasCreate(&handle); |
| cublasSetStream(handle, stream); |
| } |
| ~Blas() { |
| cublasDestroy(handle); |
| (void)cudaStreamDestroy(stream); |
| } |
| cublasHandle_t handle; |
| cudaStream_t stream; |
| }; |
| |
| Blas& blas() { |
| static Blas b; |
| return b; |
| } |
| |
| void gemm(Index n, const Scalar* a, const Scalar* b, Scalar* c) { |
| const Scalar alpha = 1, beta = 0; |
| const int m = gpu::internal::to_blas_int(n); |
| if (std::is_same<Scalar, double>::value) { |
| cublasDgemm(blas().handle, CUBLAS_OP_N, CUBLAS_OP_N, m, m, m, reinterpret_cast<const double*>(&alpha), |
| reinterpret_cast<const double*>(a), m, reinterpret_cast<const double*>(b), m, |
| reinterpret_cast<const double*>(&beta), reinterpret_cast<double*>(c), m); |
| } else { |
| cublasSgemm(blas().handle, CUBLAS_OP_N, CUBLAS_OP_N, m, m, m, reinterpret_cast<const float*>(&alpha), |
| reinterpret_cast<const float*>(a), m, reinterpret_cast<const float*>(b), m, |
| reinterpret_cast<const float*>(&beta), reinterpret_cast<float*>(c), m); |
| } |
| } |
| |
| // The six storage strategies, behind one interface. host() is null when the |
| // strategy has no host-addressable view of the allocation. |
| enum class Storage { DeviceMalloc, Pinned, Managed, Mapped, Registered }; |
| |
| class Buffer { |
| public: |
| Buffer(Storage s, size_t bytes) : storage_(s), bytes_(bytes) { |
| switch (storage_) { |
| case Storage::DeviceMalloc: |
| EIGEN_CUDA_RUNTIME_CHECK(cudaMalloc(&device_, bytes)); |
| break; |
| case Storage::Pinned: |
| EIGEN_CUDA_RUNTIME_CHECK(cudaMalloc(&device_, bytes)); |
| EIGEN_CUDA_RUNTIME_CHECK(cudaMallocHost(&host_, bytes)); |
| break; |
| case Storage::Managed: |
| EIGEN_CUDA_RUNTIME_CHECK(cudaMallocManaged(&device_, bytes)); |
| host_ = device_; |
| break; |
| case Storage::Mapped: |
| EIGEN_CUDA_RUNTIME_CHECK(cudaHostAlloc(&host_, bytes, cudaHostAllocMapped)); |
| EIGEN_CUDA_RUNTIME_CHECK(cudaHostGetDevicePointer(&device_, host_, 0)); |
| break; |
| case Storage::Registered: |
| // Registration targets caller-owned storage; adopt() supplies it. |
| break; |
| } |
| } |
| |
| // Register an existing host allocation in place. Timed separately, because |
| // the page-table work scales with the allocation and dominates for one-shot |
| // use while amortizing to nothing for a matrix uploaded repeatedly. |
| void adopt(void* host_storage) { |
| EIGEN_CUDA_RUNTIME_CHECK(cudaHostRegister(host_storage, bytes_, cudaHostRegisterMapped)); |
| host_ = host_storage; |
| registered_ = true; |
| EIGEN_CUDA_RUNTIME_CHECK(cudaHostGetDevicePointer(&device_, host_, 0)); |
| } |
| |
| ~Buffer() { |
| switch (storage_) { |
| case Storage::DeviceMalloc: |
| (void)cudaFree(device_); |
| break; |
| case Storage::Pinned: |
| (void)cudaFree(device_); |
| (void)cudaFreeHost(host_); |
| break; |
| case Storage::Managed: |
| (void)cudaFree(device_); |
| break; |
| case Storage::Mapped: |
| (void)cudaFreeHost(host_); |
| break; |
| case Storage::Registered: |
| if (registered_) (void)cudaHostUnregister(host_); |
| break; |
| } |
| } |
| |
| Buffer(const Buffer&) = delete; |
| Buffer& operator=(const Buffer&) = delete; |
| |
| Scalar* device() const { return static_cast<Scalar*>(device_); } |
| Scalar* host() const { return static_cast<Scalar*>(host_); } |
| |
| // Make `src` readable by a kernel through device(). This is the operation |
| // the upload benchmarks time. |
| void upload(const Scalar* src) { |
| switch (storage_) { |
| case Storage::DeviceMalloc: |
| EIGEN_CUDA_RUNTIME_CHECK(cudaMemcpyAsync(device_, src, bytes_, cudaMemcpyHostToDevice, blas().stream)); |
| EIGEN_CUDA_RUNTIME_CHECK(cudaStreamSynchronize(blas().stream)); |
| break; |
| case Storage::Pinned: |
| std::memcpy(host_, src, bytes_); |
| EIGEN_CUDA_RUNTIME_CHECK(cudaMemcpyAsync(device_, host_, bytes_, cudaMemcpyHostToDevice, blas().stream)); |
| EIGEN_CUDA_RUNTIME_CHECK(cudaStreamSynchronize(blas().stream)); |
| break; |
| case Storage::Managed: |
| case Storage::Mapped: |
| std::memcpy(host_, src, bytes_); |
| break; |
| case Storage::Registered: |
| // The data is already there; registration made it visible. |
| break; |
| } |
| } |
| |
| // Make a device-written result readable through host(), into `dst`. |
| void download(Scalar* dst) { |
| switch (storage_) { |
| case Storage::DeviceMalloc: |
| EIGEN_CUDA_RUNTIME_CHECK(cudaMemcpyAsync(dst, device_, bytes_, cudaMemcpyDeviceToHost, blas().stream)); |
| EIGEN_CUDA_RUNTIME_CHECK(cudaStreamSynchronize(blas().stream)); |
| break; |
| case Storage::Pinned: |
| EIGEN_CUDA_RUNTIME_CHECK(cudaMemcpyAsync(host_, device_, bytes_, cudaMemcpyDeviceToHost, blas().stream)); |
| EIGEN_CUDA_RUNTIME_CHECK(cudaStreamSynchronize(blas().stream)); |
| std::memcpy(dst, host_, bytes_); |
| break; |
| case Storage::Managed: |
| // concurrentManagedAccess is 0 on Tegra: the host may not read a |
| // managed allocation while device work is outstanding. |
| EIGEN_CUDA_RUNTIME_CHECK(cudaStreamSynchronize(blas().stream)); |
| std::memcpy(dst, host_, bytes_); |
| break; |
| case Storage::Mapped: |
| case Storage::Registered: |
| EIGEN_CUDA_RUNTIME_CHECK(cudaStreamSynchronize(blas().stream)); |
| std::memcpy(dst, host_, bytes_); |
| break; |
| } |
| } |
| |
| private: |
| Storage storage_; |
| size_t bytes_ = 0; |
| void* device_ = nullptr; |
| void* host_ = nullptr; |
| bool registered_ = false; |
| }; |
| |
| size_t byteCount(Index n) { return static_cast<size_t>(n) * static_cast<size_t>(n) * sizeof(Scalar); } |
| |
| void setBytesCounter(benchmark::State& state, Index n, int64_t buffers) { |
| state.SetBytesProcessed(state.iterations() * buffers * static_cast<int64_t>(byteCount(n))); |
| } |
| |
| // Google Benchmark finishes rate counters against CPU time unless the benchmark |
| // opts into UseRealTime(). Every case here blocks in cudaStreamSynchronize, so |
| // CPU time is a small and size-dependent fraction of wall time (0.2% at n=4096 |
| // on Orin) and a counter computed from it overstates throughput by that factor. |
| // All registrations below call UseRealTime(); see the note in CMakeLists.txt. |
| void setFlopsCounter(benchmark::State& state, Index n) { |
| const double flops = 2.0 * double(n) * double(n) * double(n); |
| state.counters["GFLOPS"] = benchmark::Counter(flops * double(state.iterations()), benchmark::Counter::kIsRate, |
| benchmark::Counter::OneK::kIs1000); |
| } |
| |
| } // namespace |
| |
| // --------------------------------------------------------------------------- |
| // Upload: cost of making an existing host matrix readable by cuBLAS. |
| // --------------------------------------------------------------------------- |
| |
| void uploadBench(benchmark::State& state, Storage storage) { |
| const Index n = state.range(0); |
| const HostMatrix src = HostMatrix::Random(n, n); |
| Buffer buf(storage, byteCount(n)); |
| if (storage == Storage::Registered) buf.adopt(const_cast<Scalar*>(src.data())); |
| for (auto _ : state) { |
| buf.upload(src.data()); |
| benchmark::DoNotOptimize(buf.device()); |
| } |
| setBytesCounter(state, n, 1); |
| } |
| |
| #define UPLOAD_CASE(Name, Enum) \ |
| static void BM_Upload_##Name(benchmark::State& state) { uploadBench(state, Storage::Enum); } \ |
| BENCHMARK(BM_Upload_##Name) \ |
| ->Arg(256) \ |
| ->Arg(1024) \ |
| ->Arg(4096) \ |
| ->Unit(benchmark::kMicrosecond) \ |
| ->UseRealTime() \ |
| ->MinWarmUpTime(0.5) |
| |
| UPLOAD_CASE(DeviceMalloc, DeviceMalloc); |
| UPLOAD_CASE(Pinned, Pinned); |
| UPLOAD_CASE(Managed, Managed); |
| UPLOAD_CASE(Mapped, Mapped); |
| UPLOAD_CASE(Registered, Registered); |
| |
| // The page-table cost that Upload_Registered amortizes away. Registering a |
| // 4096x4096 double matrix touches 32K pages; whether that is worth paying |
| // depends entirely on how often the same buffer is reused. |
| static void BM_HostRegisterCost(benchmark::State& state) { |
| const Index n = state.range(0); |
| HostMatrix src = HostMatrix::Random(n, n); |
| for (auto _ : state) { |
| EIGEN_CUDA_RUNTIME_CHECK(cudaHostRegister(src.data(), byteCount(n), cudaHostRegisterMapped)); |
| EIGEN_CUDA_RUNTIME_CHECK(cudaHostUnregister(src.data())); |
| } |
| setBytesCounter(state, n, 1); |
| } |
| BENCHMARK(BM_HostRegisterCost) |
| ->Arg(256) |
| ->Arg(1024) |
| ->Arg(4096) |
| ->Unit(benchmark::kMicrosecond) |
| ->UseRealTime() |
| ->MinWarmUpTime(0.5); |
| |
| // --------------------------------------------------------------------------- |
| // GEMM throughput with both operands in each storage class. The destination is |
| // always ordinary device memory, isolating the cost of *reading* the operands. |
| // --------------------------------------------------------------------------- |
| |
| void gemmBench(benchmark::State& state, Storage storage) { |
| const Index n = state.range(0); |
| const HostMatrix ha = HostMatrix::Random(n, n); |
| const HostMatrix hb = HostMatrix::Random(n, n); |
| Buffer a(storage, byteCount(n)), b(storage, byteCount(n)); |
| Buffer c(Storage::DeviceMalloc, byteCount(n)); |
| if (storage == Storage::Registered) { |
| a.adopt(const_cast<Scalar*>(ha.data())); |
| b.adopt(const_cast<Scalar*>(hb.data())); |
| } else { |
| a.upload(ha.data()); |
| b.upload(hb.data()); |
| } |
| for (auto _ : state) { |
| gemm(n, a.device(), b.device(), c.device()); |
| EIGEN_CUDA_RUNTIME_CHECK(cudaStreamSynchronize(blas().stream)); |
| } |
| setFlopsCounter(state, n); |
| } |
| |
| #define GEMM_CASE(Name, Enum) \ |
| static void BM_Gemm_##Name(benchmark::State& state) { gemmBench(state, Storage::Enum); } \ |
| BENCHMARK(BM_Gemm_##Name) \ |
| ->Arg(256) \ |
| ->Arg(1024) \ |
| ->Arg(4096) \ |
| ->Unit(benchmark::kMicrosecond) \ |
| ->UseRealTime() \ |
| ->MinWarmUpTime(0.5) |
| |
| GEMM_CASE(DeviceMalloc, DeviceMalloc); |
| GEMM_CASE(Managed, Managed); |
| GEMM_CASE(Mapped, Mapped); |
| GEMM_CASE(Registered, Registered); |
| |
| // --------------------------------------------------------------------------- |
| // Download: cost of making a device-written result readable by the host. |
| // --------------------------------------------------------------------------- |
| |
| void downloadBench(benchmark::State& state, Storage storage) { |
| const Index n = state.range(0); |
| HostMatrix dst(n, n); |
| Buffer buf(storage, byteCount(n)); |
| if (storage == Storage::Registered) buf.adopt(dst.data()); |
| EIGEN_CUDA_RUNTIME_CHECK(cudaMemsetAsync(buf.device(), 0, byteCount(n), blas().stream)); |
| EIGEN_CUDA_RUNTIME_CHECK(cudaStreamSynchronize(blas().stream)); |
| for (auto _ : state) { |
| buf.download(dst.data()); |
| benchmark::DoNotOptimize(dst.data()); |
| } |
| setBytesCounter(state, n, 1); |
| } |
| |
| #define DOWNLOAD_CASE(Name, Enum) \ |
| static void BM_Download_##Name(benchmark::State& state) { downloadBench(state, Storage::Enum); } \ |
| BENCHMARK(BM_Download_##Name) \ |
| ->Arg(256) \ |
| ->Arg(1024) \ |
| ->Arg(4096) \ |
| ->Unit(benchmark::kMicrosecond) \ |
| ->UseRealTime() \ |
| ->MinWarmUpTime(0.5) |
| |
| DOWNLOAD_CASE(DeviceMalloc, DeviceMalloc); |
| DOWNLOAD_CASE(Pinned, Pinned); |
| DOWNLOAD_CASE(Managed, Managed); |
| DOWNLOAD_CASE(Mapped, Mapped); |
| |
| // --------------------------------------------------------------------------- |
| // End-to-end: upload two operands, GEMM, download the result. This is the |
| // number that decides which storage DeviceMatrix should use on an integrated |
| // GPU -- a strategy can win the upload and still lose here. |
| // --------------------------------------------------------------------------- |
| |
| void roundTripBench(benchmark::State& state, Storage storage) { |
| const Index n = state.range(0); |
| const HostMatrix ha = HostMatrix::Random(n, n); |
| const HostMatrix hb = HostMatrix::Random(n, n); |
| HostMatrix hc(n, n); |
| Buffer a(storage, byteCount(n)), b(storage, byteCount(n)), c(storage, byteCount(n)); |
| if (storage == Storage::Registered) { |
| a.adopt(const_cast<Scalar*>(ha.data())); |
| b.adopt(const_cast<Scalar*>(hb.data())); |
| c.adopt(hc.data()); |
| } |
| for (auto _ : state) { |
| a.upload(ha.data()); |
| b.upload(hb.data()); |
| gemm(n, a.device(), b.device(), c.device()); |
| c.download(hc.data()); |
| } |
| setFlopsCounter(state, n); |
| } |
| |
| #define ROUNDTRIP_CASE(Name, Enum) \ |
| static void BM_RoundTrip_##Name(benchmark::State& state) { roundTripBench(state, Storage::Enum); } \ |
| BENCHMARK(BM_RoundTrip_##Name) \ |
| ->Arg(256) \ |
| ->Arg(1024) \ |
| ->Arg(4096) \ |
| ->Unit(benchmark::kMicrosecond) \ |
| ->UseRealTime() \ |
| ->MinWarmUpTime(0.5) |
| |
| ROUNDTRIP_CASE(DeviceMalloc, DeviceMalloc); |
| ROUNDTRIP_CASE(Pinned, Pinned); |
| ROUNDTRIP_CASE(Managed, Managed); |
| ROUNDTRIP_CASE(Mapped, Mapped); |
| ROUNDTRIP_CASE(Registered, Registered); |
| |
| // --------------------------------------------------------------------------- |
| // The bound the others are chasing: operands built directly in managed memory |
| // through an Eigen::Map, so no strategy has anything to copy on the way in. |
| // --------------------------------------------------------------------------- |
| |
| static void BM_RoundTrip_ManagedInPlace(benchmark::State& state) { |
| const Index n = state.range(0); |
| Buffer a(Storage::Managed, byteCount(n)), b(Storage::Managed, byteCount(n)), c(Storage::Managed, byteCount(n)); |
| HostMap(a.host(), n, n) = HostMatrix::Random(n, n); |
| HostMap(b.host(), n, n) = HostMatrix::Random(n, n); |
| for (auto _ : state) { |
| gemm(n, a.device(), b.device(), c.device()); |
| EIGEN_CUDA_RUNTIME_CHECK(cudaStreamSynchronize(blas().stream)); |
| benchmark::DoNotOptimize(HostMap(c.host(), n, n).coeff(0, 0)); |
| } |
| setFlopsCounter(state, n); |
| } |
| BENCHMARK(BM_RoundTrip_ManagedInPlace) |
| ->Arg(256) |
| ->Arg(1024) |
| ->Arg(4096) |
| ->Unit(benchmark::kMicrosecond) |
| ->UseRealTime() |
| ->MinWarmUpTime(0.5); |
| |
| // --------------------------------------------------------------------------- |
| // Reported once so the results carry the device's own answer to "is this an |
| // integrated part, and what is it allowed to do with managed memory". |
| // --------------------------------------------------------------------------- |
| |
| static void BM_ReportDeviceProperties(benchmark::State& state) { |
| int dev = 0; |
| EIGEN_CUDA_RUNTIME_CHECK(cudaGetDevice(&dev)); |
| cudaDeviceProp prop{}; |
| EIGEN_CUDA_RUNTIME_CHECK(cudaGetDeviceProperties(&prop, dev)); |
| for (auto _ : state) benchmark::DoNotOptimize(dev); |
| state.counters["integrated"] = prop.integrated; |
| state.counters["canMapHostMemory"] = prop.canMapHostMemory; |
| state.counters["managedMemory"] = prop.managedMemory; |
| state.counters["concurrentManagedAccess"] = prop.concurrentManagedAccess; |
| state.counters["pageableMemoryAccess"] = prop.pageableMemoryAccess; |
| state.counters["multiProcessorCount"] = prop.multiProcessorCount; |
| } |
| BENCHMARK(BM_ReportDeviceProperties)->Iterations(1); |