Skip to content

fix(mem): hand workers their freed blocks back at the end of every dispatch - #621

Merged
singaraiona merged 4 commits into
devfrom
fix/worker-foreign-drain-619
Sep 24, 2026
Merged

singaraiona merged 4 commits into
devfrom
fix/worker-foreign-drain-619

Conversation

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Closes #619.

Bug

A loop of left-join / inner-join calls 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 1 and anti-join stayed 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 after ray_heap_init, cleared before the worker exits), and ray_pool_dispatch / dispatch_n_round, once the parallel flag is clear, call ray_heap_reclaim_worker on each published heap and ray_heap_flush_foreign on 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.

ser-vasilich and others added 4 commits September 23, 2026 20:28
…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 singaraiona left a comment

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.

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(), and ray_pool_free frees the array only after joining
    every worker. All three ray_pool_create_impl OOM paths free it, and the array
    is allocated before any thread is spawned, so no worker can touch it before it
    exists. n_workers == 0 leaves it NULL and pool_reclaim_worker_heaps then only
    runs ray_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_end runs before pending--, and
    everything a worker executes after that — pool_claim CAS, shutdown load,
    ray_sem_wait — allocates and frees nothing on every platform backend.
    heap_drain_foreign/heap_coalesce use only the passed h, never
    ray_tl_heap, so clearing ray_rc_sync first is harmless.
  • The foreign-list/coalesce interaction, which looked like a real hazard: safe,
    because ray_free stores rc = 1 at 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
    the buddy_rc != 0 test in heap_coalesce — no fl_remove on a block that is
    not in a freelist.
  • Concurrency surface: ray_thread_create is called only from pool.c, so
    main plus pool workers are the only threads, and ray_heap_decay runs 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,
    below RAY_HEAP_POOL_ORDER 25 and above the slab orders), so the stats
    assertions balance exactly; pool_count_fn allocates nothing and task_cap is
    1024, so the 64-task dispatch is a single round and the final foreign == 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.

@singaraiona
singaraiona merged commit cf9aa21 into dev Sep 24, 2026
9 checks passed
@singaraiona
singaraiona deleted the fix/worker-foreign-drain-619 branch September 24, 2026 18:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

left-join / inner-join leak memory proportional to the right table on every call

2 participants