from __future__ import annotations
import enum
import importlib
import logging
import threading
from typing import Protocol
logger = logging.getLogger(__name__)
try:
_vmm_alloc_ext = importlib.import_module("modelexpress.vmm._alloc_ext")
except ImportError as e: _vmm_alloc_ext = None
_EXT_IMPORT_ERROR = e
else:
_EXT_IMPORT_ERROR = None
class _ArenaState(enum.Enum):
OPEN = "open"
CLOSED = "closed"
DEFAULT_VA_RESERVE = 16 * 1024 * 1024 * 1024 * 1024
_SKELETON_FAKE_BASE = 0x10_0000_0000_0000
_DEFAULT_STUB_GRANULARITY = 2 * 1024 * 1024
class VmmArenaError(Exception):
class VmmBackend(Protocol):
def reserve(self, total_bytes: int) -> int:
...
def allocate(self, va: int, size: int) -> int:
...
def deallocate(self, va: int, size: int, handle: int) -> None:
...
def release_reserve(self, base: int, total_bytes: int) -> None:
...
def allocation_granularity(self) -> int:
...
class _StubBackend:
def __init__(self, granularity: int = _DEFAULT_STUB_GRANULARITY) -> None:
self._granularity = granularity
self.reserved: list[tuple[int, int]] = [] self.allocations: list[tuple[int, int, int]] = [] self.deallocations: list[tuple[int, int, int]] = [] self.released: list[tuple[int, int]] = [] self._next_handle = 1
self._lock = threading.Lock()
def reserve(self, total_bytes: int) -> int:
with self._lock:
base = _SKELETON_FAKE_BASE + sum(s for _, s in self.reserved)
self.reserved.append((base, total_bytes))
return base
def allocate(self, va: int, size: int) -> int:
with self._lock:
handle = self._next_handle
self._next_handle += 1
self.allocations.append((va, size, handle))
return handle
def deallocate(self, va: int, size: int, handle: int) -> None:
with self._lock:
self.deallocations.append((va, size, handle))
def release_reserve(self, base: int, total_bytes: int) -> None:
with self._lock:
self.released.append((base, total_bytes))
def allocation_granularity(self) -> int:
return self._granularity
class VmmArena:
def __init__(
self,
total_bytes: int = DEFAULT_VA_RESERVE,
device: int = 0,
backend: VmmBackend | None = None,
) -> None:
if _vmm_alloc_ext is None:
raise VmmArenaError(
"_vmm_alloc_ext C extension is not available; "
f"import failed: {_EXT_IMPORT_ERROR}"
)
if total_bytes <= 0:
raise ValueError("total_bytes must be positive")
if backend is None:
raise ValueError(
"backend is required: pass CudaVmmBackend(device=...) for "
"production or _StubBackend() for tests. The previous "
"implicit-stub default made production callers silently "
"fall through to fake addresses if the backend kwarg was "
"forgotten."
)
self._device = device
self._backend: VmmBackend = backend
self._granularity = self._backend.allocation_granularity()
if self._granularity <= 0 or (self._granularity & (self._granularity - 1)) != 0:
raise VmmArenaError(
f"backend.allocation_granularity() must be a positive power of 2, got {self._granularity}"
)
rounded = ((total_bytes + self._granularity - 1) // self._granularity) * self._granularity
self._total_bytes = rounded
self._base = self._backend.reserve(rounded)
try:
self._capsule = _vmm_alloc_ext.arena_create(
self._base,
self._total_bytes,
self._granularity,
self._perform_cuda_alloc,
self._perform_cuda_dealloc,
)
except BaseException:
try:
self._backend.release_reserve(self._base, self._total_bytes)
except Exception:
logger.warning(
"failed to roll back reserved VA range for "
"base=0x%x total=%d after arena_create failure",
self._base,
self._total_bytes,
exc_info=True,
)
raise
logger.debug(
"VmmArena reserved base=0x%x total=%d granularity=%d device=%d",
self._base,
self._total_bytes,
self._granularity,
self._device,
)
@property
def capsule(self):
return self._capsule
@property
def base(self) -> int:
return self._base
@property
def total_bytes(self) -> int:
return self._total_bytes
@property
def device(self) -> int:
return self._device
@property
def granularity(self) -> int:
return self._granularity
@property
def used_bytes(self) -> int:
return int(_vmm_alloc_ext.arena_get_used_bytes(self._capsule))
@property
def mapped_bytes(self) -> int:
return int(_vmm_alloc_ext.arena_get_mapped_bytes(self._capsule))
@property
def live_allocation_count(self) -> int:
return int(_vmm_alloc_ext.arena_get_live_count(self._capsule))
@property
def closed(self) -> bool:
return bool(_vmm_alloc_ext.arena_is_closed(self._capsule))
@property
def state(self) -> _ArenaState:
return _ArenaState.CLOSED if self.closed else _ArenaState.OPEN
def _perform_cuda_alloc(self, va: int, size: int) -> int:
return self._backend.allocate(int(va), int(size))
def _perform_cuda_dealloc(self, va: int, size: int, handle: int) -> None:
self._backend.deallocate(int(va), int(size), int(handle))
def malloc(self, size: int, alignment: int | None = None) -> int:
if size <= 0:
raise ValueError("size must be positive")
if alignment is not None and alignment > self._granularity:
raise ValueError(
f"alignment {alignment} exceeds backend granularity {self._granularity}; "
"PyTorch's caching allocator should round up before reaching us"
)
try:
return int(_vmm_alloc_ext.arena_malloc(self._capsule, int(size)))
except RuntimeError as e:
msg = str(e)
if msg.startswith("arena is closed") or "would exceed reserved range" in msg:
raise VmmArenaError(msg) from e
raise
def free(self, ptr: int) -> None:
_vmm_alloc_ext.arena_free(self._capsule, int(ptr))
def registered_range(self) -> tuple[int, int]:
base, used = _vmm_alloc_ext.arena_registered_range(self._capsule)
return (int(base), int(used))
def close(self) -> None:
drained = _vmm_alloc_ext.arena_close_and_drain(self._capsule)
if drained is None:
return
first_error: Exception | None = None
for va, size, handle in drained:
try:
self._backend.deallocate(int(va), int(size), int(handle))
except Exception as e:
logger.warning(
"backend.deallocate failed during close for va=0x%x size=%d",
int(va),
int(size),
exc_info=True,
)
if first_error is None:
first_error = e
try:
self._backend.release_reserve(self._base, self._total_bytes)
except Exception as e:
logger.warning(
"backend.release_reserve failed for base=0x%x total=%d",
self._base,
self._total_bytes,
exc_info=True,
)
if first_error is None:
first_error = e
logger.debug(
"VmmArena closed base=0x%x total=%d used=%d",
self._base,
self._total_bytes,
self.used_bytes,
)
if first_error is not None:
raise first_error
def __enter__(self) -> "VmmArena":
return self
def __exit__(self, exc_type, exc, tb) -> None:
self.close()