Skip to content

Avoid leaking a MeterListener per Cache in DEBUG builds#19995

Merged
T-Gro merged 9 commits into
dotnet:mainfrom
NatElkins:fix-cachemetrics-listener-leak
Jul 8, 2026
Merged

Avoid leaking a MeterListener per Cache in DEBUG builds#19995
T-Gro merged 9 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
Comment thread src/Compiler/Utilities/Caches.fs Outdated
Comment thread src/Compiler/Utilities/Caches.fs Outdated
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.
Comment thread tests/FSharp.Compiler.Service.Tests/OverloadCacheTests.fs
Comment thread src/Compiler/Utilities/Caches.fs
@github-project-automation github-project-automation Bot moved this from New to In Progress in F# Compiler and Tooling Jul 7, 2026
@NatElkins

Copy link
Copy Markdown
Contributor Author

@T-Gro I think this should be ready as soon as it goes green.

@T-Gro T-Gro self-requested a review July 8, 2026 14:31
@T-Gro T-Gro enabled auto-merge (squash) July 8, 2026 14:32
@T-Gro T-Gro merged commit 7192705 into dotnet:main Jul 8, 2026
50 checks passed
@github-project-automation github-project-automation Bot moved this from In Progress to Done in F# Compiler and Tooling Jul 8, 2026
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

Archived in project

Development

Successfully merging this pull request may close these issues.

4 participants