Skip to content

Avoid leaking a MeterListener per Cache in DEBUG builds#19995

Open
NatElkins wants to merge 5 commits into
dotnet:mainfrom
NatElkins:fix-cachemetrics-listener-leak
Open

Avoid leaking a MeterListener per Cache in DEBUG builds#19995
NatElkins wants to merge 5 commits 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
Comment thread src/Compiler/Utilities/Caches.fs Outdated
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, removed it. That per-cache listener was the leak: each Cache created one via the #if DEBUG debugListener and never disposed it, so dropping the type fixes both at once. DebugDisplay and the cache tests now read the name-aggregated stats from the single ListenToAll listener via getTotalsByName/getRatioByName. Removed CreateOverloadCacheMetricsListener too.

Comment thread src/Compiler/Utilities/Caches.fs Outdated
@@ -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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done, cacheId and nextCacheId are gone. Measurements now carry just the name tag, so nothing filters per instance anymore and the payload sent to any connected exporter is a little smaller.

Comment thread src/Compiler/Utilities/Caches.fs Outdated
// System.Diagnostics.Metrics.MeterListener which registers in the process-global metrics registry,
// so one per Cache would never be disposed (leak) and would make every measurement O(number of
// caches) as listeners accumulate.
#if DEBUG

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This is only needed when developing features that interact with the caches.
If the impact is so bad, I would not oppose adding a different directive here (DEBUG_COMPILER_CACHES) to reduce the blast radius.

Even though it has been really useful in investigating strange perf constellations.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Ended up not needing a directive. The per-cache stats tracking this was about is gone entirely, so there is no per-op cost in DEBUG (or any config) left to gate. Stats only accumulate while a ListenToAll listener is attached (--times, the editor metrics view, or a test), so it is opt-in by lifetime. Happy to add DEBUG_COMPILER_CACHES anyway if you would still like the knob.

NatElkins added 4 commits July 6, 2026 19:44
- Remove the CacheMetrics.CacheMetricsListener type. Its only per-cache use
  was the #if DEBUG debugListener each Cache created and never disposed, which
  was the leak this PR set out to fix. (majocha)
- Drop the per-instance cacheId tag (and nextCacheId). Measurements now carry
  only the cache name, shrinking the payload published to any connected
  exporter and removing the per-instance filtering that was cacheId's only
  purpose. (majocha)
- DebugDisplay and the cache tests read the existing name-aggregated stats via
  CacheMetrics.getTotalsByName / getRatioByName, populated by the single
  process-wide ListenToAll listener. No per-cache listener is created and no
  per-operation cost is added in any configuration, so there is no DEBUG-only
  overhead left to gate behind a separate directive. (T-Gro)
- Overload-cache tests enable ListenToAll and snapshot totals before/after to
  stay scoped to their own compilation; FSharpChecker
  .CreateOverloadCacheMetricsListener is removed.
CacheMetricsListener was a public type, so dropping it changes the recorded
public surface. Remove its 10 entries from
FSharp.Compiler.Service.SurfaceArea.netstandard20.bsl; the SurfaceArea test
now passes. Also note the single-listener assumption the cache metric tests
rely on.
[<Fact>]
let ``Overload cache hit rate exceeds 70 percent for repetitive int-int calls`` () =
use listener = FSharpChecker.CreateOverloadCacheMetricsListener()
use _ = CacheMetrics.ListenToAll()

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.

This is the global state that makes OverloadCacheTests not thread safe / not parallelizable. It seems there was no comment about it. Would be nice to have it clarified for future readers :)

Comment on lines +401 to +404
// Shows the totals aggregated for this cache's name. Populated only while a metrics listener
// (CacheMetrics.ListenToAll, e.g. under --times or the editor's metrics view) is running.
member _.DebugDisplay() =
(CacheMetrics.getStatsByName name).ToString()

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.

Previously each instance held its own stats via a listener (bad). The Stats object is lighter and could be used directly to keep the functionality of per instance stats.

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.

3 participants