Files
2026-07-13 12:24:33 +08:00

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()