Skip to content

Commit d67e486

Browse files
committed
comms rdma reg cache
1 parent ce506b0 commit d67e486

4 files changed

Lines changed: 174 additions & 5 deletions

File tree

tests/test_rdma_memory_cache.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Copyright (c) Meta Platforms, Inc. and affiliates.
2+
# All rights reserved.
3+
#
4+
# This source code is licensed under the BSD-style license found in the
5+
# LICENSE file in the root directory of this source tree.
6+
import pytest
7+
import torch
8+
9+
10+
class MockRdmaMemory:
11+
"""Lightweight mock that records the tensor it was created with."""
12+
13+
def __init__(self, tensor: torch.Tensor) -> None:
14+
self.data_ptr = tensor.data_ptr()
15+
self.nbytes = tensor.nbytes
16+
17+
18+
@pytest.fixture(autouse=True)
19+
def _patch_rdma_memory(monkeypatch):
20+
import torchstore.transport.torchcomms.cache as cache_mod
21+
22+
monkeypatch.setattr(cache_mod, "RdmaMemory", MockRdmaMemory)
23+
24+
25+
from torchstore.transport.torchcomms.cache import RdmaMemoryCache # noqa: E402
26+
27+
28+
class TestRdmaMemoryCacheHitMiss:
29+
def test_cache_hit_same_tensor(self):
30+
cache = RdmaMemoryCache()
31+
tensor = torch.randn(4, 4)
32+
33+
mem1 = cache.get_or_register(tensor)
34+
mem2 = cache.get_or_register(tensor)
35+
36+
assert mem1 is mem2
37+
38+
def test_cache_miss_different_tensors(self):
39+
cache = RdmaMemoryCache()
40+
t1 = torch.randn(4, 4)
41+
t2 = torch.randn(4, 4)
42+
43+
mem1 = cache.get_or_register(t1)
44+
mem2 = cache.get_or_register(t2)
45+
46+
assert mem1 is not mem2
47+
assert len(cache._cache) == 2
48+
49+
50+
class TestRdmaMemoryCacheClear:
51+
def test_clear(self):
52+
cache = RdmaMemoryCache()
53+
t1 = torch.randn(2, 2)
54+
t2 = torch.randn(3, 3)
55+
cache.get_or_register(t1)
56+
cache.get_or_register(t2)
57+
58+
assert len(cache._cache) == 2
59+
cache.clear()
60+
assert len(cache._cache) == 0
61+
assert len(cache._storage_refs) == 0
62+
63+
64+
class TestRdmaMemoryCacheWeakrefEviction:
65+
def test_evicts_when_tensor_is_deleted(self):
66+
cache = RdmaMemoryCache()
67+
t = torch.randn(4, 4)
68+
key = (t.data_ptr(), t.nbytes)
69+
70+
cache.get_or_register(t)
71+
assert key in cache._cache
72+
73+
del t
74+
# refcount GC fires the weakref callback synchronously
75+
assert key not in cache._cache
76+
assert key not in cache._storage_refs
77+
78+
def test_survives_when_view_still_alive(self):
79+
cache = RdmaMemoryCache()
80+
t = torch.randn(4, 4)
81+
view = t[0:2] # shares the same storage
82+
key = (t.data_ptr(), t.nbytes)
83+
84+
cache.get_or_register(t)
85+
del t
86+
# storage is still alive via 'view', so entry should persist
87+
assert key in cache._cache
88+
89+
del view
90+
# storage is now freed
91+
assert key not in cache._cache
92+
93+
def test_new_tensor_after_eviction(self):
94+
cache = RdmaMemoryCache()
95+
t1 = torch.randn(4, 4)
96+
mem1 = cache.get_or_register(t1)
97+
98+
del t1 # evicts
99+
100+
t2 = torch.randn(4, 4)
101+
mem2 = cache.get_or_register(t2)
102+
103+
# should be a fresh registration, not the old one
104+
assert mem2 is not mem1
105+
assert len(cache._cache) == 1
106+
107+
108+
if __name__ == "__main__":
109+
pytest.main([__file__, "-v"])

torchstore/storage_volume.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,3 +390,4 @@ async def delete(self, key: str) -> None:
390390

391391
def reset(self) -> None:
392392
self.kv = {}
393+
self.transport_context.clear()

torchstore/transport/torchcomms/buffer.py

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
# This source code is licensed under the BSD-style license found in the
55
# LICENSE file in the root directory of this source tree.
66

7+
import os
78
from dataclasses import dataclass
89
from typing import Any, TYPE_CHECKING
910

1011
import torch
1112

1213
from torchstore.transport.buffers import TransportBuffer
13-
from torchstore.transport.torchcomms.cache import RdmaTransportCache
14+
from torchstore.transport.torchcomms.cache import RdmaMemoryCache, RdmaTransportCache
1415
from torchstore.transport.types import Request
1516

1617
try:
@@ -23,6 +24,13 @@
2324
from torchstore.transport.buffers import TransportContext
2425

2526

27+
# I'd like caching of user owned pinned memory to be toggleable.
28+
# yes, I understand we're introduce many "magic" vars. A follow up will
29+
# be to provide a clear TorchStrategy level config for these switches
30+
def _client_rdma_cache_enabled() -> bool:
31+
return os.environ.get("TORCHSTORE_CLIENT_RDMA_CACHE", "1") == "1"
32+
33+
2634
@dataclass
2735
class RdmaContext:
2836
"""Per-entry state for TorchComms RDMA batch operations."""
@@ -128,7 +136,11 @@ def __getstate__(self) -> dict[str, Any]:
128136

129137
def _allocate_ctx(self, tensor: torch.Tensor) -> RdmaContext:
130138
self._assert_valid_tensor(tensor, tensor.dtype, tensor.shape)
131-
rdma_memory = RdmaMemory(tensor)
139+
if _client_rdma_cache_enabled():
140+
cache = self.storage_volume_ref.transport_context.get(RdmaMemoryCache)
141+
rdma_memory = cache.get_or_register(tensor)
142+
else:
143+
rdma_memory = RdmaMemory(tensor)
132144
return RdmaContext(
133145
rdma_memory=rdma_memory,
134146
rdma_remote_buffer=rdma_memory.to_remote_buffer(),
@@ -185,6 +197,8 @@ async def handle_put_request(
185197
entries: list[tuple[Request, Any]],
186198
) -> list[Any]:
187199
"""Called by storage volume. Read from client's source RdmaMemory (put)."""
200+
rdma_mem_cache = ctx.get(RdmaMemoryCache)
201+
188202
results = []
189203
for entry, rdma_ctx in zip(entries, self._contexts, strict=True):
190204
_, maybe_tensor = entry
@@ -206,7 +220,7 @@ async def handle_put_request(
206220
self._assert_valid_tensor(maybe_tensor, rdma_ctx.dtype, rdma_ctx.shape)
207221

208222
# TODO: replace sequential reads with true batch RDMA operations (coming to torchcomms)
209-
receiving_buffer = RdmaMemory(maybe_tensor)
223+
receiving_buffer = rdma_mem_cache.get_or_register(maybe_tensor)
210224
res = transport.read(
211225
receiving_buffer.to_mutable_view(), rdma_ctx.rdma_remote_buffer
212226
)
@@ -226,6 +240,8 @@ async def handle_get_request(
226240
Note: SV determination of is_object is authoritative and mutates _contexts
227241
sent back to the client.
228242
"""
243+
rdma_mem_cache = ctx.get(RdmaMemoryCache)
244+
229245
for entry, rdma_ctx in zip(entries, self._contexts, strict=True):
230246
_, data = entry
231247
if not isinstance(data, torch.Tensor):
@@ -243,6 +259,11 @@ async def handle_get_request(
243259
)
244260
contiguous_buffer.copy_(tensor)
245261
tensor = contiguous_buffer
262+
# staging copy is lost, don't cache it
263+
rdma_memory = RdmaMemory(tensor)
264+
else:
265+
# stable tensor from SV, cache the registration
266+
rdma_memory = rdma_mem_cache.get_or_register(tensor)
246267

247268
transport = self._get_sv_transport(ctx, rdma_ctx.device_index)
248269

@@ -252,7 +273,6 @@ async def handle_get_request(
252273
)
253274
self._assert_valid_tensor(tensor, rdma_ctx.dtype, rdma_ctx.shape)
254275
# TODO: replace sequential writes with true batch RDMA operations (coming to torchcomms)
255-
rdma_memory = RdmaMemory(tensor)
256276

257277
res = transport.write(rdma_memory.to_view(), rdma_ctx.rdma_remote_buffer)
258278
if res != 0:

torchstore/transport/torchcomms/cache.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,15 @@
55
# LICENSE file in the root directory of this source tree.
66

77
import logging
8+
import weakref
89
from functools import cache
910

1011
import torch
1112

1213
from torchstore.transport.buffers import TransportCache
1314

1415
try:
15-
from torchcomms._transport import RdmaTransport
16+
from torchcomms._transport import RdmaMemory, RdmaTransport
1617

1718
torchcomms_available = True
1819
except ImportError:
@@ -82,3 +83,41 @@ def contains(self, key: str, device: torch.device | int) -> bool:
8283

8384
def clear(self) -> None:
8485
self.transports.clear()
86+
87+
88+
class RdmaMemoryCache(TransportCache):
89+
"""Cache for RDMA memory registrations.
90+
91+
Keyed on (data_ptr, nbytes). Avoids repeated ibv_reg_mr kernel calls for stable tensors.
92+
93+
A weakref on each tensor's ``untyped_storage()`` auto-evicts the entry when
94+
the underlying memory is released (i.e. no more live tensors on the process that reference this
95+
storage). This makes the cache safe for both server-side (stable kv tensors)
96+
and client-side (user-owned, transient) use.
97+
"""
98+
99+
def __init__(self) -> None:
100+
# {(data_ptr, nbytes): RdmaMemory}
101+
self._cache: dict[tuple[int, int], "RdmaMemory"] = {}
102+
# parallel dict keeping the weakref alive so the callback can fire
103+
self._storage_refs: dict[tuple[int, int], weakref.ref] = {}
104+
105+
def get_or_register(self, tensor: torch.Tensor) -> "RdmaMemory":
106+
key = (tensor.data_ptr(), tensor.nbytes)
107+
if key in self._cache:
108+
return self._cache[key]
109+
110+
mem = RdmaMemory(tensor)
111+
self._cache[key] = mem
112+
self._storage_refs[key] = weakref.ref(
113+
tensor.untyped_storage(), lambda _ref, _k=key: self._evict(_k)
114+
)
115+
return mem
116+
117+
def _evict(self, key: tuple[int, int]) -> None:
118+
self._cache.pop(key, None)
119+
self._storage_refs.pop(key, None)
120+
121+
def clear(self) -> None:
122+
self._cache.clear()
123+
self._storage_refs.clear()

0 commit comments

Comments
 (0)