Skip to content

Incremental GC with automatic pacing and optional scheduling hooks #372

Open

Description

@dimensionscape

Major collections currently stop application threads while tracing the live heap. Parallel marking reduces the duration, but larger live graphs can still produce visible hitches. The next collector should spread marking across short pauses and work automatically in ordinary hxcpp applications, including games whose frame loop is owned by another library.

The immediate priority is incremental marking. Concurrent marking in #327 is deferred while this is developed and measured.

Scope

  • Add an opt-in HXCPP_GC_INCREMENTAL mode. Keep the current collector as the default during development and validation.
  • Implement this in libhxcpp without requiring a Haxe compiler or standard library patch. Audit the existing generated barriers and runtime store paths before relying on them.
  • Trace the actual heap while application threads are stopped, retaining marking progress between slices. Do not build and trace a second graph of copied object references.
  • Keep new concurrent marking and concurrent sweeping outside this first change. Existing background allocator work remains subject to its current ownership rules.

Collection and mutation rules

  1. Start a major cycle before free allocation space is exhausted. Preserve marking queues, liveness state and unprocessed work between slices.
  2. Stop application threads for a bounded amount of tracing work, then resume them. Check elapsed time between small work units rather than only between entire large objects.
  3. Make pointer arrays, maps and other large containers resumable. Keep owner and mutation-version information so resizing or rehashing cannot leave a saved iterator pointing into replaced storage.
  4. Record reference changes between slices. Cover generated setters, array and map operations, Dynamic and reflection writes, closures and supported native stores. Handle allocations made during a cycle and references from new objects into older graphs.
  5. Do not reclaim or reuse storage while unfinished marking work can still refer to it. Define early buffer release and resize behavior explicitly.
  6. Preserve weak-map closure, zombies, weak references and finalizer ordering. Observable lifecycle processing happens once, after reachability is complete.
  7. Define how nursery collections and allocation interact with an unfinished major cycle. Keep existing generational and moving builds as controls, and validate supported combinations individually.
  8. Preserve the completion contracts of explicit collection, compaction, disabling collection, diagnostics and shutdown.

Root capture, thread-stop waits, weak processing, finalizers and finishing work must be included in the reported interruptions. A requested slice duration is a scheduling target until every participating operation has a resumable path. It is not a hard pause guarantee.

Automatic pacing

No frame loop is required. Allocation accounting, heap reserve and observed collection progress should determine when work is due. Advance the cycle at suitable allocator slow paths and safepoints, starting early enough to avoid routinely forcing a full collection under pressure.

Pace work over elapsed time as well as allocated bytes so small slices do not simply accumulate into another long interruption. If reserve is exhausted, complete safely and record the synchronous fallback and its full duration.

Without an engine hint the runtime cannot know frame boundaries or the time left before presentation. Automatic pacing must still work for servers, tools and games with no integration.

Optional library API

Provide Haxe wrappers owned by libhxcpp, backed by C++ runtime functions. No change to cpp.vm.Gc is required. Proposed operations:

  • step(budgetMs): request incremental work during spare application time and report whether work remains.
  • Configure the automatic slice target and collection pacing.
  • Read cycle progress, pause distributions, stopped time, allocation stalls, fallback reasons and memory overhead.

Names and signatures can be settled during implementation. A game engine can spend spare frame time through these hooks; calling them is optional. Native integrations such as Lime/OpenFL need explicit barrier and lifetime coverage as part of the supported configuration.

Tests and delivery

  • Add conventional semantic tests first, using test/regression/Name/{Main.hx,build.hxml,stdout.txt}. Reuse existing coverage where possible.
  • Cover graph rewiring, new-to-old references, container resize and rehash, weak closure, roots, thread attachment and detachment, native writes and explicit GC lifecycle operations.
  • Add deterministic slice controls and tests that mutate an already scanned object between slices. Use acknowledgements rather than timing sleeps.
  • Add an independent synchronous reachability verifier before reclamation. Partial incremental marks must not hide missed objects from the verifier.
  • Implement resumable marking and safe failure paths before enabling automatic scheduling.
  • Add define-matrix coverage, GC stress modes, sanitizer runs and CI configuration required by CONTRIBUTING.md.
  • Add game-style frame workloads, large stable graphs, large containers, burst allocation, high mutation and memory-pressure cases. Include fresh and warmed heaps and long enough runs to expose late fallbacks.
  • Document supported configurations, API semantics, budgets and fallback behavior.

Record matched payload work, compiler, runtime revision, collector settings, hardware and raw samples. Compare end-to-end GC interruptions, frame p99.9 and maximum latency, total stopped time, throughput and peak memory. Include thread-stop waits, allocator assists, retries and fallbacks.

Initial targets to evaluate on declared game workloads and hardware are p99.9 GC interruptions below 1 ms, observed maxima below 2 ms and at most 10 percent throughput loss against the existing collector, with an explicit memory budget. These are proposed acceptance targets, not measured results or guarantees.

Only consider making this the default after correctness and performance gates pass across the supported configurations. Keep an explicit option for the existing collector.

Related: #327 for deferred concurrent marking and #258 for benchmark and performance regression tracking.

Activity

  1. dimensionscape commented on Oct 27, 2026

    @dimensionscape
    MemberAuthor

    The incremental implementation is pushed on feature/incremental-gc, commit 67208e6b, with draft PR #380 for CI and review.

    Follow-up 6b621ec6 fixes the installation test's synthetic package, which omitted the new macro dependency. The missing-type failure was reproduced locally; installation, setup, update and off checks then passed on both Windows and Linux. CI is rerunning on that head.

    It implements automatic allocation pacing, retained marking queues, mutation barriers using the existing Haxe generator, resumable arrays and maps, optional hxcpp.gc.Incremental hooks, independent reachability verification and synchronous recovery. The existing collector is still the default. No compiler or standard library patch is required. Enable with -lib hxcpp -D HXCPP_GC_INCREMENTAL and regenerate all C++.

    The tests caught two implementation faults before the final runs: advancing directly from a partial epoch could make unvisited objects appear marked, and partial slices discarded usable allocator ranges. Both now have regression assertions. Strong marking also yields once before finishing, then rescans roots and mutation entries at the next stop.

    Local validation passed:

    • Nine phase and lifecycle fixtures with verification and deterministic controls at normal and big block sizes.
    • Three 30-second, eight-thread stress seeds per block size, plus another stress run after the final initialization adjustment.
    • All 1,100 runtime assertions with verification and big blocks, default collector controls and single-threaded incremental phase tests.
    • Linux ASan and UBSan phase fixtures and 30-second, eight-thread stress runs, and all 79 GCC define configurations.

    Local TSan did not reach a usable workload run under the installed WSL/runtime configuration. Cross-platform and TSan CI results are still pending.

    Matched Windows measurements, three processes with two warmups and five samples each, with verification disabled:

    Workload Collector Largest process p99.9, ms Maximum work unit, ms Median elapsed, ms
    gc_frames Existing 7.742 9.728 240.246
    gc_frames Incremental 0.969 1.858 294.961
    gc_pauses Existing 5.055 8.289 145.429
    gc_pauses Incremental 2.718 5.232 256.843

    Checksums agree across collectors. The p99.9 column is the largest per-process percentile, not a pooled percentile or a distribution of isolated GC pauses. The elapsed increases are 22.8% and 76.6%, corresponding to throughput losses of 18.5% and 43.4%. The 10% throughput gate is not met.

    The largest budgeted collector invocations were 1.346 ms and 1.783 ms against a 0.5 ms target. Cumulative unbudgeted maxima were 13.553 ms and 9.971 ms, including explicit collections outside measured frames. Weak closure, finalizers, reclamation, root capture and thread rendezvous remain possible sources of longer interruptions.

    Raw samples and protocol and implementation/API documentation are committed. This issue stays open for throughput, finishing work, broader native-integration and memory-pressure coverage, progress and metadata counters, complete stopped-thread distributions and cross-platform validation. The mode remains experimental and opt-in.

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

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions