From d3fdcde52aeaf153a0cc28c99bb1bfbaac772691 Mon Sep 17 00:00:00 2001 From: Eugene Auduchinok Date: Fri, 14 Aug 2026 08:24:38 +0200 Subject: [PATCH] IL: fix the per-reader string cache sizing 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) --- .../.FSharp.Compiler.Service/11.0.100.md | 1 + src/Compiler/AbstractIL/ilread.fs | 11 +++++------ 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md index 7dd68964cb3..583de4c08d4 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.100.md @@ -166,6 +166,7 @@ * Add symbol and type highlighting to F# diagnostics ([PR #20097](https://github.com/dotnet/fsharp/pull/20097)) * IL: add `ILPreNamespace`, make `ILPreTypeDef` creation lazy ([PR #20092](https://github.com/dotnet/fsharp/pull/20092)) * IL: use empty tables for members when possible ([PR #20249](https://github.com/dotnet/fsharp/pull/20249)) +* IL: size the string heap cache to grow and remove the type name intern table ### Improved diff --git a/src/Compiler/AbstractIL/ilread.fs b/src/Compiler/AbstractIL/ilread.fs index bc31548cbdd..0807e31f6fc 100644 --- a/src/Compiler/AbstractIL/ilread.fs +++ b/src/Compiler/AbstractIL/ilread.fs @@ -1109,7 +1109,6 @@ type ILMetadataReader = blobsStreamPhysicalLoc: int32 blobsStreamSize: int32 readUserStringHeap: int32 -> string - memoizeString: string -> string readStringHeap: int32 -> string readBlobHeap: int32 -> byte[] guidsStreamPhysicalLoc: int32 @@ -2081,7 +2080,7 @@ and readBlobHeapAsTypeName ctxt (nameIdx, namespaceIdx) = match nspace with | None -> name - | Some ns -> ctxt.memoizeString (ns + "." + name) + | Some ns -> ns + "." + name and seekReadTypeDefRowExtents (ctxt: ILMetadataReader) _info (idx: int) = if idx >= ctxt.getNumRows TableNames.TypeDef then @@ -4246,7 +4245,7 @@ let openMetadataReader let firstStreamLength = seekReadInt32 mdv (streamHeadersStart + 4) firstStreamOffset, firstStreamLength - let stringsStreamPhysicalLoc, stringsStreamSize = + let stringsStreamPhysicalLoc, _stringsStreamSize = findStream [| 0x23; 0x53; 0x74; 0x72; 0x69; 0x6e; 0x67; 0x73 |] (* #Strings *) let userStringsStreamPhysicalLoc, userStringsStreamSize = @@ -4520,8 +4519,9 @@ let openMetadataReader let cacheUserStringHeap = mkCacheGeneric reduceMemoryUsage inbase "UserStringHeap" (userStringsStreamSize / 20 + 1) // nb. Lots and lots of cache hits on this cache, hence never optimize cache away - let cacheStringHeap = - mkCacheGeneric false inbase "string heap" (stringsStreamSize / 50 + 1) + // Sized to grow rather than from the stream length: only a small fraction of a #Strings heap is ever + // read, so sizing from it left the table around 11% full, one nearly-empty table per reference. + let cacheStringHeap = mkCacheGeneric false inbase "string heap" 0 let cacheBlobHeap = mkCacheGeneric reduceMemoryUsage inbase "blob heap" (blobsStreamSize / 50 + 1) @@ -4565,7 +4565,6 @@ let openMetadataReader stringsStreamPhysicalLoc = stringsStreamPhysicalLoc blobsStreamPhysicalLoc = blobsStreamPhysicalLoc blobsStreamSize = blobsStreamSize - memoizeString = Tables.memoize id readUserStringHeap = cacheUserStringHeap (readUserStringHeapUncached ctxtH) readStringHeap = cacheStringHeap (readStringHeapUncached ctxtH) readBlobHeap = cacheBlobHeap (readBlobHeapUncached ctxtH)