Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions tests/test_mooncake_client_lease.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
# Copyright 2025 The TransferQueue Team
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Receive-buffer leasing on the MooncakeStore tensor read path.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about rename the file as test_mooncake_buffer_pool.py?


The store and the lease pool are faked, so these tests need neither mooncake nor RDMA.
"""

import ctypes
import sys

import pytest
import torch

from transfer_queue.storage.clients import mooncake_client as mcc

DTYPES = [torch.float32, torch.int64, torch.float32, torch.int16]
SHAPES = [(4, 3), (5,), (), (2, 8)]
KEYS = ["k0", "k1", "k2", "k3"]


def expected_tensors():
"""Deterministic payloads, one per key in KEYS."""
out = []
for seed, (dtype, shape) in enumerate(zip(DTYPES, SHAPES, strict=True)):
numel = torch.empty(shape).numel()
values = torch.arange(seed * 100, seed * 100 + numel)
out.append(values.to(dtype).reshape(shape))
return out


class FakeStore:
"""Writes the stored payload into whatever pointer batch_get_into is given."""

def __init__(self):
self.objects = {
key: bytes(t.contiguous().numpy().tobytes()) for key, t in zip(KEYS, expected_tensors(), strict=True)
}
self.registered: list[tuple[int, int]] = []
self.unregistered: list[int] = []

def setup(self, *args):
return 0

def register_buffer(self, ptr, size):
self.registered.append((ptr, size))
return 0

def unregister_buffer(self, ptr):
self.unregistered.append(ptr)
return 0

def batch_get_into(self, keys, ptrs, sizes):
for key, ptr, size in zip(keys, ptrs, sizes, strict=True):
ctypes.memmove(ptr, self.objects[key], size)
return list(sizes)


class FakeLease:
def __init__(self, pool, nbytes):
self._pool = pool
self._memory = torch.empty(nbytes, dtype=torch.uint8)
self.ptr = self._memory.data_ptr()
# A numpy array (not its .data memoryview): torch.frombuffer keeps a reference
# to it, so a staged view left alive at release() shows up as an extra refcount.
self.buffer = self._memory.numpy()
self._baseline_refs = sys.getrefcount(self.buffer)

def release(self):
# Mirror mooncake: the pool refuses to return a lease while a view of its
# buffer is still alive (a live torch.frombuffer tensor holds a reference).
if sys.getrefcount(self.buffer) > self._baseline_refs:
raise RuntimeError("cannot release buffer while exported views exist")
self._pool.released += 1


class FakePool:
"""Serves leases up to ``capacity`` bytes; larger requests cannot be served."""

def __init__(self, capacity=1 << 30):
self.capacity = capacity
self.acquired: list[int] = []
self.released = 0

def acquire(self, nbytes, block=True):
if nbytes > self.capacity:
return None
self.acquired.append(nbytes)
return FakeLease(self, nbytes)


@pytest.fixture
def store(monkeypatch):
fake = FakeStore()
monkeypatch.setattr(mcc, "MOONCAKE_STORE_IMPORTED", True)
# raising=False: these symbols are absent unless mooncake is installed.
monkeypatch.setattr(mcc, "MooncakeDistributedStore", lambda: fake, raising=False)
monkeypatch.setattr(mcc, "ReplicateConfig", type("ReplicateConfig", (), {}), raising=False)
return fake


def make_client(local_buffer_size=1 << 30):
return mcc.MooncakeStoreClient(
{
"local_hostname": "127.0.0.1",
"metadata_server": "127.0.0.1:8080",
"master_server_address": "127.0.0.1:8081",
"local_buffer_size": local_buffer_size,
}
)


def read_all(client):
tensors, indexes = client._get_tensors_thread_worker(KEYS, SHAPES, DTYPES, list(range(len(KEYS))))
assert indexes == list(range(len(KEYS)))
return tensors


def assert_payloads(tensors):
for got, want in zip(tensors, expected_tensors(), strict=True):
assert got.dtype == want.dtype
assert got.shape == want.shape
assert torch.equal(got, want)


def install_pool(monkeypatch, capacity=1 << 30):
"""Make the client believe mooncake provides a lease pool, and hand it a fake one."""
fake = FakePool(capacity)
monkeypatch.setattr(mcc, "MOONCAKE_BUFFER_POOL_IMPORTED", True)
monkeypatch.setattr(mcc, "BufferPool", lambda _store, max_bytes: fake, raising=False)
return fake


def test_reads_land_in_leased_buffer(store, monkeypatch):
pool = install_pool(monkeypatch)

tensors = read_all(make_client())

assert_payloads(tensors)
# The whole point: no registration on the data path, and no lease left behind.
assert store.registered == []
assert len(pool.acquired) == 1 and pool.released == 1


def test_batch_larger_than_lease_share_is_read_in_rounds(store, monkeypatch):
pool = install_pool(monkeypatch)

# Small local buffer: each thread's share holds only part of the batch.
client = make_client(local_buffer_size=256 * mcc.MAX_BATCH_WORKER_THREADS)
tensors = read_all(client)

assert_payloads(tensors)
assert len(pool.acquired) > 1
assert all(nbytes <= client._lease_bytes for nbytes in pool.acquired)
assert pool.released == len(pool.acquired)
assert store.registered == []


def test_falls_back_to_own_buffers_when_pool_cannot_serve(store, monkeypatch):
pool = install_pool(monkeypatch, capacity=0)

tensors = read_all(make_client())

assert_payloads(tensors)
assert pool.acquired == []
assert store.registered and len(store.unregistered) == len(store.registered)


def test_registers_receive_regions_without_lease_support(store, monkeypatch):
monkeypatch.setattr(mcc, "MOONCAKE_BUFFER_POOL_IMPORTED", False)

tensors = read_all(make_client())

assert_payloads(tensors)
assert store.registered and len(store.unregistered) == len(store.registered)


def test_uniform_group_copied_in_one_strided_pass(store, monkeypatch):
# Many identical small tensors (the fragmented-read case) take the single strided
# copy-out path instead of a per-tensor loop; the payloads must still round-trip.
pool = install_pool(monkeypatch)
n = 8
dtypes = [torch.float32] * n
shapes = [(16,)] * n
keys = [f"u{i}" for i in range(n)]
payloads = [torch.arange(i, i + 16, dtype=torch.float32) for i in range(n)]
store.objects = {k: bytes(t.numpy().tobytes()) for k, t in zip(keys, payloads, strict=True)}

tensors, indexes = make_client()._get_tensors_thread_worker(keys, shapes, dtypes, list(range(n)))

assert indexes == list(range(n))
for got, want in zip(tensors, payloads, strict=True):
assert torch.equal(got, want)
assert pool.acquired and pool.released == len(pool.acquired)
assert store.registered == []
129 changes: 127 additions & 2 deletions transfer_queue/storage/clients/mooncake_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,62 @@
except ImportError:
MOONCAKE_STORE_IMPORTED = False

MOONCAKE_BUFFER_POOL_IMPORTED: bool = True
try:
from mooncake.store import BufferPool
except ImportError:
# Older mooncake builds have no lease API; those fall back to registering per transfer.
MOONCAKE_BUFFER_POOL_IMPORTED = False

BATCH_SIZE_LIMIT: int = 400
MAX_BATCH_WORKER_THREADS = 4
MAX_SERIAL_WORKER_THREADS = 4
MAX_RETRIES = 3
RETRY_DELAY_SECONDS = 1.0


def _copy_lease_into_tensors(lease, targets: list[Tensor], offsets: list[int]) -> None:

@0oshowero0 0oshowero0 Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the name lease may confuse the readers. Maybe we can call it _copy_buffer_content_into_tensors?

"""Copy each staged region out of the lease buffer into the caller's tensors.

A uniform, tightly-packed group (same dtype+shape, laid out contiguously in the
target region) is copied in a single strided pass. This is the many-small-key
case, where a per-tensor Python loop otherwise dominates the read; ragged groups
fall back to a per-tensor copy. Every frombuffer view is dropped before returning:
the pool refuses to release a lease while an exported view of its buffer is alive.
"""
t0 = targets[0]
numel = t0.numel()
uniform = len(targets) > 1
if uniform:
base_off = t0.storage_offset()
base_ptr = t0.untyped_storage().data_ptr()
for j, t in enumerate(targets):
if (
t.dtype != t0.dtype
or t.shape != t0.shape
or not t.is_contiguous()
or t.storage_offset() != base_off + j * numel
or t.untyped_storage().data_ptr() != base_ptr
):
uniform = False
break

if uniform:
k = len(targets)
stride_elems = (offsets[1] - offsets[0]) // t0.element_size()
src = torch.frombuffer(
lease.buffer, dtype=t0.dtype, count=(k - 1) * stride_elems + numel
).as_strided((k, numel), (stride_elems, 1))
t0.as_strided((k, numel), (numel, 1), t0.storage_offset()).copy_(src)
del src
return

for target, off in zip(targets, offsets, strict=True):
staged = torch.frombuffer(lease.buffer, dtype=target.dtype, count=target.numel(), offset=off)
target.copy_(staged.view(target.shape))
del staged


@StorageClientFactory.register("MooncakeStoreClient")
class MooncakeStoreClient(StorageKVClient):
"""
Expand Down Expand Up @@ -134,6 +183,21 @@ def __init__(self, config: dict[str, Any]):
if ret != 0:
raise RuntimeError(f"Mooncake store setup failed with error code: {ret}")

# RDMA can only target registered (pinned) memory, and register_buffer is a kernel
# operation that costs far more than the transfer it enables. Lease receive buffers
# from the local buffer that setup() already registered instead of registering per
# transfer. See https://github.com/Ascend/TransferQueue/issues/169
# max_bytes=0: lease from that local buffer only, without an extra arena.
Comment on lines +186 to +190

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can simplify the AI comments

self._buffer_pool = BufferPool(self._store, max_bytes=0) if MOONCAKE_BUFFER_POOL_IMPORTED else None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

【AI Review】

1. max_bytes=0 does the opposite of what the comment claims

# max_bytes=0: lease from that local buffer only, without an extra arena.
self._buffer_pool = BufferPool(self._store, max_bytes=0) if MOONCAKE_BUFFER_POOL_IMPORTED else None

In mooncake-integration/store/buffer_pool.cpp, max_bytes == 0 is the constructor default and it
sets the budget to twice the local buffer:

if (max_bytes == 0) {
    max_bytes_ = local_buffer_capacity_ * 2;
} else {
    max_bytes_ = std::max(max_bytes, local_buffer_capacity_);
}

The upstream docs describe this as "the default allows one local-buffer-sized overflow burst", and
the overflow path is allocate_overflow_unlocked(): posix_memalign plus one
store.register_buffer() per lease, with unregister_buffer() on release. That is precisely the
per-transfer registration this PR exists to remove, now hidden inside mooncake. Consequences:

  • The optimization can silently no-op. When a lease can't be served from the local buffer you get
    an overflow buffer, i.e. pre-PR performance, with no error.
  • It is not observable from Python. The pool tracks allocate_count_ / oversize_allocate_count_
    but the pybind bindings only expose acquire, buffer, prewarm and close, so there's no way
    to detect the degradation from TransferQueue.
  • Up to another local_buffer_size (1 GiB by default) of unbudgeted host memory.
    This is pitfall 1 from your own issue [optim] MooncakeStore read path registers RDMA buffers on every get(), dominating end-to-end time #169 ("silent fallback on capacity shortfall"). Also note that
    because exhaustion now requires more than 2× capacity in flight, the _acquire_lease() -> None
    fallback you wrote essentially never fires.
    Suggestion: pass max_bytes=self.local_buffer_size so the in-flight budget is bounded by the
    registered region, and fix the comment. (To be precise: this bounds overflow rather than
    eliminating it — allocate_overflow_unlocked is still reachable when the local allocator fails on
    fragmentation while total_bytes_ < max_bytes_ — but it stops a whole extra arena from being
    absorbed silently.)

if self._buffer_pool is None:
logger.warning(
"mooncake.store.BufferPool is unavailable, so every tensor read registers and "
"unregisters its own receive buffer. Upgrade mooncake-transfer-engine to lease "
Comment on lines +194 to +195

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's better to tell the user which version supports this feature.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For GDR and TCP path, we can omit this warnning

"pre-registered buffers instead."
)
# One share per reader thread, so all of them can hold a lease at the same time.
self._lease_bytes = self.local_buffer_size // MAX_BATCH_WORKER_THREADS

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

【AI Review】

2. The lease share targets 100% of a buffer mooncake also uses internally

# One share per reader thread, so all of them can hold a lease at the same time.
self._lease_bytes = self.local_buffer_size // MAX_BATCH_WORKER_THREADS

MAX_BATCH_WORKER_THREADS shares of local_buffer_size / MAX_BATCH_WORKER_THREADS is the entire
buffer. The arithmetic base is right — BufferPool reads local_buffer_capacity_ from
client_buffer_allocator_->size(), which is exactly the local_buffer_size passed to setup()
but full utilization assumes exclusive ownership and lossless sub-allocation, and neither holds:

  • Not exclusive. mooncake-store/src/real_client.cpp registers this region once and also
    records it as local_buffer_region_ for internal Store staging. The design doc calls it a "soft
    isolation policy: internal Store paths and external Python leases share the local registered
    buffer".
  • Hard boundary, no slack. The allocator is OffsetAllocator. Upstream's own
    client_buffer_test.cpp shows a 64 KB buffer with 8 KB requests succeeding exactly 8 times and
    failing on the 9th, and getLargestFreeRegion() is documented as best-effort because "the actual
    allocation may still fail due to race conditions or fragmentation".
  • Requests round up to a bin. OffsetAllocator bins with a 3-bit mantissa (documented worst
    case +12.5%), and the mooncake wrapper notes it rounds the allocated size up to a bin size.
    Replicating uintToFloatRoundUp, a 250 MiB batch consumes 256 MiB, a 225 MiB batch consumes
    240 MiB. So the usable share is slightly smaller than the nominal one.
    With defaults, four 256 MiB shares sum to exactly 1024 MiB — a perfect fit with zero margin (the
    share is a power of two, so bin rounding doesn't inflate it). Any internal staging use or
    fragmentation makes one thread's local allocation fail, which lands on issue 1's silent overflow.
    That is why these two compound: the 100% split makes local allocation failure the expected case,
    and max_bytes=0 makes that failure invisible.
    Suggestion: leave headroom (e.g. // (2 * MAX_BATCH_WORKER_THREADS)) and say in the comment
    that the share is coupled to the local_buffer_size config.
    While we're here, the coupling is worth documenting because it also changes the number of round
    trips. For a 400 × 1 MiB batch, which used to be a single batch_get_into:
    | local_buffer_size | share | sequential rounds |
    |---|---|---|
    | 64 MB | 16 MB | 25 |
    | 256 MB | 64 MB | 7 |
    | 1024 MB (default) | 256 MB | 2 |
    | 4096 MB | 1024 MB | 1 |
    The default is fine, but anyone who shrinks local_buffer_size gets 25 serialized rounds without
    changing anything else.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following up on point 2 of my earlier review (the lease share consuming 100% of the local buffer).
I described it as a performance concern; looking at what actually happens when the buffer fills up,
the consequence is worse than that, and I think it moves the sizing from "worth tuning" to "worth
fixing before merge".

Filling the buffer doesn't fail — and it doesn't reach your fallback

The pool's admission check doesn't look at the local buffer at all:

bool BufferPoolNative::has_capacity_for_locked(size_t size_class) const {
    if (size_class > max_bytes_) return false;
    if (total_bytes_ > max_bytes_ - reserved_bytes_) return false;
    ...

max_bytes_ is the pool's own budget (2× capacity with max_bytes=0), not the allocator's
remaining space. So once the local buffer is full, acquire() still admits the request,
client_buffer_allocator_->allocate() returns nullopt, and try_acquire_locked falls through to
allocate_overflow_unlocked(). Nothing is raised, so _acquire_lease() never returns None and
the register-per-read fallback you wrote is never reached. The degradation happens entirely inside
mooncake.

The overflow path is slower than the pre-PR baseline

This is the part I'd flag hardest, because the intuition is "worst case we're back where we
started", and that isn't what happens:

before this PR overflow lease
receive memory the caller's own torch.empty tensors posix_memalign, 8 MiB aligned, fresh pages
registration register_buffer on the merged target regions register_buffer on the overflow buffer
RDMA lands in the target tensors directly the overflow buffer
copy none one full copy-out
teardown unregister_buffer unregister_buffer + free back to the OS

The registration cost is unchanged — by your own analysis it's dominated by per-byte page pinning,
and the total bytes pinned are the same. On top of that you now pay a full copy-out (~11 ms per
GiB in my measurements), a large fresh allocation whose pages have to be zero-filled when they're
pinned, and a free that returns them to the OS so the next lease faults them in again.

So whenever overflow kicks in, this PR is a net regression rather than a no-op.

And it's not observable

No exception means the logger.warning in _acquire_lease never fires. The pool does count
allocate_count_ and oversize_allocate_count_, but the pybind bindings only export acquire,
buffer, prewarm and close, so there's no way to read them from Python. The symptom in
production is "the optimization doesn't seem to help" with nothing to grep for.

With offload.enabled: true this can become a hard read failure

This is the part I missed the first time, and it's the real argument for headroom.

Inside batch_get_into, any key served by a remote DISK replica needs a scratch allocation from
the same client local buffer that the leases come from
(RealClient::batch_get_into_multi_buffers):

for (auto &[key, op] : valid_local_disk_ops) {
    if (op.is_local_disk) continue;
    auto alloc_result = client_buffer_allocator_->allocate(op.total_size);
    if (!alloc_result) {
        LOG(ERROR) << "Failed to allocate temp buffer for DISK read, key: " << key ...
        results[op.original_index] = tl::make_unexpected(ErrorCode::NO_AVAILABLE_HANDLE);

That sets up a self-starvation:

  1. MAX_BATCH_WORKER_THREADS reader threads each hold a lease, together covering 100% of the local
    buffer.
  2. A key inside one of those same batch_get_into calls lives on SSD, so mooncake tries to
    allocate scratch space from that now-empty buffer.
  3. The allocation fails, the key comes back NO_AVAILABLE_HANDLE, i.e. a negative return code.
  4. _batch_get_into_with_retry retries 3 times with a 1 s delay — but the lease is held across the
    retries, so the memory the retry needs is held by the retrier. All three attempts fail
    identically.
  5. The read raises RuntimeError.

The retry logic can't help here by construction, which is what makes this different from ordinary
capacity pressure.

Preconditions: offload.enabled: true (config.yaml describes it as essential when total CPU DRAM
is smaller than GPU HBM), the object already evicted to SSD, and the reader not running on the node
hosting the offload client — which, given the documented single-node centralized offload pool, is
every other node in the cluster.

What headroom actually buys

Two separate things, which is why I'd change the sizing rather than just document it:

  • It keeps overflow an exception instead of the steady state at full concurrency, so the
    optimization keeps working and doesn't silently invert.
  • It leaves the store's internal paths the scratch space they need within the same call, so TQ
    doesn't starve its own reads.

Restating the suggestion from the earlier comment, now with the reasoning above behind it:

# Leave headroom: the local buffer is shared with mooncake's internal staging paths,
# including the scratch allocation batch_get_into needs for SSD-resident keys.
self._lease_bytes = self.local_buffer_size // (2 * MAX_BATCH_WORKER_THREADS)

together with max_bytes=self.local_buffer_size on the pool. With both in place, genuine exhaustion
raises instead of silently overflowing, and the explicit fallback in _read_group_via_lease finally
does the job it was written for.


def put(self, keys: list[str], values: list[Any]) -> list[dict | None]:
"""Stores multiple key-value pairs to MooncakeStore.

Expand Down Expand Up @@ -408,13 +472,70 @@ def _get_tensors_thread_worker(
batch_dtypes, batch_shapes
)

if self._buffer_pool is None:
self._read_into_own_buffers(batch_keys, batch_buffer_ptrs, batch_nbytes, region_ptrs, region_sizes)
return batch_buffer_tensors, indexes

# split_by_bytes() keeps every lease request within one thread's share of the pool,
# so a batch larger than that share is read in several rounds instead of failing.
for group in split_by_bytes(batch_nbytes, self._lease_bytes):
self._read_group_via_lease(group, batch_keys, batch_buffer_ptrs, batch_nbytes, batch_buffer_tensors)

return batch_buffer_tensors, indexes

def _read_into_own_buffers(
self, keys: list[str], ptrs: list[int], nbytes: list[int], region_ptrs: list[int], region_sizes: list[int]
) -> None:
"""Register the receive regions for one transfer, read into them, then unregister."""
self._register_all_buffers(region_ptrs, region_sizes)
try:
self._batch_get_into_with_retry(batch_keys, batch_buffer_ptrs, batch_nbytes)
self._batch_get_into_with_retry(keys, ptrs, nbytes)
finally:
self._unregister_all_buffers(region_ptrs)

return batch_buffer_tensors, indexes
def _read_group_via_lease(

@0oshowero0 0oshowero0 Sep 21, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We better optimize the function name so that it can let users directly get the idea that this is the counterpart of _read_into_own_buffers. Maybe we can consider _read_into_tensors and _read_into_buffers? Just for inspiration :)

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

make sense~

self,
group: list[int],
batch_keys: list[str],
batch_ptrs: list[int],
batch_nbytes: list[int],
batch_tensors: list[Tensor],
) -> None:
"""Read one group of keys into leased memory, then copy into the caller's tensors.

The copy is what lets the lease return to the pool immediately, keeping the
returned tensors owned by the caller exactly as the register-per-read path does.
"""
keys = [batch_keys[i] for i in group]
nbytes = [batch_nbytes[i] for i in group]
offsets, total = _aligned_offsets(nbytes)

lease = self._acquire_lease(total)
if lease is None:
ptrs = [batch_ptrs[i] for i in group]
region_ptrs, region_sizes = merge_contiguous_memory(ptrs, nbytes)
self._read_into_own_buffers(keys, ptrs, nbytes, region_ptrs, region_sizes)
return

try:
self._batch_get_into_with_retry(keys, [lease.ptr + off for off in offsets], nbytes)
_copy_lease_into_tensors(lease, [batch_tensors[i] for i in group], offsets)
finally:
lease.release()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

【AI Review】

4. lease.release() in finally can mask the original error and then block close()

try:
    self._batch_get_into_with_retry(keys, [lease.ptr + off for off in offsets], nbytes)
    _copy_lease_into_tensors(lease, [batch_tensors[i] for i in group], offsets)
finally:
    lease.release()

release() throws cannot release buffer while exported views exist when exports_ != 0. The
del src / del staged calls handle the happy path, but if copy-out raises, the torch.frombuffer
view stays alive in the traceback's frame, so release() raises from the finally and that
becomes the surfaced exception (the real one survives only as __context__). I reproduced this
with a lease that models the upstream check.

The follow-on matters more: close() throws cannot close buffer pool with active leases while any
lease is in use, and it's the first thing MooncakeStoreClient.close() does — so
_gdr_staging.close() and _store.close() would both be skipped. Suggest tolerating failure on
both release() and pool.close() (log and continue). Mitigating factor:
~BufferLeaseNative calls release_lease(false) on GC, so this isn't a permanent leak.


def _acquire_lease(self, nbytes: int):
"""Lease ``nbytes`` of pre-registered memory, or None when the pool cannot serve it.

Never block: mooncake would otherwise wait for capacity that a request larger than

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

【AI Review】

6. acquire(block=False) never returns None, so the docstring and one test cover an unreachable branch

The docstring says exhaustion "surfaces as None in some builds and as an exception in others", but
BufferPoolNative::acquire only ever throws — buffer pool is exhausted when block is false, or
requested buffer size exceeds pool capacity when the size class exceeds the budget. Meanwhile
FakePool.acquire returns None, so test_falls_back_to_own_buffers_when_pool_cannot_serve
exercises a branch that can't occur while the branch that actually runs in production is untested.
I verified the exception path does behave correctly (values round-trip, falls back to
register-per-read), so this is a test-coverage gap rather than a bug. Suggest making the fake raise
and dropping the speculative sentence.

the local buffer never gets. Exhaustion surfaces as None in some builds and as an
exception in others, and both mean the caller should register its own memory.
"""
assert self._buffer_pool is not None
try:
return self._buffer_pool.acquire(nbytes, block=False)
except Exception as e:
logger.warning(f"Leasing {nbytes} B of pre-registered memory failed ({e}); registering own buffer.")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

【AI Review】

5. The fallback logs one WARNING per group per read

except Exception as e:
    logger.warning(f"Leasing {nbytes} B of pre-registered memory failed ({e}); registering own buffer.")

In a constructed exhaustion scenario a single get() emitted 10 WARNING lines. Multiplied by
MAX_BATCH_WORKER_THREADS and by reads per step, that floods logs at WARNING level from the hot
path. Suggest warn-once, or debug per occurrence plus a single warning.

return None

def _get_tensors_gdr(
self,
Expand Down Expand Up @@ -535,6 +656,10 @@ def clear(self, keys: list[str], custom_backend_meta: list[Any] | None = None) -

def close(self):
"""Closes MooncakeStore."""
# Release the leased regions before the store they belong to goes away.
if self._buffer_pool is not None:
self._buffer_pool.close()
self._buffer_pool = None
if self._gdr_staging is not None:
self._gdr_staging.close(self._store)
self._gdr_staging = None
Expand Down
Loading