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 4 commits into
base: main
Choose a base branch
from

Conversation

javiercn
Copy link
Member

@javiercn javiercn commented Jul 8, 2025

  • Introduces methods for populating existing collections of values.
  • Reuses well-known values during serialization and deserialization.

Performance Results by Component

1. StaticWebAssetEndpointProperty Benchmarks

.NET 10.0 Results

Method Mean Error StdDev Gen0 Allocated Performance
FromMetadataValue_Current 651.8 ns 4.64 ns 4.12 ns 0.0372 952 B Baseline
PopulateFromMetadataValue_New 285.2 ns 1.48 ns 1.31 ns 0.0067 168 B 56% Faster
ToMetadataValue_Current 254.6 ns 1.07 ns 0.95 ns 0.0105 264 B Baseline
ToMetadataValue_New 211.9 ns 2.78 ns 2.60 ns 0.0105 264 B 17% Faster

.NET Framework 4.8.1 Results

Method Mean Error StdDev Gen0 Allocated Performance
FromMetadataValue_Current 2,493.7 ns 25.82 ns 22.89 ns 0.1564 987 B Baseline
PopulateFromMetadataValue_New 1,486.6 ns 8.64 ns 7.66 ns 0.0267 177 B 40% Faster
ToMetadataValue_Current 1,014.8 ns 5.54 ns 4.91 ns 0.0420 273 B Baseline
ToMetadataValue_New 947.4 ns 18.20 ns 15.19 ns 0.0429 273 B 7% Faster

2. StaticWebAssetEndpointResponseHeader Benchmarks

.NET 10.0 Results

Method Mean Error StdDev Gen0 Allocated Performance
FromMetadataValue_Current 1,929.1 ns 33.32 ns 29.54 ns 0.0725 1896 B Baseline
PopulateFromMetadataValue_New 851.0 ns 10.57 ns 8.82 ns 0.0057 160 B 56% Faster
ToMetadataValue_Current 745.6 ns 2.71 ns 2.53 ns 0.0296 752 B Baseline
ToMetadataValue_New 637.3 ns 2.51 ns 2.35 ns 0.0296 752 B 15% Faster

.NET Framework 4.8.1 Results

Method Mean Error StdDev Gen0 Allocated Performance
FromMetadataValue_Current 8.204 us 0.0517 us 0.0458 us 0.3052 2014 B Baseline
PopulateFromMetadataValue_New 4.922 us 0.0817 us 0.0973 us 0.0229 177 B 40% Faster
ToMetadataValue_Current 2.946 us 0.0271 us 0.0253 us 0.1183 762 B Baseline
ToMetadataValue_New 2.838 us 0.0247 us 0.0231 us 0.1183 762 B 4% Faster

3. StaticWebAssetEndpointSelector Benchmarks

.NET 10.0 Results

Method Mean Error StdDev Gen0 Allocated Performance
FromMetadataValue_Current 845.8 ns 11.52 ns 10.22 ns 0.0410 1040 B Baseline
PopulateFromMetadataValue_New 339.2 ns 1.86 ns 1.65 ns 0.0033 88 B 60% Faster
ToMetadataValue_Current 316.0 ns 2.26 ns 2.00 ns 0.0110 280 B Baseline
ToMetadataValue_New 283.0 ns 2.45 ns 2.29 ns 0.0110 280 B 10% Faster

.NET Framework 4.8.1 Results

Method Mean Error StdDev Gen0 Allocated Performance
FromMetadataValue_Current 3.131 us 0.0616 us 0.0633 us 0.1678 1075 B Baseline
PopulateFromMetadataValue_New 1.838 us 0.0136 us 0.0114 us 0.0134 88 B 41% Faster
ToMetadataValue_Current 1.192 us 0.0048 us 0.0040 us 0.0458 289 B Baseline
ToMetadataValue_New 1.131 us 0.0127 us 0.0112 us 0.0458 289 B 5% Faster

@github-actions github-actions bot added the Area-AspNetCore RazorSDK, BlazorWebAssemblySDK, dotnet-watch label Jul 8, 2025
Copy link
Contributor

Thanks for your PR, @@javiercn.
To learn about the PR process and branching schedule of this repo, please take a look at the SDK PR Guide.

@javiercn javiercn force-pushed the javiercn/improve-json-allocs branch from 3ab44b5 to bc59fb1 Compare July 8, 2025 16:20
@javiercn javiercn marked this pull request as ready for review July 8, 2025 22:31
@javiercn javiercn requested a review from a team as a code owner July 8, 2025 22:31
@javiercn javiercn force-pushed the javiercn/improve-json-allocs branch 2 times, most recently from 38a718b to 0bbc810 Compare July 8, 2025 22:42
…tions

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.
@javiercn javiercn force-pushed the javiercn/improve-json-allocs branch from 0bbc810 to 67f679f Compare July 8, 2025 22:42
@javiercn javiercn requested a review from MackinnonBuck July 8, 2025 22:44
@Copilot Copilot AI review requested due to automatic review settings August 9, 2025 19:51
Copy link
Contributor

@Copilot Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull Request Overview

This PR introduces optimized collection processing for StaticWebAssets serialization and deserialization to reduce memory allocations and improve performance. The changes add new methods for populating existing collections and reuse well-known values during serialization/deserialization.

Key Changes:

  • Introduces PopulateFromMetadataValue methods that reuse existing collections instead of creating new arrays
  • Adds string interning for well-known header names, values, selector names, and property names
  • Implements pooled JSON writers and array buffer writers for reduced allocations

Reviewed Changes

Copilot reviewed 39 out of 40 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointProperty.cs Adds PopulateFromMetadataValue method and optimized serialization with string interning
src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointSelector.cs Adds PopulateFromMetadataValue method and optimized serialization for selectors
src/StaticWebAssetsSdk/Tasks/Data/StaticWebAssetEndpointResponseHeader.cs Adds PopulateFromMetadataValue method and optimized serialization for response headers
src/StaticWebAssetsSdk/Tasks/Data/WellKnown*.cs New classes containing interned strings for common values to reduce allocations
src/StaticWebAssetsSdk/Tasks/Utils/JsonWriterContext.cs Reusable JSON writing context with pooled buffers
test/Microsoft.NET.Sdk.StaticWebAssets.Tests/StaticWebAssets/*Test.cs Comprehensive test coverage for new functionality
src/StaticWebAssetsSdk/benchmarks/*.cs Benchmark classes to measure performance improvements

}

var result = JsonSerializer.Deserialize(value, _jsonTypeInfo);
Array.Sort(result);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the sort here? Is the sortedness of the endpoint properties utilized anywhere or is it just for the sake of having a predictable and deterministic ordering?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Predictable deterministic ordering for when we write them into files.

// Expect to be positioned at start of array
if (reader.TokenType != JsonTokenType.StartArray)
{
reader.Read(); // Move to start array if not already there
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we assert here that the token type is now JsonTokenType.StartArray after reading?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could do Debug.Assert here, but that won't help customers as it will be stripped out on Release builds. In general, this is trusted data that we read and write, so we can expect that it's in the right shape.

}
}

public static void PopulateFromMetadataValue(ref Utf8JsonReader reader, List<StaticWebAssetEndpointProperty> properties)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the performance gains from this method primarily due to the fact that we're reusing the properties list to reduce allocations? Or is it that the STJ source generator doesn't generally produce code as optimized/tailored code?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We save in a bunch of places:

  • Decoding properties: We skip because we map the utf8 bits to the well-known strings.
  • Allocations: We reuse the strings everywhere, which makes string comparisons O(1) instead of O(N) (Because equal strings share the same memory address).
  • Perfect hashing: We don't have to check things on a dictionary or write an list of ifs we can just check for well-known stuff way more efficiently.

return JsonSerializer.Serialize(properties, _jsonTypeInfo);
}

internal static string ToMetadataValue(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see that this yields up to a 17% perf improvement on microbenchmarks. Do you have measurements or predictions on what the impact of this optimization would be on a typical build? Just want to have an idea on what the tradeoff is between performance and maintainability here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This happens on the main loop of a bunch of Tasks, like DefineStaticWebAssetEndpoints, ApplyCompressionNegotiation, GenerateStaticWebAssetsManifest that happen on incremental builds.

It's hard to predict the results here until we have merged it as we can't compare it against a released SDK (It has been PGOd, which gives it an additional boost).

But in general, anything where we can remove allocations and speed up items going through the main loop should be worth it.

As for maintainability, I had the same concern, but with Copilot it is actually much more straightforward to maintain this code. We can if we want to, add explicit instructions to ensure that it updates these paths.

Comment on lines +155 to +159
internal static JsonWriterContext CreateWriter()
{
var context = new JsonWriterContext();
return context;
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this helper necessary? Can't the caller just new-up their own JsonWriterContext() directly?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new-ing up a JsonWritterContext is expensive, so it's worth caching

/// <returns>The interned header value or null if not well-known</returns>
public static string TryGetInternedHeaderValue(ReadOnlySpan<byte> headerValueSpan)
{
return headerValueSpan.Length switch
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like it could be a good application for generated code (e.g., using a T4 template). That way this wouldn't all have to be hard-coded. Although I don't see that kind of thing happening already in this repo. This is good as-is, just a passing thought.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I actually used copilot instead to generate this. Essentially asked it:

  • Write a minimal perfect hash for matching against these values.

This is essentially faster than having a dictionary and hashing over the string.

Comment on lines +100 to +101
var internedName = WellKnownEndpointPropertyNames.TryGetInternedPropertyName(reader.ValueSpan);
property.Name = internedName ?? reader.GetString();
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where the interning happens

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area-AspNetCore RazorSDK, BlazorWebAssemblySDK, dotnet-watch
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants