Skip to content

WIP: DO NOT REVIEW - QueryFeature duplicate-key promotion allocation (perf experiment)#67769

Draft
artl93 wants to merge 3 commits into
dotnet:mainfrom
artl93:artl93-http-dup-query-presize
Draft

WIP: DO NOT REVIEW - QueryFeature duplicate-key promotion allocation (perf experiment)#67769
artl93 wants to merge 3 commits into
dotnet:mainfrom
artl93:artl93-http-dup-query-presize

Conversation

@artl93

@artl93 artl93 commented Jul 14, 2026

Copy link
Copy Markdown
Member

I am running an experiment - full analysis to follow. Contact @artl93


⚠️ WIP — DO NOT REVIEW. This PR is a performance experiment and will remain a draft. Do not merge.

What this changes

QueryFeature.KvpAccumulator (internal) promotes a repeated query key into a List<string>. The original code did:

var list = new List<string>();
list.AddRange(values);   // boxes the StringValues struct (IEnumerable<string>)
list.Add(value);

AddRange(values) boxes the StringValues value type when it is passed as IEnumerable<string>. This PR replaces it with an indexed copy loop, which avoids the box while letting the list grow to its default capacity in a single step:

var list = new List<string>();
for (var i = 0; i < values.Count; i++)
{
    list.Add(values[i]!);
}
list.Add(value);

Ordering and semantics are preserved exactly (KvpAccumulator is internal; values[i]! is a compile-time-only null-forgiving; values.Count is effectively 1 at promotion).

Lineage (for auditability)

  • parent: f297235c48c2cffc8fb61ff41803d709b30ddab2
  • v1 (superseded, pre-size to Count+1): dd93cf022e456292a23da958582dab199b6333c4
  • published head (Variant-C, this PR): e121cb9ec296b0a6c4af21c3868a45c732b586b1

v1 was a tradeoff (see table) and is intentionally superseded by the strictly-better Variant-C after a skeptical review.

Measured allocation (BenchmarkDotNet [MemoryDiagnoser], deterministic bytes/op)

Interleaved crossover A/B: whole QueryFeature.cs swapped + rebuilt per arm, Http.dll hashed each arm. Server GC, ShortRun + default throughput job. Apple M5 Pro, .NET SDK 11.0.100-preview.6 (Arm64).

Canonical scenario (ParseNewDuplicateKeys: key1×3 + key2×2):

arm bytes/op vs parent
parent 1056
v1 pre-size 1032 −24
Variant-C (this PR) 1008 −48

Scaling — one key repeated N times (RepeatedSingleKey):

Occurrences parent Variant-C v1 pre-size Variant-C vs parent v1 vs parent
1 (no dup) 304 304 304 0 0
2 680 656 640 −24 −40
3 760 736 776 −24 +16
4 840 816 856 −24 +16
8 1248 1224 1264 −24 +16
16 2040 2016 2056 −24 +16
32 3600 3576 3616 −24 +16

Interpretation & honest limitations

  • No-duplicate traffic (the common case): 0 B change. The promotion branch never runs when a key appears once; parent == Variant-C == 304 B. This change only affects requests that actually repeat a query key.
  • Variant-C: uniform −24 B per duplicate promotion at every cardinality, zero regression. The −24 B is exactly the boxed StringValues.
  • v1 (pre-size) is a tradeoff and was rejected: −40 B at exactly-2 occurrences but +16 B regression at 3+ (it undersized to string[2], forcing a 2→4 regrow). Variant-C removes that regression.
  • No throughput claim. Timing deltas are within run-to-run noise; this is an allocation-only improvement.
  • Applicability is unknown without telemetry. How often real requests repeat a query key is not quantified here; the change is strictly non-negative on allocation for all inputs (no regression on any path), so it is safe regardless of duplicate frequency.
  • Environment caveat: measured on a .NET 11 preview SDK on Apple ARM64. Numbers should reproduce on supported Linux x64, but that has not been re-measured.

Correctness / invariants checked

Value ordering through promotion, empty values, percent-encoded keys/values, OrdinalIgnoreCase comparer, threefold keys — all covered by existing QueryFeatureTests. Local run: 28/28 Query tests pass.

Reproduce

Benchmarks are committed:

  • src/Http/Http/perf/Microbenchmarks/QueryCollectionBenchmarks.cs (ParseNewDuplicateKeys)
  • src/Http/Http/perf/Microbenchmarks/QueryDuplicateKeyScalingBenchmarks.cs (RepeatedSingleKey, controls)
dotnet build src/Http/Http/src/Microsoft.AspNetCore.Http.csproj -c Release
dotnet run -c Release --project src/Http/Http/perf/Microbenchmarks \
  -- --filter "*QueryDuplicateKeyScalingBenchmarks.RepeatedSingleKey*" --memory --job Short

Raw BDN Markdown/CSV/JSON/logs, per-arm binary hashes, source hashes, environment, and a SHA256SUMS manifest are retained as experiment artifacts.

Art Leonard and others added 3 commits July 12, 2026 00:16
When a duplicate query key is encountered, KvpAccumulator promoted the
values to a List<string> with default capacity and then added the
existing value(s) plus the new one, forcing an immediate growth/copy on
the second value. Initialize the list with the known capacity
(values.Count + 1) and add the indexed values directly. Ordering and
semantics are preserved exactly.

Also adds a DuplicateKeys benchmark case to QueryCollectionBenchmarks.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d2560db8-c6d9-4e9d-aaa7-8d205d12d86e
Adds QueryDuplicateKeyScalingBenchmarks to characterize how QueryFeature
parsing allocations scale with the number of repeated query keys, plus
unique-key, multi-duplicate-key, percent-encoded, and long-value
controls. Benchmark-only change; exercises the KvpAccumulator promotion
path affected by the duplicate-key list pre-size optimization.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d2560db8-c6d9-4e9d-aaa7-8d205d12d86e
Replace the pre-sized-list variant with a default-capacity List<string>
populated by an indexed copy loop. AddRange(values) on the original code
boxed the StringValues struct (passed as IEnumerable<string>); indexed
adds avoid that box while letting the list grow to its default capacity in
a single step.

Empirical allocation (BenchmarkDotNet, deterministic, crossover A/B vs
parent f297235):
  - No duplicate (key appears once): 0 B change (promotion path not hit).
  - Every duplicate promotion: -24 B (removes the StringValues box), at
    every cardinality (key repeated 2/3/4/8/16/32x): uniform -24 B with
    no regrow penalty.

This supersedes the earlier pre-size-to-(Count+1) approach (dd93cf0),
which saved 40 B at exactly-2 occurrences but regressed +16 B at 3+.
Ordering and semantics are preserved; KvpAccumulator is internal.

Also correct the scaling benchmark XML doc (the un-optimized path performs
a single promotion, not Occurrences-1 growth events).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: d2560db8-c6d9-4e9d-aaa7-8d205d12d86e
@artl93 artl93 changed the title WIP: DO NOT REVIEW - QueryFeature duplicate-key list pre-size (perf experiment) WIP: DO NOT REVIEW - QueryFeature duplicate-key promotion allocation (perf experiment) Jul 14, 2026
@artl93

artl93 commented Jul 14, 2026

Copy link
Copy Markdown
Member Author

Still WIP — do not review or merge. Posting the canonical experiment write-up after a skeptical implementation/evidence review.

Key outcome of review

The original premise ("pre-size the list to avoid growth/copy on the second value") was only partly right. The real cost on the parent path is the boxing of StringValues in list.AddRange(values), not list growth. The first pre-size attempt (new List<string>(values.Count + 1), commit dd93cf022e) was a tradeoff: −40 B at exactly-2 occurrences but +16 B regression at 3+. It has been superseded by an indexed-copy variant that keeps the default capacity and removes the box.

Lineage

  • parent f297235c48c2cffc8fb61ff41803d709b30ddab2
  • v1 pre-size (superseded) dd93cf022e456292a23da958582dab199b6333c4
  • published head e121cb9ec296b0a6c4af21c3868a45c732b586b1

Allocation (deterministic bytes/op, interleaved crossover A/B)

Scaling, one key repeated N times:

Occ parent Variant-C (head) v1 pre-size Variant-C vs parent
1 (no dup) 304 304 304 0
2 680 656 640 −24
3 760 736 776 −24
4 840 816 856 −24
8 1248 1224 1264 −24
16 2040 2016 2056 −24
32 3600 3576 3616 −24

Canonical scenario (key1×3 + key2×2): parent 1056 → head 1008 (−48); v1 1032.

Bottom line

  • Uniform −24 B per duplicate promotion, no regression at any cardinality, 0 B change on non-duplicate traffic.
  • No throughput claim — timing within noise. Allocation-only win.
  • Applicability depends on how often real requests repeat a query key (not quantified); the change is non-negative on all inputs.
  • Measured on .NET 11 preview / Apple ARM64; not yet re-measured on supported Linux x64.

Provenance / artifacts

  • Committed benchmarks: QueryCollectionBenchmarks.ParseNewDuplicateKeys, QueryDuplicateKeyScalingBenchmarks.
  • Retained BDN Markdown/CSV/JSON/logs + per-arm Http.dll hashes + arm source hashes + environment.
  • Artifact manifest: 239 files, SHA256SUMS sha256 = 036ec1375f96af60209f7791fc9fffe67c9fb6c8cc44ca437f74a3a349b6e3ab.
  • Environment: BenchmarkDotNet v0.13.0, Server GC, ShortRun + default throughput job, .NET SDK 11.0.100-preview.6.

This PR stays a draft indefinitely.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant