Skip to content

IL: fix the per-reader string cache sizing - #20261

Open
auduchinok wants to merge 1 commit into
dotnet:mainfrom
auduchinok:il-stringCacheSizing
Open

IL: fix the per-reader string cache sizing#20261
auduchinok wants to merge 1 commit into
dotnet:mainfrom
auduchinok:il-stringCacheSizing

Conversation

@auduchinok

Copy link
Copy Markdown
Member

ILMetadataReader keeps two string tables per referenced assembly. Both were sized from something
unrelated to how many strings are actually read.

cacheStringHeap was sized stringsStreamSize / 50 + 1, i.e. from the length of the #Strings stream.
Only a small fraction of a #Strings heap is ever read, so the table sat around 11% full — one
nearly-empty table per reference. It is now sized to grow.

memoizeString interned one computed string: the ns + "." + name concatenation in
readBlobHeapAsTypeName. Every caller of that function is already cached or one-shot per row
(typeDefReader, seekReadTypeDefAsTypeRefUncached, seekReadTypeRefUncached, and the exported-type
readers), so the table could only pay when two different rows produce identical text. It went through
Tables.memoize, whose fixed 1000-entry capacity cost about 3.0 MB in buckets across a large reference
set while collapsing at most 0.24 MB of duplicate names — so the table is removed rather than resized.
Tables.memoize keeps its ilmorph.fs caller.

Retained memory after ParseAndCheckProject, mean of 3 runs in fresh processes per project:

Project Before After Retained
consoleapp 33.25 MB 31.11 MB -2.14 MB (-6.4%)
Oxpecker 69.65 MB 65.77 MB -3.88 MB (-5.6%)
IcedTasks 47.10 MB 44.93 MB -2.17 MB (-4.6%)
FsToolkit 69.97 MB 67.86 MB -2.11 MB (-3.0%)
Fantomas.Benchmarks 64.26 MB 61.33 MB -2.93 MB (-4.6%)
Prime 100.99 MB 99.44 MB -1.55 MB (-1.5%)
Fantomas.Core 107.74 MB 106.21 MB -1.53 MB (-1.4%)
Fantomas.Core.Tests 140.20 MB 137.61 MB -2.59 MB (-1.8%)
FSharp.Common 261.81 MB 251.68 MB -10.13 MB (-3.9%)
fcs 1226.73 MB 1223.99 MB -2.74 MB (-0.2%)

The saving is two tables per reader, so it scales with the number of referenced assemblies rather than
project size: the smallest subject gains most in relative terms, and FSharp.Common, with 489 references,
most in absolute terms. Total allocation drops 1-34 MB per project. Analysis time is unchanged within
measurement noise.

Without the intern table, ~2,077 duplicate name strings survive per FSharp.Common analysis (~0.24 MB,
an upper bound). 98% are one structural pair: the same type def read once as ILTypeDef.Name and once
as ILTypeRef.Name. That is the 0.24 MB the table used to collapse, and it is well below what the table
itself cost.

ILMetadataReader keeps two string tables per referenced assembly. Both were
sized from something unrelated to how many strings are actually read, and the
second one had nothing to cache.

cacheStringHeap was sized stringsStreamSize / 50 + 1, i.e. from the length of
the #Strings stream. Only a small fraction of a #Strings heap is ever read, so
the table sat around 11% full: one nearly-empty table per reference. It is now
sized to grow.

memoizeString had a single caller, the ns + "." + name concatenation in
readBlobHeapAsTypeName. Every caller of that function is already cached or
one-shot per row (typeDefReader, seekReadTypeDefAsTypeRefUncached,
seekReadTypeRefUncached, and the exported-type readers), so the concatenation
happens about once per typedef, typeref or exported-type row, and the table
could only pay when two different rows produced identical text: the same name
under a different resolution scope, or a type forwarder. Measured
within-assembly retained string duplication is 0.00 MB, so it collapsed
nothing, while holding every namespaced type name alive for the reader's
lifetime as both key and value. Removed.

Retained memory after ParseAndCheckProject drops 1.5-10.1 MB per project
(-1.4% to -6.4%) across ten projects, and total allocation drops 1-34 MB. The
saving scales with the number of referenced assemblies rather than project
size, since the cost was two tables per reader, so the smallest subject gains
most in relative terms and the one with 489 references gains most in absolute
terms. Analysis time is unchanged within measurement noise.

Tables.memoize still has a caller in ilmorph.fs, so it stays.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

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:

Warning

No PR link found in some release notes, please consider adding it.

Change path Release notes path Description
`src/Compiler` docs/release-notes/.FSharp.Compiler.Service/11.0.100.md No current pull request URL (#20261) found, please consider adding it

@github-actions github-actions Bot added the AI-Tooling-Check-Scanned-Clean Tooling check: diff analyzed, no interesting infrastructure files label Aug 14, 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

Status: New

Development

Successfully merging this pull request may close these issues.

1 participant