Skip to content

Move expired large-buffer cache release out of the global GC pause #332

Closed

Description

@dimensionscape

The collector frees every buffer left over in largeObjectRecycle at the start of each large-allocation sweep, while all Haxe threads are stopped. After a burst, a collection with almost no live graph work can spend tens of milliseconds returning already-dead cached buffers to the native allocator.

Reproduced on Windows x64, Haxe 4.3.7, MSVC 19.44.35213, default GC, libhxcpp 920fa85.

The probe keeps one 256 MiB Bytes buffer alive to provide a stable live heap and enough recycle allowance. Each round:

  1. Force a full collection to clear the previous cache.
  2. On a joined native worker attached through hx::NativeAttach, allocate N separate 4096-byte Bytes buffers, touch them, and drop them when the worker exits. Automatic GC is disabled only during this setup so the burst reaches the collection intact.
  3. Re-enable GC and run Gc.run(false) once. This discovers the garbage and puts its large allocations into the recycle cache.
  4. Time the next Gc.run(false), which purges that cache. Immediately time another collection as the empty-cache control.
  5. Check that the retained buffer is intact.

Nine rounds, discard the first two, median of seven. These measurements use an ordinary build without collector profiling:

Dead payload in the cache Number of 4096-byte buffers Purging collection Following empty-cache collection
32 MiB 8192 2.9521 ms 0.0756 ms
128 MiB 32768 12.9377 ms 0.0879 ms
256 MiB 65536 28.4493 ms 0.1056 ms

A second process at 65536 buffers measured 26.1782 ms versus 0.1027 ms. The temporary array's backing allocation also enters the cache, so the dead payload column counts the byte buffers rather than every cached byte.

A separate build with HXCPP_GC_PROFILE_COLLECT confirms where the time goes: the final seven purge collections have median total 25.98 ms, with 25.89 ms in the large sweep. Each reports large(2->2, recyc 65537). The live large-allocation count is unchanged, and the phase is dominated by releasing cached garbage rather than scanning a large live list.

In src/hx/gc/Immix.cpp, Collect loops over the old largeObjectRecycle entries and calls HxFree on every one before resetting its size and examining mLargeList. Mutators are resumed much later. This is separate from allocation-time cache lookup (#300), large-list removal (#298), and sweeping old live allocations during minor collections (#318).

Consider detaching the expired recycle batch while stopped, then releasing it after mutators resume, or using bounded background cleanup. Keep ownership unambiguous, account for pending native memory, prevent reuse after a buffer is queued for release, and bound retained memory and cleanup backlog. Explicit compaction must retain a useful way to request memory release. Merely keeping every expired batch would trade the pause for uncontrolled memory retention.

A local conventional test/regression/LargeRecyclePurgeLifecycle covers partial cache reuse followed by cache purges, mixed-size allocation churn, zero initialization, and independent retained buffer contents. It passes with default and moving GC. This is correctness coverage for a future change; the timing probe is separate and the regression has no timing assertion. No runtime changes were made. The work can be done entirely in libhxcpp.

Activity

  1. added a commit that references this issue on Oct 27, 2026
    127c25d
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions