# SPDX-License-Identifier: Apache-2.0 # Standard from dataclasses import dataclass from enum import Enum, auto from functools import cache, wraps from typing import Any, List, Optional, Tuple, Union import abc import ctypes import os import threading # Third Party from sortedcontainers import SortedList import torch # First Party from lmcache import torch_dev from lmcache import torch_device_type as torch_device_type # noqa: F401 from lmcache.integration.vllm.utils import get_size_bytes from lmcache.logging import init_logger from lmcache.observability import LMCStatsMonitor from lmcache.utils import _lmcache_nvtx_annotate from lmcache.v1.pin_monitor import PinMonitor from lmcache.v1.platform import current_device_spec as current_device_spec # noqa: F401 from lmcache.v1.system_detection import NUMAMapping import lmcache.c_ops as lmc_ops logger = init_logger(__name__) # Cache for ctypes ubyte-array types keyed by length. # # ctypes does not cache `(c_ubyte * N)` array types -- each call to the `*` # operator builds a fresh heap type via PyCArrayType_from_ctype. The heap # type metadata stays alive forever (held by the type system), so calling # `(ctypes.c_ubyte * N).from_address(...)` on every TensorMemoryObj.byte_array # access leaks ~1-2 kB per call. Under long-running remote-backend put/get # workloads this is the dominant source of monotonic anonymous-memory growth # (see https://github.com/LMCache/LMCache/issues/3767). # # Caching by length is safe: the `from_address(addr)` instance never owns the # underlying buffer, only the metadata (length, item-type), and that metadata # depends solely on `N`. ``functools.cache`` provides a thread-safe unbounded # memoization primitive, so concurrent first-time accesses for the same `N` # cannot race to create distinct heap types. @cache def _get_cached_ubyte_array_type(num_bytes: int) -> type[ctypes.Array[ctypes.c_ubyte]]: """Return a cached ``ctypes.c_ubyte * num_bytes`` array type. Args: num_bytes: The length of the array type in bytes. Returns: The cached ``ctypes.Array`` subclass for the given length. Subsequent calls with the same ``num_bytes`` return the same type object. """ return ctypes.c_ubyte * num_bytes # Helper functions for thread safety def synchronized(lock_attr_name): """ Decorator to make a method thread-safe by acquiring the lock specified by lock_attr_name on the instance. """ def decorator(method): @wraps(method) def wrapper(self, *args, **kwargs): lock = getattr(self, lock_attr_name) with lock: return method(self, *args, **kwargs) return wrapper return decorator class MemoryFormat(Enum): UNDEFINED = 0 """[2, num_layers, num_tokens, hidden_dim] """ # KV_BLOB = 1 KV_2LTD = auto() """[num_tokens, 2, hidden_dim] """ # LAYER_KV_BLOB = 2 KV_T2D = auto() """[2, num_tokens, hidden_dim] """ KV_2TD = auto() """Compressed binary array format """ BINARY = auto() BINARY_BUFFER = auto() KV_MLA_FMT = auto() """[1, num_layers, num_tokens, aligned_head_size] """ # This is for the encoder cache (EC) tensor format EC_TD = auto() """[num_tokens, hidden_dim] """ # Hidden-state store (HS) tensor format. Same logical shape as EC_TD # ([num_tokens, hidden_dim]) but tagged separately so the allocator and # any future mp/serialization paths can distinguish encoder-cache entries # from hidden-state entries. HS_TD = auto() """[num_tokens, hidden_dim] """ def token_dim(self) -> int: if self == MemoryFormat.KV_2LTD: return 2 elif self == MemoryFormat.KV_T2D: return 1 elif self == MemoryFormat.KV_2TD: return 0 elif self == MemoryFormat.BINARY: return 0 elif self == MemoryFormat.BINARY_BUFFER: return 0 elif self == MemoryFormat.KV_MLA_FMT: return 2 elif self == MemoryFormat.EC_TD: return 0 elif self == MemoryFormat.HS_TD: return 0 return 0 @dataclass class FreeBlock: """Metadata class used by the memory allocators""" start: int size: int def can_be_coalesced(self, succ: "FreeBlock") -> bool: return self.start + self.size == succ.start @dataclass class MemoryObjMetadata: # TODO(chunxiaozheng): use shapes and dtypes to replace shape and dtype # The 'logical' shape of the tensor shape: torch.Size # The 'logical' dtype of the tensor dtype: Optional[torch.dtype] # The 'physical address' of the tensor address: int # The 'physical size' in bytes of the allocated memory phy_size: int # Reference count ref_count: int # Whether the object is pinned and cannot be evicted # lookup pins are temporary # cache controller pins are persistent pin_count: int = 0 # The 'logical' format of the tensor fmt: MemoryFormat = MemoryFormat.UNDEFINED # Positions when the cache is stored cached_positions: Optional[torch.Tensor] = None # shapes and dtypes should be used in the future shapes: Optional[list[torch.Size]] = None dtypes: Optional[list[torch.dtype]] = None def to_dict(self): # Note(Kuntai): this is used for serializing MemoryObjMetadata via # msgpack. return { "__type__": "MemoryObjMetadata", "shape": list(self.shape), # torch.Size -> list "dtype": str(self.dtype) if self.dtype else None, "address": self.address, "phy_size": self.phy_size, "ref_count": self.ref_count, "fmt": self.fmt.value, "shapes": [list(shape) for shape in self.shapes] if self.shapes else None, "dtypes": [str(dtype) for dtype in self.dtypes] if self.dtypes else None, } @staticmethod def from_dict(d): dtype_str = d["dtype"] dtype = getattr(torch, dtype_str.replace("torch.", "")) if dtype_str else None shapes_list = d["shapes"] shapes = [torch.Size(s) for s in shapes_list] if shapes_list else None dtypes_list = d["dtypes"] dtypes = ( [getattr(torch, d_str.replace("torch.", "")) for d_str in dtypes_list] if dtypes_list else None ) return MemoryObjMetadata( shape=torch.Size(d["shape"]), dtype=dtype, address=d["address"], phy_size=d["phy_size"], ref_count=d["ref_count"], fmt=MemoryFormat(d["fmt"]), shapes=shapes, dtypes=dtypes, ) def get_size(self) -> int: if self.shapes is not None and self.dtypes is not None: return get_size_bytes(self.shapes, self.dtypes) return self.shape.numel() * self.dtype.itemsize # type: ignore class MemoryObj(metaclass=abc.ABCMeta): """ MemoryObj interface. """ # subclasses should expose raw_data differently raw_data: Any def __init__(self, metadata: MemoryObjMetadata): self.meta = metadata @abc.abstractmethod def invalidate(self): """ Invalidate the MemoryObj. """ raise NotImplementedError @abc.abstractmethod def is_valid(self): """ Check if the MemoryObj is valid. """ raise NotImplementedError @abc.abstractmethod def get_size(self) -> int: """ Get the size of the MemoryObj in bytes. Note that this number could be smaller than the physical size. The physical size is aligned to the allocator's alignment. """ raise NotImplementedError @abc.abstractmethod def get_shape(self) -> torch.Size: """ Get the shape of the MemoryObj. """ raise NotImplementedError def get_dtype(self) -> Optional[torch.dtype]: """ Get the dtype of the MemoryObj. """ return None @abc.abstractmethod def get_shapes(self) -> list[torch.Size]: """ Get the shapes of the MemoryObj. """ raise NotImplementedError @abc.abstractmethod def get_dtypes(self) -> list[torch.dtype]: """ Get the dtypes of the MemoryObj. """ raise NotImplementedError @abc.abstractmethod def get_memory_format(self) -> MemoryFormat: """ Get the memory format of the MemoryObj. """ raise NotImplementedError @abc.abstractmethod def get_physical_size(self) -> int: """ Get the physical size of the MemoryObj in bytes. """ raise NotImplementedError def set_used_size(self, n: int) -> None: # noqa: B027 """Narrow this buffer's logical size to the first ``n`` bytes. Optional hook for callers that have just written ``n`` bytes into a buffer originally allocated with an upper-bound size (e.g. the async serde processor, where the destination is sized from ``estimate_serialized_size`` but ``serialize`` returns the actual ``n`` it wrote). After this call, ``get_size()`` / ``byte_array`` / any downstream L2 adapter that reads the logical size will see exactly ``n`` bytes. Default is a no-op so subclasses without a "used vs allocated" distinction (e.g. :class:`BytesBufferMemoryObj`, where the raw bytes already are the actual contents) keep working unchanged. Args: n: bytes actually used in this buffer. Subclasses that implement this must validate ``n`` and raise ``ValueError`` on out-of-range or unsupported layouts. """ pass @abc.abstractmethod def pin(self) -> bool: """ Pin the memory obj so that it will not be evicted. """ raise NotImplementedError @abc.abstractmethod def ref_count_up(self): """ Increase ref count for the given MemoryObj by one. """ raise NotImplementedError @abc.abstractmethod def unpin(self) -> bool: """ Unpin the memory obj so that it can be evicted. """ raise NotImplementedError @abc.abstractmethod def ref_count_down(self): """ Decrease ref count for the given MemoryObj by one. """ raise NotImplementedError @abc.abstractmethod def get_ref_count(self) -> int: """ Get ref count for the given MemoryObj. """ raise NotImplementedError @abc.abstractmethod def get_num_tokens(self) -> int: """ Get token number for the given MemoryObj. """ raise NotImplementedError @property def shm_offset(self) -> int: """Return the byte offset of this object inside the SHM pool.""" return self.meta.address @property def shm_byte_length(self) -> int: """Return the byte length of this object inside the SHM pool.""" return self.get_size() @property @abc.abstractmethod def metadata(self) -> MemoryObjMetadata: """ Get the metada of the MemoryObj. """ raise NotImplementedError @property @abc.abstractmethod def tensor(self) -> Optional[torch.Tensor]: """ Get the tensor from the MemoryObj. """ raise NotImplementedError @property @abc.abstractmethod def byte_array(self) -> bytes: """ Get the byte array from the MemoryObj. The size is will be the physical size instead of the unaligned size. """ raise NotImplementedError @property @abc.abstractmethod def data_ptr(self) -> int: """ Get the data pointer of the MemoryObj. This is used to access the raw data in the memory. """ raise NotImplementedError @property @abc.abstractmethod def is_pinned(self) -> bool: """ Check whether the memory obj is pinned. """ raise NotImplementedError @property @abc.abstractmethod def can_evict(self) -> bool: """ Check whether the memory obj can be evicted. """ raise NotImplementedError @property @abc.abstractmethod def raw_tensor(self) -> Optional[torch.Tensor]: """ Get the raw tensor from the MemoryObj. """ raise NotImplementedError @abc.abstractmethod def get_tensor(self, index: int) -> Optional[torch.Tensor]: """ Get the tensor from the MemoryObj at the given index(group). """ raise NotImplementedError @abc.abstractmethod def parent(self) -> Optional["MemoryAllocatorInterface"]: """ Get the allocator that allocates this memory object """ raise NotImplementedError @dataclass class PinnedAllocFree: """Resolved alloc/free function pair for pinned CPU memory.""" alloc_fn: Any alloc_args: tuple free_fn: Any free_args: tuple def alloc(self) -> int: """Allocate pinned memory and return the raw pointer.""" return self.alloc_fn(*self.alloc_args) def free(self, ptr: int) -> None: """Free a previously allocated pinned-memory pointer.""" self.free_fn(ptr, *self.free_args) def _resolve_pinned_alloc_free( numa_mapping: Optional[NUMAMapping] = None, shm_name: Optional[str] = None, size: Optional[int] = None, use_hugepages: bool = False, ) -> PinnedAllocFree: """Resolve the alloc/free function pair based on memory type. Returns: A PinnedAllocFree with the resolved functions and their extra arguments. Call ``ptr = resolved.alloc()`` and ``resolved.free(ptr)``. """ if shm_name: if use_hugepages: raise ValueError("Hugepages are not supported with shared memory (shm)") return PinnedAllocFree( alloc_fn=lmc_ops.alloc_shm_pinned_ptr, alloc_args=(size, shm_name), free_fn=lmc_ops.free_shm_pinned_ptr, free_args=(size, shm_name), ) elif numa_mapping: if torch_dev.is_available(): current_device_id = torch_dev.current_device() else: current_device_id = 0 gpu_to_numa_mapping = numa_mapping.gpu_to_numa_mapping assert current_device_id in gpu_to_numa_mapping, ( f"Current device {current_device_id} is not in the GPU NUMA mapping." ) numa_id = gpu_to_numa_mapping[current_device_id] if use_hugepages: return PinnedAllocFree( alloc_fn=lmc_ops.alloc_hugepage_pinned_numa_ptr, alloc_args=(size, numa_id), free_fn=lmc_ops.free_hugepage_pinned_numa_ptr, free_args=(size,), ) else: return PinnedAllocFree( alloc_fn=lmc_ops.alloc_pinned_numa_ptr, alloc_args=(size, numa_id), free_fn=lmc_ops.free_pinned_numa_ptr, free_args=(size,), ) else: flags = 0 if use_hugepages: return PinnedAllocFree( alloc_fn=lmc_ops.alloc_hugepage_pinned_ptr, alloc_args=(size, flags), free_fn=lmc_ops.free_hugepage_pinned_ptr, free_args=(size,), ) else: return PinnedAllocFree( alloc_fn=lmc_ops.alloc_pinned_ptr, alloc_args=(size, flags), free_fn=lmc_ops.free_pinned_ptr, free_args=(), ) def _read_hugepage_info() -> Optional[Tuple[int, int, int]]: """Read hugepage pool stats from sysfs. NOTE: We only use 2 MiB hugepages, so the pool stats are taken from the 2 MiB pool directly rather than the system default pool reported in ``/proc/meminfo`` (which can be 1 GiB on some hosts). Returns: ``(nr_hugepages, free_hugepages, page_size_mb)`` for the hugepage pool, or ``None`` if the sysfs entries are unavailable. """ base = "/sys/kernel/mm/hugepages/hugepages-2048kB" try: with open(f"{base}/nr_hugepages") as f: total = int(f.read().strip()) with open(f"{base}/free_hugepages") as f: free = int(f.read().strip()) return total, free, 2 except (OSError, ValueError): return None def _allocate_cpu_memory( size: int, numa_mapping: Optional[NUMAMapping] = None, shm_name: Optional[str] = None, use_hugepages: bool = False, ) -> torch.Tensor: if size == 0: return torch.empty(0, dtype=torch.uint8) resolved = _resolve_pinned_alloc_free( numa_mapping, shm_name, size, use_hugepages, ) try: ptr = resolved.alloc() except RuntimeError as e: if use_hugepages and "mmap failed" in str(e): diag = _read_hugepage_info() if diag is not None: total, free, page_mb = diag page_bytes = page_mb * 1024 * 1024 needed = (size + page_bytes - 1) // page_bytes logger.error( "Failed to allocate huge pages. " "Pool has %d pages (%d free, each %d MiB). " "Requested %d bytes (%d pages). " "Please grow the %d MiB hugepage pool.", total, free, page_mb, size, needed, page_mb, ) else: logger.error( "Failed to allocate huge pages. " "Please grow the 2 MiB hugepage pool." ) raise array_type = ctypes.c_uint8 * size buf = array_type.from_address(ptr) buffer = torch.frombuffer(buf, dtype=torch.uint8) return buffer def _free_cpu_memory( buffer: torch.Tensor, size: int | None = None, numa_mapping: Optional[NUMAMapping] = None, shm_name: Optional[str] = None, use_hugepages: bool = False, ) -> None: if torch_dev.is_available(): torch_dev.synchronize() resolved = _resolve_pinned_alloc_free( numa_mapping, shm_name, size, use_hugepages, ) resolved.free(buffer.data_ptr()) def _allocate_gpu_memory( size: int, device: str, ) -> Tuple[torch.Tensor, torch.Tensor]: page_size = os.sysconf("SC_PAGESIZE") # Over-allocate base_buffer = torch.empty(size + page_size, dtype=torch.uint8, device=device) offset = -base_buffer.data_ptr() % page_size # Make aligned view aligned_buffer = base_buffer[offset : offset + size] # Need to return the base buffer as well in order to prevent GC return base_buffer, aligned_buffer class TensorMemoryObj(MemoryObj): """ Wraps a raw flat tensor with some metadata """ monitor = LMCStatsMonitor.GetOrCreate() def __init__( self, raw_data: torch.Tensor, metadata: MemoryObjMetadata, parent_allocator: Optional["MemoryAllocatorInterface"], ): assert metadata.dtype is not None, "dtype must be specified for TensorMemoryObj" super().__init__(metadata) self.raw_data = raw_data self.valid = True self.lock = threading.Lock() self.parent_allocator = parent_allocator # ``None`` means "use the layout-derived size from # group_prefix_sum"; a non-None value narrows the logical view to # exactly that many bytes (see set_used_size). Allocator reuse # paths must reset this to None along with the rest of the # per-allocation metadata. self._used_size_override: Optional[int] = None # Calculate the prefix sum of the group sizes # If there are two groups, the prefix sum will be # [0, size_of_group_1, size_of_group_1 + size_of_group_2] self.group_prefix_sum = [0] if self.meta.shapes is not None and self.meta.dtypes is not None: size_in_bytes = 0 for shape, dtype in zip(self.meta.shapes, self.meta.dtypes, strict=True): size_in_bytes += shape.numel() * dtype.itemsize self.group_prefix_sum.append(size_in_bytes) else: self.group_prefix_sum.append(self.meta.get_size()) def __del__(self): """ Destructor to ensure memory is released when the object is garbage collected. This acts as a safety net to prevent memory leaks if ref_count_down() is not called properly somewhere in the code path. """ if self.parent_allocator is not None and self.is_valid(): if self.meta.ref_count > 0 or self.meta.pin_count > 0: logger.warning( "MemoryObj at %s is being garbage collected " "with ref_count=%d, pin_count=%d. " "This indicates ref_count_down()/unpin() was not called properly.", self.meta.address, self.meta.ref_count, self.meta.pin_count, ) self.parent_allocator.free(self) def invalidate(self): self.valid = False def is_valid(self): return self.valid def get_size(self) -> int: if self._used_size_override is not None: return self._used_size_override return self.group_prefix_sum[-1] def set_used_size(self, n: int) -> None: """Narrow the logical size to ``n`` bytes after a write. After this call, ``get_size()`` returns ``n`` and ``byte_array`` exposes exactly ``n`` bytes from the start of ``raw_data``. The physical allocation (``get_physical_size``) and ``raw_data`` buffer are unchanged. Allocator reuse resets this override to ``None`` so a recycled block returns to its layout-derived size. Note: the ``tensor`` property still derives its shape from ``meta.shape``, so accessing ``.tensor`` on a buffer narrowed below its layout size will fail to reshape. Use ``byte_array`` (or read ``raw_data[: get_size()]`` directly) for downstream I/O that must honor the narrowed size. Args: n: bytes actually written. Must satisfy ``0 <= n <= get_physical_size()``. Raises: ValueError: if ``n`` is outside the allowed range. """ if n < 0 or n > self.meta.phy_size: raise ValueError( f"set_used_size: n={n} out of range [0, {self.meta.phy_size}]" ) with self.lock: self._used_size_override = n # TODO(chunxiaozheng): use get_shapes and get_dtypes to replace # get_shape and get_dtype def get_shape(self) -> torch.Size: return self.meta.shape def get_dtype(self) -> torch.dtype: assert self.meta.dtype is not None return self.meta.dtype def get_shapes(self) -> list[torch.Size]: assert self.meta.shapes is not None return self.meta.shapes def get_dtypes(self) -> list[torch.dtype]: assert self.meta.dtypes is not None return self.meta.dtypes def get_memory_format(self) -> MemoryFormat: with self.lock: return self.meta.fmt def get_physical_size(self) -> int: return self.meta.phy_size def ref_count_up(self): with self.lock: self.meta.ref_count += 1 def ref_count_down(self): with self.lock: self.meta.ref_count -= 1 if self.meta.ref_count < 0: logger.warning( f"Ref count of MemoryObj {self.meta.address}" f"is negative: {self.meta.ref_count}." "Double free occurred somewhere." "Setting ref count back to 0 as a hack but please find the bug." ) self.meta.ref_count = 0 if ( self.meta.ref_count == 0 and self.parent_allocator is not None and self.meta.pin_count == 0 ): self.parent_allocator.free(self) def get_ref_count(self) -> int: with self.lock: return self.meta.ref_count def get_num_tokens(self) -> int: with self.lock: token_dim = self.meta.fmt.token_dim() return self.meta.shape[token_dim] def pin(self) -> bool: with self.lock: # if pin_count is 0, indicates that the object is pinned for the first time if self.meta.pin_count == 0: TensorMemoryObj.monitor.update_pinned_memory_objs_count(1) self.meta.pin_count += 1 # Register/update with PinMonitor for timeout tracking on every pin pin_monitor = PinMonitor.GetOrCreate() pin_monitor.on_pin(self) return True def unpin(self) -> bool: with self.lock: self.meta.pin_count -= 1 # if pin_count is 0, indicates that the object is unpinned if self.meta.pin_count == 0: TensorMemoryObj.monitor.update_pinned_memory_objs_count(-1) # Unregister from PinMonitor when fully unpinned pin_monitor = PinMonitor.GetOrCreate() pin_monitor.on_unpin(self) if self.meta.pin_count <= 0 and self.meta.ref_count <= 0: if self.parent_allocator is None: logger.error( "Parent allocator is None when trying to free MemoryObj." "This could cause memory leak" ) else: self.parent_allocator.free(self) if self.meta.pin_count < 0: logger.warning( f"Pin count of MemoryObj {self.meta.address}" f"is negative: {self.meta.pin_count}." "Double unpin occurred somewhere." "Setting pin count back to 0 as a hack but please find the bug." ) self.meta.pin_count = 0 return True @property def metadata(self) -> MemoryObjMetadata: with self.lock: return self.meta @property def tensor(self) -> Optional[torch.Tensor]: if not self.valid: logger.warning("Trying to access an invalidated MemoryObj") return None assert self.meta.dtype is not None if self._used_size_override is not None: # Narrowed byte buffer (see set_used_size): expose exactly # the used bytes as a flat uint8 view. Reshaping to the # original meta.shape would raise -- fewer than shape-many # bytes are logically present -- so keep the view consistent # with get_size()/byte_array/shm_byte_length, which all # report the narrowed length. Consumers that build SHM # transport slots from ``tensor.shape`` and # ``shm_byte_length`` then stay self-consistent. return self.raw_data[: self._used_size_override].view(torch.uint8) # TODO(Jiayi): consider caching the `get_size()` return ( self.raw_data[: self.get_size()].view(self.meta.dtype).view(self.meta.shape) ) @property def byte_array(self) -> memoryview: # TODO: consider using one of the alternatives # Alternative 1: # # PyTorch tensors support buffer protocol directly for CPU tensors # return memoryview(self.raw_data) # Alternative 2: # assert self.raw_data.device.type == 'cpu', # "byte_array only works with CPU tensors" # return memoryview(self.raw_data.contiguous().numpy()) # Use logical size (get_size) rather than raw_data physical size. # The raw_data buffer may include alignment padding (e.g. from # batched_allocate) that must not be exposed to callers such as # remote-backend put/get which rely on byte_array length matching # the metadata length. num_bytes = self.get_size() ptr = self.raw_data.data_ptr() # ctypes does not cache (c_ubyte * N) array types -- each `*` builds a # fresh heap type. With this property accessed once per remote put/get, # uncached creation leaks ~1-2 kB per call (heap-type metadata is held # by the type system and never reclaimed). Cache the array type per # size so steady-state usage reuses a fixed set of types. See # https://github.com/LMCache/LMCache/issues/3767. arr_type = _get_cached_ubyte_array_type(num_bytes) ubyte_ptr = ctypes.cast(ptr, ctypes.POINTER(ctypes.c_ubyte)) byte_array = arr_type.from_address(ctypes.addressof(ubyte_ptr.contents)) return memoryview(byte_array) @property def data_ptr(self) -> int: return self.raw_data.data_ptr() @property def is_pinned(self) -> bool: return self.metadata.pin_count > 0 @property def can_evict(self) -> bool: """ Check whether the memory obj can be evicted. A memory obj can be evicted if it is not pinned and ref_count=1. """ return not self.is_pinned and self.get_ref_count() == 1 @property def raw_tensor(self) -> Optional[torch.Tensor]: if not self.valid: logger.warning("Trying to access an invalidated MemoryObj") return None return self.raw_data def get_tensor(self, index: int) -> Optional[torch.Tensor]: if not self.valid: logger.warning("Trying to access an invalidated MemoryObj") return None assert self.meta.shapes is not None assert self.meta.dtypes is not None begin = self.group_prefix_sum[index] end = self.group_prefix_sum[index + 1] return ( self.raw_data[begin:end] .view(self.meta.dtypes[index]) .view(self.meta.shapes[index]) ) def parent(self) -> Optional["MemoryAllocatorInterface"]: return self.parent_allocator class BytesBufferMemoryObj(MemoryObj): """ Wraps a raw flat tensor with some metadata """ def __init__(self, raw_bytes: bytes, metadata: Optional[MemoryObjMetadata] = None): self.raw_data = raw_bytes if metadata is None: bytes_shape = torch.Size([len(self.raw_data), 0, 0, 0]) metadata = MemoryObjMetadata( shape=bytes_shape, dtype=None, address=0, phy_size=0, ref_count=1, pin_count=0, fmt=MemoryFormat.BINARY_BUFFER, ) super().__init__(metadata) self.valid = True def invalidate(self): self.valid = False def is_valid(self): return self.valid def get_size(self) -> int: return len(self.raw_data) def get_shape(self) -> torch.Size: return torch.Size([len(self.raw_data), 0, 0, 0]) def get_dtype(self) -> Optional[torch.dtype]: return None def get_shapes(self) -> list[torch.Size]: return [self.get_shape()] def get_dtypes(self) -> list[torch.dtype]: return [] def get_memory_format(self) -> MemoryFormat: return self.metadata.fmt def get_physical_size(self) -> int: return self.metadata.phy_size def pin(self) -> bool: self.metadata.pin_count += 1 return True def unpin(self) -> bool: self.metadata.pin_count -= 1 if self.metadata.pin_count < 0: logger.warning( f"Pin count of MemoryObj {self.meta.address}" f"is negative: {self.meta.pin_count}." "Double unpin occurred somewhere." "Setting pin count back to 0 as a hack but please find the bug." ) self.metadata.pin_count = 0 return True def ref_count_up(self): pass def ref_count_down(self): pass def get_ref_count(self) -> int: return 1 def get_num_tokens(self) -> int: # TODO(Jiayi): record the number of tokens somehow return 1 @property def metadata(self) -> MemoryObjMetadata: return self.meta @property def tensor(self) -> Optional[torch.Tensor]: if not self.valid: logger.warning("Trying to access an invalidated MemoryObj") return None return None @property def byte_array(self) -> bytes: return self.raw_data @property def data_ptr(self) -> int: mv = memoryview(self.raw_data) addr = ctypes.addressof(ctypes.c_char.from_buffer(mv)) return addr @property def is_pinned(self) -> bool: return self.metadata.pin_count > 0 @property def can_evict(self) -> bool: """ Check whether the memory obj can be evicted. A buffer memory obj can be evicted if it is not pinned. """ return not self.is_pinned @property def raw_tensor(self) -> Optional[torch.Tensor]: if not self.valid: logger.warning("Trying to access an invalidated MemoryObj") return None return None def get_tensor(self, index: int) -> Optional[torch.Tensor]: return None def parent(self) -> Optional["MemoryAllocatorInterface"]: # NOTE: BytesBufferMemoryObj may not be allocated by any allocator, # so just return None here return None class GDSMemoryObject(MemoryObj): """A slab-anchored ``MemoryObj`` for the GDS L1 tier. The bytes live in the GDS slab file, not in host or device memory, so this object carries only the slab ``(offset, size)`` (in ``meta.address`` / ``meta.phy_size``) and is otherwise a placeholder: ``tensor`` is always ``None`` and ``byte_array`` / ``data_ptr`` raise. """ def __init__(self, metadata: MemoryObjMetadata) -> None: super().__init__(metadata) self.valid = True @property def slab_offset(self) -> int: """Byte offset of this chunk within the slab file (== ``meta.address``).""" return self.meta.address def invalidate(self) -> None: self.valid = False def is_valid(self) -> bool: return self.valid def get_size(self) -> int: return self.meta.phy_size def get_shape(self) -> torch.Size: return self.meta.shape def get_dtype(self) -> Optional[torch.dtype]: return self.meta.dtype def get_shapes(self) -> list[torch.Size]: raise NotImplementedError( "GDSMemoryObject.get_shapes: per-group shapes are not tracked on " "the GDS path (only the singular meta.shape is); use get_shape()" ) def get_dtypes(self) -> list[torch.dtype]: raise NotImplementedError( "GDSMemoryObject.get_dtypes: per-group dtypes are not tracked on " "the GDS path (only the singular meta.dtype is); use get_dtype()" ) def get_memory_format(self) -> MemoryFormat: return self.meta.fmt def get_physical_size(self) -> int: return self.meta.phy_size def ref_count_up(self) -> None: raise NotImplementedError( "GDSMemoryObject.ref_count_up: not used on the GDS path" ) def ref_count_down(self) -> None: raise NotImplementedError( "GDSMemoryObject.ref_count_down: not used on the GDS path" ) def get_ref_count(self) -> int: raise NotImplementedError( "GDSMemoryObject.get_ref_count: not used on the GDS path" ) def get_num_tokens(self) -> int: raise NotImplementedError( "GDSMemoryObject.get_num_tokens: not used on the GDS path" ) def pin(self) -> bool: raise NotImplementedError("GDSMemoryObject.pin: not used on the GDS path") def unpin(self) -> bool: raise NotImplementedError("GDSMemoryObject.unpin: not used on the GDS path") @property def metadata(self) -> MemoryObjMetadata: return self.meta @property def tensor(self) -> Optional[torch.Tensor]: return None @property def byte_array(self) -> bytes: raise NotImplementedError( f"GDSMemoryObject(slab_offset={self.slab_offset}).byte_array is not " "supported; bytes live in the GDS slab file and the staging buffer " "is registered VRAM (no buffer protocol)." ) @property def data_ptr(self) -> int: raise NotImplementedError( f"GDSMemoryObject(slab_offset={self.slab_offset}).data_ptr is not " "supported; GDS reads/writes use gpu_buffer.data_ptr() via the " "gpu_ops dispatch, never the MemoryObj's data_ptr." ) @property def is_pinned(self) -> bool: raise NotImplementedError("GDSMemoryObject.is_pinned: not used on the GDS path") @property def can_evict(self) -> bool: raise NotImplementedError("GDSMemoryObject.can_evict: not used on the GDS path") @property def raw_tensor(self) -> Optional[torch.Tensor]: return None def get_tensor(self, index: int) -> Optional[torch.Tensor]: return None def parent(self) -> Optional["MemoryAllocatorInterface"]: # The GDS slab is not a MemoryAllocatorInterface; dispatch in gpu_ops # keys off the GDSMemoryObject type, not the parent allocator. return None class MemoryAllocatorInterface(metaclass=abc.ABCMeta): @abc.abstractmethod def allocate( self, shapes: Union[torch.Size, list[torch.Size]], dtypes: Union[torch.dtype, list[torch.dtype]], fmt: MemoryFormat = MemoryFormat.UNDEFINED, allocator_type: Optional[str] = None, ) -> Optional[MemoryObj]: """ Allocates the memory to hold a tensor of the given shape. :param torch.Size shapes: The shape of the tensor to allocate. :param torch.dtype dtypes: The dtype of the tensor to allocate. :param MemoryFormat fmt: The format of the memory to allocate. :return: A MemoryObj wrapping the allocated memory. Returns None if the allocation failed. :rtype: Optional[MemoryObj] """ raise NotImplementedError @abc.abstractmethod def batched_allocate( self, shapes: Union[torch.Size, list[torch.Size]], dtypes: Union[torch.dtype, list[torch.dtype]], batch_size: int, fmt: MemoryFormat = MemoryFormat.UNDEFINED, allocator_type: Optional[str] = None, ) -> Optional[List[MemoryObj]]: """ Batched allocate the memory to hold a tensor of the given shape. :param torch.Size shapes: The shape of the tensor to allocate. :param torch.dtype dtypes: The dtype of the tensor to allocate. :param int batch_size: The number of tensors to allocate. :param MemoryFormat fmt: The format of the memory to allocate. :return: A list of MemoryObjs wrapping the allocated memory. Returns None if the allocation failed. :rtype: Optional[List[MemoryObj]] """ raise NotImplementedError @abc.abstractmethod def free( self, memory_obj: MemoryObj, allocator_type: Optional[str] = None, ): """ Frees the memory allocated for the given MemoryObj. Note that this function shouldn't be explicitly called. Instead, use `ref_count_down` to decrease ref count. :param MemoryObj memory_obj: The MemoryObj to free. """ raise NotImplementedError @abc.abstractmethod def batched_free( self, memory_objs: List[MemoryObj], allocator_type: Optional[str] = None, update_stats: bool = True, ): """ Frees the memory allocated for the given list of MemoryObjs. :param List[MemoryObj] memory_objs: The list of MemoryObjs to free. """ raise NotImplementedError def close(self): """ Closes the memory allocator. This is called when the LMCacheEngine is closed. """ return def memcheck(self) -> bool: """ Checks the memory allocator for consistency. Returns: True if everything is fine otherwise False """ return True # TODO(chunxiaozheng): remove if after all params replaced by shapes/dtypes def _adapt_shapes_and_dtypes( self, shapes: Union[torch.Size, list[torch.Size]], dtypes: Union[torch.dtype, list[torch.dtype]], ) -> Tuple[list[torch.Size], list[torch.dtype]]: if isinstance(shapes, torch.Size): shapes = [shapes] if isinstance(dtypes, torch.dtype): dtypes = [dtypes] assert len(shapes) == len(dtypes), ( f"shapes and dtypes must have the same length, " f"got {len(shapes)} and {len(dtypes)}, " f"shapes: {shapes}, dtypes: {dtypes}" ) return shapes, dtypes class AddressManager: """ Manages a virtual address space starting from 0 for memory allocation. Key interfaces: - allocate(size): Allocate a block of memory of the given size. The starting address and the actual allocated size will be aligned. - free(address, size): Free a previously allocated region. Note that if the region is not "allocated" before, it may have internal errors. - sbrk(size): Expand the virtual address space by the given size. The size will be aligned internally. Core assumptions: - The allocated size should be aligned with ALIGN_BYTES. """ ALIGN_BYTES = 4096 def __init__(self, size: int, align_bytes: int = ALIGN_BYTES): """ Initializes the AddressManager with a given size. Args: size: The initial size of the virtual address space. align_bytes: The alignment requirement for allocations. """ self._size = size self._align = align_bytes # Current implementation: explicit list self._explicit_list: SortedList[FreeBlock] = SortedList(key=lambda x: x.start) self._explicit_list.add(FreeBlock(start=0, size=size)) # thread safe lock self._lock = threading.Lock() # For debugging purposes self.total_allocated_size = 0 def compute_aligned_size(self, raw_size: int) -> int: """ Helper function to compute the aligned size for a given raw size. Args: raw_size: The raw size to be aligned. Returns: The aligned size. """ return (raw_size + self._align - 1) & ~(self._align - 1) def _can_merge_with_prev( self, curr_block: FreeBlock, prev_block: FreeBlock ) -> bool: """Hook: Check if curr_block can merge with prev_block.""" return prev_block.can_be_coalesced(curr_block) def _can_merge_with_succ( self, curr_block: FreeBlock, succ_block: FreeBlock ) -> bool: """Hook: Check if curr_block can merge with succ_block.""" return curr_block.can_be_coalesced(succ_block) @_lmcache_nvtx_annotate def _coalesce( self, curr_block: FreeBlock, prev_block: Optional[FreeBlock], succ_block: Optional[FreeBlock], ): """ Coalesces the current block with the previous and/or successor block. This assumes the curr_block is NOT in self._explicit_list Returns True if the current block was coalesced, otherwise False. """ merge_prev = prev_block is not None and self._can_merge_with_prev( curr_block, prev_block ) merge_succ = succ_block is not None and self._can_merge_with_succ( curr_block, succ_block ) if merge_prev and merge_succ: prev_block.size += curr_block.size + succ_block.size # type: ignore self._explicit_list.remove(succ_block) elif merge_prev: prev_block.size += curr_block.size # type: ignore elif merge_succ: # NOTE: logically, this won't change the order of the succ_block, # so we don't need to do a "remove" and "reinsert" here self._explicit_list.remove(succ_block) succ_block.start -= curr_block.size # type: ignore succ_block.size += curr_block.size # type: ignore self._explicit_list.add(succ_block) return merge_prev or merge_succ @_lmcache_nvtx_annotate @synchronized("_lock") def allocate(self, size: int) -> tuple[int, int]: """ Allocate a block of memory from the virtual address space of a given size. The actual allocated size could be larger than the requested size in order to satisfy alignment requirements. Args: size: The requested size of the memory block. Should be greater than 0. Returns: A tuple (address, allocated_size) where address is the starting address of the allocated block and allocated_size is the actual size of the allocated block. Raises: RuntimeError: If no memory is available to allocate. """ aligned_size = self.compute_aligned_size(size) for block in self._explicit_list: if block.size >= aligned_size: break else: logger.warning( "Failed to allocate memory block of size %d " "because no memory is available", size, ) raise RuntimeError( f"Failed to allocate memory block of size {size} " "because no memory is available" ) self._explicit_list.remove(block) if block.size > aligned_size: self._explicit_list.add( FreeBlock( start=block.start + aligned_size, size=block.size - aligned_size, ) ) # For debug self.total_allocated_size += aligned_size return block.start, aligned_size @_lmcache_nvtx_annotate @synchronized("_lock") def batched_allocate(self, size: int, batch_size: int) -> list[tuple[int, int]]: """ Allocate blocks of memory from the virtual address space of a given size and batch size. The actual allocated size could be larger than the requested size in order to satisfy alignment requirements. Args: size: The requested size of the memory block. Should be greater than 0. batch_size: The number of memory blocks to allocate. Returns: A list of tuple (address, allocated_size) where address is the starting address of the allocated block and allocated_size is the actual size of the allocated block. Note: the length of the return list is the same as the batch_size. Raises: RuntimeError: If no memory is available to allocate. """ aligned_size = self.compute_aligned_size(size) remaining = batch_size allocate_result: list[tuple[int, int]] = [] blocks_to_remove: list[FreeBlock] = [] blocks_to_add: list[FreeBlock] = [] for block in self._explicit_list: if remaining <= 0: break if block.size < aligned_size: continue # Greedily carve out as many aligned_size chunks as possible num_from_block = min(remaining, block.size // aligned_size) start = block.start for i in range(num_from_block): allocate_result.append((start + i * aligned_size, aligned_size)) remaining -= num_from_block # Mark the original block for removal blocks_to_remove.append(block) # Keep the remaining tail as a new free block if any space is left used = num_from_block * aligned_size if block.size > used: blocks_to_add.append( FreeBlock(start=block.start + used, size=block.size - used) ) if remaining > 0: # Not enough memory; free list is untouched, no rollback needed logger.warning( "Failed to batched allocate %d memory blocks of size %d " "because no enough memory is available (short by %d blocks)", batch_size, size, remaining, ) raise RuntimeError( f"Failed to batched allocate {batch_size} memory blocks " f"of size {size} because no enough memory is available" ) if len(allocate_result) != batch_size: # The length of allocate_result is not equal to batch_size; # free list is untouched, no rollback needed logger.warning( "Failed to batched allocate %d memory blocks of size %d " "because the length of allocate_result %d is not equal to batch_size", batch_size, size, len(allocate_result), ) raise RuntimeError( f"Failed to batched allocate {batch_size} memory blocks " f"of size {size} because the length of allocate_result " f"{len(allocate_result)} is not equal to batch_size" ) # Allocation succeeded; batch-update the free list for block in blocks_to_remove: self._explicit_list.remove(block) for block in blocks_to_add: self._explicit_list.add(block) # Update debug statistics total_allocated = aligned_size * batch_size self.total_allocated_size += total_allocated return allocate_result @_lmcache_nvtx_annotate @synchronized("_lock") def free(self, address: int, size: int): """ Free a previously allocated block of memory. Args: address: The starting address of the block to free. size: The size of the block to free. Should be greater than 0. """ new_free_block = FreeBlock(start=address, size=size) index = self._explicit_list.bisect_left(new_free_block) prev_block = self._explicit_list[index - 1] if index > 0 else None succ_block = ( self._explicit_list[index] if index < len(self._explicit_list) else None ) coalesced = self._coalesce(new_free_block, prev_block, succ_block) if not coalesced: self._explicit_list.add(new_free_block) # For debug self.total_allocated_size -= size @synchronized("_lock") def sbrk(self, size: int): """ Expand the virtual address space by a given size. Args: size: The size to expand the address space. Will be aligned internally with the ALIGN_BYTES """ size = self.compute_aligned_size(size) new_block = FreeBlock(start=self._size, size=size) prev_block = self._explicit_list[-1] if len(self._explicit_list) > 0 else None succ_block = None coalesced = self._coalesce(new_block, prev_block, succ_block) if not coalesced: self._explicit_list.add(new_block) self._size += size def get_heap_size(self) -> int: """ Get the total size of the address space. Returns: The total size in bytes. """ return self._size def get_free_size(self) -> int: """ Get the total free size in the address space. Returns: The total free size in bytes. """ return self._size - self.total_allocated_size def check_consistency(self) -> bool: """ Check if the address manager is consistent. Returns: True if consistent, False otherwise. """ # Check if free blocks are properly coalesced for prev, succ in zip( self._explicit_list[:-1], self._explicit_list[1:], strict=False ): if prev.can_be_coalesced(succ): return False # Check if total size matches total_free_size = sum(block.size for block in self._explicit_list) if total_free_size + self.total_allocated_size != self._size: return False return True _CORE_EXPORTS = [ "AddressManager", "BytesBufferMemoryObj", "FreeBlock", "GDSMemoryObject", "MemoryAllocatorInterface", "MemoryFormat", "MemoryObj", "MemoryObjMetadata", "TensorMemoryObj", "torch_device_type", ] __all__ = list(_CORE_EXPORTS)