Repository navigation
Conversation
- Look up content types by extension span via Dictionary alternate lookup instead of allocating a substring. - Build the ETag string with a single allocation. - Write Last-Modified/ETag directly to the header dictionary instead of allocating ResponseHeaders. - Avoid the params array when computing the precondition state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟢 Approval recommended
No active review issues remain for the static-file allocation optimizations.
No open issues 🎉
What changed in this PR
Reduces allocations in the static-file request path while preserving content-type lookup behavior and response headers.
Changes:
- Uses span-based extension lookups for compatible dictionaries, retaining the string-based fallback for other mappings.
- Removes intermediate ETag strings, typed response-header wrappers, and precondition arrays.
- Adds regression tests for custom mappings, mapping mutations, and exact response-header values.
| File | Description |
|---|---|
src/Middleware/StaticFiles/test/UnitTests/StaticFileContextTest.cs |
Checks exact ETag and response headers, including negative hash values. |
src/Middleware/StaticFiles/test/UnitTests/DefaultContentTypeProviderTests.cs |
Covers mapping compatibility, mutations, and extension edge cases. |
src/Middleware/StaticFiles/src/StaticFileContext.cs |
Reduces allocations in ETag formatting, header writing, and precondition evaluation. |
src/Middleware/StaticFiles/src/FileExtensionContentTypeProvider.cs |
Adds allocation-free span lookups for compatible dictionaries. |
Review effort: Balanced
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Retriggering CI with an empty commit. The failures are unrelated to this change (it only touches StaticFiles content-type lookup and header writes):
🤖 Posted by ASP.NET Core Perf 2 (AI Agent) |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Pushed empty commit a1d008f to retrigger CI. The only failures were in
Neither test depends on content-type mapping or ETag formatting. The static file responses those tests receive keep byte-identical headers, which the StaticFiles tests cover. 🤖 Posted by ASP.NET Core Perf 2 (AI Agent) |
…n PRs) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Pushed empty commit 38d1685 to retrigger CI. Everything except
are the same set failing right now on unrelated PRs: #69752 (only touches the RequestDelegateGenerator) fails exactly these four, and #70068 fails the first two. That's 3 of the ~12 recent PRs I sampled. Static file content types are unchanged by this PR. 🤖 Posted by ASP.NET Core Perf 2 (AI Agent) |
|
/review |
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
On 55c4a17, Failing test: Why it's unrelated
I didn't retrigger CI because main E2E is currently flaky. A maintainer can rerun the failed leg if needed. 🤖 Posted by ASP.NET Core Perf 2 (AI Agent) |
Reduce per-request allocations in StaticFileMiddleware
Remove avoidable allocations from the
UseStaticFilesrequest path.Description
Every request that reaches
StaticFileMiddlewarecallsFileExtensionContentTypeProvider.TryGetContentType. That call allocated an extension substring for the lookup. Each served or revalidated file also paid for a few small allocations inStaticFileContext. This PR removes them. All changes are internal.FileExtensionContentTypeProviderMappingsis exactly aDictionary<string, string>whose comparer supports span lookups, the constructor caches anAlternateLookup<ReadOnlySpan<char>>. This covers the default instance (OrdinalIgnoreCase) and user dictionaries withOrdinalorOrdinalIgnoreCase.IDictionary.TryGetValue(string).Mappingsare still seen. The extension rules (IsNullOrWhiteSpace, then last.to the end) are unchanged.StaticFileContext.LookupFileInfostring.Create(InvariantCulture, $"\"{hash:x}\""), replacing'"' + Convert.ToString(hash, 16) + '"'(two strings).Convert.ToStringfor 1M random values plus edge cases, and a new test covers negative hashes.StaticFileContext.ApplyResponseHeadersAsyncHeaderUtilities.FormatDate(lastModified)andetag.ToString()straight intoResponse.Headers, so it no longer allocates a typedResponseHeaderswrapper.Content-Range.GetMaxPreconditionState: no longer takes aparams PreconditionState[], which allocated an array per request.Headers, status codes and log output are unchanged.
Benchmark
I used a local BenchmarkDotNet harness (not included). It runs
StaticFileMiddlewarewith aPhysicalFileProvideron aDefaultHttpContext. Timings are on 2 cores, 3 runs each.FileExtensionContentTypeProvider.TryGetContentType(default provider)StaticFileMiddleware.Invoke: time is dominated by the physical file stat, so these show only the allocation drop.That is four fewer allocations per served or revalidated file, and one fewer for every request that has an extension.
Tests
ApplyResponseHeaders_WritesExpectedETagAndLastModifiedchecks the exact ETag against the oldConvert.ToString(hash, 16)formula, including negative hashes, and checks Last-Modified, Accept-Ranges and Content-Type.Mappingsafter construction, a trailing-dot key, a non-Dictionarymapping (SortedDictionary), and a derived dictionary that re-implementsTryGetValue.Benchmark (3 interleaved rounds)
New
StaticFileBenchmarkinsrc/Middleware/StaticFiles/perf/Microbenchmarks, added in this PR. It replaces the local harness described above.StaticFileMiddlewareserves/css/site.css(1 KB) from an in-memoryIFileProvider, so the numbers measure the changed CPU and allocation path without disk I/O.TryGetContentType_Default(span lookup): 20.1–21.1 ns → 12.0–12.1 ns (about −40%), 32 B → 0 B.StaticFileMiddleware.Invoke(Head,Get,GetIfNoneMatch→ 304): −144 B per request in every case. Time is within noise: the PR was 2–6% faster in rounds 1–2 and 2–6% slower in round 3.TryGetContentType_NonDictionaryMappings(aSortedDictionarymapping on the unchanged string fallback path): still 32 B. It is about 1 ns slower (12.7–13.2 ns → 13.6–14.9 ns) because of the added_useSpanLookupcheck. This affects only non-Dictionarycustom mappings.Machine: AMD EPYC 9V74, 2 cores, Ubuntu 24.04 container; .NET SDK 12.0.100-alpha.1.26521.103, runtime 12.0.0 (12.0.26.52911); BenchmarkDotNet 0.13.0
--config perflab(InProcess, MemoryDiagnoser), WarmupCount=3, IterationCount=15. Order per round: main, then PR. "main" is the PR branch with the product source files reverted to the merge base (4f556f7), so both builds run the same benchmark source. Means and bytes come from the BDN JSON exporter.🤖 Posted by ASP.NET Core Perf 2 (AI Agent)