Repository navigation
Opt-in concurrent marking for the GC (6.x) #327
Description
Every collection stops the world today. Marking is already split across the GC threads (HX_MULTI_THREAD_MARKING), and block zeroing and part of the reclaim run in the background (tpjAsyncZero, ReclaimAsync), but all Haxe threads wait while the heap is marked. Pauses are already short: in the benchmark suite the slowest 0.1% of gc_pauses work units take 5.1 ms against 12.7 ms on stock hxcpp 4.3.53, and the worst 6.4 ms against 18.2 ms. They still grow with the size of the live heap, so a game or tool that keeps a few hundred MB alive pays for all of it in every major collection.
This proposes an opt-in mode, HXCPP_GC_CONCURRENT, where the collector marks while the program runs and stops the world only briefly at the start and end of a cycle. It is for a later 6.x release, not 6.0.0, and it stays off by default.
What it would do
- A short pause starts the cycle: switch on marking, mark the roots (class statics, the root set, zombies), and take a snapshot of each thread's stack and registers.
- The GC threads mark the heap while the program keeps running. Objects allocated during the cycle count as live.
- Every pointer store the program makes while marking runs goes through a write barrier that marks the stored value, so nothing the program moves into an already scanned object is missed.
- A short final pause rescans the stacks and registers (which have no barrier), drains what the barrier recorded, and then does what has to happen with the world stopped: weak map values (
MarkWeakHashValues), zombies (FindZombies), weak references and finalizers. - Sweeping runs as it does now, with the existing background reclaim where it applies.
The final pause depends on stack sizes and on how much was stored during the cycle, not on the size of the heap.
How it fits with the existing modes
Write barriers. Haxe already emits a barrier on every field and array store, HX_OBJ_WB(obj, value) in the generated setters, but only when HXCPP_GC_GENERATIONAL is defined: a default build has none. So a concurrent build has to turn the barriers on as well. libhxcpp can do that without a change in Haxe: extraParams.hxml already defines symbols through haxe.macro.Compiler.define, so HXCPP_GC_CONCURRENT can imply the generational barrier build. The barrier call sites pass both the holder and the value, which is what a marking barrier needs; the macro gets a second check, "is marking running", next to the current old-holder, young-value check.
Generational mode. The two share the barrier and the mark byte. A first version can do concurrent marking for major collections only and keep minor collections as they are, stop-the-world and fast. The remembered set and the marking barrier would be separate lists, so a minor collection during a concurrent cycle either waits for the cycle or is skipped. New objects start with mark byte 0 in nursery builds, so "allocated during the cycle counts as live" needs care here.
Moving mode. Objects cannot move while the program is reading them and the markers are tracing them, so compaction (HXCPP_GC_MOVING, Gc.compact()) stays a stop-the-world step. Gc.compact() would finish or abandon a running cycle first.
Barriers in the runtime itself. The runtime's own C++ has to cover every store too: array copies and splices, maps, Dynamic and reflection writes, CFFI, the cppia interpreter and the cppia JIT (genWriteBarrier). In generational mode a missing barrier lets one young object escape a collection, which HXCPP_GC_VERIFY catches. In concurrent mode the same gap frees a live object. #168 went through these paths and closed as not reproducible, so the barrier audit is a starting point, not a guarantee, and the verify build below is how we would know.
Making it safe to ship
The generational verifier is the model. With HXCPP_GC_VERIFY and HXCPP_GC_GENERATIONAL, every generational collection is followed by a full stop-the-world mark, and any object the full mark reaches that the generational mark did not is reported with its type and the object holding it, then trapped (aca76bb).
A concurrent verify build would do the same after each concurrent cycle: once the final pause has finished marking, and before anything is swept, run a full stop-the-world mark. Any object it reaches that the concurrent mark did not is a missing barrier or a missed root; print the object, its type and its holder, and trap. That check runs in:
- the GC stress test, in a new
concurrent-verifymode next togenerational-verify, on every change; - the
gc soakworkflow, run by hand or from asoak/branch; - the regression suite and the std tests under the sanitizers, with TSan in particular for the marker and mutator races on mark bytes.
Plan, in steps that can each land on their own:
- The marking barrier behind
HXCPP_GC_CONCURRENT, with collections still stop-the-world, and the verify pass. This shows barrier coverage before anything runs concurrently. - Concurrent marking for major collections, with the start and final pauses.
- Measurements against the stop-the-world collector on the benchmark suite, with a larger heap added to
gc_pausesso the difference is visible, plus throughput and peak memory, since the barrier costs something on every store. - Docs in docs/build_xml/Defines.md, and a note in the README once the numbers are in.
It would ship as experimental until the verify build has run clean in CI for a release cycle.
Activity
dimensionscape commentedon Oct 25, 2026
Open issues to deal with before this starts, from the GC issues open today:
Understand first
Land first, since they change what the barrier records
Stop-the-world work that the start and final pauses would still pay for
ERegmatch keeps its thread from reaching a safe point, andpcre2_match_8andpcre2_match_16inRegExp.cpprun without leaving the managed zone. Both pauses of a concurrent cycle would wait for it.fclose, which can block on I/O while every thread is stopped. Finalizers run in the final pause.Helpful, not blocking
The single-threaded Emscripten issues do not apply, since this mode needs threads.
dimensionscape commentedon Oct 25, 2026
Proposed design for the opt-in mode, leaving the prerequisite fixes in the previous comment as separate work.
The main change from the original proposal is to have background workers trace immutable edge records captured during stop-the-world slices. Directly running
__Mark()against mutable C++ fields would introduce data races, even with a write barrier.For the first version:
HXCPP_GC_CONCURRENToff by default. Use major collections, with no nursery, moving or concurrent sweeping.This needs no compiler or standard library patch, provided the generated-store audit confirms complete coverage through existing barriers and runtime methods. Snapshot copying adds CPU and memory overhead, and the final pause can still depend on heap size. The performance case needs measured pause, throughput and memory results before enabling this beyond an experiment.
Full design, correctness conditions, test plan and rollout
Opt-in concurrent GC design
Status: proposed architecture, not an implemented or validated collector.
Based on #327, against revision
290638e71351c4e67f8e10cf33c0b187d46c6e22.The prerequisite fixes in the issue comment remain separate work.
Decision
Implement an experimental concurrent snapshot marker behind
-D HXCPP_GC_CONCURRENT. Application threads run while GC workers traverse immutable records of heap edges. Capture those records during cooperative stop-the-world slices. Finish strong closure, weak processing and reclamation while stopped.This is concurrent tracing with incremental, paused capture. It is not direct background traversal of live C++ objects. That distinction is part of the public documentation and benchmark reporting.
The first implementation uses fresh records every cycle. It does not maintain a persistent shadow heap, run minor collections during a cycle, move objects during a cycle, or sweep concurrently. These choices keep the lifetime and synchronization rules small enough to verify. All are candidates for later designs, rather than assumptions hidden in this one.
The performance objective is a smaller longest GC pause on substantial, mostly stable heaps. Snapshot copying adds work and memory. Whether this is a useful improvement must be measured before shipping it as anything beyond an experiment.
Why the direct marking proposal needs a different access protocol
ObjectPtr::mPtr, array elements and container links are ordinary C++ memory. A background__Mark()or__Visit()reading a field at the same time a mutator writes it introduces a C++ data race. Recording the new value after a store does not legalize the field read. Making only the reader atomic does not fix mixed ordinary and atomic accesses either. Resizing arrays and hashes also changes their storage and traversal boundaries.The current header-byte barriers and marker queues have additional stopped-mutator assumptions.
HX_OBJ_WB_CTXchecks an old holder and a nursery value, then modifies an ordinary mark byte. Its filtering is unsuitable for a concurrent major collection.A future direct marker would need a compatible access protocol for every participating pointer slot and mutable container layout, including generated code and native integrations. This design avoids that requirement: workers cannot access application payload, allocation headers, mutable allocation indexes or running thread stacks.
Supported first version
An unsupported build combination fails at build time. A supported build may fall back to a synchronous cycle for a documented runtime reason. It must report that reason in diagnostics, rather than silently presenting every collection as concurrent.
Build policy without a compiler or standard library patch
The existing Haxe generator emits relevant write barriers when
HXCPP_GC_GENERATIONALis defined. A library-owned configuration macro, loaded conditionally fromextraParams.hxml, must arrange that definition before C++ generation for a concurrent build. A late C++ compiler flag is insufficient.Separate three concepts in the runtime:
Keep the compatibility definition available wherever generated C++ guards depend on it. Introduce an internal effective generational-mode definition for runtime nursery behavior, and audit every existing generational conditional when making that separation. In particular,
include/hxcpp.hcurrently derivesHXCPP_GC_NURSERYdirectly fromHXCPP_GC_GENERATIONAL. Merely implying the latter would enable the wrong allocation behavior.Concurrent builds must have ordinary non-nursery allocation headers and full-major accounting. Reject an explicitly requested nursery or moving combination for version one. Treat the mode and barrier policy as part of the runtime and generated-code build identity. Rebuild all generated C++, native objects and extensions that participate in heap mutation; reject a mismatched linked runtime at startup where detectable.
Before accepting this route, compile small examples with the installed supported Haxe versions and inspect emitted stores for fields, constructors, anonymous objects, closures, arrays and reflection. If a necessary store has no usable generated barrier and cannot be covered inside libhxcpp, leave that configuration unsupported. A build define is not evidence of complete barrier coverage.
A generation-only probe with Haxe 4.3.7 confirmed that a dynamic instance field uses an
_hx_set_edgehelper containingHX_OBJ_WBin its constructor, ordinary setter and reflective setter. Anonymous construction uses runtimesetFixed, and a mutable captured variable uses a runtime virtual-array cell. These are useful runtime interception points, not a completed store audit. The probe did not compile or run a concurrent collector.Cycle data and ownership
Each cycle owns native metadata, allocated outside the GC heap:
Assign handles only while stopped, after validating and normalizing pointers through the collector's existing allocation membership rules. Preserve distinctions between object pointers, backing allocations, constant memory and registered interior/interface pointers. Do not dereference a pointer merely because it appeared in a conservative root scan. The worker-visible graph contains integer handles, never pointers it may follow into the heap.
Handles are valid only for their cycle. Start no reclamation, moving operation, memory reuse or other collection after activating a cycle and before its workers have parked and its result has been accepted or abandoned. Allocations already dead before the cycle still cannot be reused during it. Finish any previous asynchronous reclamation that could violate this rule before activation. New allocations can use genuinely free space.
This removes address-reuse ambiguity without requiring permanent per-allocation IDs. Destroy the graph before allowing storage reuse. Do not cache its edges across cycles.
Deferral also applies to explicit runtime release paths, not just collection.
InternalRealloccallsInternalReleaseMem, which can pass large backing storage toFreeLargefor recycling immediately. During a cycle, enqueue such releases in a coordinator-owned retirement list instead. Do not clear their headers, remove their allocation identity or recycle them while a handle can still refer to them. Charge retained bytes to reserve pressure. After retiring the graph, consume each deferred release exactly once under allocator ownership, coordinated with sweeping so it cannot free the same allocation twice. Audit every other early release and storage-reuse path for this rule.One coordinator owns the phase state. Each published batch has a single immutable lifetime, released only after worker acknowledgement. Use release publication and acquire consumption; use atomic side reachability claims and correctly synchronized queues. Workers write only cycle metadata. The ordinary allocator mark epoch stays unchanged during background tracing.
Mutation protocol
During an active cycle, every reference-changing heap operation records its owning object or an equivalent traced owner before its thread acknowledges another safepoint. Logging applies regardless of the holder's age, the target's age, or whether the holder is currently marked. It is independent of the generational remembered set.
The concurrent branch of
HX_OBJ_WB_CTX, its pessimistic variants and runtime array/map barriers must record the holder without accessing or modifying its mark byte. Record deletion and null stores too. They can conservatively retain old snapshot edges, but must not be treated as evidence that a cached capture is current.Current call sites include barriers both before and after assignment. A pause must never split an unlogged store from the log record required for that store. A pre-store barrier may not itself allocate, collect or enter a safepoint before the store completes. A post-store path may not acknowledge a safepoint until its log is committed. Bulk mutations that can allocate or block need an explicit mutation scope or smaller completed, logged steps. Such a scope cannot cover a potentially unbounded native operation.
Use fixed-capacity thread-local log storage on the fast path. Duplicate records are acceptable. On overflow, atomically set
mustFinishSynchronouslybefore losing an entry. A thread in that condition must reach a safepoint before participating in another collection. No dropped record may be followed by sweeping from the concurrent result.Audit these paths explicitly:
Dirty a stable tracing owner, not an untracked backing-buffer address. Primitive byte writes do not need an edge barrier, but replacement of their GC-managed storage does. New allocations are retained for the entire cycle and recorded even if allocation happens before publication to another object. They must also have their outgoing edges captured before acceptance, since they can be the only path to an older allocation.
Native code that writes traced storage directly must use the supported barrier and safepoint contract. Native code that may change traced storage while in a GC-free zone does not satisfy that contract. A registered native component without supported capture and mutation behavior disables concurrent cycles process-wide in version one. An unknown extension cannot be made safe by waiting for verification to notice a lost edge.
Phases
Start pause
Acquire collector ownership independently of the world-stop flag. The existing collection ownership and world-stop mechanism must be separated: mutators need to run during
Trace, while competing collectors remain excluded.Stop all participating threads using the existing register/stack handshake. Finish incompatible reclaim jobs, allocate the minimum cycle metadata and establish the no-reclaim lifetime interval. Publish the active logging epoch to every context before resuming any of them.
Capture roots into handles: class statics, registered roots and offset roots, stacks/registers and zombie roots. Scan stacks only while their owners are stopped. Use the same conservative validation and pinning rules as the synchronous collector. Do not save a pointer to a stack location for a worker to inspect later.
Root capture seeds the graph but does not synchronously trace all descendants. Resume mutators and start workers over the published records. Missing object records produce capture requests.
Root-set changes and new thread attachment are serialized with cycle ownership and the handshake. Attaching threads must inherit the active logging epoch before accessing traced memory. Detaching threads must transfer their allocation and dirty logs before destroying their context.
Background tracing and capture pauses
Workers claim reachable handles and traverse available immutable edges. A reachable handle with no complete record requests capture. Workers never call application
__Mark()or__Visit().The coordinator requests a capture pause when outstanding requests or dirty work justify it. Park workers at a batch boundary and stop mutators. Drain completed logs, merge allocation records, validate pointers and capture requested objects and selected dirty holders.
A read-only
VisitContextadapter can collect immediate edges from generated__Visit()methods while paused. It must not rewrite slots, invoke user code, run finalizers or recursively trace the graph. Runtime types need an explicit semantic adapter audit. An inherited no-op visitor does not prove that a native type has no edges.Keep every previously captured strong edge for the remainder of the cycle and append edges from recaptures. Removing an edge never removes reachability already discovered in this cycle. Dirty objects whose handle is already reachable must have newly captured edges scheduled again; a visited bit cannot suppress that work. This is an incremental-update overapproximation, not a single instantaneous heap snapshot.
Publish completed batches, acknowledge drained logs while still paused, then resume threads and workers. A subsequent mutation creates a new log entry. Do not clear a dirty flag concurrently with a mutator setting it; version one can simply retain duplicate per-thread records instead of using shared header flags.
The pause budget is advisory. Generic
__Visit()is indivisible and may traverse a huge array or hash. Bounded range adapters can later slice those types, with layout inspected afresh while stopped and mutation generations checked between slices. Never resume a saved raw iterator into mutable storage. Without such adapters, a single large object can exceed the requested pause budget.Final pause and strong closure
Enter finalization when outstanding work is small, collection age reaches its limit, or allocation reserve is low. Park workers and stop all mutators. No subsequent mutator execution is allowed until closure and reclamation finish.
At acceptance every currently reachable strong object has a complete current outgoing-edge capture, or a retained older capture plus all subsequent mutations represented by a final recapture. New allocations are retained and their strong edges participate in closure. Final root contents are included. This can require tracing a large newly published graph while stopped.
Only after this check should the coordinator rotate the ordinary mark epoch, clear and reconstruct row/large-allocation liveness, and transfer the side reachable set to the existing collector's representation. Preserve allocation categories, container flags, pinned objects and finalizable-allocation rules. This needs a dedicated mark-publication routine; assigning a header byte alone does not establish valid row accounting.
Weak references, finalizers and reclaim
Weak references and weak-key maps require semantic adapters. The generic hash visitor visits keys and values and cannot supply a strong-edge snapshot for a weak-key map. Its internal storage remains live as appropriate, but weak keys and conditional values must not be inserted as ordinary strong edges.
For version one, defer ephemeron processing until the final pause and run it against the current heap. After publishing strong liveness, use the existing stopped-world marker to resolve conditional values and their descendants. Preserve the current ordering: weak-map closure, zombie discovery, a second weak-map closure, weak-reference/finalizer processing and script lifetime cleanup where supported. A newly reached ordinary object is scanned by the synchronous marker during this tail.
Keep current weak semantics, including any conservative retention. Do not allow a background worker to clear a weak slot, finalize an object or release a resource. Do not resurrect dead weak keys by treating the snapshot metadata itself as a GC root.
Perform current row/large-allocation sweeping and accounting while stopped. Any asynchronous zeroing or reclaim scheduled afterward must obey its existing ownership rules and must not race destruction of cycle records. Park and release all cycle workers before permitting address reuse.
Correctness argument and limits
Workers cannot race on application payload because only stopped threads capture it. Graph records are immutable and object storage cannot disappear during the cycle.
For a strong path reachable from a root at the final pause, its root is reseeded. For each successive live holder, either its captured edges are current or it was changed after capture and remains pending for recapture. Therefore closure discovers the next object on the path. Repeating this argument reaches every currently strongly reachable allocation. New allocations are retained explicitly and scanned for older descendants. Weak reachability is resolved afterward under the existing stopped-world rules.
This argument depends on complete mutation coverage, complete visitors, correct pointer normalization, the safepoint handshake and the absence of reclaim during the cycle. Each is a release gate, not an assumption to waive when a benchmark looks good. Overretention is permitted for a cycle; collecting a reachable allocation is not.
Total pause time still includes root capture, object-edge capture, the final dirty closure, mark publication, weak closure, finalizers and sweeping. Graph capture is proportional to the edges copied, and fresh records repeat that work each cycle. There is no heap-independent final-pause guarantee. Allocation throughput, retained garbage, additional CPU use and metadata memory can all regress.
Progress, reserve and fallback
Start a cycle before exhausting allocation reserve. Define soft thresholds for elapsed cycle time, metadata bytes, outstanding captures and dirty rate. Expose their effective values in diagnostics; choose defaults from measurements rather than embedding an untested millisecond promise.
On persistent mutation, insufficient reserve, metadata allocation failure, log overflow, invalid capture capability or an internal consistency failure: stop mutators, park workers, discard the side result and perform a complete ordinary major collection from current roots. Reset the ordinary collector's marking state independently; partial concurrent marks are not an authoritative starting point. The fallback must not require allocating another shadow graph.
If the ordinary collector cannot obtain sufficient memory, retain its established allocation-failure behavior. Concurrent mode does not guarantee recovery from process memory exhaustion.
Gc.runGc.compactGc.enable(false)The coordinator must not wait for a worker that is waiting for the coordinator's world-stop lock. Test all cancellation and exception exits explicitly. The independent fixes to current diagnostics and finalizer behavior remain prerequisites rather than changes included in this proposal.
Verification
Add
HXCPP_GC_CONCURRENT_VERIFY. Before reclaim, while all threads and workers are stopped, compute an independent synchronous reachability oracle. It needs separate visited state or a deliberately saved and restored mark epoch. Reusing concurrent marks would hide omissions.Compare strong reachability before weak processing, then verify weak/zombie closure using the same semantic rules. Every oracle-live allocation must survive the concurrent result. Extra survivors are counted as retention, not mistaken for a correctness failure. The oracle must not invoke finalizers, clear weak references or free modules; observable lifecycle processing happens once, after verification succeeds.
On mismatch, print the allocation identity, tracing category, phase and available predecessor/capture history, then fail the test before reclaim. Verification is a test tool and cannot substitute for audited barriers in a release build.
Tests before runtime implementation
First add conventional tests of existing collector behavior. Use the existing
test/regression/Name/{Main.hx,build.hxml,stdout.txt}layout, plus native helpers where necessary. Reuse relevant existing coverage instead of duplicating it. Tests should establish payload survival, eventual release and weak semantics independently of the new scheduling hooks.Then add a test-only phase controller with explicit acknowledgements for start, capture publication, final closure and fallback. Native tests can call it without a standard library API change. Synchronization must use latches or semaphores with timeouts, not sleeps that hope to hit an interleaving.
ConcurrentGcPublishAfterCaptureConcurrentGcLateReachableDirtyConcurrentGcNewToOldConcurrentGcBulkMutationConcurrentGcRootsAndThreadsConcurrentGcMutationBoundaryConcurrentGcWeakClosureConcurrentGcFallbackConcurrentGcNoAddressReuseConcurrentGcApiLifecycleConcurrentGcNativeParticipationFinalization tests must clear incidental stack roots and allow multiple collections. Do not require exact prompt reclamation in one cycle. Verify that the next ordinary cycle or completed subsequent concurrent cycles release floating garbage.
Add the define checks, stress configuration, documentation and CI jobs required by
CONTRIBUTING.mdbefore enabling the option. Run default collection as a control, concurrent plus verification, forced fallback, big blocks and supported architecture combinations. Keep generation-only and moving-only regression jobs as controls for the build-policy refactor. Use ASan/UBSan and TSan on supported toolchains, with allocator and metadata races treated separately from application-level races.Performance gate
Extend the benchmark suite with stable large object graphs, large pointer arrays, sparse maps, weak-map chains, burst allocation, new-to-old references, pointer churn and many-thread server-style request workloads. Run both fresh and warmed heaps, and include heaps that approach the configured memory reserve. Primitive byte buffers alone do not represent tracing cost.
Record identical payload work and heap limits across the existing collector, its existing parallel marker and the concurrent candidate. Include compiler, OS, CPU/core count, Haxe version, runtime revision, defines, worker count, warmup, repetitions and raw samples.
Report longest and percentile world-stop durations, total stopped time, safepoint waiting, capture bytes/time, graph tracing CPU, final strong closure, weak processing, sweep time, fallback reasons/count, throughput, request latency, peak committed memory and metadata high-water usage. Distinguish GC CPU time from elapsed cycle duration. Fallback pauses remain part of the reported distribution.
Agree workload-specific throughput and memory regression budgets before comparing results. Require repeatable pause improvement beyond run-to-run noise on the target server workloads. If copied edges, mark publication or the synchronous tail dominate, keep this experimental or stop this route. Do not describe it as a successful low-pause collector merely because worker threads run alongside mutators.
Delivery sequence
Source integration map
extraParams.hxmland a library-owned configuration macrotoolchain/common-defines.xml,include/hxcpp.hinclude/hx/GC.hinclude/hx/Object.h,include/Array.h,src/hx/Hash.hsrc/hx/gc/Immix.cppsrc/hx/Class.cppand root registriessrc/hx/cppia/Cppia.cppand runtime typestest/regression,test/gc_stress,test/gc-defines, CI and benchmarksNo runtime implementation, benchmark result or claim of proven pause improvement is included in this design.
dimensionscape commentedon Oct 26, 2026
Putting concurrent marking on hold while #372 is developed.
The local snapshot prototype did not meet the pause or throughput goals. It adds paused reference capture and metadata work, and the measured fallbacks make the longest interruptions worse than the ordinary collector. It should remain an experiment rather than the next collector rollout.
The immediate priority is incremental marking of the actual heap in short pauses, with automatic allocation-based pacing and optional library-owned scheduling hooks. This needs no frame-loop knowledge in hxcpp and is intended to avoid a compiler or standard library change.
The concurrent prototype and its test and benchmark results are being preserved separately. This issue stays open for later research. A future concurrent marker still needs a complete field, header and container-storage access protocol, along with its own correctness and performance gates.
dimensionscape commentedon Oct 27, 2026
The incremental collector is now pushed on
feature/incremental-gc, commit 67208e6b, with draft PR #380. Implementation status, tests, raw measurements and remaining gates are recorded in #372.This implementation traces the actual heap during stopped-world slices and retains progress between them. It does not run marker workers while application threads mutate fields. Concurrent marking remains deferred, and the separate concurrent prototype worktree is preserved.
The measured game workload has shorter tail frames, but incremental throughput and finishing-pause targets are still open. The existing collector remains the default. There is no claim that the concurrent design's goals have been completed by this PR.
Add opt-in incremental GC with automatic pacing