Skip to content

Aging dependent handles - #78746

Merged
janvorli merged 2 commits into
dotnet:mainfrom
VSadov:ageDep
Jun 18, 2026
Merged

Aging dependent handles#78746
janvorli merged 2 commits into
dotnet:mainfrom
VSadov:ageDep

Conversation

@VSadov

@VSadovVSadov commented Nov 23, 2022

Copy link
Copy Markdown
Member

We do not age dependent handles like all other kinds of handles. As a result dependent handles stay in Gen 0 and get visited on every GC. That is unnecessary work when dependent handles refer to tenured objects, which is a common case.

This change enables aging of dependent handles (and rejuvenation, when needed).

@ghostghost added the area-GC-coreclr label Nov 23, 2022
@ghostghost assigned VSadovNov 23, 2022
@ghost

Copy link
Copy Markdown

Tagging subscribers to this area: @dotnet/gc
See info in area-owners.md if you want to be subscribed.

Issue Details

We do not age dependent handles like all other kinds of handles. As a result dependent handles stay in Gen 0 and get visited on every GC. That is unnecessary work when dependent handles refer to tenured objects, which is a fairly common case.

This change enables aging of dependent handles (and rejuvenation, when needed).

Author:VSadov
Assignees:-
Labels:

area-GC-coreclr

Milestone:-

@VSadov
VSadov requested a review from jkotasNovember 23, 2022 05:37
@Maoni0

Copy link
Copy Markdown
Member

what kind of testing has been done on this?

@VSadov

VSadov commented Nov 23, 2022

Copy link
Copy Markdown
MemberAuthor

what kind of testing has been done on this?

I think regular libraries tests are sensitive enough to this change.

There are two parts in the change:

  • the actual aging is very simple and works exactly the same as with other kinds of handles. I do not think this can go wrong. Aging is optional - even if we do not age, nothing bad will happen. And aging is conservative - it just increases the age of the handle as it is visited by GC, it does not need to look at the referenced objects as they get checked/aged in the same GC cycle.

  • the rejuvenation is precise and thus is more delicate.
    Unlike with ordinary handles, we also need to look at the generation of the secondary object, in case it is younger than the primary. This is the same approach as with async pinned handles where the age of the handle is assumed to be the minimum of the ages of all objects it refers to.
    If I intentionally do not consider the secondary object in the age computation, many libraries tests fail. Some tests like System.Text.RegularExpressions fail 100% consistently. I think we have sufficient coverage for rejuvenation scenario.

@VSadov

Copy link
Copy Markdown
MemberAuthor

I think I can run System.Text.RegularExpressions test in a loop 100 times, in Release and Checked configurations - with Server GC to be sure.

That will be a few hours of stress, so it may take a while.

@Maoni0

Copy link
Copy Markdown
Member

I'm much more worried about the possibility of this causing a heap corruption which could easily take weeks to debug. do you have a real customer workload (that's not a simple test) that uses dependent handles that demonstrate the perf benefits? the problem with little tests is you could run it for hours but due to the simple patterns it generates, longer testing will not show more problems.

@VSadov

Copy link
Copy Markdown
MemberAuthor

do you have a real customer workload (that's not a simple test) that uses dependent handles that demonstrate the perf benefits?

Similarly to other handle kinds, reducing dependent handle scans should be a win, but, no, I do not have an actual app that demonstrates the benefits.

@Maoni0

Copy link
Copy Markdown
Member

DHs are a lot more intricate than other handle types and our testing there is not great. so I would not consider doing this now - the amount of risk it brings does not justify the code churn.

@VSadov

Copy link
Copy Markdown
MemberAuthor

The change does not touch the "tricky" parts of dependent handles. The iterative tracing is tricky. This is in handle aging, which is simple. There is no need for a handle to be Gen-N when the objects it represents are older than Gen-N.
It looks like someone just missed to add dependent handle kind in the methods that update the handle ages. This early in release cycle could be a good time to fix issues like this.

@cshung

Copy link
Copy Markdown
Contributor

do you have a real customer workload (that's not a simple test) that uses dependent handles that demonstrate the perf benefits?

Similarly to other handle kinds, reducing dependent handle scans should be a win, but, no, I do not have an actual app that demonstrates the benefits.

We do have a scenario where not aging DependentHandle is causing a problem here.

@VSadov

Copy link
Copy Markdown
MemberAuthor

One thing that @cshung asked me to mention, as another data point, is that NativeAOT uses dependent handles extensively.

Since the runtime is mostly managed code, DependentHandle or ConditionalWeakTable it is a logical choice when arbitrary objects need to be augmented with some additional data (monitor locks, COM info, marshalling stubs, etc..).

This does not mean that every NativeAOT program uses a lot of dependent handles. The uses are pay-for-play, so some small programs may not create any.
It is however likely that real-world size NativeAOT programs will use some dependent handles. I.E. blocking on a monitor lock will result in a Lock object referenced via a dependent handle.

====== A few examples where NativeAOT runtime uses dependent handles.

In Monitor Locks:

DependentHandlehandle=newDependentHandle(obj,collector);

In Monitor Conditions:

privatestaticConditionalWeakTable<object,Condition>s_conditionTable=newConditionalWeakTable<object,Condition>();

In COM interop:

privatereadonlyConditionalWeakTable<object,ManagedObjectWrapperHolder>_ccwTable=newConditionalWeakTable<object,ManagedObjectWrapperHolder>();

In PInvoke delegate thunks

privatestaticConditionalWeakTable<Delegate,PInvokeDelegateThunk>s_pInvokeDelegates;

@VSadov

Copy link
Copy Markdown
MemberAuthor

Per offline discussion - with all the ongoing work and investigations in GC area, this is not something that GC team would be comfortable with taking at the moment.

We can track this as an issue for some future milestone.

@VSadov

Copy link
Copy Markdown
MemberAuthor

@EgorBot -amd -intel -arm

usingSystem;usingSystem.Runtime;usingBenchmarkDotNet.Attributes;// Benchmark for https://github.com/dotnet/runtime/pull/78746 — "Aging dependent handles".//// Pathology this PR fixes:// Dependent handles never age, so they stay in Gen 0 and get visited on every// ephemeral GC, even when their primary/secondary objects are already in Gen 2.//// Setup:// * Allocate N DependentHandles with a strongly-rooted primary and a small secondary.// * Force 3 blocking Gen 2 collections so everything tenures to Gen 2.// (Post-PR the handles also age; pre-PR they stay at age 0 forever.)//// Measure:// * Cost of one forced Gen 0 / Gen 1 / Gen 2 collection.//// Expected post-PR delta:// * ForcedGen0, ForcedGen1: large speedup; cost should become ~independent of N.// * ForcedGen2: roughly flat (Gen 2 already scanned dependent handles).[MemoryDiagnoser][GcServer(true),GcConcurrent(false)]publicclassDependentHandleAging{privateobject[]?_keys;privateDependentHandle[]?_handles;[Params(100_000,1_000_000)]publicintN;[GlobalSetup]publicvoidSetup(){_keys=newobject[N];_handles=newDependentHandle[N];for(inti=0;i<N;i++){// Key is strongly rooted by the _keys array so the DH entry stays live.objectkey=new();_keys[i]=key;_handles[i]=newDependentHandle(key,newbyte[16]);}// Tenure handles + referents to Gen 2 (this is the scenario the PR targets).for(inti=0;i<3;i++){GC.Collect(2,GCCollectionMode.Forced,blocking:true,compacting:true);GC.WaitForPendingFinalizers();}// Sanity check: the primaries should be in Gen 2 before measurement starts.intgen=GC.GetGeneration(_keys[0]);if(gen!=GC.MaxGeneration)thrownewInvalidOperationException($"Setup did not tenure to Gen{GC.MaxGeneration}; got Gen{gen}.");}[GlobalCleanup]publicvoidCleanup(){if(_handlesisnull)return;for(inti=0;i<_handles.Length;i++)_handles[i].Dispose();_handles=null;_keys=null;}// Pre-PR: must visit every dependent handle (they're stuck in Gen 0).// Post-PR: handles aged to Gen 2 -> Gen 0 GC skips them entirely.[Benchmark]publicvoidForcedGen0()=>GC.Collect(0,GCCollectionMode.Forced,blocking:true);// Same pathology as Gen 0 — ephemeral GCs scan dependent handles pre-PR.[Benchmark]publicvoidForcedGen1()=>GC.Collect(1,GCCollectionMode.Forced,blocking:true);// Control: Gen 2 always scans dependent handles, so this should be ~unchanged// (or fractionally slower because rejuvenation now looks at the secondary too).[Benchmark]publicvoidForcedGen2()=>GC.Collect(2,GCCollectionMode.Forced,blocking:true);}

@VSadov

VSadov commented Jun 12, 2026

Copy link
Copy Markdown
MemberAuthor

On the microbenchmark, depending on number of dependent handles we can spend 10X to 30X less time in GC pauses.

With this change, ephemeral GCs get practically insensitive to the quantity of the handles.
As the objects that are referenced are promoted to higher generations, the handles also aged and thus skipped in bulk in handle scanning.
(there is still cost in Gen2, as the handles and objects they refer to are still there)

Results for ubuntu24_azure_emeraldrapids

@VSadov

BenchmarkDotNet v0.15.8, Linux Ubuntu 24.04.4 LTS (Noble Numbat)
INTEL XEON PLATINUM 8573C 2.30GHz, 1 CPU, 8 logical and 4 physical cores
.NET SDK 11.0.100-preview.6.26311.113
[Host] : .NET 10.0.9 (10.0.9, 10.0.926.27113), X64 RyuJIT x86-64-v4
Concurrent=False Server=True 
MethodToolchainNMeanErrorRatioGen0Gen1Gen2AllocatedAlloc Ratio
ForcedGen0PR #78746**100000142.6 μs1.78 μs1.001000.0000---NA
ForcedGen0main1000001,552.2 μs10.53 μs10.881000.0000---NA
ForcedGen1PR #78746100000150.4 μs1.12 μs1.001000.00001000.0000--NA
ForcedGen1main1000001,554.1 μs5.61 μs10.331000.00001000.0000--NA
ForcedGen2PR #787461000003,543.3 μs48.23 μs1.001000.00001000.00001000.0000109 B1.00
ForcedGen2main1000003,567.9 μs48.16 μs1.011000.00001000.00001000.000096 B0.88
ForcedGen0PR #78746**1000000437.4 μs8.74 μs1.001000.0000---NA
ForcedGen0main100000013,014.5 μs24.84 μs29.781000.0000---NA
ForcedGen1PR #787461000000432.8 μs8.50 μs1.001000.00001000.0000--NA
ForcedGen1main100000013,024.3 μs40.48 μs30.131000.00001000.0000--NA
ForcedGen2PR #78746100000027,114.2 μs519.39 μs1.001000.00001000.00001000.0000116 B1.00
ForcedGen2main100000027,248.0 μs542.15 μs1.011000.00001000.00001000.0000105 B0.91

Full logs

@dotnetdotnet unlocked this conversation Jun 12, 2026
Comment threadsrc/coreclr/gc/handletablescan.cpp Outdated
@VSadov

Copy link
Copy Markdown
MemberAuthor

what kind of testing has been done on this?

Satori has this fix for a few years now. There were numerous stress runs and investigations for unrelated bugs since then. This fix was never causing trouble and now seems an unnecessarily diff between two GCs.

I highly suspect it was never intentional to have an age-less handle in a generational GC and it may be just a result of some typo or mismerge.

Perhaps it is time to merge this in main?

@VSadov
VSadov marked this pull request as ready for review June 12, 2026 15:30

@VSadovVSadov left a comment

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.

Comment threadsrc/coreclr/gc/handletablescan.cpp Outdated
Comment threadsrc/coreclr/gc/handletablescan.cpp Outdated
@jkotas
jkotas requested review from janvorli and kkokosaJune 12, 2026 15:43
@jkotas

Copy link
Copy Markdown
Member

Perhaps it is time to merge this in main?

Sounds good to me.

@janvorli@kkokosa PTLA

@mangod9

Copy link
Copy Markdown
Member

We do not age dependent handles like all other kinds of handles. As a result dependent handles stay in Gen 0 and get visited on every GC. That is unnecessary work when dependent handles refer to tenured objects, which is a common case.

This change enables aging of dependent handles (and rejuvenation, when needed).

was there an original reason for not aging dependent handles?

@VSadov

Copy link
Copy Markdown
MemberAuthor

We do not age dependent handles like all other kinds of handles. As a result dependent handles stay in Gen 0 and get visited on every GC. That is unnecessary work when dependent handles refer to tenured objects, which is a common case.
This change enables aging of dependent handles (and rejuvenation, when needed).

was there an original reason for not aging dependent handles?

I can’t think of a reason to do this intentionally.

@kkokosa

Copy link
Copy Markdown
Member

Well, I can't think of any reason either. Please let me settle in a little before I express my final opinion.

@janvorli

Copy link
Copy Markdown
Member

/ba-g the failure is #129329

@janvorli
janvorli merged commit f39bc38 into dotnet:mainJun 18, 2026
109 of 113 checks passed
@VSadov
VSadov deleted the ageDep branch June 18, 2026 13:21
@VSadov

Copy link
Copy Markdown
MemberAuthor

Thanks!

@dotnet-milestone-botdotnet-milestone-botBot added this to the 11.0-preview6 milestone Jun 19, 2026
eiriktsarpalis pushed a commit that referenced this pull request Jul 15, 2026
We do not age dependent handles like all other kinds of handles. As a
result dependent handles stay in Gen 0 and get visited on every GC. That
is unnecessary work when dependent handles refer to tenured objects,
which is a common case.
This change enables aging of dependent handles (and rejuvenation, when
needed).
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jul 20, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants

@VSadov@Maoni0@cshung@jkotas@mangod9@kkokosa@janvorli