Skip to content

ZeroGC: stop depending on MethodTable internal layout for IsLargeObject - #3298

Open
kkokosa wants to merge 2 commits into
dotnet:feature/ZeroGCfrom
kkokosa:fix/zerogc-no-methodtable-dep
Open

ZeroGC: stop depending on MethodTable internal layout for IsLargeObject#3298
kkokosa wants to merge 2 commits into
dotnet:feature/ZeroGCfrom
kkokosa:fix/zerogc-no-methodtable-dep

Conversation

@kkokosa

@kkokosa kkokosa commented Aug 3, 2026

Copy link
Copy Markdown
Member

Summary

Removes ZeroGC's only dependency on MethodTable's internal field layout, in response to a question about whether ZeroGC breaks the standalone-GC design goal (per Maoni) of letting a newer GC binary run against an older runtime as long as possible.

Background

CoreCLR's actual GC/EE interface (gcinterface.h's GC_INTERFACE_MAJOR_VERSION/GC_INTERFACE_MINOR_VERSION) is explicitly designed for this: the EE only rejects a loaded GC whose reported major version is older than expected (gcheaputilities.cpp) - an equal-or-newer GC is accepted. That mechanism is untouched by ZeroGC and still works as designed.

Where ZeroGC actually diverges: it's compiled directly against the VM's internal object-model headers (gcenv.object.h's MethodTable/Object classes, which mirror the real VM-internal layout byte-for-byte) rather than staying entirely within the versioned public interface surface. Those internal types have no separate version negotiation - they can change shape across runtime major versions with zero interface-version bump. Auditing the codebase, this was needed in exactly one place: ZeroGCHeap::IsLargeObject, which called pObj->GetGCSafeMethodTable()->GetBaseSize().

What changed

IsLargeObject no longer touches the object's MethodTable at all. ZeroGC already segregates large/pinned allocations into their own dedicated arena chunk at allocation time (see the isLarge branch in AllocateFromArena), so it already knows an allocation's large/small-ness without asking the object. This change:

  • Records the exact address range of every allocation meeting the LOH size threshold (alignedSize >= LARGE_OBJECT_SIZE, matching the exact semantics of the removed check - deliberately narrower than the broader isLarge chunk-placement flag, which also covers small pinned/POH allocations that are not "large objects" by size) into a std::map<start, end> guarded by a critical section.
  • IsLargeObject now does a range lookup (upper_bound) against that map instead of dereferencing the object's MethodTable.

What's still required (and why)

The gcenv.*.h shim headers remain a hard dependency - Object*/MethodTable*/gc_alloc_context* type names appear throughout the mandatory IGCHeap/IGCHandleManager interface signatures ZeroGC must implement, which is unavoidable for any standalone GC. What changed is that ZeroGC no longer dereferences any field inside MethodTable's actual struct layout anywhere in the codebase - so that header's internal field-layout stability (which is not covered by the GC interface's version contract) no longer affects ZeroGC's correctness.

Testing

  • Builds clean via src/ZeroGC/native/build.ps1.
  • Smoke-tested against GCPerfSim with -lohar/-lohsr (LOH) and pinned-object (pin every 100, POH) allocation args - exercises exactly the code path this change touches. No crashes; collection_counts: [0, 0, 0] as expected for ZeroGC.

Konrad Kokosa added 2 commits August 3, 2026 16:28
gcperfsim-cache (Workstation and Server GC modes) previously had no raw
per-second dotnet-counters CSV capture in results/raw/, only the ZeroGC
capture existed. This meant its TimeSeries.PauseTimePct chart data (stride-
decimated to ~150 points from the full capture) could not be cross-checked
or re-derived from source, and its reported PauseTimePctStats.Max included
a rare one-off pause spike (11.80% Workstation) that the decimated chart
series did not show (chart max ~0.46%), a ~26x visual understatement in
report.html.

This change re-runs gcperfsim-cache for both Workstation and Server GC for
the same 600s duration as the rest of the suite, using an isolated output
directory so the other 31 existing runs in results-full.json are left
untouched, then merges just the two refreshed gcperfsim-cache entries back
in and regenerates report.html (synced to docs/zerogc/report.html).

Note: the new capture's Workstation run does not reproduce the prior rare
pause spike (new Max 0.47% vs old 11.80%) -- GC pause spikes are inherently
stochastic (e.g. one-off blocking Gen2 collection under memory pressure),
so this is expected run-to-run noise, not a regression. Avg/P50/P90 for
Workstation are consistent with the prior run (Avg 0.142% -> 0.120%,
P50 0.149% -> 0.147%, P90 0.354% -> 0.350%). Full raw CSVs are now checked
in for both modes, closing the previously-missing data gap.
…ject

IsLargeObject(Object*) was the only place in ZeroGC that dereferenced a
MethodTable's internal fields (via GetGCSafeMethodTable()->GetBaseSize()).
MethodTable/Object's field layout (as mirrored in gcenv.object.h) is private
VM implementation detail, not part of the versioned GC/EE interface
(gcinterface.h's GC_INTERFACE_MAJOR/MINOR_VERSION) - it can change shape
across runtime major versions with no interface version bump at all, which
is exactly why ZeroGC.dll binaries are pinned to one target runtime major
version.

Since ZeroGC already segregates large/pinned objects into their own
dedicated arena chunk at allocation time (the isLarge branch in
AllocateFromArena), it already knows which objects are large without asking
the object itself. This change records the exact address range of every
allocation that meets the LOH size threshold (alignedSize >= LARGE_OBJECT_SIZE,
matching the removed check's semantics precisely - not the broader isLarge
flag, which also covers small pinned/POH allocations) into a small
std::map<start, end> under a critical section, and answers IsLargeObject
via a range lookup instead.

This removes ZeroGC's only remaining runtime-internal-layout dependency for
object introspection. The gcenv.*.h shim headers are still required (their
Object*/MethodTable*/gc_alloc_context* type names appear throughout the
mandatory IGCHeap/IGCHandleManager interface signatures - unavoidable for
any standalone GC), but ZeroGC no longer dereferences any field inside
MethodTable's actual struct layout anywhere, so that header's internal
field-layout stability no longer matters for this GC's correctness.

Verified: builds clean (native/build.ps1), and smoke-tested against
GCPerfSim with -lohar/-lohsr/pinned-object args (exercises the LOH and POH
allocation paths this change touches) - no crashes, collection_counts stay
[0,0,0] as expected for ZeroGC.
@jkotas

jkotas commented Aug 3, 2026

Copy link
Copy Markdown
Member

Those internal types have no separate version negotiation - they can change shape across runtime major versions with zero interface-version bump

This is invalid statement.

MethodTable data required to walk the object graph are a binary contract between the GC and EE for performance reasons. Regular shipping GC depends on this data heavily.

Changing this data in incompatible way would definitely require major GC/EE interface version bump.

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.

2 participants