Skip to content

Read the backing array through a compiled accessor in CollectionsMarshal.AsSpan - #707

Merged
SimonCropp merged 1 commit into
mainfrom
perf/collectionsmarshal-asspan
Oct 12, 2026
Merged

SimonCropp merged 1 commit into
mainfrom
perf/collectionsmarshal-asspan

Conversation

@SimonCropp

@SimonCropp SimonCropp commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

The CollectionsMarshal.AsSpan polyfill (below net5) reads List<T>'s private _items field. It did that with FieldInfo.GetValue on every call, which costs far more than the rest of the method, and AsSpan is normally called to make a loop faster.

The field read is now compiled once per T (Expression.Field(...) + Compile()) into a Func<List<T>, T[]> held in the existing static generic ItemsAccessor<T>. If the expression can not be compiled on the current platform, it falls back to a delegate that calls FieldInfo.GetValue, as before. The //Note: is unchanged and still accurate: the backing array is still located via reflection.

Measurements

net48, Release, x64, Stopwatch, best of 9, ns per call (includes the calling lambda and building the span):

Before After
AsSpan(List<int>) 80.0 17.7
AsSpan(List<string>) 111.7 45.1

The trade-off is a one-off cost the first time AsSpan is used for each T. Measured in fresh processes on a shared (noisy) machine: 6 to 8 ms for the first T in the process, against about 1.3 ms for the first reflection based call before, and 2 to 4 ms for each further T, against about 0.7 ms. At roughly 60 ns saved per call, that is repaid after about 100,000 calls for the first T and 30,000 to 50,000 for each further one. Worth it for the loops AsSpan is meant for; a net loss for code that calls it a handful of times.

Tests

Added to PolyfillTests_CollectionsMarshal.cs: several element types in one process (string? with nulls, a struct, byte, arrays) with reads and writes through the span; length being Count rather than Capacity, and zero after Clear; a list followed through 200 growth steps, since each growth replaces the backing array and a cached accessor must read the current one; two lists of the same T interleaved; a class derived from List<T>.

…onsMarshal.AsSpan

AsSpan called FieldInfo.GetValue on every call. Compile the field read once per T and cache the delegate.
@SimonCropp SimonCropp modified the milestones: 11.4.4, 11.4.5 Oct 12, 2026
@SimonCropp
SimonCropp merged commit 163e303 into main Oct 12, 2026
8 checks passed
@SimonCropp
SimonCropp deleted the perf/collectionsmarshal-asspan branch October 12, 2026 12:11
This was referenced Oct 12, 2026
This was referenced Oct 14, 2026
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