324 lines
12 KiB
Python
324 lines
12 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Unit tests for the GDS cuFile context (``GDSContext``).
|
|
|
|
Most tests are pure (no cuFile): they exercise the public interface
|
|
(singleton/no-op semantics, the <=16 MiB region split observed at the ``ca``
|
|
cuFile seam, and the registered-region mapping driven through
|
|
:meth:`GDSContext.transfer_async`). The ``test_gds_*_roundtrip`` tests
|
|
exercise the real GDS DMA path (cuFile on NVIDIA, hipFile on AMD ROCm) and are
|
|
skipped unless that stack is present (see :func:`_gds_available`).
|
|
"""
|
|
|
|
# Standard
|
|
from types import SimpleNamespace
|
|
import os
|
|
|
|
# Third Party
|
|
import pytest
|
|
import torch
|
|
|
|
# First Party
|
|
from lmcache import torch_dev
|
|
from lmcache.v1.distributed.api import MemoryLayoutDesc
|
|
from lmcache.v1.distributed.config import GdsL1Config
|
|
from lmcache.v1.distributed.error import L1Error
|
|
from lmcache.v1.distributed.memory_manager import GDSL1MemoryManager
|
|
from lmcache.v1.gpu_connector import _gds_async as ca
|
|
from lmcache.v1.gpu_connector.gds_context import (
|
|
GDSContext,
|
|
SlabDirection,
|
|
get_gds_context,
|
|
initialize_gds_context,
|
|
)
|
|
|
|
|
|
def _fake_stream(handle: int):
|
|
"""A stand-in for ``torch_dev.current_stream()`` (no CUDA needed)."""
|
|
return SimpleNamespace(cuda_stream=handle, synchronize=lambda: None)
|
|
|
|
|
|
def _gds_available() -> bool:
|
|
"""Whether a real GPUDirect Storage stack is present for the roundtrip tests.
|
|
|
|
NVIDIA: CUDA plus the nvidia-fs kernel module (``/proc/driver/nvidia-fs``).
|
|
AMD ROCm: a loadable ``libhipfile.so`` (the hipFile GPU IO library). When
|
|
the GDS-capable driver/filesystem is absent hipFile still round-trips
|
|
correctly via its host-bounce fallback, so library loadability is a
|
|
sufficient gate for the correctness checks below.
|
|
"""
|
|
if not torch.cuda.is_available():
|
|
return False
|
|
if torch.version.hip is not None:
|
|
# Standard
|
|
import ctypes
|
|
|
|
try:
|
|
ctypes.CDLL("libhipfile.so")
|
|
except OSError:
|
|
return False
|
|
return True
|
|
return os.path.exists("/proc/driver/nvidia-fs/stats")
|
|
|
|
|
|
requires_gds = pytest.mark.skipif(
|
|
not _gds_available(),
|
|
reason="needs CUDA + nvidia-fs or ROCm + libhipfile.so (real GPUDirect Storage)",
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_singleton():
|
|
"""Drop the process-global GDSContext between tests."""
|
|
get_gds_context.cache_clear()
|
|
yield
|
|
get_gds_context.cache_clear()
|
|
|
|
|
|
class TestSingleton:
|
|
def test_singleton_identity(self):
|
|
assert get_gds_context() is get_gds_context()
|
|
|
|
def test_fresh_context_is_off(self):
|
|
assert GDSContext().initialized is False
|
|
|
|
def test_initialize_with_none_is_noop(self):
|
|
ctx = initialize_gds_context(None)
|
|
assert ctx is get_gds_context()
|
|
assert ctx.initialized is False
|
|
|
|
|
|
class TestRegisterGpuBuffer:
|
|
def test_noop_when_uninitialized(self, monkeypatch):
|
|
ctx = GDSContext()
|
|
registered = []
|
|
monkeypatch.setattr(ca, "register_buffer", registered.append)
|
|
# GDS off -> registers nothing, makes no cuFile calls.
|
|
ctx.register_gpu_buffer(torch.empty(4096, dtype=torch.uint8))
|
|
assert registered == []
|
|
|
|
def test_splits_buffer_into_regions(self, monkeypatch):
|
|
ctx = GDSContext()
|
|
ctx.initialized = True
|
|
# Record each cuFile registration's byte size at the ca seam.
|
|
sizes = []
|
|
monkeypatch.setattr(
|
|
ca,
|
|
"register_buffer",
|
|
lambda buf: sizes.append(buf.numel() * buf.element_size()),
|
|
)
|
|
monkeypatch.setattr(ca, "register_stream", lambda raw: None)
|
|
monkeypatch.setattr(torch_dev, "current_stream", lambda: _fake_stream(0))
|
|
|
|
# The whole buffer is registered in <=16 MiB regions, irrespective of
|
|
# any chunk/slot layout. A 40 MiB buffer -> 16 + 16 + 8 MiB.
|
|
# A CPU tensor is fine: the cuFile calls are mocked.
|
|
buf = torch.empty(40 << 20, dtype=torch.uint8)
|
|
ctx.register_gpu_buffer(buf)
|
|
|
|
assert sizes == [16 << 20, 16 << 20, 8 << 20]
|
|
|
|
|
|
class TestResolveBuffer:
|
|
"""Region mapping: a buffer slice resolves to ``(region base, offset)``,
|
|
exercised through the public ``transfer_async`` path."""
|
|
|
|
def _registered_ctx(self, monkeypatch, buf: torch.Tensor):
|
|
"""Register ``buf``; capture the ``(base, offset)`` that
|
|
``transfer_async`` resolves a slice to before handing it to the slab."""
|
|
ctx = GDSContext()
|
|
ctx.initialized = True
|
|
monkeypatch.setattr(ca, "register_buffer", lambda b: None)
|
|
monkeypatch.setattr(ca, "register_stream", lambda raw: None)
|
|
monkeypatch.setattr(torch_dev, "current_stream", lambda: _fake_stream(0))
|
|
ctx.register_gpu_buffer(buf)
|
|
resolved: list[tuple[int, int]] = []
|
|
monkeypatch.setattr(
|
|
ctx,
|
|
"_slab_write",
|
|
lambda slab_offset, size, dev_offset, buf_base: resolved.append(
|
|
(buf_base, dev_offset)
|
|
),
|
|
)
|
|
return ctx, resolved
|
|
|
|
def test_maps_slice_to_base_and_offset(self, monkeypatch):
|
|
buf = torch.empty(8192, dtype=torch.uint8)
|
|
ctx, resolved = self._registered_ctx(monkeypatch, buf)
|
|
mem_obj = SimpleNamespace(get_size=lambda: 4096, slab_offset=0)
|
|
# A slice 4 KiB into the region must map to (region base, offset 4096).
|
|
ctx.transfer_async(mem_obj, buf[4096:], SlabDirection.WRITE)
|
|
assert resolved == [(buf.data_ptr(), 4096)]
|
|
|
|
|
|
class TestPerStreamRegistration:
|
|
"""Each distinct stream is cuFile-registered once and deregistered once its
|
|
last region is gone -- observed at the ``ca`` seam (no private state)."""
|
|
|
|
def test_register_and_deregister_per_stream(self, monkeypatch):
|
|
ctx = GDSContext()
|
|
ctx.initialized = True
|
|
reg_str: list[int] = []
|
|
dereg_str: list[int] = []
|
|
dereg_buf: list[int] = []
|
|
monkeypatch.setattr(ca, "register_buffer", lambda b: None)
|
|
monkeypatch.setattr(
|
|
ca, "deregister_buffer", lambda b: dereg_buf.append(b.data_ptr())
|
|
)
|
|
monkeypatch.setattr(ca, "register_stream", reg_str.append)
|
|
monkeypatch.setattr(ca, "deregister_stream", dereg_str.append)
|
|
|
|
def use_stream(handle: int):
|
|
monkeypatch.setattr(
|
|
torch_dev, "current_stream", lambda: _fake_stream(handle)
|
|
)
|
|
|
|
buf_a = torch.empty(24 << 20, dtype=torch.uint8) # 2 regions on stream 11
|
|
buf_b = torch.empty(4096, dtype=torch.uint8) # 1 region on stream 22
|
|
use_stream(11)
|
|
ctx.register_gpu_buffer(buf_a)
|
|
use_stream(22)
|
|
ctx.register_gpu_buffer(buf_b)
|
|
# Each distinct stream registered exactly once.
|
|
assert reg_str == [11, 22]
|
|
|
|
# Deregistering buf_b frees stream 22's only region -> stream 22 dropped.
|
|
use_stream(22)
|
|
ctx.deregister_gpu_buffer(buf_b)
|
|
assert dereg_str == [22]
|
|
# Stream 11 still has 2 regions (24 MiB -> 16 + 8), so not yet dropped.
|
|
use_stream(11)
|
|
ctx.deregister_gpu_buffer(buf_a)
|
|
assert dereg_str == [22, 11]
|
|
assert len(dereg_buf) == 3 # all three slots deregistered
|
|
|
|
|
|
@requires_gds
|
|
def test_gds_two_stream_write_read(tmp_path):
|
|
"""Two CUDA streams each register their own buffer and round-trip a chunk
|
|
through real cuFile DMA; verify the data stays isolated per stream."""
|
|
cfg = GdsL1Config(file_location=str(tmp_path), size_in_bytes=64 << 20)
|
|
chunk_bytes = 8 << 20
|
|
ctx = GDSContext()
|
|
ctx.initialize(cfg)
|
|
mgr = GDSL1MemoryManager(cfg)
|
|
|
|
def register_and_write(stream, pattern):
|
|
"""Register a buffer on ``stream`` and write ``pattern`` to a chunk."""
|
|
with torch.cuda.stream(stream):
|
|
buf = torch.empty(chunk_bytes, dtype=torch.uint8, device="cuda")
|
|
ctx.register_gpu_buffer(buf)
|
|
err, objs = mgr.allocate(
|
|
MemoryLayoutDesc(
|
|
shapes=[torch.Size([chunk_bytes])], dtypes=[torch.uint8]
|
|
),
|
|
1,
|
|
)
|
|
assert err == L1Error.SUCCESS
|
|
buf.fill_(pattern)
|
|
torch.cuda.synchronize()
|
|
ctx.transfer_async(objs[0], buf, SlabDirection.WRITE)
|
|
torch.cuda.synchronize()
|
|
return buf, objs[0]
|
|
|
|
stream_a = torch.cuda.Stream()
|
|
stream_b = torch.cuda.Stream()
|
|
try:
|
|
buf_a, mem_a = register_and_write(stream_a, 0xA1)
|
|
buf_b, mem_b = register_and_write(stream_b, 0xB2)
|
|
|
|
# Read each chunk back on its own stream; each must see its own pattern,
|
|
# confirming the two streams' buffers/regions don't clobber each other.
|
|
for stream, buf, mem, pattern in (
|
|
(stream_a, buf_a, mem_a, 0xA1),
|
|
(stream_b, buf_b, mem_b, 0xB2),
|
|
):
|
|
with torch.cuda.stream(stream):
|
|
buf.zero_()
|
|
torch.cuda.synchronize()
|
|
ctx.transfer_async(mem, buf, SlabDirection.READ)
|
|
torch.cuda.synchronize()
|
|
expected = torch.full((chunk_bytes,), pattern, dtype=torch.uint8)
|
|
assert torch.equal(buf.cpu(), expected)
|
|
|
|
# Deregister each buffer on its own stream.
|
|
for stream, buf in ((stream_a, buf_a), (stream_b, buf_b)):
|
|
with torch.cuda.stream(stream):
|
|
ctx.deregister_gpu_buffer(buf)
|
|
finally:
|
|
ctx.close()
|
|
|
|
|
|
@requires_gds
|
|
def test_gds_write_read_roundtrip(tmp_path):
|
|
"""Cold write then read of a chunk through the real cuFile DMA path."""
|
|
cfg = GdsL1Config(file_location=str(tmp_path), size_in_bytes=64 << 20)
|
|
ctx = GDSContext()
|
|
ctx.initialize(cfg)
|
|
try:
|
|
chunk_bytes = 8 << 20
|
|
buf = torch.empty(chunk_bytes, dtype=torch.uint8, device="cuda")
|
|
ctx.register_gpu_buffer(buf)
|
|
|
|
mgr = GDSL1MemoryManager(cfg)
|
|
err, objs = mgr.allocate(
|
|
MemoryLayoutDesc(shapes=[torch.Size([chunk_bytes])], dtypes=[torch.uint8]),
|
|
1,
|
|
)
|
|
assert err == L1Error.SUCCESS
|
|
mem_obj = objs[0]
|
|
|
|
buf.fill_(0xAB)
|
|
torch.cuda.synchronize()
|
|
ctx.transfer_async(mem_obj, buf, SlabDirection.WRITE)
|
|
|
|
buf.zero_()
|
|
torch.cuda.synchronize()
|
|
ctx.transfer_async(mem_obj, buf, SlabDirection.READ)
|
|
torch.cuda.synchronize()
|
|
|
|
expected = torch.full((chunk_bytes,), 0xAB, dtype=torch.uint8)
|
|
assert torch.equal(buf.cpu(), expected)
|
|
finally:
|
|
ctx.close()
|
|
|
|
|
|
@requires_gds
|
|
def test_gds_chunk_larger_than_region_roundtrip(tmp_path):
|
|
"""A chunk larger than the 16 MiB cuFile region cap round-trips correctly.
|
|
|
|
Exercises the multi-region registration and the split (per-segment) DMA
|
|
path: a 24 MiB chunk is registered/transferred as a 16 MiB + 8 MiB pair.
|
|
"""
|
|
cfg = GdsL1Config(file_location=str(tmp_path), size_in_bytes=64 << 20)
|
|
ctx = GDSContext()
|
|
ctx.initialize(cfg)
|
|
try:
|
|
chunk_bytes = 24 << 20 # > 16 MiB -> two registered regions / two DMAs
|
|
buf = torch.empty(chunk_bytes, dtype=torch.uint8, device="cuda")
|
|
ctx.register_gpu_buffer(buf)
|
|
|
|
mgr = GDSL1MemoryManager(cfg)
|
|
err, objs = mgr.allocate(
|
|
MemoryLayoutDesc(shapes=[torch.Size([chunk_bytes])], dtypes=[torch.uint8]),
|
|
1,
|
|
)
|
|
assert err == L1Error.SUCCESS
|
|
mem_obj = objs[0]
|
|
|
|
# Position-dependent pattern: a mis-offset or swapped segment (e.g. the
|
|
# second segment using the wrong slab offset) would corrupt the bytes
|
|
# around the 16 MiB boundary, which a uniform fill would not catch.
|
|
pattern = (torch.arange(chunk_bytes, dtype=torch.int64) % 251).to(torch.uint8)
|
|
buf.copy_(pattern.cuda())
|
|
torch.cuda.synchronize()
|
|
ctx.transfer_async(mem_obj, buf, SlabDirection.WRITE)
|
|
|
|
buf.zero_()
|
|
torch.cuda.synchronize()
|
|
ctx.transfer_async(mem_obj, buf, SlabDirection.READ)
|
|
torch.cuda.synchronize()
|
|
|
|
assert torch.equal(buf.cpu(), pattern)
|
|
finally:
|
|
ctx.close()
|