[optim] Lease pre-registered receive buffers on the MooncakeStore read path - #175
weiziyoung wants to merge 1 commit into
Conversation
…d path Tensor reads allocated a receive buffer and called register_buffer / unregister_buffer around every transfer. Registration is a kernel operation (page pinning + MR setup) whose throughput is an order of magnitude below the RDMA transfer it enables, so it dominated read time and left the RDMA backend slower than the TCP one. Lease the receive buffer from the local buffer that store.setup() already registered, then copy into the caller's tensors so the returned tensors keep owning their memory. split_by_bytes() bounds each lease by one reader thread's share of the pool, so a read no longer needs local_buffer_size to exceed the batch size. Builds without mooncake.store.BufferPool, and requests the pool cannot serve, still register their own receive buffers. Refs: Ascend#169 Signed-off-by: weiziyang <weiziyang@baidu.com>
CLA Signature Guide@weiziyoung , thanks for your pull request. The following commit(s) are not associated with a signed Contributor License Agreement (CLA).
To sign CLA, click here. To check if your email is configured correctly, refer to the FAQs. Once you've signed the CLA or updating your email, please comment |
|
/check-cla |
CLA Signature Guide@weiziyoung , thanks for your pull request. The following commit(s) are not associated with a signed Contributor License Agreement (CLA).
To sign CLA, click here. To check if your email is configured correctly, refer to the FAQs. Once you've signed the CLA or updating your email, please comment |
|
/check-cla |
CLA Signature Passweiziyoung, thanks for your pull request. All authors of the commits have signed the CLA. 👍 |
| # See the License for the specific language governing permissions and | ||
| # limitations under the License. | ||
|
|
||
| """Receive-buffer leasing on the MooncakeStore tensor read path. |
There was a problem hiding this comment.
How about rename the file as test_mooncake_buffer_pool.py?
| RETRY_DELAY_SECONDS = 1.0 | ||
|
|
||
|
|
||
| def _copy_lease_into_tensors(lease, targets: list[Tensor], offsets: list[int]) -> None: |
There was a problem hiding this comment.
the name lease may confuse the readers. Maybe we can call it _copy_buffer_content_into_tensors?
| # 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. |
There was a problem hiding this comment.
We can simplify the AI comments
| "mooncake.store.BufferPool is unavailable, so every tensor read registers and " | ||
| "unregisters its own receive buffer. Upgrade mooncake-transfer-engine to lease " |
There was a problem hiding this comment.
It's better to tell the user which version supports this feature.
There was a problem hiding this comment.
For GDR and TCP path, we can omit this warnning
| self._unregister_all_buffers(region_ptrs) | ||
|
|
||
| return batch_buffer_tensors, indexes | ||
| def _read_group_via_lease( |
There was a problem hiding this comment.
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 :)
| # 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. | ||
| self._buffer_pool = BufferPool(self._store, max_bytes=0) if MOONCAKE_BUFFER_POOL_IMPORTED else None |
There was a problem hiding this comment.
【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 NoneIn 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 exposeacquire,buffer,prewarmandclose, 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: passmax_bytes=self.local_buffer_sizeso 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_unlockedis still reachable when the local allocator fails on
fragmentation whiletotal_bytes_ < max_bytes_— but it stops a whole extra arena from being
absorbed silently.)
| "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 |
There was a problem hiding this comment.
【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_THREADSMAX_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.cppregisters this region once and also
records it aslocal_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.cppshows a 64 KB buffer with 8 KB requests succeeding exactly 8 times and
failing on the 9th, andgetLargestFreeRegion()is documented as best-effort because "the actual
allocation may still fail due to race conditions or fragmentation". - Requests round up to a bin.
OffsetAllocatorbins 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.
ReplicatinguintToFloatRoundUp, 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,
andmax_bytes=0makes that failure invisible.
Suggestion: leave headroom (e.g.// (2 * MAX_BATCH_WORKER_THREADS)) and say in the comment
that the share is coupled to thelocal_buffer_sizeconfig.
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 singlebatch_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 shrinkslocal_buffer_sizegets 25 serialized rounds without
changing anything else.
There was a problem hiding this comment.
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:
MAX_BATCH_WORKER_THREADSreader threads each hold a lease, together covering 100% of the local
buffer.- A key inside one of those same
batch_get_intocalls lives on SSD, so mooncake tries to
allocate scratch space from that now-empty buffer. - The allocation fails, the key comes back
NO_AVAILABLE_HANDLE, i.e. a negative return code. _batch_get_into_with_retryretries 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.- 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.
| 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() |
There was a problem hiding this comment.
【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.
| 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.") |
There was a problem hiding this comment.
【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.
| 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 |
There was a problem hiding this comment.
【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.
|
And please run precommit script: |
Refs: #169
Thanks for the pointer! I did benchmark the
BATCH_SIZE_LIMITknob before going withthe buffer pool, and I think the data makes a clear case that batch-size tuning
cannot substitute for the pool。
1. Tuning
BATCH_SIZE_LIMITdoes not helpRegister-per-read, threads=4, both reads swept over the same
BATCH_SIZE_LIMITgrid (read time, ms, median; per-key size fixed at 1MB, only the key count differs):
The optimum sits at small values (~50 for 1GB, ~200 for 4GB) and grows worse as the
batch grows. The reason is
that
register_buffercost is dominated by per-byte page pinning, not per-calloverhead: the total bytes to pin per read is fixed, so a larger batch does not reduce
the pinning work — it only reduces how many worker threads share it (fewer, larger
chunks → less parallelism), plus a marginal merge-before-register saving. Even at 4GB,
where
num_keys/threads = 1024keeps 4 chunks well pastBATCH_SIZE_LIMIT=1024, thecurve still degrades — so this isn't purely a chunk-count artifact.
2. The pool removes registration entirely
Breakdown at threads=1 (segment sums ≈ wall), 1024×1MB (1GB):
Registration is ~79% of the read; leasing pre-registered buffers removes it. Crucially,
the pool beats the best-tuned baseline at every size (e.g. 4GB: pool 387 ms vs the
baseline's best 419 ms), so no
BATCH_SIZE_LIMITvalue closes the gap.3. Where the win is big, and where it's honestly marginal
baseline vs pool, threads=4,
BATCH_SIZE_LIMIT=400(read time, ms, median):Two honest caveats:
is ~3.4×; at threads=4 the register cost is spread across workers, and the pool's
copy-out (staged buffer → caller tensors) scales with bytes, so a 4GB read narrows
the gain to ~1.08×. The big wins are single-reader / few-large / mid-size reads;
pool also lowers step-to-step jitter (e.g. 1024×1MB threads=1 stdev 19.6 → 7.8 ms).
vs. that shape's cheap merged registration. I vectorized the copy-out (uniform groups
are copied in one strided pass instead of a per-tensor Python loop), which cut this
case from ~2× slower to ~0.8×, but it's still a net loss at extreme fragmentation.
If desirable, a small
num_keys/per-key-size heuristic could fall back toregister-per-read here; happy to add it, though real trajectory reads (≈1024×1MB)
don't hit it.
The copy is what keeps returned tensors caller-owned so the lease returns immediately.
A zero-copy view mode (return tensors backed by the lease, release on the next read)
would restore the large-read win but requires a "no cross-read retention" contract, so
I kept the copy path as the safe default.
4. Compatibility
Per your suggestion, the register-per-read path is preserved as a fallback: the client
gates on
mooncake.store.BufferPoolavailability, so builds without it (i.e.mooncake-transfer-engine < v0.3.12) transparently keep registering their own receivebuffers. Requests the pool cannot serve also fall back.