Skip to content

Avoid array covariance checks in ArrayList<T> hot paths#42

Open
lahma wants to merge 1 commit into
masterfrom
perf/arraylist-no-covariance-check
Open

Avoid array covariance checks in ArrayList<T> hot paths#42
lahma wants to merge 1 commit into
masterfrom
perf/arraylist-no-covariance-check

Conversation

@lahma

@lahma lahma commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

CPU profiling of the FileParsingBenchmark workload (ETW sampling at 8190 Hz via ultra) showed CastHelpers.LdelemaRef at ~1.2% of parse time — array covariance checks on interior refs (ldelema) into arrays of reference types inside ArrayList<T>, driven by PushRef (every Add/Push during node list building) and Peek/Pop (token context and label stacks).

Changes:

  • PushRef and the indexer setter obtain the element ref via MemoryMarshal.GetArrayDataReference on net8.0+, which needs no covariance check. Both indices are structurally guaranteed valid (capacity was just ensured / the setter checks against _count).
  • Peek/Pop read elements by value (ldelem) and clear via a null store, neither of which requires a covariance check — this part benefits all TFMs and keeps full bounds checking.

GetItemRef/PeekRef/PopRef deliberately keep the original ldelema form: an experiment that replaced them with an explicit bounds check + GetArrayDataReference inflated the stack frames of the recursive parser methods and cost ~20% of max parse depth (588 → 471 paren levels on a 1 MiB-stack probe), so ref-returning accessors with non-guaranteed indices were left untouched. The final change is at exact depth parity with master (588 vs 588).

Expectation management

This is a small win by design — the profile caps it at ~1.2%. Two independent baseline-vs-branch A/B measurements both landed on ~0.9% average improvement on .NET 10 (up to ~2% on individual files), neutral on .NET Framework 4.8, with no regressions in any benchmark of the repository suite. Reporting it transparently so you can decide whether it clears the bar (and verify on your hardware, which past experience says behaves differently).

Correctness

  • Full unit test suite passes on net10.0 (12 957) and net462 (12 955).
  • Max recursion depth unaffected: 588 vs 588 (binary-search probe, 1 MiB-stack thread).
  • Allocations byte-identical on every benchmark case.

Benchmarks

Full repository suite, --job medium --runtimes net10.0 net48, AMD Ryzen 9 5950X, Windows 11, .NET SDK 10.0.301. Two independent A/B pairs were measured back-to-back; EsprimaParse (fixed NuGet reference) ran in the same sessions as a machine-noise control. The table shows the cleaner pair (adjacent runs, controls ≤ ±1.8%); "adj" = Acornima delta minus control delta.

.NET 10.0 (pair 1: adjacent runs)

File master PR Δ Acornima Δ control adj
angular-1.2.5 5,739.1 μs 5,717.2 μs −0.4% −0.3% −0.1pp
angular-1.7.9 11,765.2 μs 11,771.2 μs +0.1% +0.2% −0.1pp
backbone-1.1.0 751.6 μs 745.0 μs −0.9% −1.4% +0.5pp
jquery-1.9.1 4,268.1 μs 4,265.2 μs −0.1% −0.3% +0.3pp
jquery.mobile-1.4.2 7,092.8 μs 6,915.8 μs −2.5% −0.5% −2.0pp
mootools-1.4.5 3,525.1 μs 3,480.2 μs −1.3% +0.5% −1.8pp
underscore-1.5.2 636.2 μs 634.1 μs −0.3% +1.8% −2.1pp
yui-3.12.0 3,290.2 μs 3,235.9 μs −1.7% −0.1% −1.6pp

.NET Framework 4.8 (same pair): adjusted deltas between −0.6pp and +0.9pp, i.e. neutral.

The second A/B pair (different sessions) independently shows the same aggregate: −0.9% average adjusted on .NET 10, ~0 on net48. ObtainNodeFromIntfBenchmark is neutral (±0.1% on the iter=10000 cases).

No MethodImpl hints were added or changed.

🤖 Generated with Claude Code

https://claude.ai/code/session_011pMkJKBxYw3x9VSvbvukjv

Profiling the FileParsingBenchmark workload with ETW sampling showed
CastHelpers.LdelemaRef at ~1.2% of parse time, driven by interior refs
(ldelema) into arrays of reference types in ArrayList<T>:

- PushRef (every Add/Push during node list building),
- Peek/Pop (token context and label stacks).

Changes:
- PushRef and the indexer setter obtain the element ref via
  MemoryMarshal.GetArrayDataReference on net8.0+, which needs no
  covariance check. Both indices are structurally guaranteed valid
  (capacity was just ensured / the setter checks against _count).
- Peek/Pop read elements by value (ldelem) and clear via a null store,
  neither of which requires a covariance check on any TFM.

GetItemRef/PeekRef/PopRef keep the original ldelema form: duplicating
the bounds check there inflated the stack frames of the recursive
parser methods and cost ~20% of max parse depth, so ref-returning
accessors with non-guaranteed indices are left untouched.

Max recursion depth is unaffected (588 vs 588 paren levels on a 1 MiB
stack probe).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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