Skip to content

[StaticWebAssets] Process collection properties with amortized allocation #49682

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,16 @@ Localization:
- Consider localizing strings in .resx files when possible.

Documentation:
- Do not manually edit files under documentation/manpages/sdk as these are generated based on documentation and should not be manually modified.
- Do not manually edit files under documentation/manpages/sdk as these are generated based on documentation and should not be manually modified.

Benchmarking:
- Use BenchmarkDotNet for performance measurements with the [MemoryDiagnoser] attribute to track memory allocations.
- Run benchmarks before and after changes to demonstrate performance improvements.
- To run benchmarks:
```
cd src/StaticWebAssetsSdk/benchmarks
dotnet run --framework <framework> -c Release -- --filter "*MethodName*"
```
- Compare both throughput (ops/s) and memory allocations (bytes allocated) in benchmark results.
- Include benchmark results in PR descriptions when claiming performance improvements.
- Consider benchmarking on both .NET Framework and modern .NET when targeting multiple frameworks.
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,11 @@ cmake/

# Test results
**/*.trx
/TestResults
**/TestResults
/test/dotnet.Tests/CompletionTests/snapshots/**/**.received.*

# Benchmarks
**/BenchmarkDotNet.Artifacts/

# Live Unit Testing
*.lutconfig
3 changes: 2 additions & 1 deletion src/RazorSdk/Razor.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"test\\Microsoft.NET.Sdk.BlazorWebAssembly.Tests\\Microsoft.NET.Sdk.BlazorWebAssembly.Tests.csproj",
"test\\Microsoft.NET.Sdk.Razor.Tests\\Microsoft.NET.Sdk.Razor.Tests.csproj",
"test\\Microsoft.NET.Sdk.Razor.Tool.Tests\\Microsoft.NET.Sdk.Razor.Tool.Tests.csproj",
"test\\Microsoft.NET.TestFramework\\Microsoft.NET.TestFramework.csproj"
"test\\Microsoft.NET.TestFramework\\Microsoft.NET.TestFramework.csproj",
"test\\Microsoft.NET.Sdk.StaticWebAssets.Tests\\Microsoft.NET.Sdk.StaticWebAssets.Tests.csproj"
]
}
}
139 changes: 90 additions & 49 deletions src/StaticWebAssetsSdk/Tasks/ApplyCompressionNegotiation.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

using System.Globalization;
using Microsoft.Build.Framework;
using Microsoft.AspNetCore.StaticWebAssets.Tasks.Utils;

namespace Microsoft.AspNetCore.StaticWebAssets.Tasks;

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

private readonly List<StaticWebAssetEndpointSelector> _selectorsList = [];
private readonly List<StaticWebAssetEndpointResponseHeader> _headersList = [];
private readonly List<StaticWebAssetEndpointResponseHeader> _tempHeadersList = [];
private readonly List<StaticWebAssetEndpointProperty> _propertiesList = [];
private const int ExpectedCompressionHeadersCount = 2;

public override bool Execute()
{
var assetsById = StaticWebAsset.ToAssetDictionary(CandidateAssets);
Expand All @@ -27,9 +34,23 @@ public override bool Execute()

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

var compressionHeadersByEncoding = new Dictionary<string, StaticWebAssetEndpointResponseHeader[]>(2);
var compressionHeadersByEncoding = new Dictionary<string, StaticWebAssetEndpointResponseHeader[]>(ExpectedCompressionHeadersCount);

using var jsonContext = new JsonWriterContext();

// Add response headers to compressed endpoints
ProcessCompressedAssets(assetsById, endpointsByAsset, updatedEndpoints, compressionHeadersByEncoding, jsonContext);
AddRemainingEndpoints(endpointsByAsset, updatedEndpoints);
UpdatedEndpoints = StaticWebAssetEndpoint.ToTaskItems(updatedEndpoints);
return true;
}

private void ProcessCompressedAssets(
Dictionary<string, StaticWebAsset> assetsById,
IDictionary<string, List<StaticWebAssetEndpoint>> endpointsByAsset,
HashSet<StaticWebAssetEndpoint> updatedEndpoints,
Dictionary<string, StaticWebAssetEndpointResponseHeader[]> compressionHeadersByEncoding,
JsonWriterContext jsonContext)
{
foreach (var compressedAsset in assetsById.Values)
{
if (!string.Equals(compressedAsset.AssetTraitName, "Content-Encoding", StringComparison.Ordinal))
Expand All @@ -53,11 +74,11 @@ public override bool Execute()

if (!HasContentEncodingResponseHeader(compressedEndpoint))
{
// Add the Content-Encoding and Vary headers
compressedEndpoint.ResponseHeaders = [
..compressedEndpoint.ResponseHeaders,
..compressionHeaders
];
StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(compressedEndpoint.ResponseHeadersString, _headersList);
var currentCompressionHeaders = GetOrCreateCompressionHeaders(compressionHeadersByEncoding, compressedAsset);
_headersList.AddRange(currentCompressionHeaders);
var headersString = StaticWebAssetEndpointResponseHeader.ToMetadataValue(_headersList, jsonContext);
compressedEndpoint.SetResponseHeadersString(headersString);
}

var compressedHeaders = GetCompressedHeaders(compressedEndpoint);
Expand All @@ -72,7 +93,7 @@ public override bool Execute()
continue;
}

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

// Before we return the updated endpoints we need to capture any other endpoint whose asset is not associated
// with the compressed asset. This is because we are going to remove the endpoints from the associated item group
// and the route is the ItemSpec, so it will cause those endpoints to be removed.
// 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
// and the second asset is a publish asset.
// If we are processing build assets, we'll mistakenly remove the endpoints associated with the publish asset.
// Before we return the updated endpoints we need to capture any other endpoint whose asset is not associated
// with the compressed asset. This is because we are going to remove the endpoints from the associated item group
// and the route is the ItemSpec, so it will cause those endpoints to be removed.
// 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
// and the second asset is a publish asset.
// If we are processing build assets, we'll mistakenly remove the endpoints associated with the publish asset.

// Iterate over the endpoints and find those endpoints whose route is in the set of updated endpoints but whose asset
// is not, and add them to the updated endpoints.
// Iterate over the endpoints and find those endpoints whose route is in the set of updated endpoints but whose asset
// is not, and add them to the updated endpoints.

// Reuse the map we created at the beginning.
// Remove all the endpoints that were updated to avoid adding them again.
// Reuse the map we created at the beginning.
// Remove all the endpoints that were updated to avoid adding them again.
private void AddRemainingEndpoints(
IDictionary<string, List<StaticWebAssetEndpoint>> endpointsByAsset,
HashSet<StaticWebAssetEndpoint> updatedEndpoints)
{
foreach (var endpoint in updatedEndpoints)
{
if (endpointsByAsset.TryGetValue(endpoint.AssetFile, out var endpointsToSkip))
Expand Down Expand Up @@ -131,18 +157,16 @@ public override bool Execute()
}

updatedEndpoints.UnionWith(additionalUpdatedEndpoints);

UpdatedEndpoints = StaticWebAssetEndpoint.ToTaskItems(updatedEndpoints);

return true;
}

private static HashSet<string> GetCompressedHeaders(StaticWebAssetEndpoint compressedEndpoint)
private HashSet<string> GetCompressedHeaders(StaticWebAssetEndpoint compressedEndpoint)
{
var result = new HashSet<string>(compressedEndpoint.ResponseHeaders.Length, StringComparer.Ordinal);
for (var i = 0; i < compressedEndpoint.ResponseHeaders.Length; i++)
StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(compressedEndpoint.ResponseHeadersString, _headersList);

var result = new HashSet<string>(_headersList.Count, StringComparer.Ordinal);
for (var i = 0; i < _headersList.Count; i++)
{
var responseHeader = compressedEndpoint.ResponseHeaders[i];
var responseHeader = _headersList[i];
result.Add(responseHeader.Name);
}

Expand Down Expand Up @@ -200,7 +224,8 @@ private StaticWebAssetEndpoint CreateUpdatedEndpoint(
string quality,
StaticWebAssetEndpoint compressedEndpoint,
HashSet<string> compressedHeaders,
StaticWebAssetEndpoint relatedEndpointCandidate)
StaticWebAssetEndpoint relatedEndpointCandidate,
JsonWriterContext jsonContext)
{
Log.LogMessage(MessageImportance.Low, "Processing related endpoint '{0}'", relatedEndpointCandidate.Route);
var encodingSelector = new StaticWebAssetEndpointSelector
Expand All @@ -210,31 +235,39 @@ private StaticWebAssetEndpoint CreateUpdatedEndpoint(
Quality = quality
};
Log.LogMessage(MessageImportance.Low, " Created Content-Encoding selector for compressed asset '{0}' with size '{1}' is '{2}'", encodingSelector.Value, encodingSelector.Quality, relatedEndpointCandidate.Route);

StaticWebAssetEndpointSelector.PopulateFromMetadataValue(relatedEndpointCandidate.SelectorsString, _selectorsList);
_selectorsList.Add(encodingSelector);
var selectorsString = StaticWebAssetEndpointSelector.ToMetadataValue(_selectorsList, jsonContext);

var endpointCopy = new StaticWebAssetEndpoint
{
AssetFile = compressedAsset.Identity,
Route = relatedEndpointCandidate.Route,
Selectors = [
..relatedEndpointCandidate.Selectors,
encodingSelector
],
EndpointProperties = relatedEndpointCandidate.EndpointProperties
};
var headers = new List<StaticWebAssetEndpointResponseHeader>(7);
ApplyCompressedEndpointHeaders(headers, compressedEndpoint, relatedEndpointCandidate.Route);
ApplyRelatedEndpointCandidateHeaders(headers, relatedEndpointCandidate, compressedHeaders);
endpointCopy.ResponseHeaders = [.. headers];

endpointCopy.SetSelectorsString(selectorsString);
endpointCopy.SetEndpointPropertiesString(relatedEndpointCandidate.EndpointPropertiesString);

// Build headers using reusable list
_headersList.Clear();
ApplyCompressedEndpointHeaders(_headersList, compressedEndpoint, relatedEndpointCandidate.Route);
ApplyRelatedEndpointCandidateHeaders(_headersList, relatedEndpointCandidate, compressedHeaders);
var headersString = StaticWebAssetEndpointResponseHeader.ToMetadataValue(_headersList, jsonContext);
endpointCopy.SetResponseHeadersString(headersString);

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

private static bool HasContentEncodingResponseHeader(StaticWebAssetEndpoint compressedEndpoint)
private bool HasContentEncodingResponseHeader(StaticWebAssetEndpoint compressedEndpoint)
{
for (var i = 0; i < compressedEndpoint.ResponseHeaders.Length; i++)
StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(compressedEndpoint.ResponseHeadersString, _headersList);

for (var i = 0; i < _headersList.Count; i++)
{
var responseHeader = compressedEndpoint.ResponseHeaders[i];
var responseHeader = _headersList[i];
if (string.Equals(responseHeader.Name, "Content-Encoding", StringComparison.Ordinal))
{
return true;
Expand All @@ -244,11 +277,13 @@ private static bool HasContentEncodingResponseHeader(StaticWebAssetEndpoint comp
return false;
}

private static bool HasContentEncodingSelector(StaticWebAssetEndpoint compressedEndpoint)
private bool HasContentEncodingSelector(StaticWebAssetEndpoint compressedEndpoint)
{
for (var i = 0; i < compressedEndpoint.Selectors.Length; i++)
StaticWebAssetEndpointSelector.PopulateFromMetadataValue(compressedEndpoint.SelectorsString, _selectorsList);

for (var i = 0; i < _selectorsList.Count; i++)
{
var selector = compressedEndpoint.Selectors[i];
var selector = _selectorsList[i];
if (string.Equals(selector.Name, "Content-Encoding", StringComparison.Ordinal))
{
return true;
Expand Down Expand Up @@ -287,16 +322,18 @@ private static bool HasContentEncodingSelector(StaticWebAssetEndpoint compressed
private static string ResolveQuality(StaticWebAsset compressedAsset) =>
Math.Round(1.0 / (compressedAsset.FileLength + 1), 12).ToString("F12", CultureInfo.InvariantCulture);

private static bool IsCompatible(StaticWebAssetEndpoint compressedEndpoint, StaticWebAssetEndpoint relatedEndpointCandidate)
private bool IsCompatible(StaticWebAssetEndpoint compressedEndpoint, StaticWebAssetEndpoint relatedEndpointCandidate)
{
var compressedFingerprint = ResolveFingerprint(compressedEndpoint);
var relatedFingerprint = ResolveFingerprint(relatedEndpointCandidate);
var compressedFingerprint = ResolveFingerprint(compressedEndpoint, _propertiesList);
var relatedFingerprint = ResolveFingerprint(relatedEndpointCandidate, _propertiesList);
return string.Equals(compressedFingerprint.Value, relatedFingerprint.Value, StringComparison.Ordinal);
}

private static StaticWebAssetEndpointProperty ResolveFingerprint(StaticWebAssetEndpoint compressedEndpoint)
private static StaticWebAssetEndpointProperty ResolveFingerprint(StaticWebAssetEndpoint compressedEndpoint, List<StaticWebAssetEndpointProperty> tempList)
{
foreach (var property in compressedEndpoint.EndpointProperties)
StaticWebAssetEndpointProperty.PopulateFromMetadataValue(compressedEndpoint.EndpointPropertiesString, tempList);

foreach (var property in tempList)
{
if (string.Equals(property.Name, "fingerprint", StringComparison.Ordinal))
{
Expand All @@ -308,7 +345,9 @@ private static StaticWebAssetEndpointProperty ResolveFingerprint(StaticWebAssetE

private void ApplyCompressedEndpointHeaders(List<StaticWebAssetEndpointResponseHeader> headers, StaticWebAssetEndpoint compressedEndpoint, string relatedEndpointCandidateRoute)
{
foreach (var header in compressedEndpoint.ResponseHeaders)
StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(compressedEndpoint.ResponseHeadersString, _tempHeadersList);

foreach (var header in _tempHeadersList)
{
if (string.Equals(header.Name, "Content-Type", StringComparison.Ordinal))
{
Expand All @@ -326,7 +365,9 @@ private void ApplyCompressedEndpointHeaders(List<StaticWebAssetEndpointResponseH

private void ApplyRelatedEndpointCandidateHeaders(List<StaticWebAssetEndpointResponseHeader> headers, StaticWebAssetEndpoint relatedEndpointCandidate, HashSet<string> compressedHeaders)
{
foreach (var header in relatedEndpointCandidate.ResponseHeaders)
StaticWebAssetEndpointResponseHeader.PopulateFromMetadataValue(relatedEndpointCandidate.ResponseHeadersString, _tempHeadersList);

foreach (var header in _tempHeadersList)
{
// We need to keep the headers that are specific to the compressed asset like Content-Length,
// Last-Modified and ETag. Any other header we should add it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ public override bool Execute()

var result = CandidateEndpoints;

// Reusable list for optimized endpoint property parsing
var endpointPropertiesList = new List<StaticWebAssetEndpointProperty>(4);

using var context = StaticWebAssetEndpointProperty.CreateWriter();

for (var i = 0; i < CandidateEndpoints.Length; i++)
{
var candidateEndpoint = StaticWebAssetEndpoint.FromTaskItem(CandidateEndpoints[i]);
Expand All @@ -43,19 +48,30 @@ public override bool Execute()
{
candidateEndpoint.Route = StaticWebAsset.CombineNormalizedPaths("", asset.BasePath, candidateEndpoint.Route, '/');

for (var j = 0; j < candidateEndpoint.EndpointProperties.Length; j++)
// Use optimized property parsing to avoid allocations
var endpointPropertiesString = CandidateEndpoints[i].GetMetadata(nameof(StaticWebAssetEndpoint.EndpointProperties));
StaticWebAssetEndpointProperty.PopulateFromMetadataValue(endpointPropertiesString, endpointPropertiesList);

// Modify label properties in the reusable list
var propertiesModified = false;
for (var j = 0; j < endpointPropertiesList.Count; j++)
{
ref var property = ref candidateEndpoint.EndpointProperties[j];
var property = endpointPropertiesList[j];
if (string.Equals(property.Name, "label", StringComparison.OrdinalIgnoreCase))
{
property.Value = StaticWebAsset.CombineNormalizedPaths("", asset.BasePath, property.Value, '/');
// We need to do this because we are modifying the properties in place.
// We could instead do candidateEndpoint.EndpointProperties = candidateEndpoint.EndpointProperties
// but that's more obscure than this.
candidateEndpoint.MarkProperiesAsModified();
endpointPropertiesList[j] = property;
propertiesModified = true;
}
}

if (propertiesModified)
{
// Serialize modified properties back using optimized method
candidateEndpoint.SetEndpointPropertiesString(
StaticWebAssetEndpointProperty.ToMetadataValue(endpointPropertiesList, context));
}

Log.LogMessage(MessageImportance.Low, "Adding endpoint {0} for asset {1} with updated route {2}.", candidateEndpoint.Route, candidateEndpoint.AssetFile, candidateEndpoint.Route);

result[i] = candidateEndpoint.ToTaskItem();
Expand Down
Loading