fix(mem): hand workers their freed blocks back at the end of every dispatch - #621
Conversation
…spatch A parallel operator's workers allocate per-task buffers from their own heaps and the main thread frees them once the dispatch has completed, so each block lands on the owning worker's foreign list. The owner drained that list only when an allocation found its freelists empty, which a warm worker with a partly cut pool rarely does: it kept splitting fresh pool space instead, touching new pages every round while its own freed blocks waited. Under a steady stream of such operators (a join against a large table per incoming batch) the process grew by a full 32 MB pool per worker before any block was reused, and only the idle decay, which needs the process to sit quiet, drained it earlier. The dispatcher now drains every registered heap's foreign list at the end of each parallel region (ray_heap_reclaim_workers), under the same conditions the idle decay already relies on: the flag is clear, so every worker has finished its last task and neither allocates nor frees until the next dispatch. The blocks go back to freelists only, no pages are released, so the next round reuses them without faulting. Tests: heap/reclaim_workers_drains_owner (a block owned by one heap and freed from another leaves the owner's list on reclaim and is handed out again at the same address, no new pool) and pool/dispatch_reclaims_worker_blocks (blocks allocated by real workers and freed by the main thread are back with their owners by the end of the next dispatch). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The heap registry holds every thread that ever allocated, not only pool workers: a server's poll thread or an embedding's own threads are there too, and they may be allocating while a dispatch on another thread ends. Draining such a heap's foreign list from the dispatcher coalesces into freelists their owner is using at that moment. Only a pool worker parked on its semaphore is known to touch nothing of its own until the next dispatch, so each worker now publishes its heap in the pool (worker_heaps, set after ray_heap_init, cleared before it exits) and the dispatcher reclaims exactly those, plus its own list on its own thread. ray_heap_reclaim_worker takes one heap; the registry walk is gone, which also removes its per-dispatch scan of every registry slot. The heap test now checks the block's bytes leave the owner's books only on reclaim, instead of expecting the next allocation at the same address; the pool test reads the worker heaps the pool publishes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…p and adopted heaps The pool test read a worker's heap slot right after ray_pool_create, which returns before the workers have started, and passed vacuously when the main thread took every task. It now waits for each worker to publish its heap, records which worker allocated each block and repeats the round until a worker really allocated something, then checks that the next dispatch left no worker with a foreign list. The heap test drains whatever an adopted heap already held before taking its baseline. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
cppcheck cannot build an AST for a cast to `_Atomic(void*)*` and fails the static-analysis run on it. The cast is not needed in C: ray_sys_alloc returns void*, which converts to the slot pointer type on its own, and sizeof(*pool->worker_heaps) names the element size without spelling the type. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
singaraiona
left a comment
There was a problem hiding this comment.
Approving and merging. One non-blocking note in the test, and a record of what
the review cleared, since the interesting part of this patch is what could have
gone wrong and does not.
Note: the test can hang instead of failing — test/test_pool.c:432
for (uint32_t w = 0; w < pool.n_workers; w++)
while (!atomic_load(&pool.worker_heaps[w])) RAY_CPU_RELAX();Unbounded and non-yielding. If ray_heap_init() fails in a worker — vm_alloc
failure, heap-id exhaustion — the slot stays NULL forever and the test binary
spins rather than reporting. CI then shows a job timeout instead of a named
failing assertion, on a path that is already broken and wants a clear message
most. A bounded spin with a TEST_ASSERT after it would cost nothing. Not worth
holding the merge for.
What I checked and cleared
- Publish/clear lifecycle (
src/core/pool.c:85,:128):worker_heaps[worker_id - 1]
is indexed correctly for 1-based ids, the NULL store precedes
ray_heap_abandon(), andray_pool_freefrees the array only after joining
every worker. All threeray_pool_create_implOOM paths free it, and the array
is allocated before any thread is spawned, so no worker can touch it before it
exists.n_workers == 0leaves it NULL andpool_reclaim_worker_heapsthen only
runsray_heap_flush_foreign();ray_sys_free(NULL)is a no-op. - Is draining a parked worker's heap sound? Yes, and this was the one I
expected to break.ray_qstats_task_endruns beforepending--, and
everything a worker executes after that —pool_claimCAS,shutdownload,
ray_sem_wait— allocates and frees nothing on every platform backend.
heap_drain_foreign/heap_coalesceuse only the passedh, never
ray_tl_heap, so clearingray_rc_syncfirst is harmless. - The foreign-list/coalesce interaction, which looked like a real hazard: safe,
becauseray_freestoresrc = 1at entry (src/mem/heap.c:1619) and never
resets it before the CAS push, so a block parked on a foreign list is skipped by
thebuddy_rc != 0test inheap_coalesce— nofl_removeon a block that is
not in a freelist. - Concurrency surface:
ray_thread_createis called only frompool.c, so
main plus pool workers are the only threads, andray_heap_decayruns only on
the event-loop thread — no second thread coalescing into a worker heap. A nested
dispatch would escalate this from ring corruption to freelist corruption, but the
interpreter never runs on workers, so I could not construct a reachable case.
Worth keeping in mind as a constraint this patch now depends on. - Test determinism: the 256 KB allocations land on the buddy path (order ~18,
belowRAY_HEAP_POOL_ORDER25 and above the slab orders), so the stats
assertions balance exactly;pool_count_fnallocates nothing andtask_capis
1024, so the 64-task dispatch is a single round and the finalforeign == NULL
assertions are deterministic rather than flaky.
CI green on all nine checks. This is the right fix for the shape in #439's
neighbourhood — handing a worker its own blocks back at the end of the dispatch
rather than waiting for it to go looking.
Closes #619.
Bug
A loop of
left-join/inner-joincalls with a right table above 65 536 rows grew the process's resident memory on every call until it reached one 32 MB pool per worker thread, then stayed there;-c 1andanti-joinstayed flat. Live bytes did not grow: the memory was free but unused.Above that row count the join takes the parallel radix path. Its workers allocate the per-partition buffers from their own heaps, and the main thread frees them once the dispatch has completed, so every such block is a cross-thread free and lands on the owning worker's foreign list. A worker takes that list back only when an allocation finds its freelists empty at the order it needs. A warm worker with a partly cut pool rarely does: it keeps splitting fresh pool space instead, touching new pages every round while its own freed blocks wait on the list. The idle decay would drain the lists, but it needs the process to sit quiet for ten seconds, which a steady stream of joins never allows.
Fix
The dispatcher hands each worker its freed blocks back at the end of every parallel region. Each worker publishes its heap in the pool (
worker_heaps, set afterray_heap_init, cleared before the worker exits), andray_pool_dispatch/dispatch_n_round, once the parallel flag is clear, callray_heap_reclaim_workeron each published heap andray_heap_flush_foreignon the dispatcher's own. The conditions are the ones the idle decay already relies on: a worker parked on the semaphore has finished its last task and neither allocates nor frees until the next dispatch, and a concurrent push onto a foreign list is safe because the drain takes the whole list in one exchange. Only the pool's own workers are reclaimed, never the registry at large, since the registry also holds the heaps of other live threads whose freelists only their owner may touch. The blocks go back to freelists only; no pages are released, so the next round reuses them without faulting.On the reporter's reproducer (24 threads, 300 iterations) resident memory now stays at the footprint of one join instead of growing to the pool-per-worker bound; the join loop itself runs faster, since blocks come from freelists rather than fresh pages, and loops of small dispatches are unchanged.
Tests
heap/reclaim_worker_drains_owner: a block owned by one heap and freed from another stays parked, on the owner's books, until the owner is reclaimed, and is left alone while the parallel flag is set.pool/dispatch_reclaims_worker_blocks: blocks allocated by real workers inside a dispatch and freed by the main thread after it are back with their owners by the end of the next dispatch; the test waits for the workers to publish their heaps and repeats until a worker really allocated something.