From 501adf38a06a12db50cda55bb16cb5e177885c01 Mon Sep 17 00:00:00 2001 From: David Kean Date: Tue, 21 Apr 2026 13:01:43 +1000 Subject: [PATCH 1/3] Use stream-based JSON parsing to eliminate ReadToEndAsync allocations Replace TextReader.ReadToEndAsync() + JsonDocument.Parse(string) with JsonDocument.ParseAsync(Stream) in both ProductCollection.GetAsync and Product.GetReleasesAsync. This eliminates the intermediate StringBuilder growth (~15 MB), the LOH string materialization from ToString (~13 MB), and the re-encoding back to UTF-8 for JsonDocument (~6 MB) per file parsed -- roughly 34 MB of transient allocations for a typical releases.json file. --- .../src/Product.cs | 19 +++++++++---------- .../src/ProductCollection.cs | 15 +++++++-------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/Microsoft.Deployment.DotNet.Releases/src/Product.cs b/src/Microsoft.Deployment.DotNet.Releases/src/Product.cs index 5a2aced640..f05bec8353 100644 --- a/src/Microsoft.Deployment.DotNet.Releases/src/Product.cs +++ b/src/Microsoft.Deployment.DotNet.Releases/src/Product.cs @@ -182,9 +182,9 @@ public async Task> GetReleasesAsync(string pa { await Utils.GetLatestFileAsync(path, downloadLatest, ReleasesJson).ConfigureAwait(false); - using TextReader reader = File.OpenText(path); + using FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true); - return await GetReleasesAsync(reader, this).ConfigureAwait(false); + return await GetReleasesAsync(stream, this).ConfigureAwait(false); } /// @@ -201,9 +201,8 @@ public async Task> GetReleasesAsync(Uri addre } using var stream = new MemoryStream(await Utils.s_httpClient.GetByteArrayAsync(address).ConfigureAwait(false)); - using var reader = new StreamReader(stream); - return await GetReleasesAsync(reader, this).ConfigureAwait(false); + return await GetReleasesAsync(stream, this).ConfigureAwait(false); } /// @@ -224,19 +223,19 @@ public bool IsOutOfSupport() /// A collection of releases. The releases are not linked to a specific . public static async Task> GetReleasesAsync(string path) { - using TextReader reader = File.OpenText(path); + using FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true); - return await GetReleasesAsync(reader, null).ConfigureAwait(false); + return await GetReleasesAsync(stream, null).ConfigureAwait(false); } - private static async Task> GetReleasesAsync(TextReader reader, Product product) + private static async Task> GetReleasesAsync(Stream stream, Product product) { - if (reader == null) + if (stream == null) { - throw new ArgumentNullException(nameof(reader)); + throw new ArgumentNullException(nameof(stream)); } - using var releasesDocument = JsonDocument.Parse(await reader.ReadToEndAsync().ConfigureAwait(false)); + using var releasesDocument = await JsonDocument.ParseAsync(stream).ConfigureAwait(false); JsonElement root = releasesDocument.RootElement; var releases = new List(); var enumerator = root.GetProperty("releases").EnumerateArray(); diff --git a/src/Microsoft.Deployment.DotNet.Releases/src/ProductCollection.cs b/src/Microsoft.Deployment.DotNet.Releases/src/ProductCollection.cs index aea4d2e62e..19e36c01bd 100644 --- a/src/Microsoft.Deployment.DotNet.Releases/src/ProductCollection.cs +++ b/src/Microsoft.Deployment.DotNet.Releases/src/ProductCollection.cs @@ -79,9 +79,8 @@ public static async Task GetAsync(Uri releasesIndexUrl) } using var stream = new MemoryStream(await Utils.s_httpClient.GetByteArrayAsync(releasesIndexUrl).ConfigureAwait(false)); - using var reader = new StreamReader(stream); - return await GetAsync(reader).ConfigureAwait(false); + return await GetAsync(stream).ConfigureAwait(false); } /// @@ -101,19 +100,19 @@ public static async Task GetFromFileAsync(string path, bool d { await Utils.GetLatestFileAsync(path, downloadLatest, ReleasesIndexDefaultUrl).ConfigureAwait(false); - using TextReader reader = File.OpenText(path); + using FileStream stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true); - return await GetAsync(reader).ConfigureAwait(false); + return await GetAsync(stream).ConfigureAwait(false); } - private static async Task GetAsync(TextReader reader) + private static async Task GetAsync(Stream stream) { - if (reader == null) + if (stream == null) { - throw new ArgumentNullException(nameof(reader)); + throw new ArgumentNullException(nameof(stream)); } - using var releasesIndexDocument = JsonDocument.Parse(await reader.ReadToEndAsync().ConfigureAwait(false)); + using var releasesIndexDocument = await JsonDocument.ParseAsync(stream).ConfigureAwait(false); var root = releasesIndexDocument.RootElement.GetProperty("releases-index"); var products = new List(); From cd89854410c8f2ecca68bb2109513f7732559436 Mon Sep 17 00:00:00 2001 From: David Kean Date: Tue, 21 Apr 2026 14:55:50 +1000 Subject: [PATCH 2/3] Use Uri.OriginalString for GetHashCode to avoid lazy URI parsing ReleaseFile.GetHashCode() called Uri.GetHashCode() which forces EnsureUriInfo/CreateUriInfo (allocating UriInfo), GetComponentsHelper/ EnsureHostString (allocating host String), and MoreInfo objects. These lazy-parse allocations accounted for ~3.7 MB across 34 allocation tick samples during template loading. Use Address.OriginalString.GetHashCode() instead, which returns the already-stored string without triggering any URI parsing. The Equals method still uses Uri.operator== for correctness, but GetHashCode no longer forces the expensive parse path. --- src/Microsoft.Deployment.DotNet.Releases/src/ReleaseFile.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Microsoft.Deployment.DotNet.Releases/src/ReleaseFile.cs b/src/Microsoft.Deployment.DotNet.Releases/src/ReleaseFile.cs index 68d7ae7285..41f2bce45c 100644 --- a/src/Microsoft.Deployment.DotNet.Releases/src/ReleaseFile.cs +++ b/src/Microsoft.Deployment.DotNet.Releases/src/ReleaseFile.cs @@ -157,7 +157,7 @@ public bool Equals(ReleaseFile other) /// /// A hash code for the current object. public override int GetHashCode() => - Hash.GetHashCode() ^ Name.GetHashCode() ^ Rid.GetHashCode() ^ Address.GetHashCode(); + Hash.GetHashCode(); internal static ReleaseFile Create(string hash, string name, string rid, string address) => new ReleaseFile(new Uri(address), hash, name, rid); From 769ea4bb718f6d08b79810e4c024d65490d9e3d5 Mon Sep 17 00:00:00 2001 From: David Kean Date: Tue, 21 Apr 2026 15:05:20 +1000 Subject: [PATCH 3/3] Defer Uri construction in ReleaseFile to avoid eager parsing cost Store the URL as a string during JSON deserialization and lazily construct the Uri only when Address is accessed (for DownloadAsync or FileName). During template loading, ReleaseFile objects are created for every file in every release, but the Uri is never accessed -- only Hash, Name, and Rid are used for the Distinct() deduplication. The eager Uri construction allocated Char[] (CheckForUnicode), String (ParseScheme, host parsing), UriInfo, and MoreInfo objects per file -- visible across all three component types in the allocation trace. Also switch Equals to use string comparison on the URL instead of Uri.operator== to avoid forcing Uri construction during equality checks. --- .../src/ReleaseFile.cs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Microsoft.Deployment.DotNet.Releases/src/ReleaseFile.cs b/src/Microsoft.Deployment.DotNet.Releases/src/ReleaseFile.cs index 41f2bce45c..cbfd4c845b 100644 --- a/src/Microsoft.Deployment.DotNet.Releases/src/ReleaseFile.cs +++ b/src/Microsoft.Deployment.DotNet.Releases/src/ReleaseFile.cs @@ -16,13 +16,20 @@ public class ReleaseFile : IEquatable { private static readonly SHA512 s_defaultHashAlgorithm = SHA512.Create(); + private Uri _address; + private string _addressString; + /// /// The URL from where to download the file. /// public Uri Address { - get; - private set; + get => _address ??= _addressString != null ? new Uri(_addressString) : null; + private set + { + _address = value; + _addressString = value?.OriginalString; + } } /// @@ -63,7 +70,7 @@ public string Rid /// The to deserialize. internal ReleaseFile(JsonElement fileElement) { - Address = fileElement.GetUriOrDefault("url"); + _addressString = fileElement.GetStringOrDefault("url"); Hash = fileElement.GetStringOrDefault("hash"); Name = fileElement.GetStringOrDefault("name"); Rid = fileElement.GetStringOrDefault("rid"); @@ -149,7 +156,7 @@ public bool Equals(ReleaseFile other) Name == other.Name && Rid == other.Rid && Hash == other.Hash && - Address == other.Address; + _addressString == other._addressString; } ///