Repository navigation
Conversation
…imes The chunked allocator freed a block on the rank whose garbage collector dropped the tensor, and the next allocation of that size reused it. A symmetric tensor reachable only through a reference cycle is freed when the cyclic GC runs, which differs between ranks, so the rank that had collected reused the slot while another bump-allocated. The same allocation then landed at different offsets, the bump pointers diverged, and every later remote access went to the wrong peer address, silently. Frees still come from GC finalizers, but a freed block now only becomes reusable once every rank has freed it. SymmetricHeap.collect() runs gc.collect() and a device sync on every rank, exchanges the released offsets through the rendezvous store, and moves the common ones to the free lists in sorted order. Allocation reads only state that is identical across ranks, so every rank takes the same path: it tries the free lists and the bump pointer first, and calls collect() only when the heap would otherwise grow. The common path stays free of collectives. Reuse now waits for that point (or an explicit collect()) instead of the next allocation; the two reuse tests call collect() accordingly. New tests cover reuse before growth, and, across ranks, offsets staying symmetric when GC runs on one rank first and a block one rank still holds not being handed out. Also drop freed offsets from alloc_sizes. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
There was a problem hiding this comment.
Warning
Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.
Copilot review overview
2 open findings
🧠 Review effort: Lite
nirvedhmeshram
left a comment
There was a problem hiding this comment.
I am still reviewing this but wanted to share one thing flagged by the agent in the time being, AI comment below
Before this PR, a churn pattern reused the same block every time and bump stayed low. Now every freed block stays out of the free lists until the heap would grow. A churn loop therefore consumes the whole mapped extent as small blocks first. The free lists are exact power-of-two size classes with no coalescing, so all that memory can then only serve that size class. The first larger allocation finds nothing to reuse after collect() and grows the heap.
Model of the PR's exact bookkeeping (256 MB heap and chunk, 1000 iterations of a freed 1 MB scratch buffer, then one 128 MB buffer):
before: bump=129 MB mapped=256 MB grows=0
after : bump=384 MB mapped=512 MB grows=1
So a workload that fit in its heap now maps another chunk, pays a refresh_peer_access(), and doubles its physical footprint. When the VA budget is tight, it can hit VA space exhausted where it used to succeed. This is a realistic pattern: a per-iteration scratch buffer plus one late large allocation.
Suggested fix (deterministic, small): in reuse(), before pushing to the free lists, walk the common offsets from the highest down. While a block ends exactly at bump, lower bump to that block's start instead of pushing it onto a free list. The common set and bump are identical on every rank, so this keeps symmetry. In the same model:
after+rollback: bump=128 MB mapped=256 MB grows=0
def reuse(self, offsets) -> None:
with self.lock:
offsets = sorted(offsets)
for offset in offsets:
self.alloc_sizes.pop(offset, None)
# Blocks at the top of the bump region go back to the bump pointer, not a size class
while offsets and offsets[-1] + self._released[offsets[-1]] == self.bump:
self.bump = offsets[-1]
del self._released[offsets.pop()]
for offset in offsets:
self.free_lists[self._released.pop(offset)].append(offset)(This assumes bump is always the end of the last bump allocation, which holds today. An import_external_tensor() moves bump to mapped_extent, past the import, so blocks below an import never match and the rollback stops there, which is correct.)
This doesn't fix fragmentation in the middle of the heap, but that existed before the PR. It does stop the PR from making it systematically worse. Please also add a test: churn, then a large allocation, then assert get_num_chunks() is unchanged.
Deferring reuse until the heap would grow let a churning scratch buffer fill the mapped heap with blocks of one size class first. The free lists are exact power-of-two classes without coalescing, so a later, larger allocation found nothing reusable and grew the heap, where before it fit. reuse() now walks the common offsets down from the top: while a block ends exactly at the bump pointer, the pointer moves down to its start instead of the block going onto a free list. The offsets and the bump pointer are identical on every rank, so this stays symmetric. Blocks below a live allocation or an import still go to their free list. Also drop a freed block from alloc_sizes as soon as its finalizer's free is drained (in allocate() and get_stats() as well as collect()), so num_active_allocs counts only live allocations. test_chunked_churn_then_large_allocation_does_not_grow covers the churn case (it grows the heap without this change); the size-class test keeps a live allocation above its blocks so it still exercises the free lists. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
Thanks for the comment, the bug has been fixed as written. Response to the raised bug by an agent:
This can also be added but I wanted to check if this is worth doing. |
|
Thanks for adding the rollback. On whether the periodic Model: 256 MB heap. Each of 20,000 steps allocates and frees a 1 MB scratch buffer, every 100th step also keeps a 512 KB buffer, and a 64 MB allocation comes at the end.
Why rollback alone grows: once the heap is cut into 1 MB blocks, a 512 KB request has nothing of its size to reuse, so it bump-allocates past the end and grows the heap. Each kept buffer also stops the next rollback, so the cycle repeats. Why counting bump bytes isn't enough: after a Sketch: # VMemChunkedAllocator.allocate(), once the offset is chosen (either branch)
self._bytes_since_collect += aligned_size
# VMemChunkedAllocator.reuse()
self._bytes_since_collect = 0
# SymmetricHeap.allocate(), before the grow=False attempt
if self.allocator._bytes_since_collect >= self.allocator.mapped_extent // self.allocator.COLLECT_FRACTION:
self.collect()On cost: besides For a test: extend the churn test so it keeps a small buffer every N iterations, then assert that the large allocation still doesn't add a chunk. With rollback only, that fails today. |
The bump rollback only reclaims freed blocks at the top of the heap. A churn loop that also keeps an occasional buffer stops the rollback, so the scratch blocks below it stay in their size class, smaller requests of another class bump past the end, and the heap grows: in the new test, from 1 chunk to 3, where before deferred reuse it stayed at 1. Count the bytes handed out since the last reuse() (free-list pops as well as bump allocations) and call collect() from SymmetricHeap.allocate once they reach 1/COLLECT_FRACTION (8) of the mapped heap. Counting only bump bytes would let the scratch working set grow by one threshold per cycle; counting everything handed out caps it. The count follows the allocation sequence, so it is identical on every rank and the decision stays symmetric. The churn test is parametrized to also keep a buffer every 16 iterations, with scratch and kept sizes in different size classes. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
|
Also made this change per your sketch. |


Problem
vmem_chunkedfrees a block when Python garbage-collects the tensor, independently on each rank, andthe next allocation of that size reuses it. A symmetric tensor that is only reachable through a
reference cycle is freed when CPython's cyclic GC next runs, and that moment differs between ranks.
Until it has run everywhere, the rank that collected reuses the freed slot while another rank
bump-allocates. The same
ctx.zeros(...)call then returns different offsets on different ranks,the bump pointers stay out of step from then on, and remote loads, stores, atomics and copies go to
the wrong peer address with no error.
Fix
Garbage collection still decides when a rank is done with a tensor; only reuse becomes collective.
in a per-rank released set instead of putting it straight on a free list.
SymmetricHeap.collect()(collective) runsgc.collect()and a device sync on every rank,exchanges the released offsets through the rendezvous store (no NCCL collective, like
_hostnames_by_rank), and moves the offsets every rank released onto the free lists in sortedorder. Offsets only some ranks released stay pending.
every rank takes the same branch: free list, then bump, and only when the heap would otherwise grow,
collect()first and retry. The common path stays collective-free, as before.The device sync before the exchange also means no rank is still accessing a block when it is handed
out again.
Behavior change
A freed block is reused only after
collect(): automatically when the heap would otherwise grow,or explicitly via
ctx.heap.collect()to reuse memory sooner. Previously it was reused by the nextallocation of its size class on that rank.
test_chunked_gc_free_reuseandtest_chunked_free_list_size_classesnow callcollect()before reallocating.If a rank never reaches the allocation that triggers
collect()(an asymmetric allocation sequence),the exchange fails when the store times out, with an error naming the missing ranks.
Cost
gc.collect(), one device sync and one small store exchange per rank.Growth already runs
refresh_peer_access().Tests
tests/unittests/test_vmem_chunked_allocator.py, new:test_chunked_freed_block_waits_for_collect: a freed block is not reused beforecollect(), and is after.test_chunked_reuses_before_growing: an allocation that would grow the heap reuses a freed block instead.test_chunked_offsets_symmetric_across_gc_timing(2+ ranks): a reference cycle collected on rank 0only no longer changes where the next allocation lands; a block one rank still holds is not handed
out after
collect().Ran on 8× MI300X (gfx942, ROCm 7.1.0): the chunked allocator tests pass at 1, 2, 4 and 8 ranks, and
test_multihost_guard.pyat 2 ranks.