Skip to content

Block copy in List AddRange(ReadOnlySpan) and CopyTo(Span) - #685

Merged
SimonCropp merged 1 commit into
mainfrom
perf/list-addrange-copyto
Oct 12, 2026
Merged

SimonCropp merged 1 commit into
mainfrom
perf/list-addrange-copyto

Conversation

@SimonCropp

Copy link
Copy Markdown
Owner

List<T>.AddRange(ReadOnlySpan<T>) and List<T>.CopyTo(Span<T>) (polyfilled below net8) worked one element at a time: AddRange called Add without reserving capacity, and CopyTo read every element through the indexer.

AddRange

  • Reserves capacity once (same doubling as List<T>, so repeated small appends stay amortized) before adding.
  • Spans of 64 or more items are copied to a pooled array and appended with List<T>.AddRange(ArraySegment<T>), a block copy. On .NET Framework and netstandard targets that is only done for primitive T: Framework's List<T>.AddRange copies the collection through a temporary array first, which measured no better than Add for reference types.

CopyTo

  • net5 to net7: CollectionsMarshal.AsSpan(list).CopyTo(destination).
  • Older targets: lists of 64 or more items go through List<T>.CopyTo(int, T[], int, int) into a pooled array and one span copy. Smaller lists keep the loop (renting costs more than it saves).

Pooled arrays have their used part cleared before being returned unless T is a primitive. No behaviour change: an empty span is still a no-op that does not invalidate enumerators, and the destination-too-short check is unchanged and still runs before anything is written.

Measurements

Release, x64, Stopwatch, best of 15 runs, ns per call. AddRange is into a new empty list. Shared machine, so the small sizes are within noise.

net48:

Items AddRange int before after AddRange string before after CopyTo int before after CopyTo string before after
32 294 237 412 372 104 128 329 364
256 1,631 318 1,438 1,169 301 253 1,748 1,588
2,000 12,116 943 7,571 7,355 1,753 814 15,631 10,695
20,000 193,155 13,700 281,612 120,079 37,749 5,653 166,577 104,153

The .NET (Core) code path (what net5 to net7 consumers get), measured on the net8 runtime since older runtimes are not installed here:

Items AddRange int before after AddRange string before after CopyTo int before after CopyTo string before after
32 224 120 365 116 24 6 188 22
256 1,396 254 1,748 562 393 40 1,458 70
2,000 7,483 571 4,635 1,857 2,821 217 8,197 877
20,000 187,532 8,739 252,087 72,936 26,129 1,922 113,394 6,114

Tests

Added to PolyfillTests_List.cs: AddRange compared with List<T>.AddRange(IEnumerable<T>) for sizes either side of the block copy threshold (0, 1, 63, 64, 65, 5000), for int, string? with nulls and a non-primitive struct; repeated appends of mixed sizes; growth staying amortized; appending a list's own span; enumerator invalidation (none for an empty span). CopyTo for the same sizes with spare list capacity, an offset and longer destination (head and tail untouched), reference types, and the too-short check on the large path.

AddRange added one element at a time without reserving capacity, and CopyTo copied through the indexer. Reserve capacity up front, and block copy larger spans.
@SimonCropp SimonCropp added this to the 11.4.5 milestone Oct 12, 2026
@SimonCropp
SimonCropp merged commit 819e1c8 into main Oct 12, 2026
5 checks passed
@SimonCropp
SimonCropp deleted the perf/list-addrange-copyto branch October 12, 2026 12:05
This was referenced Oct 12, 2026
This was referenced Oct 13, 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