Improvements to Internal.Utilities.Hashing.Md5Hasher (src/Compiler/Facilities/Hashing.fs) to reduce allocations and fix a broken ThreadLocal<MD5> workaround.
Repro steps
Not applicable — this is a performance/code-quality improvement, not a functional bug repro. Relevant code path: Md5Hasher.computeHash, Md5Hasher.hashString, Md5Hasher.hashStringToString in src/Compiler/Facilities/Hashing.fs, used throughout the compiler for cache-key hashing (e.g. FSharpProjectSnapshot.fs, prim-lexing.fs).
Expected behavior
Md5Hasher.computeHash should use the thread-local MD5 instance (or MD5.HashData on modern TFMs) instead of allocating a brand-new MD5 instance on every call.
- Hashing a string should avoid allocating an intermediate UTF8-encoded byte array on every call, using a pooled buffer instead.
- A hex-string hash helper should be available without needing to convert through an intermediate
byte array -> BitConverter.ToString allocation path when only the string result is needed.
Actual behavior
Md5Hasher.computeHash previously created a new MD5 instance on every call via System.Security.Cryptography.MD5.Create(), ignoring the existing ThreadLocal<MD5> (md5) that was supposed to provide reuse, with a // TODO: the threadlocal is not working in new VS extension comment marking the workaround.
Md5Hasher.hashString allocated a full UTF8-encoded byte array for the input string via Encoding.UTF8.GetBytes before hashing, for every call.
- There was no way to compute the MD5 hash of a string directly into a caller-provided buffer or get a hex string result without allocating both the encoded input bytes and (separately) converting the hash bytes to a display string.
Proposed fix
open System.Security.Cryptography
module internal Md5Hasher =
#if NETSTANDARD2_0
let private md5 =
new ThreadLocal<_>(fun () -> MD5.Create())
let computeHash (bytes: byte array) = md5.Value.ComputeHash(bytes)
#else
let computeHash (bytes: byte array) = MD5.HashData(bytes)
#endif
let empty = Array.empty
/// Computes the MD5 hash of a string directly into a caller-allocated 16-byte buffer,
/// avoiding the extra allocation of an intermediate hash-result array.
/// The UTF8 encoding buffer is rented from the shared ArrayPool to avoid allocating
/// a byte array the size of the input string on every call.
let hashStringInto (s: string) (destination: Span<byte>) =
let encoding = System.Text.Encoding.UTF8
let maxByteCount = encoding.GetMaxByteCount(s.Length)
let rented = System.Buffers.ArrayPool<byte>.Shared.Rent(maxByteCount)
try
let byteCount = encoding.GetBytes(s, 0, s.Length, rented, 0)
#if NETSTANDARD2_0
let hash = md5.Value.ComputeHash(rented, 0, byteCount)
hash.CopyTo(destination)
#else
let mutable bytesWritten = 0
MD5.TryHashData(ReadOnlySpan(rented, 0, byteCount), destination, &bytesWritten) |> ignore
#endif
finally
System.Buffers.ArrayPool<byte>.Shared.Return(rented)
/// Computes the MD5 hash of a string and returns it as a hex string (matching the format of
/// `toString`), without allocating an intermediate byte array for the UTF8-encoded input
/// (only the 16-byte hash result is allocated).
let hashStringToString (s: string) =
let bytes = Array.zeroCreate<byte> 16
hashStringInto s (Span bytes)
BitConverter.ToString(bytes)
hashString is reimplemented in terms of hashStringInto to keep a single hashing code path:
let hashString (s: string) =
let bytes = Array.zeroCreate<byte> 16
hashStringInto s (Span bytes)
bytes
Known workarounds
None required for correctness — the previous code was functionally correct, just allocation-heavy and not reusing the ThreadLocal<MD5> instance as intended.
Related information
- Affected file:
src/Compiler/Facilities/Hashing.fs (and its signature file src/Compiler/Facilities/Hashing.fsi)
- Callers affected (indirectly, no source changes required):
src/Compiler/Facilities/prim-lexing.fs, src/Compiler/Service/FSharpProjectSnapshot.fs
- .NET Runtime kind: affects both
.NET Standard 2.0 (ThreadLocal<MD5> path) and modern TFMs (MD5.HashData/MD5.TryHashData path)
- Repo: dotnet/fsharp
Improvements to
Internal.Utilities.Hashing.Md5Hasher(src/Compiler/Facilities/Hashing.fs) to reduce allocations and fix a brokenThreadLocal<MD5>workaround.Repro steps
Not applicable — this is a performance/code-quality improvement, not a functional bug repro. Relevant code path:
Md5Hasher.computeHash,Md5Hasher.hashString,Md5Hasher.hashStringToStringinsrc/Compiler/Facilities/Hashing.fs, used throughout the compiler for cache-key hashing (e.g.FSharpProjectSnapshot.fs,prim-lexing.fs).Expected behavior
Md5Hasher.computeHashshould use the thread-localMD5instance (orMD5.HashDataon modern TFMs) instead of allocating a brand-newMD5instance on every call.byte array -> BitConverter.ToStringallocation path when only the string result is needed.Actual behavior
Md5Hasher.computeHashpreviously created a newMD5instance on every call viaSystem.Security.Cryptography.MD5.Create(), ignoring the existingThreadLocal<MD5>(md5) that was supposed to provide reuse, with a// TODO: the threadlocal is not working in new VS extensioncomment marking the workaround.Md5Hasher.hashStringallocated a full UTF8-encodedbyte arrayfor the input string viaEncoding.UTF8.GetBytesbefore hashing, for every call.Proposed fix
hashStringis reimplemented in terms ofhashStringIntoto keep a single hashing code path:Known workarounds
None required for correctness — the previous code was functionally correct, just allocation-heavy and not reusing the
ThreadLocal<MD5>instance as intended.Related information
src/Compiler/Facilities/Hashing.fs(and its signature filesrc/Compiler/Facilities/Hashing.fsi)src/Compiler/Facilities/prim-lexing.fs,src/Compiler/Service/FSharpProjectSnapshot.fs.NET Standard 2.0(ThreadLocal<MD5>path) and modern TFMs (MD5.HashData/MD5.TryHashDatapath)