Files
2026-07-13 13:17:40 +08:00

346 lines
13 KiB
Python

"""Unit tests for MemoryPoolManager.
"""
import sys
import pytest
import torch
from ray.experimental.rdt.nixl_memory_pool import (
MemoryPoolManager,
NixlOutOfMemoryError,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_tensor(values, dtype=torch.float32):
"""Create a contiguous CPU tensor."""
return torch.tensor(values, dtype=dtype)
# ---------------------------------------------------------------------------
# allocate_for_tensors — basic allocation and data copy
# ---------------------------------------------------------------------------
class TestAllocateForTensors:
def test_single_tensor(self):
t = _make_tensor([1.0, 2.0, 3.0])
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t])
assert len(views) == 1
assert torch.equal(views[0], t)
assert pool.has_block(t)
def test_multiple_independent_tensors(self):
t1 = _make_tensor([1.0, 2.0])
t2 = _make_tensor([3.0, 4.0, 5.0])
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t1, t2])
assert len(views) == 2
assert torch.equal(views[0], t1)
assert torch.equal(views[1], t2)
assert pool.has_block(t1)
assert pool.has_block(t2)
def test_pool_views_are_backed_by_pool_tensor(self):
"""Returned views should be backed by the pool's internal tensor,
not the source tensor's storage."""
t = _make_tensor([10.0, 20.0])
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t])
# The view's storage should be the pool tensor's storage.
assert (
views[0].untyped_storage().data_ptr()
== pool.get_pool_tensor().untyped_storage().data_ptr()
)
def test_data_is_copied_not_aliased(self):
"""Mutating the source tensor after allocation should not affect
the pool copy."""
t = _make_tensor([1.0, 2.0, 3.0])
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t])
original = views[0].clone()
t[0] = 999.0
assert torch.equal(views[0], original)
# ---------------------------------------------------------------------------
# allocate_for_tensors — storage deduplication
# ---------------------------------------------------------------------------
class TestStorageDeduplication:
def test_views_of_same_storage_share_one_block(self):
"""Two views of the same underlying storage should produce only one
pool allocation."""
base = _make_tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
view_a = base[0:2]
view_b = base[1:3]
storage_size = base.untyped_storage().nbytes()
# Pool is exactly one storage — a second allocation would OOM.
pool = MemoryPoolManager(pool_size=storage_size, device=torch.device("cpu"))
views = pool.allocate_for_tensors([view_a, view_b])
assert len(views) == 2
assert torch.equal(views[0], view_a)
assert torch.equal(views[1], view_b)
def test_duplicate_tensor_in_list(self):
"""The exact same tensor object appearing twice should deduplicate."""
t = _make_tensor([1.0, 2.0])
storage_size = t.untyped_storage().nbytes()
pool = MemoryPoolManager(pool_size=storage_size, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t, t])
assert len(views) == 2
assert torch.equal(views[0], t)
assert torch.equal(views[1], t)
def test_cross_call_reuse(self):
"""A second allocate_for_tensors call with the same tensor should
reuse the existing pool block (cache hit), not allocate a new one."""
t = _make_tensor([1.0, 2.0, 3.0])
storage_size = t.untyped_storage().nbytes()
# Pool fits exactly one storage.
pool = MemoryPoolManager(pool_size=storage_size, device=torch.device("cpu"))
views1 = pool.allocate_for_tensors([t])
# Second call should hit cache, not OOM.
views2 = pool.allocate_for_tensors([t])
assert torch.equal(views1[0], t)
assert torch.equal(views2[0], t)
def test_mixed_cache_hit_and_new_allocation(self):
"""One call with a mix of already-allocated and new tensors should
only allocate for the new ones."""
t1 = _make_tensor([1.0, 2.0])
t2 = _make_tensor([3.0, 4.0, 5.0])
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
# Pre-allocate t1.
pool.allocate_for_tensors([t1])
# Now allocate both — t1 should cache-hit, t2 should get new block.
views = pool.allocate_for_tensors([t1, t2])
assert len(views) == 2
assert torch.equal(views[0], t1)
assert torch.equal(views[1], t2)
assert pool.has_block(t2)
# ---------------------------------------------------------------------------
# allocate_for_tensors — OOM
# ---------------------------------------------------------------------------
class TestOOM:
def test_oom_single_tensor(self):
t = _make_tensor([1.0, 2.0, 3.0]) # 12 bytes
pool = MemoryPoolManager(pool_size=4, device=torch.device("cpu"))
with pytest.raises(NixlOutOfMemoryError, match="out of memory"):
pool.allocate_for_tensors([t])
def test_oom_does_not_corrupt_pool_state(self):
"""After an OOM error, the pool state should be unchanged — previously
allocated blocks remain valid and no partial allocation leaks."""
t1 = _make_tensor([1.0, 2.0]) # 8 bytes
t2 = _make_tensor([3.0, 4.0, 5.0]) # 12 bytes
pool = MemoryPoolManager(pool_size=12, device=torch.device("cpu"))
views1 = pool.allocate_for_tensors([t1])
assert torch.equal(views1[0], t1)
# t2 doesn't fit in the remaining 4 bytes.
with pytest.raises(NixlOutOfMemoryError):
pool.allocate_for_tensors([t2])
# Pool should still be intact — t1's block is still valid.
assert pool.has_block(t1)
def test_atomic_allocation_failure(self):
"""When allocating multiple tensors atomically, if one doesn't fit,
none should be allocated."""
t1 = _make_tensor([1.0]) # 4 bytes
t2 = _make_tensor([1.0] * 100) # 400 bytes — won't fit
pool = MemoryPoolManager(pool_size=64, device=torch.device("cpu"))
with pytest.raises(NixlOutOfMemoryError):
pool.allocate_for_tensors([t1, t2])
# Neither tensor should have been tracked.
assert not pool.has_block(t1)
assert not pool.has_block(t2)
# ---------------------------------------------------------------------------
# free_tensors
# ---------------------------------------------------------------------------
class TestFreeTensors:
def test_free_and_reallocate(self):
"""After freeing, the space should be reusable."""
t1 = _make_tensor([1.0, 2.0]) # 8 bytes
pool = MemoryPoolManager(pool_size=8, device=torch.device("cpu"))
pool.allocate_for_tensors([t1])
assert pool.has_block(t1)
pool.free_tensors([t1])
assert not pool.has_block(t1)
# Now a new tensor of the same size should fit.
t2 = _make_tensor([3.0, 4.0])
views = pool.allocate_for_tensors([t2])
assert torch.equal(views[0], t2)
def test_free_unknown_tensor_is_noop(self):
"""Freeing a tensor that was never allocated should not raise."""
t = _make_tensor([1.0])
pool = MemoryPoolManager(pool_size=64, device=torch.device("cpu"))
# Should not raise.
pool.free_tensors([t])
def test_free_multiple_tensors(self):
t1 = _make_tensor([1.0, 2.0])
t2 = _make_tensor([3.0, 4.0])
pool = MemoryPoolManager(pool_size=64, device=torch.device("cpu"))
pool.allocate_for_tensors([t1])
pool.allocate_for_tensors([t2])
pool.free_tensors([t1, t2])
assert not pool.has_block(t1)
assert not pool.has_block(t2)
def test_free_then_cross_call_reuse_is_broken(self):
"""After freeing, the same tensor should NOT get a cache hit — it
should allocate a fresh block."""
t = _make_tensor([1.0, 2.0])
pool = MemoryPoolManager(pool_size=64, device=torch.device("cpu"))
pool.allocate_for_tensors([t])
pool.free_tensors([t])
assert not pool.has_block(t)
# Re-allocate — should work (fresh allocation, not cache hit).
views = pool.allocate_for_tensors([t])
assert torch.equal(views[0], t)
assert pool.has_block(t)
def test_double_free_is_noop(self):
"""Freeing an already-freed tensor should not raise or corrupt state."""
t = _make_tensor([1.0, 2.0])
pool = MemoryPoolManager(pool_size=64, device=torch.device("cpu"))
pool.allocate_for_tensors([t])
pool.free_tensors([t])
# Second free — should be a no-op.
pool.free_tensors([t])
assert not pool.has_block(t)
# ---------------------------------------------------------------------------
# Block merging — allocation succeeds only after freed blocks are coalesced
# ---------------------------------------------------------------------------
class TestBlockMerging:
def test_allocation_requires_merged_free_space(self):
"""After freeing adjacent blocks, the merged space should be usable
for a single large allocation that wouldn't fit in either fragment."""
# Pool: 24 bytes, allocate three 8-byte tensors to fill it.
t1 = _make_tensor([1.0, 2.0]) # 8 bytes
t2 = _make_tensor([3.0, 4.0]) # 8 bytes
t3 = _make_tensor([5.0, 6.0]) # 8 bytes
pool = MemoryPoolManager(pool_size=24, device=torch.device("cpu"))
pool.allocate_for_tensors([t1, t2, t3])
t_big = _make_tensor([7.0, 8.0, 9.0, 10.0]) # 16 bytes
# Free only t1 — 8 bytes free, not enough for t_big (16 bytes).
pool.free_tensors([t1])
with pytest.raises(NixlOutOfMemoryError):
pool.allocate_for_tensors([t_big])
# Free t2 — now t1+t2 are adjacent and merged into 16 bytes free.
pool.free_tensors([t2])
views = pool.allocate_for_tensors([t_big])
assert torch.equal(views[0], t_big)
# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------
class TestEdgeCases:
def test_empty_tensor_list(self):
"""allocate_for_tensors with an empty list should return an empty list."""
pool = MemoryPoolManager(pool_size=64, device=torch.device("cpu"))
views = pool.allocate_for_tensors([])
assert views == []
def test_different_dtypes(self):
"""Tensors of different dtypes should each get their own block."""
t_f32 = torch.tensor([1.0], dtype=torch.float32)
t_f64 = torch.tensor([1.0], dtype=torch.float64)
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t_f32, t_f64])
assert views[0].dtype == torch.float32
assert views[1].dtype == torch.float64
assert torch.equal(views[0], t_f32)
assert torch.equal(views[1], t_f64)
def test_view_with_storage_offset(self):
"""A tensor view with non-zero storage offset should be correctly
mapped to the pool."""
base = _make_tensor([1.0, 2.0, 3.0, 4.0, 5.0])
view = base[2:4] # [3.0, 4.0], storage_offset = 2
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([view])
assert torch.equal(views[0], view)
assert views[0].shape == (2,)
def test_multidimensional_tensor_shape_preserved(self):
"""Multi-dimensional tensor shapes should be preserved in pool views."""
t = torch.tensor([[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]])
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
views = pool.allocate_for_tensors([t])
assert views[0].shape == (3, 2)
assert torch.equal(views[0], t)
def test_allocate_multiple_preserves_request_order(self):
"""_allocate_multiple should return blocks in the same order as the
input sizes, even though it allocates largest-first internally."""
pool = MemoryPoolManager(pool_size=1024, device=torch.device("cpu"))
# Sizes in non-sorted order.
sizes = [10, 50, 20, 40]
result = pool._allocate_multiple(sizes)
assert result is not None
# Each result block should match the requested size, in order.
for i, size in enumerate(sizes):
assert result[i].size == size
if __name__ == "__main__":
sys.exit(pytest.main(["-sv", __file__]))