Skip to content

Avoid leaking a MeterListener per Cache in DEBUG builds#19995

Open
NatElkins wants to merge 1 commit into
dotnet:mainfrom
NatElkins:fix-cachemetrics-listener-leak
Open

Avoid leaking a MeterListener per Cache in DEBUG builds#19995
NatElkins wants to merge 1 commit into
dotnet:mainfrom
NatElkins:fix-cachemetrics-listener-leak

Conversation

@NatElkins

@NatElkins NatElkins commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

In DEBUG builds, Cache (src/Compiler/Utilities/Caches.fs) created a CacheMetrics.CacheMetricsListener per instance. Each listener starts a System.Diagnostics.Metrics.MeterListener that registers in the process-global metrics registry and was never disposed, so the listeners accumulated for the lifetime of the process.

Because every cache hit/miss/add publishes a measurement to all registered listeners, the cost of each cache operation grows linearly with the number of leaked listeners. Workloads that create many caches over time (repeated ParseAndCheckProject, per-file checks, long IDE sessions, FCS test runs) therefore slow down steadily.

How it shows up

I found this while measuring repeated project checks against a Debug FCS. Driving ParseAndCheckProject on the same project across 10 edits (only one file changed each time, so the incremental check should be roughly constant):

edit per-edit typecheck (ComputeProjectExtras)
1 945 ms
10 7758 ms

A heap dump after those 10 edits showed the accumulation directly:

  • 16,338 System.Diagnostics.Metrics.MeterListener
  • 16,338 CacheMetrics.CacheMetricsListener
  • 98,028 System.Diagnostics.DiagNode<...Instrument> + 98,028 DiagNode<...ListenerSubscription> (the global metrics subscription lists)

The obvious suspects were ruled out first: cache size factor (1 vs 100 both grow), the project snapshot, a fresh FSharpChecker per edit (still grows, so it is process-global), and GC (a forced full collection each check, still grows). The dump named the accumulator.

Fix

The per-cache CacheMetricsListener existed only to back DebugDisplay(). Instead of standing up a MeterListener per cache, the cache now tracks those totals directly in a small Stats object, incremented alongside the existing global Meter counters via a single recordMetric helper. No MeterListener is created, so nothing leaks and there is no per-operation "publish to all listeners" cost. DebugDisplay() keeps working with no configuration, and Release builds are unchanged. The now-unused CacheMetrics.Hit/Miss/Add/Update/Eviction/EvictionFail helpers are removed in favour of recordMetric.

With the listener removed, the same loop is flat:

edit per-edit typecheck
1 620 ms
10 ~125 ms (flat)

Notes

  • The leak is gated behind #if DEBUG, so Release builds were already unaffected. The impact is on Debug FCS: the compiler-dev inner loop, profiling accuracy (a Debug profile is dominated by the metrics publish-to-all-listeners cost), and Debug FCS test runs.
  • This reproduces on main and is independent of any feature work. The per-cache debugListener was introduced in Print cache stats with --times #18930.

@github-actions

github-actions Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

❗ Release notes required

You can open this PR in browser to add release notes: open in github.dev


✅ Found changes and release notes in following paths:

Change path Release notes path Description
src/Compiler docs/release-notes/.FSharp.Compiler.Service/11.0.100.md

In DEBUG builds, every Cache instance created a CacheMetrics.CacheMetricsListener,
which starts a System.Diagnostics.Metrics.MeterListener registered in the
process-global metrics registry. These were never disposed, so they accumulated for
the lifetime of the process. Because every cache hit/miss/add publishes a measurement
to all registered listeners, the per-operation cost grew linearly with the number of
leaked listeners, so workloads that create many caches (for example repeated
ParseAndCheckProject / per-file checks) slowed down steadily.

Track the per-cache totals used by DebugDisplay directly, incrementing a small Stats
object alongside the existing global Meter counters, instead of via a per-cache
MeterListener. No listener is created, so nothing leaks, and DebugDisplay still works.
The now-unused CacheMetrics.Hit/Miss/Add/Update/Eviction/EvictionFail helpers are
replaced by a single recordMetric helper.
@NatElkins NatElkins force-pushed the fix-cachemetrics-listener-leak branch from c3e009a to f96b70e Compare June 25, 2026 18:25
@NatElkins NatElkins marked this pull request as ready for review June 25, 2026 20:44
@NatElkins NatElkins requested a review from a team as a code owner June 25, 2026 20:44
@github-actions github-actions Bot added the AI-Tooling-Check-Scanned-Clean Tooling check: diff analyzed, no interesting infrastructure files label Jun 25, 2026
recordMetric CacheMetrics.updates
post (EvictionQueueMessage.Update result)

member _.CreateMetricsListener() =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

CacheMetricsListener is used only in tests and it just wraps Stats. It would be great to get rid of it.

@@ -32,12 +32,6 @@ module CacheMetrics =
tags.Add("cacheId", box cacheId)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We should get rid of this cacheId also. This is used only to filter per-instance and without doubt bogs things down when any exporter is connected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI-Tooling-Check-Scanned-Clean Tooling check: diff analyzed, no interesting infrastructure files

Projects

Status: New

Development

Successfully merging this pull request may close these issues.

2 participants