Skip to content

Commit 0bbc810

Browse files
committed
Optimize JSON serialization in StaticWebAssets tasks to reduce allocations
This change significantly improves performance of static web asset endpoint processing by optimizing JSON serialization and deserialization operations: Key optimizations: - Added reusable List<T> collections to eliminate repeated allocations - Implemented JsonWriterContext for efficient JSON serialization with buffer reuse - Added string interning for well-known header names, selector names, and property values - Introduced direct string property setters to bypass expensive array recreations - Used ArrayPool and stack allocation for UTF-8 encoding buffers - Added optimized PopulateFromMetadataValue methods that populate existing lists Performance improvements (typical): - FromMetadataValue operations: 40-60% faster, 80-90% less memory allocation - ToMetadataValue operations: 5-17% faster with same memory usage - StaticWebAssetEndpointProperty: 56% faster deserialization, 17% faster serialization - StaticWebAssetEndpointResponseHeader: 56% faster deserialization, 15% faster serialization - StaticWebAssetEndpointSelector: 60% faster deserialization, 10% faster serialization These optimizations reduce build-time overhead when processing large numbers of static web assets in ASP.NET Core applications.
1 parent 1a8e397 commit 0bbc810

File tree

41 files changed

+3535
-238
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+3535
-238
lines changed

.github/copilot-instructions.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,16 @@ Localization:
1515
- Consider localizing strings in .resx files when possible.
1616

1717
Documentation:
18-
- Do not manually edit files under documentation/manpages/sdk as these are generated based on documentation and should not be manually modified.
18+
- Do not manually edit files under documentation/manpages/sdk as these are generated based on documentation and should not be manually modified.
19+
20+
Benchmarking:
21+
- Use BenchmarkDotNet for performance measurements with the [MemoryDiagnoser] attribute to track memory allocations.
22+
- Run benchmarks before and after changes to demonstrate performance improvements.
23+
- To run benchmarks:
24+
```
25+
cd src/StaticWebAssetsSdk/benchmarks
26+
dotnet run --framework <framework> -c Release -- --filter "*MethodName*"
27+
```
28+
- Compare both throughput (ops/s) and memory allocations (bytes allocated) in benchmark results.
29+
- Include benchmark results in PR descriptions when claiming performance improvements.
30+
- Consider benchmarking on both .NET Framework and modern .NET when targeting multiple frameworks.

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,11 @@ cmake/
4545

4646
# Test results
4747
**/*.trx
48-
/TestResults
48+
**/TestResults
4949
/test/dotnet.Tests/CompletionTests/snapshots/**/**.received.*
5050

51+
# Benchmarks
52+
**/BenchmarkDotNet.Artifacts/
53+
5154
# Live Unit Testing
5255
*.lutconfig

src/RazorSdk/Razor.slnf

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"test\\Microsoft.NET.Sdk.BlazorWebAssembly.Tests\\Microsoft.NET.Sdk.BlazorWebAssembly.Tests.csproj",
1717
"test\\Microsoft.NET.Sdk.Razor.Tests\\Microsoft.NET.Sdk.Razor.Tests.csproj",
1818
"test\\Microsoft.NET.Sdk.Razor.Tool.Tests\\Microsoft.NET.Sdk.Razor.Tool.Tests.csproj",
19-
"test\\Microsoft.NET.TestFramework\\Microsoft.NET.TestFramework.csproj"
19+
"test\\Microsoft.NET.TestFramework\\Microsoft.NET.TestFramework.csproj",
20+
"test\\Microsoft.NET.Sdk.StaticWebAssets.Tests\\Microsoft.NET.Sdk.StaticWebAssets.Tests.csproj"
2021
]
2122
}
2223
}

src/StaticWebAssetsSdk/Tasks/ApplyCompressionNegotiation.cs

Lines changed: 90 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
using System.Globalization;
77
using Microsoft.Build.Framework;
8+
using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils;
89

910
namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;
1011

@@ -19,6 +20,12 @@ public class ApplyCompressionNegotiation : Task
1920
[Output]
2021
public ITaskItem[] UpdatedEndpoints { get; set; }
2122

23+
private readonly List<StaticWebAssetEndpointSelector> _selectorsList = [];
24+
private readonly List<StaticWebAssetEndpointResponseHeader> _headersList = [];
25+
private readonly List<StaticWebAssetEndpointResponseHeader> _tempHeadersList = [];
26+
private readonly List<StaticWebAssetEndpointProperty> _propertiesList = [];
27+
private const int ExpectedCompressionHeadersCount = 2;
28+
2229
public override bool Execute()
2330
{
2431
var assetsById = StaticWebAsset.ToAssetDictionary(CandidateAssets);
@@ -27,9 +34,23 @@ public override bool Execute()
2734

2835
var updatedEndpoints = new HashSet<StaticWebAssetEndpoint>(CandidateEndpoints.Length, StaticWebAssetEndpoint.RouteAndAssetComparer);
2936

30-
var compressionHeadersByEncoding = new Dictionary<string, StaticWebAssetEndpointResponseHeader[]>(2);
37+
var compressionHeadersByEncoding = new Dictionary<string, StaticWebAssetEndpointResponseHeader[]>(ExpectedCompressionHeadersCount);
38+
39+
using var jsonContext = new JsonWriterContext();
3140

32-
// Add response headers to compressed endpoints
41+
ProcessCompressedAssets(assetsById, endpointsByAsset, updatedEndpoints, compressionHeadersByEncoding, jsonContext);
42+
AddRemainingEndpoints(endpointsByAsset, updatedEndpoints);
43+
UpdatedEndpoints = StaticWebAssetEndpoint.ToTaskItems(updatedEndpoints);
44+
return true;
45+
}
46+
47+
private void ProcessCompressedAssets(
48+
Dictionary<string, StaticWebAsset> assetsById,
49+
IDictionary<string, List<StaticWebAssetEndpoint>> endpointsByAsset,
50+
HashSet<StaticWebAssetEndpoint> updatedEndpoints,
51+
Dictionary<string, StaticWebAssetEndpointResponseHeader[]> compressionHeadersByEncoding,
52+
JsonWriterContext jsonContext)
53+
{
3354
foreach (var compressedAsset in assetsById.Values)
3455
{
3556
if (!string.Equals(compressedAsset.AssetTraitName, "Content-Encoding", StringComparison.Ordinal))
@@ -53,11 +74,11 @@ public override bool Execute()
5374

5475
if (!HasContentEncodingResponseHeader(compressedEndpoint))
5576
{
56-
// Add the Content-Encoding and Vary headers
57-
compressedEndpoint.ResponseHeaders = [
58-
..compressedEndpoint.ResponseHeaders,
59-
..compressionHeaders
60-
];
77+
StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(compressedEndpoint.ResponseHeadersString, _headersList);
78+
var currentCompressionHeaders = GetOrCreateCompressionHeaders(compressionHeadersByEncoding, compressedAsset);
79+
_headersList.AddRange(currentCompressionHeaders);
80+
var headersString = StaticWebAssetEndpointResponseHeader.ToMetadataValue(_headersList, jsonContext);
81+
compressedEndpoint.SetResponseHeadersString(headersString);
6182
}
6283

6384
var compressedHeaders = GetCompressedHeaders(compressedEndpoint);
@@ -72,7 +93,7 @@ public override bool Execute()
7293
continue;
7394
}
7495

75-
var endpointCopy = CreateUpdatedEndpoint(compressedAsset, quality, compressedEndpoint, compressedHeaders, relatedEndpointCandidate);
96+
var endpointCopy = CreateUpdatedEndpoint(compressedAsset, quality, compressedEndpoint, compressedHeaders, relatedEndpointCandidate, jsonContext);
7697
updatedEndpoints.Add(endpointCopy);
7798
// Since we are going to remove the endpoints from the associated item group and the route is
7899
// the ItemSpec, we want to add the original as well so that it gets re-added.
@@ -82,19 +103,24 @@ public override bool Execute()
82103
}
83104
}
84105
}
106+
}
85107

86-
// Before we return the updated endpoints we need to capture any other endpoint whose asset is not associated
87-
// with the compressed asset. This is because we are going to remove the endpoints from the associated item group
88-
// and the route is the ItemSpec, so it will cause those endpoints to be removed.
89-
// For example, we have css/app.css and Link/css/app.css where Link=css/app.css and the first asset is a build asset
90-
// and the second asset is a publish asset.
91-
// If we are processing build assets, we'll mistakenly remove the endpoints associated with the publish asset.
108+
// Before we return the updated endpoints we need to capture any other endpoint whose asset is not associated
109+
// with the compressed asset. This is because we are going to remove the endpoints from the associated item group
110+
// and the route is the ItemSpec, so it will cause those endpoints to be removed.
111+
// For example, we have css/app.css and Link/css/app.css where Link=css/app.css and the first asset is a build asset
112+
// and the second asset is a publish asset.
113+
// If we are processing build assets, we'll mistakenly remove the endpoints associated with the publish asset.
92114

93-
// Iterate over the endpoints and find those endpoints whose route is in the set of updated endpoints but whose asset
94-
// is not, and add them to the updated endpoints.
115+
// Iterate over the endpoints and find those endpoints whose route is in the set of updated endpoints but whose asset
116+
// is not, and add them to the updated endpoints.
95117

96-
// Reuse the map we created at the beginning.
97-
// Remove all the endpoints that were updated to avoid adding them again.
118+
// Reuse the map we created at the beginning.
119+
// Remove all the endpoints that were updated to avoid adding them again.
120+
private void AddRemainingEndpoints(
121+
IDictionary<string, List<StaticWebAssetEndpoint>> endpointsByAsset,
122+
HashSet<StaticWebAssetEndpoint> updatedEndpoints)
123+
{
98124
foreach (var endpoint in updatedEndpoints)
99125
{
100126
if (endpointsByAsset.TryGetValue(endpoint.AssetFile, out var endpointsToSkip))
@@ -131,18 +157,16 @@ public override bool Execute()
131157
}
132158

133159
updatedEndpoints.UnionWith(additionalUpdatedEndpoints);
134-
135-
UpdatedEndpoints = StaticWebAssetEndpoint.ToTaskItems(updatedEndpoints);
136-
137-
return true;
138160
}
139161

140-
private static HashSet<string> GetCompressedHeaders(StaticWebAssetEndpoint compressedEndpoint)
162+
private HashSet<string> GetCompressedHeaders(StaticWebAssetEndpoint compressedEndpoint)
141163
{
142-
var result = new HashSet<string>(compressedEndpoint.ResponseHeaders.Length, StringComparer.Ordinal);
143-
for (var i = 0; i < compressedEndpoint.ResponseHeaders.Length; i++)
164+
StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(compressedEndpoint.ResponseHeadersString, _headersList);
165+
166+
var result = new HashSet<string>(_headersList.Count, StringComparer.Ordinal);
167+
for (var i = 0; i < _headersList.Count; i++)
144168
{
145-
var responseHeader = compressedEndpoint.ResponseHeaders[i];
169+
var responseHeader = _headersList[i];
146170
result.Add(responseHeader.Name);
147171
}
148172

@@ -200,7 +224,8 @@ private StaticWebAssetEndpoint CreateUpdatedEndpoint(
200224
string quality,
201225
StaticWebAssetEndpoint compressedEndpoint,
202226
HashSet<string> compressedHeaders,
203-
StaticWebAssetEndpoint relatedEndpointCandidate)
227+
StaticWebAssetEndpoint relatedEndpointCandidate,
228+
JsonWriterContext jsonContext)
204229
{
205230
Log.LogMessage(MessageImportance.Low, "Processing related endpoint '{0}'", relatedEndpointCandidate.Route);
206231
var encodingSelector = new StaticWebAssetEndpointSelector
@@ -210,31 +235,39 @@ private StaticWebAssetEndpoint CreateUpdatedEndpoint(
210235
Quality = quality
211236
};
212237
Log.LogMessage(MessageImportance.Low, " Created Content-Encoding selector for compressed asset '{0}' with size '{1}' is '{2}'", encodingSelector.Value, encodingSelector.Quality, relatedEndpointCandidate.Route);
238+
239+
StaticWebAssetEndpointSelector.PopulateFromMetadataValue(relatedEndpointCandidate.SelectorsString, _selectorsList);
240+
_selectorsList.Add(encodingSelector);
241+
var selectorsString = StaticWebAssetEndpointSelector.ToMetadataValue(_selectorsList, jsonContext);
242+
213243
var endpointCopy = new StaticWebAssetEndpoint
214244
{
215245
AssetFile = compressedAsset.Identity,
216246
Route = relatedEndpointCandidate.Route,
217-
Selectors = [
218-
..relatedEndpointCandidate.Selectors,
219-
encodingSelector
220-
],
221-
EndpointProperties = relatedEndpointCandidate.EndpointProperties
222247
};
223-
var headers = new List<StaticWebAssetEndpointResponseHeader>(7);
224-
ApplyCompressedEndpointHeaders(headers, compressedEndpoint, relatedEndpointCandidate.Route);
225-
ApplyRelatedEndpointCandidateHeaders(headers, relatedEndpointCandidate, compressedHeaders);
226-
endpointCopy.ResponseHeaders = [.. headers];
248+
249+
endpointCopy.SetSelectorsString(selectorsString);
250+
endpointCopy.SetEndpointPropertiesString(relatedEndpointCandidate.EndpointPropertiesString);
251+
252+
// Build headers using reusable list
253+
_headersList.Clear();
254+
ApplyCompressedEndpointHeaders(_headersList, compressedEndpoint, relatedEndpointCandidate.Route);
255+
ApplyRelatedEndpointCandidateHeaders(_headersList, relatedEndpointCandidate, compressedHeaders);
256+
var headersString = StaticWebAssetEndpointResponseHeader.ToMetadataValue(_headersList, jsonContext);
257+
endpointCopy.SetResponseHeadersString(headersString);
227258

228259
// Update the endpoint
229260
Log.LogMessage(MessageImportance.Low, " Updated related endpoint '{0}' with Content-Encoding selector '{1}={2}'", relatedEndpointCandidate.Route, encodingSelector.Value, encodingSelector.Quality);
230261
return endpointCopy;
231262
}
232263

233-
private static bool HasContentEncodingResponseHeader(StaticWebAssetEndpoint compressedEndpoint)
264+
private bool HasContentEncodingResponseHeader(StaticWebAssetEndpoint compressedEndpoint)
234265
{
235-
for (var i = 0; i < compressedEndpoint.ResponseHeaders.Length; i++)
266+
StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(compressedEndpoint.ResponseHeadersString, _headersList);
267+
268+
for (var i = 0; i < _headersList.Count; i++)
236269
{
237-
var responseHeader = compressedEndpoint.ResponseHeaders[i];
270+
var responseHeader = _headersList[i];
238271
if (string.Equals(responseHeader.Name, "Content-Encoding", StringComparison.Ordinal))
239272
{
240273
return true;
@@ -244,11 +277,13 @@ private static bool HasContentEncodingResponseHeader(StaticWebAssetEndpoint comp
244277
return false;
245278
}
246279

247-
private static bool HasContentEncodingSelector(StaticWebAssetEndpoint compressedEndpoint)
280+
private bool HasContentEncodingSelector(StaticWebAssetEndpoint compressedEndpoint)
248281
{
249-
for (var i = 0; i < compressedEndpoint.Selectors.Length; i++)
282+
StaticWebAssetEndpointSelector.PopulateFromMetadataValue(compressedEndpoint.SelectorsString, _selectorsList);
283+
284+
for (var i = 0; i < _selectorsList.Count; i++)
250285
{
251-
var selector = compressedEndpoint.Selectors[i];
286+
var selector = _selectorsList[i];
252287
if (string.Equals(selector.Name, "Content-Encoding", StringComparison.Ordinal))
253288
{
254289
return true;
@@ -287,16 +322,18 @@ private static bool HasContentEncodingSelector(StaticWebAssetEndpoint compressed
287322
private static string ResolveQuality(StaticWebAsset compressedAsset) =>
288323
Math.Round(1.0 / (compressedAsset.FileLength + 1), 12).ToString("F12", CultureInfo.InvariantCulture);
289324

290-
private static bool IsCompatible(StaticWebAssetEndpoint compressedEndpoint, StaticWebAssetEndpoint relatedEndpointCandidate)
325+
private bool IsCompatible(StaticWebAssetEndpoint compressedEndpoint, StaticWebAssetEndpoint relatedEndpointCandidate)
291326
{
292-
var compressedFingerprint = ResolveFingerprint(compressedEndpoint);
293-
var relatedFingerprint = ResolveFingerprint(relatedEndpointCandidate);
327+
var compressedFingerprint = ResolveFingerprint(compressedEndpoint, _propertiesList);
328+
var relatedFingerprint = ResolveFingerprint(relatedEndpointCandidate, _propertiesList);
294329
return string.Equals(compressedFingerprint.Value, relatedFingerprint.Value, StringComparison.Ordinal);
295330
}
296331

297-
private static StaticWebAssetEndpointProperty ResolveFingerprint(StaticWebAssetEndpoint compressedEndpoint)
332+
private static StaticWebAssetEndpointProperty ResolveFingerprint(StaticWebAssetEndpoint compressedEndpoint, List<StaticWebAssetEndpointProperty> tempList)
298333
{
299-
foreach (var property in compressedEndpoint.EndpointProperties)
334+
StaticWebAssetEndpointProperty.PopulateFromMetadataValue(compressedEndpoint.EndpointPropertiesString, tempList);
335+
336+
foreach (var property in tempList)
300337
{
301338
if (string.Equals(property.Name, "fingerprint", StringComparison.Ordinal))
302339
{
@@ -308,7 +345,9 @@ private static StaticWebAssetEndpointProperty ResolveFingerprint(StaticWebAssetE
308345

309346
private void ApplyCompressedEndpointHeaders(List<StaticWebAssetEndpointResponseHeader> headers, StaticWebAssetEndpoint compressedEndpoint, string relatedEndpointCandidateRoute)
310347
{
311-
foreach (var header in compressedEndpoint.ResponseHeaders)
348+
StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(compressedEndpoint.ResponseHeadersString, _tempHeadersList);
349+
350+
foreach (var header in _tempHeadersList)
312351
{
313352
if (string.Equals(header.Name, "Content-Type", StringComparison.Ordinal))
314353
{
@@ -326,7 +365,9 @@ private void ApplyCompressedEndpointHeaders(List<StaticWebAssetEndpointResponseH
326365

327366
private void ApplyRelatedEndpointCandidateHeaders(List<StaticWebAssetEndpointResponseHeader> headers, StaticWebAssetEndpoint relatedEndpointCandidate, HashSet<string> compressedHeaders)
328367
{
329-
foreach (var header in relatedEndpointCandidate.ResponseHeaders)
368+
StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(relatedEndpointCandidate.ResponseHeadersString, _tempHeadersList);
369+
370+
foreach (var header in _tempHeadersList)
330371
{
331372
// We need to keep the headers that are specific to the compressed asset like Content-Length,
332373
// Last-Modified and ETag. Any other header we should add it.

src/StaticWebAssetsSdk/Tasks/ComputeEndpointsForReferenceStaticWebAssets.cs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ public override bool Execute()
2424

2525
var result = CandidateEndpoints;
2626

27+
// Reusable list for optimized endpoint property parsing
28+
var endpointPropertiesList = new List<StaticWebAssetEndpointProperty>(4);
29+
30+
using var context = StaticWebAssetEndpointProperty.CreateWriter();
31+
2732
for (var i = 0; i < CandidateEndpoints.Length; i++)
2833
{
2934
var candidateEndpoint = StaticWebAssetEndpoint.FromTaskItem(CandidateEndpoints[i]);
@@ -43,19 +48,30 @@ public override bool Execute()
4348
{
4449
candidateEndpoint.Route = StaticWebAsset.CombineNormalizedPaths("", asset.BasePath, candidateEndpoint.Route, '/');
4550

46-
for (var j = 0; j < candidateEndpoint.EndpointProperties.Length; j++)
51+
// Use optimized property parsing to avoid allocations
52+
var endpointPropertiesString = CandidateEndpoints[i].GetMetadata(nameof(StaticWebAssetEndpoint.EndpointProperties));
53+
StaticWebAssetEndpointProperty.PopulateFromMetadataValue(endpointPropertiesString, endpointPropertiesList);
54+
55+
// Modify label properties in the reusable list
56+
var propertiesModified = false;
57+
for (var j = 0; j < endpointPropertiesList.Count; j++)
4758
{
48-
ref var property = ref candidateEndpoint.EndpointProperties[j];
59+
var property = endpointPropertiesList[j];
4960
if (string.Equals(property.Name, "label", StringComparison.OrdinalIgnoreCase))
5061
{
5162
property.Value = StaticWebAsset.CombineNormalizedPaths("", asset.BasePath, property.Value, '/');
52-
// We need to do this because we are modifying the properties in place.
53-
// We could instead do candidateEndpoint.EndpointProperties = candidateEndpoint.EndpointProperties
54-
// but that's more obscure than this.
55-
candidateEndpoint.MarkProperiesAsModified();
63+
endpointPropertiesList[j] = property;
64+
propertiesModified = true;
5665
}
5766
}
5867

68+
if (propertiesModified)
69+
{
70+
// Serialize modified properties back using optimized method
71+
candidateEndpoint.SetEndpointPropertiesString(
72+
StaticWebAssetEndpointProperty.ToMetadataValue(endpointPropertiesList, context));
73+
}
74+
5975
Log.LogMessage(MessageImportance.Low, "Adding endpoint {0} for asset {1} with updated route {2}.", candidateEndpoint.Route, candidateEndpoint.AssetFile, candidateEndpoint.Route);
6076

6177
result[i] = candidateEndpoint.ToTaskItem();

0 commit comments

Comments
 (0)