1627 lines
52 KiB
Python
1627 lines
52 KiB
Python
# 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)
|