From b9d338560a7d77cfce452f19f53ef606f42d0c3a 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 | 3 +++ src/Compiler/AbstractIL/ilread.fs | 11 +++++------ 2 files changed, 8 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 b30a501d979..85f37d1af06 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,9 @@ * Support for the `` XML documentation tag: at compile time, documentation is copied from an external XML file selected by an XPath query and emitted into the generated documentation file. `` remains unsupported. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19186](https://github.com/dotnet/fsharp/pull/19186)) * Expand `` at tooling time. In IDE tooltips, completion, and signature help, documentation is inherited from base classes, interfaces, overridden members, and constructors (matched by parameter signature). The FCS Symbols API (`FSharpSymbol.XmlDoc`) additionally resolves explicit `cref` targets, but does not expand constructor inheritance. The compiler emits the tag verbatim into generated XML documentation files, matching C#; `` is not implemented. ([Issue #19175](https://github.com/dotnet/fsharp/issues/19175), [PR #19188](https://github.com/dotnet/fsharp/pull/19188)) * 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 d0eae0bc82f..8f44a5203e0 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)