#![allow(clippy::uninlined_format_args)]
use std::ptr::NonNull;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use cudarc::driver::CudaContext;
use onnx_runtime_memory_governor::{DeviceAllocator, DeviceKey, MemoryError, Tier};
#[derive(Debug)]
struct ExternalEagerAllocator {
context: Arc<CudaContext>,
device: DeviceKey,
cumemalloc_calls: AtomicU64,
frees: AtomicU64,
}
impl ExternalEagerAllocator {
fn new(context: Arc<CudaContext>) -> Self {
let ordinal = context.ordinal() as u32;
Self {
context,
device: DeviceKey::device(ordinal),
cumemalloc_calls: AtomicU64::new(0),
frees: AtomicU64::new(0),
}
}
fn cumemalloc_calls(&self) -> u64 {
self.cumemalloc_calls.load(Ordering::Relaxed)
}
fn frees(&self) -> u64 {
self.frees.load(Ordering::Relaxed)
}
}
impl DeviceAllocator for ExternalEagerAllocator {
fn allocate(&self, bytes: usize, align: usize) -> Result<NonNull<u8>, MemoryError> {
if align == 0 || !align.is_power_of_two() || align > 256 {
return Err(MemoryError::InvalidRequest {
tier: Tier::Device.name(),
requested: bytes as u64,
reason: "cuMemAlloc guarantees 256-byte alignment and this allocator does not \
over-allocate to exceed it",
});
}
self.context
.bind_to_thread()
.map_err(|error| MemoryError::AllocationFailed {
tier: Tier::Device.name(),
requested: bytes as u64,
reason: format!("could not bind the CUDA context: {error}"),
})?;
let dptr =
unsafe { cudarc::driver::result::malloc_sync(bytes.max(1)) }.map_err(|error| {
MemoryError::AllocationFailed {
tier: Tier::Device.name(),
requested: bytes as u64,
reason: format!("cuMemAlloc refused: {error}"),
}
})?;
NonNull::new(dptr as *mut u8)
.ok_or(MemoryError::AllocationFailed {
tier: Tier::Device.name(),
requested: bytes as u64,
reason: String::from("cuMemAlloc returned a null device pointer"),
})
.inspect(|_| {
self.cumemalloc_calls.fetch_add(1, Ordering::Relaxed);
})
}
unsafe fn deallocate(&self, ptr: NonNull<u8>, _bytes: usize, _align: usize) {
let _ = self.context.bind_to_thread();
let _ = unsafe {
cudarc::driver::result::free_sync(ptr.as_ptr() as cudarc::driver::sys::CUdeviceptr)
};
self.frees.fetch_add(1, Ordering::Relaxed);
}
fn device(&self) -> DeviceKey {
self.device
}
}
#[derive(Debug)]
struct StrictSizes {
inner: ExternalEagerAllocator,
live: std::sync::Mutex<std::collections::HashMap<usize, usize>>,
mismatches: AtomicU64,
unknown: AtomicU64,
}
impl StrictSizes {
fn new(inner: ExternalEagerAllocator) -> Self {
Self {
inner,
live: std::sync::Mutex::new(std::collections::HashMap::new()),
mismatches: AtomicU64::new(0),
unknown: AtomicU64::new(0),
}
}
}
impl DeviceAllocator for StrictSizes {
fn allocate(&self, bytes: usize, align: usize) -> Result<NonNull<u8>, MemoryError> {
let ptr = self.inner.allocate(bytes, align)?;
self.live
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.insert(ptr.as_ptr() as usize, bytes);
Ok(ptr)
}
unsafe fn deallocate(&self, ptr: NonNull<u8>, bytes: usize, align: usize) {
match self
.live
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.remove(&(ptr.as_ptr() as usize))
{
Some(allocated) if allocated == bytes => {}
Some(allocated) => {
self.mismatches.fetch_add(1, Ordering::Relaxed);
eprintln!("freed {bytes} bytes for a pointer allocated as {allocated}");
}
None => {
self.unknown.fetch_add(1, Ordering::Relaxed);
eprintln!("freed a pointer this allocator never handed out");
}
}
unsafe { self.inner.deallocate(ptr, bytes, align) };
}
fn device(&self) -> DeviceKey {
self.inner.device()
}
}
fn drain_releases(provider: &onnx_runtime_ep_cuda::provider::CudaExecutionProvider, what: &str) {
assert!(
provider
.release_queue()
.wait_until_idle(std::time::Duration::from_secs(30)),
"the deferred release queue must drain before {what} is asserted: {:?}",
provider.deferred_release_stats()
);
}
fn require_provider(what: &str) -> onnx_runtime_ep_cuda::provider::CudaExecutionProvider {
match onnx_runtime_ep_cuda::provider::CudaExecutionProvider::new(0) {
Ok(provider) => provider,
Err(error) => {
eprintln!("SKIPPED (no CUDA runtime): {what} did NOT run: {error}");
panic!("CUDA test path did not run; report as a failed GPU test, not a pass");
}
}
}
fn require_context(what: &str) -> Arc<CudaContext> {
match CudaContext::new(0) {
Ok(context) => context,
Err(error) => {
eprintln!("SKIPPED (no CUDA driver): {what} did NOT run: {error}");
panic!("CUDA test path did not run; report as a failed GPU test, not a pass");
}
}
}
#[cfg_attr(
not(feature = "gpu-tests"),
ignore = "requires CUDA device; enable the gpu-tests feature on a CUDA runner"
)]
#[test]
fn the_default_provider_allocates_through_the_built_in_vmm_arena() {
use onnx_runtime_ep_api::ExecutionProvider;
assert!(
std::env::var("ONNX_GENAI_CUDA_VMM").is_err(),
"this test must reach the arena with no opt-in; something set the removed flag"
);
let provider = require_provider("the default built-in mechanism check");
assert!(
provider.commits_on_demand(),
"the default CUDA provider must allocate through the on-demand VMM arena"
);
let bytes = 1 << 20;
let buffer = provider.allocate(bytes, 256).expect("device memory");
let pattern: Vec<u8> = (0..bytes).map(|index| (index % 251) as u8).collect();
let mut read_back = vec![0u8; bytes];
unsafe {
use cudarc::driver::sys as cu;
let address = buffer.as_ptr() as cu::CUdeviceptr;
assert_eq!(
cu::cuMemcpyHtoD_v2(address, pattern.as_ptr().cast(), bytes),
cu::CUresult::CUDA_SUCCESS
);
assert_eq!(
cu::cuMemcpyDtoH_v2(read_back.as_mut_ptr().cast(), address, bytes),
cu::CUresult::CUDA_SUCCESS
);
}
assert_eq!(read_back, pattern, "arena memory did not round-trip");
provider.deallocate(buffer).expect("returned to the arena");
let eager = require_provider("the eager-contrast half of the default-mechanism check")
.with_memory(Arc::new(ExternalEagerAllocator::new(require_context(
"the eager-contrast half of the default-mechanism check",
))))
.expect("an eager allocator for this device is a legal injection");
assert!(
!eager.commits_on_demand(),
"premise: `commits_on_demand` must distinguish the arena from an eager allocator, or \
the assertion above says nothing about which mechanism is live"
);
}
#[cfg_attr(
not(feature = "gpu-tests"),
ignore = "requires CUDA device; enable the gpu-tests feature on a CUDA runner"
)]
#[test]
fn an_injected_external_eager_allocator_replaces_the_built_in_arena() {
use onnx_runtime_ep_api::ExecutionProvider;
let provider = require_provider("the authoritative-injection check");
let injected = Arc::new(ExternalEagerAllocator::new(require_context(
"the authoritative-injection check",
)));
assert_eq!(injected.cumemalloc_calls(), 0);
let provider = provider
.with_memory(Arc::clone(&injected) as Arc<dyn DeviceAllocator>)
.expect("an allocator for this EP's own device must be accepted");
assert!(
!provider.commits_on_demand(),
"the injected eager mechanism, not the arena, must now be the live one"
);
let buffer = provider.allocate(4096, 256).expect("device memory");
assert_eq!(
injected.cumemalloc_calls(),
1,
"the allocation must have gone through the injected allocator, not the retired arena"
);
unsafe {
use cudarc::driver::sys as cu;
let value: u32 = 0x736;
assert_eq!(
cu::cuMemcpyHtoD_v2(
buffer.as_ptr() as cu::CUdeviceptr,
std::ptr::addr_of!(value).cast(),
4
),
cu::CUresult::CUDA_SUCCESS,
"memory from the injected allocator must be usable device memory"
);
}
provider.deallocate(buffer).expect("free via the injection");
drain_releases(&provider, "the injected allocator's free count");
assert_eq!(
injected.frees(),
1,
"the release must go back to the injected allocator too"
);
}
#[cfg_attr(
not(feature = "gpu-tests"),
ignore = "requires CUDA device; enable the gpu-tests feature on a CUDA runner"
)]
#[test]
fn an_allocator_for_the_wrong_device_is_refused() {
let provider = require_provider("the device-mismatch check");
let error = provider
.with_memory(Arc::new(onnx_runtime_memory_governor::HostAllocator))
.expect_err("host memory is not CUDA device memory");
let message = error.to_string();
assert!(
message.contains("CUDA device 0"),
"the error must name the device that was expected: {message}"
);
}
#[cfg_attr(
not(feature = "gpu-tests"),
ignore = "requires CUDA device; enable the gpu-tests feature on a CUDA runner"
)]
#[test]
fn injection_is_refused_once_the_live_mechanism_has_served_memory() {
use onnx_runtime_ep_api::ExecutionProvider;
let provider = require_provider("the late-injection refusal check");
let buffer = provider.allocate(4096, 256).expect("device memory");
provider
.deallocate(buffer)
.expect("the mechanism that served the pointer can release it");
let injected = Arc::new(ExternalEagerAllocator::new(require_context(
"the late-injection refusal check",
)));
let error = provider
.with_memory(Arc::clone(&injected) as Arc<dyn DeviceAllocator>)
.expect_err("a mechanism that has served memory cannot be replaced");
assert!(
error.to_string().contains("cannot do so underneath"),
"the refusal must explain what is outstanding: {error}"
);
assert_eq!(
injected.cumemalloc_calls(),
0,
"a refused allocator must never have been used"
);
}
#[cfg_attr(
not(feature = "gpu-tests"),
ignore = "requires CUDA device; enable the gpu-tests feature on a CUDA runner"
)]
#[test]
fn a_zero_byte_allocation_is_freed_with_the_size_it_was_allocated_with() {
use onnx_runtime_ep_api::ExecutionProvider;
let provider = require_provider("the zero-byte size-agreement check");
let strict = Arc::new(StrictSizes::new(ExternalEagerAllocator::new(
require_context("the zero-byte size-agreement check"),
)));
let provider = provider
.with_memory(Arc::clone(&strict) as Arc<dyn DeviceAllocator>)
.expect("an allocator for this EP's own device must be accepted");
let buffer = provider
.allocate(0, 256)
.expect("a zero-byte buffer must still be allocatable");
provider.deallocate(buffer).expect("and freeable");
drain_releases(&provider, "the strict allocator's size bookkeeping");
assert_eq!(
strict.mismatches.load(Ordering::Relaxed),
0,
"the size passed to allocate and the size passed to deallocate disagree"
);
assert_eq!(
strict.unknown.load(Ordering::Relaxed),
0,
"a pointer was freed that this allocator never handed out"
);
let live = strict
.live
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.len();
assert_eq!(live, 0, "the buffer leaked");
}