Skip to content

perf: ofArray builds the tree from a span, and the temporary arrays of long lists come from the pool - #45

Open
xperiandri wants to merge 2 commits into
perf/indexed-listfrom
perf/indexed-list-arrays
Open

xperiandri wants to merge 2 commits into
perf/indexed-listfrom
perf/indexed-list-arrays

Conversation

@xperiandri

@xperiandri xperiandri commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Proposed Changes

Builds on #49 (perf/indexed-list: a linear ofList, interface walks, a pooled buffer in choose), which is below this pull request in the stack and builds on #31. Here ofArray and every function that builds its result from a fresh array use ImmutableList.Create(ReadOnlySpan), replicate fills its array with Array.Fill, and init, replicate, sub, rev, mapi, scan and mapFold take their temporary array from the pool for long lists. No public signature changes.

The measurements are in the comments below. The fourth is about this pull request; the first (method, and which numbers to trust), the second, the third and the fifth were made for the whole stack and are linked from #49. Every number in the commit messages and in this description comes from them.

Commit What it does Measured effect
4d00ab3 perf: ofArray builds the tree from a span, and replicate fills its array with Array.Fill ofFreshArray and ofArray use ImmutableList.Create(ReadOnlySpan); the functions of the Tuple namespace call IndexedList.ofArray strings 14 to 21 % less time for 100 to 100 000 elements and 10 to 12 % for a million; replicate of strings 0.75 to 0.84; integers unchanged
1d985f9 perf: the temporary arrays of init, replicate, sub, rev, mapi, scan and mapFold come from the pool for long lists Buffers.ofFilled and ofFilledWith for large lists only: replicate 0.71 to 0.79 (integers) and 0.67 to 0.72 (strings), init of strings 0.68 to 0.78, the rest inside the noise. Separate so that it can be dropped on its own

Ratios are the time relative to the code before; below 1 is faster. A difference inside the noise (about ±10 %, and ±34 % for calls of 100 elements) is not claimed.

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

A performance change that keeps every result and every signature.

Checklist

  • Build and tests pass locally (the whole test project: 16 730 test runs on net8.0 and net10.0, 16 skipped as before)
  • I have added tests that prove my fix is effective or that my feature works: long lists for ofArray and replicate; the helper around the length where the pool starts
  • I have added necessary documentation: the doc comments of the helpers carry the measured numbers, as the comments of Collectors do

Further comments

The pool for the temporary arrays is the part to judge. It helps replicate (integers and strings) and init of strings, where the new array is garbage of the large object heap and causes collections of the second generation (4 to 46 per 1000 calls, gone with the pool). For rev and mapi of strings the gain is small, and for sub and the integer init, mapi and rev it is inside the noise; init and mapi of a million integers read 1.14 and 1.10, so the pool may cost a little there. A list of 100 strings pays for the helper and its closure, up to 8 % of a call of 2 to 4 µs. The commit is separate so that it can be dropped without touching the span build.

What is not here. scan and mapFold take their temporary array from the pool in the last commit, but were not measured on their own. The benchmark scripts and the raw tables are not part of the pull request.

🤖 Generated with Claude Code

xperiandri and others added 2 commits October 21, 2026 03:21
…s array with `Array.Fill`

`ImmutableList.CreateRange` reads an array through `IReadOnlyList` and checks the range at every node, where
`ImmutableList.Create(ReadOnlySpan)` indexes the array directly. Compiled into the library, the build from a span took
14 to 21 % less time for arrays of 100 to 100 000 strings and 10 to 12 % less for a million, and the same time for
integers, within the noise of 10 %. Every function that builds its result from a fresh array goes through
`ofFreshArray`, so `init`, `sub`, `rev`, `mapi` and the others get it too: for strings of 100 to 10 000 elements they
took 0.78 to 0.99 times as long, for integers the same time. The functions of the `Tuple` namespace call
`IndexedList.ofArray` now instead of `CreateRange`.

`Array.Fill` is vectorized, where `Array.create` stores the elements one by one: `replicate` took 0.75 to 0.84 times as
long for strings of 100 to 10 000 elements and 0.92 to 0.96 for 100 000 and a million, and about the same time for
integers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…i`, `scan` and `mapFold` come from the pool for long lists

A new array of 1024 elements or more, which the tree copies and nobody reads again, is garbage of the large object heap
from about 20 000 integers or 10 000 references. `Buffers.ofFilled` and `ofFilledWith` give such a function an array
that is new below 1024 elements and rented from the pool above, cleared as far as it was written when it holds references.

Against the previous commit, compiled into the library and measured with one build in a process (the median of three
processes), the functions took 0.85 to 1.08 times as long for 1000 to 10 000 elements, inside the scatter of the
processes, and a list of 100 strings pays for the call of the helper and its closure, up to 8 % of a call of 2 to 4 µs.
For 100 000 and a million elements:

- `replicate` took 0.71 and 0.79 times as long for integers and 0.67 and 0.72 for strings, `init` of strings 0.68 and
  0.78;
- `rev` of strings took 0.92 and 0.94 times as long, `mapi` of strings 0.95 and 1.01;
- `sub`, and `init`, `mapi` and `rev` of integers, took the same time within the noise (`init` and `mapi` of a million
  integers read 1.14 and 1.10 against a scatter of ±12 and ±4 %);
- the collections of the second generation that the new arrays caused, 4 to 46 per 1000 calls, are gone.

`scan` and `mapFold` use the same helper and were not measured separately.

The helper is a function, not an inline one: copied into every caller, with the lambda in both of its arrays, it made
`sub` and `mapi` of integers of 100 to 1000 elements take 1.1 to 1.3 times as long.

The commit is separate from the previous one so that it can be dropped: the gain is for large lists only, and the cost is
a call of the pool and a closure for each call of these functions.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@xperiandri

xperiandri commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Measurements 1 of 5: how they were made, and which numbers to trust

Every number in the commit messages and in the descriptions of this pull request and of #49 comes from the tables in the five comments here. The measurements were made for the whole stack (#49 below this pull request, this one on top), so the comments stay on this pull request and #49 links to them. The raw CSV files and the scripts are not part of the pull requests.

Setup

  • Windows 11 desktop, 32 logical cores, other programs running, so two identical variants differ by 5 to 10 % between runs.
  • Runtime 10.0.13 (dotnet fsi started in the repository folder, where global.json pins SDK 10), workstation non-concurrent GC (DOTNET_gcServer=0, DOTNET_gcConcurrent=0), --optimize+, tiered PGO at its default.
  • The library under test is always the Release DLL of the build in question (L in the tables). A copy of the old code compiled by dotnet fsi is not the same code: the same source ran 25 % faster in the script for one loop and 50 % slower for another, so a change is judged by two builds of the library, or by the library against a copy of the old code in the same process (which validates the script when the base build is used: it must read 1.00).
  • Allocation is GC.GetAllocatedBytesForCurrentThread around one call after warm-up, which is exact. Collections and pauses are the deltas of GC.CollectionCount and GC.GetTotalPauseDuration during the batches.

Protocols

  1. Several variants in one process (the alternatives for choose, unfold, the temporary arrays, ofList, enumeration; the Walk functions). Rounds are interleaved: each round runs one batch of every variant, in an order that rotates and reverses from round to round. The length of a batch follows from the least of five calls (60 ms, or 0.5 s with full collections before each batch when a call needs 3 ms or more). Warm-up is at least 100 calls and 0.5 s per variant, on a list of 5 000 elements. This is sound for lists up to 10 000 elements. From 100 000 elements on it is not: the garbage in the large object heap makes the collections of the second generation fall on some variants and not on others, so that an identical copy of the old code (A2) took 17 to 20 % longer than A in some cells. Those cells are in the tables of the alternatives for orientation only.
  2. One variant, or one build, per process (everything at 100 000 and a million elements, and the three builds of the arrays branch). Three replicate processes per variant, in interleaved orders (A D L A2, A2 L D A, D A2 A L for choose; head, A, B in three Latin orders for the builds), 10 to 15 rounds of batches of 0.5 s each, with full collections before each batch. The scatter of the replicates (the "±" in the tables is half the range of the replicate means, in percent) and the difference between the processes of A and A2, which are the same code, are the noise: about ±7 to 12 % at 100 000 elements and ±3 to 8 % at a million. Differences inside that are not claimed.

Corrections to earlier numbers

  • Five runs of the arrays branch ran on .NET 11 by mistake (the script was started from another folder, which resolved a different SDK) and were compared with runs on .NET 10. They are replaced by the runs of protocol 2 and by protocol 1 at small sizes, all on 10.0.13.
  • The mapFold loop of Walk was 1.1 to 1.4 times slower, not 1.2 to 1.5.
  • The indexer of a tree joined from chunks was 6 to 10 % slower on average, not 6 to 17 %: only one of two runs showed 17 %.
  • An earlier statement that the span build of ofArray did not reproduce in the library was wrong: two builds compared directly show 14 to 21 % less for strings.

Legend of the variants of choose

buffer tree
A ResizeArray() (the code before this branch) CreateRange
A2 A again, the noise of the method
B ResizeArray(n) with the capacity of the source CreateRange
C0 ResizeArray() Create(ReadOnlySpan)
C ResizeArray(n) Create(ReadOnlySpan)
E a new array of the length of the source Create(ReadOnlySpan)
D an array of that length rented from ArrayPool<T>.Shared and returned Create(ReadOnlySpan)
S1, S2 chunks of 1024 or 8192 elements, built as subtrees and joined Concat of the subtrees
F ImmutableList.Builder, Add per element ToImmutable
L IndexedList.choose of the build in question

🤖 Generated with Claude Code

@xperiandri

Copy link
Copy Markdown
Collaborator Author

Measurements 2 of 4: ofList, enumeration and the Walk loops

ofList and ofSeq of an F# list (protocol 1)

An F# list implements IReadOnlyList<T>, and its indexer walks the list from the head. ImmutableList.CreateRange builds the tree by indexing a list that implements IReadOnlyList<T>, so the old build took time that grows with the square of the length. Time per call in microseconds; the last column is the same fix written as a script, to check that the library does what it should.

n CreateRange of the list, us library ofList after, us library ofSeq of a list after, us copy to an array and build from a span, us
int
100 7.3 1.3 1.2 1.3
1000 684.5 11.7 11.7 11.8
10000 60067.8 143.6 146.1 145.3
100000 6584392.6 1586.2 1590.1 2071.0
string
100 7.7 1.7 1.7 1.7
1000 664.8 16.0 15.0 15.8
10000 64165.9 177.5 172.8 169.7
100000 6426022.2 1935.2 5029.6 2480.1

Walking an ImmutableList<T>: the struct enumerator against the interface (protocol 1)

ImmutableList<T>.GetEnumerator returns a struct. For a value type that is the fastest walk. The code of a generic function is shared by all reference types, and each call of a member of a generic struct in it needs a lookup of the exact type at run time. A walk through IEnumerable<T> (a boxed enumerator, calls through the interface) took 0.56 to 0.7 times as long for strings and 1.0 to 1.5 times as long for integers. Time relative to the loop for item in list do (the "conservative" one of two runs; struct2 is the same loop again, the noise):

int

n interface foreach copy struct2
10 1.54 1.27 1.02 1.00
100 1.06 1.00 1.00 1.00
1 000 1.00 1.00 1.00 1.00
10 000 1.12 1.00 1.00 1.00
100 000 1.00 1.00 1.00 0.97
1 000 000 1.05 1.02 1.02 1.00

string

n interface foreach copy struct2
10 0.87 1.15 1.26 1.00
100 0.62 1.00 1.09 0.99
1 000 0.59 1.00 1.17 1.00
10 000 0.56 1.00 1.29 1.00
100 000 0.60 0.98 1.30 1.00
1 000 000 0.90 1.01 1.00 0.94

foreach is ImmutableList<T>.ForEach with a delegate, copy is CopyTo into an array and a loop over it.

The Walk loops in the library (protocol 1)

Walk picks the enumerator with typeof<'T>.IsValueType, which is a constant for the compiler of the run time, and keeps the body of the loop as an inlined lambda. Library function of this branch against a copy of its old loop in the same process, in brackets the same ratio for the build of the base branch (it must be 1.00: the check of the script). Strings and integers, 100 to 100 000 elements:

function type 100 1 000 10 000 100 000
iter string 0.62 (0.99) 0.61 (0.99) 0.59 (0.98) 0.63 (1.00)
iter int 0.90 (0.98) 0.91 (1.00) 0.90 (1.00) 0.96 (1.01)
fold string 0.66 (1.00) 0.66 (1.01) 0.67 (0.98) 0.70 (0.97)
fold int 0.99 (1.00) 0.99 (1.00) 0.99 (1.00) 0.99 (1.01)
mapi string 0.80 (1.00) 0.79 (1.00) 0.78 (1.01) 0.89 (0.99)
mapi int 1.01 (0.98) 1.00 (1.01) 0.98 (1.00) 1.25 (1.02)
scan string 0.78 (0.99) 0.79 (1.01) 0.80 (0.99) 0.81 (1.07)
scan int 0.97 (1.00) 0.98 (1.00) 1.00 (1.00) 0.95 (0.97)
mapFold string 0.98 (0.98) 0.99 (0.98) 0.98 (0.98) 0.99 (0.85)
mapFold int 0.97 (0.98) 1.00 (1.00) 0.99 (1.00) 0.88 (1.01)

Two cells deserve a remark. The 100 000-element cells share the noise of the second-generation collections (protocol 1 is unreliable there, see the first comment); mapi of integers at 100 000 elements is checked again below with one build per process. mapFold is not a Walk loop: when it was, it took longer in the library (next table), so it keeps the loop it had, and the row above reads 1.00.

tryPick and collect have no copy of the old loop to compare with, so the library of this branch is compared with the library of the base branch (4 pairs of runs): tryPick of strings took 0.60 to 0.69 times as long, of integers 0.87 to 0.97; collect of strings 0.87 to 1.11 and of integers 0.89 to 1.07, that is the same time.

The same functions at 100 000 and a million elements, one build per process (protocol 2)

The library before this branch (before) against the library of this branch, three replicate processes each, in the order before, branch, branch, before, before, branch; the time of before is the median of its three replicates, the ratio is of the medians, and the last column lists the three replicate means of each build.

The replicates scatter by 10 to 40 % here, even for functions that allocate nothing: mapFold of integers, whose code this branch did not change, reads 1.26 (±23 %) and 0.93. So only the large effects can be read from this table: iter and tryPick of strings at 100 000 elements (0.62 and 0.64, as in the table before), and at a million elements the effect shrinks (0.72 and 0.97), because a pass over 48 MB of nodes waits for memory more than for the enumerator. For the functions that allocate nothing (iter, fold, tryPick) the in-process table above is the better evidence than this one, since two variants in one process do not suffer the differences between processes; integers read 0.85 to 1.26 here and 0.90 to 1.01 there, apart from mapi at 100 000 elements (1.25 there, which allocates, so it suffers from the uneven collections described in the first comment).

function type n before this branch this branch / before replicates (before, branch)
iter string 100 000 2.81 ms (±10%) 0.62 (±30%) 3.09 ms 2.81 ms 2.52 ms ; 2.71 ms 1.74 ms 1.52 ms
iter string 1 000 000 57.29 ms (±16%) 0.72 (±6%) 57.29 ms 64.34 ms 46.63 ms ; 45.23 ms 40.18 ms 41.52 ms
iter int 100 000 1.04 ms (±11%) 1.06 (±13%) 1.23 ms 986 us 1.04 ms ; 1.10 ms 1.37 ms 1.07 ms
iter int 1 000 000 27.31 ms (±14%) 1.09 (±14%) 32.83 ms 27.31 ms 24.81 ms ; 33.69 ms 29.70 ms 25.28 ms
fold string 100 000 3.10 ms (±13%) 0.95 (±36%) 3.74 ms 3.10 ms 2.88 ms ; 4.41 ms 2.93 ms 2.16 ms
fold string 1 000 000 48.90 ms (±13%) 0.98 (±9%) 61.89 ms 48.10 ms 48.90 ms ; 47.01 ms 55.67 ms 47.68 ms
fold int 100 000 1.54 ms (±10%) 1.18 (±12%) 1.82 ms 1.49 ms 1.54 ms ; 1.99 ms 1.82 ms 1.55 ms
fold int 1 000 000 29.35 ms (±13%) 1.17 (±12%) 37.22 ms 29.35 ms 29.22 ms ; 34.31 ms 38.67 ms 30.15 ms
mapi string 100 000 5.81 ms (±4%) 1.19 (±39%) 6.19 ms 5.81 ms 5.70 ms ; 6.92 ms 10.84 ms 4.90 ms
mapi string 1 000 000 82.26 ms (±2%) 0.86 (±6%) 82.26 ms 82.44 ms 79.77 ms ; 70.98 ms 79.22 ms 71.09 ms
mapi int 100 000 3.88 ms (±8%) 1.04 (±13%) 4.42 ms 3.88 ms 3.75 ms ; 4.58 ms 4.02 ms 3.54 ms
mapi int 1 000 000 52.86 ms (±6%) 1.00 (±12%) 57.79 ms 51.75 ms 52.86 ms ; 64.73 ms 50.84 ms 53.12 ms
scan string 100 000 5.04 ms (±9%) 1.13 (±9%) 5.04 ms 4.80 ms 5.73 ms ; 5.69 ms 6.27 ms 5.28 ms
scan string 1 000 000 68.82 ms (±10%) 0.93 (±6%) 81.35 ms 66.86 ms 68.82 ms ; 70.41 ms 62.97 ms 63.78 ms
scan int 100 000 3.90 ms (±14%) 1.25 (±14%) 4.97 ms 3.90 ms 3.75 ms ; 4.86 ms 4.88 ms 3.61 ms
scan int 1 000 000 55.10 ms (±8%) 1.04 (±8%) 62.08 ms 55.10 ms 52.93 ms ; 66.29 ms 57.36 ms 57.19 ms
mapFold string 100 000 5.79 ms (±10%) 1.17 (±3%) 6.93 ms 5.79 ms 5.71 ms ; 6.77 ms 6.55 ms 6.95 ms
mapFold string 1 000 000 77.02 ms (±11%) 1.05 (±4%) 92.70 ms 74.60 ms 77.02 ms ; 86.16 ms 80.00 ms 81.12 ms
mapFold int 100 000 3.91 ms (±28%) 1.26 (±23%) 5.70 ms 3.91 ms 3.31 ms ; 4.92 ms 5.40 ms 3.31 ms
mapFold int 1 000 000 59.54 ms (±10%) 0.93 (±10%) 60.71 ms 59.54 ms 49.30 ms ; 61.25 ms 55.24 ms 49.80 ms
collect string 100 000 46.90 ms (±3%) 1.02 (±2%) 48.64 ms 46.90 ms 45.80 ms ; 49.30 ms 47.98 ms 47.44 ms
collect string 1 000 000 390.31 ms (±5%) 0.92 (±11%) 397.43 ms 360.49 ms 390.31 ms ; 441.93 ms 357.31 ms 356.78 ms
collect int 100 000 22.44 ms (±10%) 1.19 (±6%) 25.80 ms 21.21 ms 22.44 ms ; 27.97 ms 26.72 ms 24.88 ms
collect int 1 000 000 234.05 ms (±18%) 1.20 (±9%) 309.51 ms 217.61 ms 234.05 ms ; 281.01 ms 296.64 ms 247.35 ms
tryPick string 100 000 2.42 ms (±7%) 0.64 (±7%) 2.45 ms 2.42 ms 2.12 ms ; 1.55 ms 1.79 ms 1.56 ms
tryPick string 1 000 000 44.29 ms (±15%) 0.97 (±11%) 56.76 ms 42.35 ms 44.29 ms ; 45.40 ms 35.95 ms 43.03 ms
tryPick int 100 000 963 us (±9%) 1.18 (±13%) 1.10 ms 919 us 963 us ; 1.15 ms 1.13 ms 876 us
tryPick int 1 000 000 32.18 ms (±16%) 0.85 (±27%) 32.18 ms 32.97 ms 23.48 ms ; 27.47 ms 40.31 ms 23.87 ms

Why mapFold stays as it was (protocol 1, strings and integers)

Library mapFold written with the Walk loop, against a copy of the old loop in the same process, in four runs:

run type 10 100 1 000 10 000 100 000 1 000 000
1 string 1.23 1.20 1.16 1.15 1.19 1.05
2 string 1.21 1.23 1.19 1.19 1.12 1.07
3 string - 1.23 1.13 1.18 1.30 1.10
4 string - 1.19 1.19 1.30 1.37 1.04
1 int 0.99 1.00 1.01 0.93 0.90 1.04
2 int 0.99 0.98 0.99 1.06 0.92 1.02
3 int - 1.04 1.02 0.99 1.01 1.03
4 int - 0.99 1.02 0.98 1.06 1.00

For strings the loop of Walk took 1.1 to 1.4 times as long, although the machine code of the two loops differs only in the order of a copy of the struct that the mapping returns, and the same source in a script ran 0.8 times as long. The reason was not found; the old loop stays.

🤖 Generated with Claude Code

@xperiandri

Copy link
Copy Markdown
Collaborator Author

Measurements 3 of 4: choose

The elements that choose selects are not counted in advance, so they wait in a buffer until the balanced tree is built from them once. The variants are in the legend of the first comment.

Alternatives for the buffer, 10 to 10 000 elements (protocol 1)

Time relative to A, the code before this branch (the "conservative" one of two runs; A2 is A again). 100 000 and a million elements are in the next sections, measured with one variant per process.

int, 100 % of the elements chosen

n B C0 C E D S1 S2 F A2
10 1.00 1.00 1.00 0.93 1.00 0.91 0.85 1.18 1.00
100 0.83 0.94 1.00 1.00 0.99 1.00 0.97 1.91 1.00
1 000 0.98 1.00 1.00 1.00 0.98 0.91 0.91 2.85 0.99
10 000 0.89 1.00 0.93 0.83 0.78 0.84 0.90 3.32 0.89

int, 50 % of the elements chosen

n B C0 C E D S1 S2 F A2
10 0.93 0.98 1.00 1.00 0.93 1.00 0.98 1.21 1.00
100 0.98 0.97 0.96 0.98 0.97 0.97 0.98 1.53 0.97
1 000 1.00 1.00 1.00 1.00 1.00 1.00 1.01 2.30 1.00
10 000 1.00 1.00 1.00 0.96 0.96 0.95 1.00 2.77 1.01

int, 1 % of the elements chosen

n B C0 C E D S1 S2 F A2
10 1.00 1.01 1.01 1.00 1.00 1.09 1.10 1.12 1.07
100 1.00 1.00 0.99 1.00 1.00 1.00 1.00 1.00 0.96
1 000 1.03 1.00 1.06 1.01 1.00 1.02 1.03 1.01 1.00
10 000 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00

string, 100 % of the elements chosen

n B C0 C E D S1 S2 F A2
10 0.85 0.99 0.81 0.86 0.83 0.80 0.80 0.99 0.97
100 0.98 1.04 0.94 0.87 0.93 0.92 0.98 1.52 1.00
1 000 0.94 1.00 1.00 0.99 1.00 1.00 0.97 2.01 0.98
10 000 0.91 1.00 0.91 0.90 0.87 0.91 0.92 2.28 1.00

string, 50 % of the elements chosen

n B C0 C E D S1 S2 F A2
10 0.85 0.96 0.90 0.81 0.89 0.95 0.90 1.00 0.99
100 0.99 0.97 0.91 0.94 0.95 1.00 0.95 1.19 0.97
1 000 1.00 0.99 1.00 0.98 0.98 0.96 1.00 1.54 1.00
10 000 1.05 1.01 0.99 1.00 1.00 1.00 1.01 1.91 1.00

string, 1 % of the elements chosen

n B C0 C E D S1 S2 F A2
10 1.00 0.91 0.90 0.92 0.89 0.88 0.91 0.89 0.96
100 1.00 0.99 1.01 1.03 1.00 1.00 1.03 1.00 1.00
1 000 1.00 1.00 1.00 1.00 1.00 1.00 1.00 1.00 0.98
10 000 1.02 1.00 0.99 1.00 0.98 0.97 1.00 1.01 1.00

Reading: the variants B, C0, C, E, D, S1 and S2 are within 0.78 to 1.10 of A, which is about the size of the noise (A2 reads 0.89 to 1.07), and the buffer is a minority of the time anyway, because the tree allocates one node of 48 bytes per element and builds it once. The lowest cells are E and D at 10 000 elements when half or all of the elements are chosen (0.78 to 0.96). The builder (F) is 1.2 to 3.3 times slower when half or all of the elements are chosen, and as fast when 1 % are.

Allocation of one call for a million elements (exact)

variant int 100 % int 50 % int 1 % string 100 % string 50 % string 1 %
A 55067.5 KB 27533.9 KB 597.2 KB 63259.5 KB 31629.9 KB 725.2 KB
B 50781.3 KB 27343.7 KB 4375.2 KB 54687.6 KB 31250.0 KB 8281.4 KB
C0 55067.5 KB 27533.9 KB 597.2 KB 63259.5 KB 31629.9 KB 725.2 KB
C 50781.3 KB 27343.7 KB 4375.2 KB 54687.6 KB 31250.0 KB 8281.4 KB
E 50781.3 KB 27343.7 KB 4375.1 KB 54687.5 KB 31250.0 KB 8281.4 KB
D 46875.0 KB 23437.4 KB 468.9 KB 46875.0 KB 23437.4 KB 468.9 KB
S1 48295.0 KB 24126.5 KB 483.0 KB 48299.0 KB 24130.5 KB 487.0 KB
S2 47106.6 KB 23566.2 KB 502.0 KB 47138.6 KB 23598.2 KB 534.0 KB
F 46875.0 KB 23437.4 KB 468.9 KB 46875.0 KB 23437.4 KB 468.9 KB

The tree needs 46 875 KB for a million elements. A allocates 17 % (integers) to 35 % (strings) more than the tree when half or all of the elements are chosen (the arrays of its growth). An array of the length of the source that is allocated anew (B, C, E) allocates 7 to 11 times as much as A (9 to 18 times as much as the tree needs) when 1 % of the elements are chosen, and a buffer of that length would be an array of a hundred million elements for a list of that size; a rented array (D) does not allocate it, and the branch caps the rented length at 1 048 576 elements and spills a longer result into a list.

Where the pool starts to pay (protocol 1, 100 % chosen)

An array of its own (E) and a rented one (D) took about the same time as the list (A) from 4 to 2 048 elements, 0.80 to 1.10 of its time and mostly 0.9 to 1.0; the first call that rents an array of a length also allocates it. The branch uses an array of its own below 1024 elements and the pool from 1024.

n int 100 %: A, us exact array / A pooled array / A first call, us string 100 %: A, us exact / A pooled / A first call, us
4 0.089 0.91 0.89 5.9 0.156 0.85 0.88 11.2
8 0.155 0.86 0.88 6.4 0.253 0.88 0.88 11.6
16 0.309 0.93 0.80 6.0 0.445 0.92 0.92 11.8
32 0.430 0.99 1.04 6.3 1.137 0.85 0.83 11.6
64 0.890 0.97 0.93 9.9 1.657 0.97 0.94 13.8
128 1.719 0.93 0.95 10.0 3.399 0.90 0.91 20.0
256 3.826 0.99 0.91 13.3 6.458 1.01 1.00 21.2
512 7.328 1.00 1.10 21.9 13.188 0.92 0.90 39.6
1024 14.476 0.97 0.97 34.5 25.756 0.98 0.96 81.4
2048 27.656 0.97 0.95 44.3 51.187 0.92 0.96 72.9

First call of the rented variant against the warm calls, microseconds (the first call allocates the array of its bucket):

type n first call of D bytes of the first call D, warm E, warm A
int 10 7.30 592 B 0.24 0.23 0.26
int 100 18.00 5.2 KB 2.75 3.06 2.90
int 1000 84.20 50.9 KB 16.85 18.07 18.54
int 10000 233 468.8 KB 172 180 219
int 100000 5248 5199.5 KB 2413 2596 3009
int 1000000 40.3 ms 50971.0 KB 43.3 ms 47.4 ms 45.3 ms
string 10 15.80 656 B 0.36 0.37 0.45
string 100 20.50 5.7 KB 2.95 2.75 3.19
string 1000 81.35 54.9 KB 31.59 29.35 31.77
string 10000 1026 596.8 KB 307 327 407
string 100000 8293 5711.5 KB 4079 4169 4951
string 1000000 72.2 ms 55067.0 KB 62.5 ms 59.0 ms 69.2 ms

The tree built from joined chunks (the shape experiment, protocol 1, int)

Relative time of a pass over a tree built from chunks of 8192 elements and joined, against a tree built in one piece; the average of two runs, which disagree at some sizes (single runs read 0.77 to 1.17 for the indexer). The indexer is the cost that decided against the chunks.

type n indexer: joined / built enumeration: joined / built Add: joined / built
int 10 0.93 0.99 0.93
int 100 1.06 1.08 0.84
int 1000 1.09 1.12 0.96
int 10000 1.09 1.04 1.07
int 100000 1.10 0.87 1.00
int 1000000 0.93 1.04 1.03

100 000 and a million elements, one variant per process (protocol 2)

Mean time per call of the old code (A and A2, six processes together) with the scatter of the replicates; D is the array from the pool with the old loop; L is IndexedList.choose of this branch (the pool and the Walk loop). The ratios are to the old code. Allocation, collections of the second generation per 1000 calls and the GC pause per call are given as old -> new.

elements n old code (A and A2, 6 processes) D: pool, old loop L: this branch allocation old -> new gen2 per 1000 calls old -> new GC pause per call old -> new
int, 100 % 100 000 3.8 ms (±12%) 0.79 (±1%) 0.94 (±22%) 5712 -> 4688 KB 6.7 -> 0.0 1.0 ms -> 350 us
int, 100 % 1 000 000 52.9 ms (±8%) 0.99 (±4%) 0.98 (±4%) 55067 -> 46875 KB 14.8 -> 0.0 10.4 ms -> 8.3 ms
int, 50 % 100 000 2.5 ms (±11%) 0.85 (±7%) 0.90 (±5%) 2856 -> 2344 KB 3.1 -> 0.0 454 us -> 137 us
int, 50 % 1 000 000 39.3 ms (±12%) 0.98 (±4%) 1.03 (±11%) 27534 -> 23437 KB 0.0 -> 0.0 2.4 ms -> 2.6 ms
int, 1 % 100 000 1.2 ms (±8%) 1.06 (±4%) 1.11 (±19%) 55 -> 47 KB 0.0 -> 0.0 3 us -> 2 us
int, 1 % 1 000 000 28.2 ms (±4%) 1.00 (±6%) 1.02 (±4%) 597 -> 469 KB 0.0 -> 0.0 0 us -> 0 us
string, 100 % 100 000 7.4 ms (±10%) 0.75 (±25%) 0.65 (±15%) 6736 -> 4688 KB 9.8 -> 0.0 1.7 ms -> 366 us
string, 100 % 1 000 000 78.1 ms (±6%) 0.94 (±6%) 0.84 (±7%) 63259 -> 46875 KB 40.0 -> 0.0 15.7 ms -> 9.2 ms
string, 50 % 100 000 4.7 ms (±11%) 0.95 (±24%) 0.67 (±8%) 3368 -> 2344 KB 2.5 -> 0.0 507 us -> 138 us
string, 50 % 1 000 000 58.8 ms (±6%) 0.98 (±1%) 0.89 (±10%) 31630 -> 23438 KB 0.0 -> 0.0 2.6 ms -> 2.8 ms
string, 1 % 100 000 2.7 ms (±17%) 1.01 (±15%) 0.73 (±6%) 63 -> 47 KB 0.0 -> 0.0 0 us -> 1 us
string, 1 % 1 000 000 44.1 ms (±9%) 1.00 (±1%) 0.85 (±8%) 725 -> 469 KB 0.0 -> 0.0 0 us -> 0 us

Reading:

  • Integers: the time is inside the noise (0.90 to 1.03); the one cell above it, 1 % of 100 000, comes from one replicate (1675 us, the others 1150 to 1290).
  • Strings: 0.65 to 0.89. Most of it is the Walk loop, not the pool: D alone is about 1.0 except where the old code's collections are large (100 % chosen).
  • Allocation: 15 to 21 % less for integers and 25 to 35 % less for strings.
  • Collections of the second generation: with the pool there were none in any cell. The old code had up to 11 per 1000 calls at 100 000 elements (6.7 and 10.9 in single processes when everything is chosen), and between 0 and 77 at a million depending on the process, so its pause is very variable there.
  • GC pause per call at 100 000 elements when everything is chosen: 1.0 ms -> 0.35 ms for integers, 1.7 ms -> 0.37 ms for strings.
The same runs per variant: mean time, scatter, allocation, collections, pause

int, 100 % of the elements chosen

n A: mean us (± spread %), KB, gen2/1000 calls, pause us/call A2: mean us (± spread %), KB, gen2/1000 calls, pause us/call D: mean us (± spread %), KB, gen2/1000 calls, pause us/call L: mean us (± spread %), KB, gen2/1000 calls, pause us/call
100000 3914.1 (±11.1) 5712 6.7 1037.4 3621.4 (±7.5) 5712 6.7 1017.0 2986.8 (±0.7) 4688 0.0 338.9 3524.1 (±21.9) 4688 0.0 350.1
1000000 53190.7 (±7.8) 55067 1.9 8988.4 52543.2 (±8.3) 55067 27.8 11817.1 52374.4 (±4.4) 46875 0.0 8980.8 51623.8 (±3.9) 46875 0.0 8303.7

Ratio to A, mean / median of the rounds (A2 is A again):

n A2 D L
100000 0.93 / 0.95 0.76 / 0.80 0.90 / 0.90
1000000 0.99 / 1.01 0.98 / 1.01 0.97 / 1.00

int, 50 % of the elements chosen

n A: mean us (± spread %), KB, gen2/1000 calls, pause us/call A2: mean us (± spread %), KB, gen2/1000 calls, pause us/call D: mean us (± spread %), KB, gen2/1000 calls, pause us/call L: mean us (± spread %), KB, gen2/1000 calls, pause us/call
100000 2708.9 (±3.7) 2856 3.5 503.9 2372.4 (±6.8) 2856 2.7 394.5 2170.8 (±6.8) 2344 0.0 138.5 2278.7 (±5.2) 2344 0.0 137.0
1000000 39826.2 (±2.3) 27534 0.0 2752.2 38758.0 (±12.0) 27534 0.0 1971.5 38402.7 (±4.2) 23437 0.0 2838.9 40461.6 (±10.9) 23437 0.0 2567.2

Ratio to A, mean / median of the rounds (A2 is A again):

n A2 D L
100000 0.88 / 0.90 0.80 / 0.80 0.84 / 0.84
1000000 0.97 / 0.93 0.96 / 0.96 1.02 / 0.99

int, 1 % of the elements chosen

n A: mean us (± spread %), KB, gen2/1000 calls, pause us/call A2: mean us (± spread %), KB, gen2/1000 calls, pause us/call D: mean us (± spread %), KB, gen2/1000 calls, pause us/call L: mean us (± spread %), KB, gen2/1000 calls, pause us/call
100000 1279.2 (±4.0) 55 0.0 2.7 1198.7 (±7.7) 55 0.0 2.6 1308.4 (±4.2) 47 0.0 1.1 1371.6 (±19.1) 47 0.0 2.0
1000000 28749.2 (±2.2) 597 0.0 0.0 27710.3 (±2.8) 597 0.0 0.0 28335.1 (±6.3) 469 0.0 0.0 28829.3 (±4.3) 469 0.0 0.0

Ratio to A, mean / median of the rounds (A2 is A again):

n A2 D L
100000 0.94 / 0.94 1.02 / 1.01 1.07 / 1.05
1000000 0.96 / 0.95 0.99 / 0.97 1.00 / 0.98

string, 100 % of the elements chosen

n A: mean us (± spread %), KB, gen2/1000 calls, pause us/call A2: mean us (± spread %), KB, gen2/1000 calls, pause us/call D: mean us (± spread %), KB, gen2/1000 calls, pause us/call L: mean us (± spread %), KB, gen2/1000 calls, pause us/call
100000 7705.0 (±1.7) 6736 10.9 1871.5 7157.6 (±10.0) 6736 8.9 1631.5 5589.9 (±24.7) 4688 0.0 364.1 4823.5 (±15.1) 4688 0.0 366.3
1000000 75590.5 (±2.4) 63259 0.0 10329.8 80612.3 (±4.5) 63259 76.9 20576.4 73050.0 (±6.2) 46875 0.0 9666.0 65890.9 (±6.7) 46875 0.0 9153.6

Ratio to A, mean / median of the rounds (A2 is A again):

n A2 D L
100000 0.93 / 0.89 0.73 / 0.68 0.63 / 0.60
1000000 1.07 / 1.08 0.97 / 0.96 0.87 / 0.89

string, 50 % of the elements chosen

n A: mean us (± spread %), KB, gen2/1000 calls, pause us/call A2: mean us (± spread %), KB, gen2/1000 calls, pause us/call D: mean us (± spread %), KB, gen2/1000 calls, pause us/call L: mean us (± spread %), KB, gen2/1000 calls, pause us/call
100000 4586.2 (±2.6) 3368 0.0 252.2 4898.8 (±11.0) 3368 4.3 688.1 4483.0 (±24.2) 2344 0.0 146.0 3197.2 (±7.5) 2344 0.0 138.1
1000000 57963.5 (±0.9) 31630 0.0 2211.7 59567.4 (±6.0) 31630 0.0 2943.6 57518.1 (±0.9) 23437 0.0 2581.1 52375.6 (±9.9) 23438 0.0 2799.2

Ratio to A, mean / median of the rounds (A2 is A again):

n A2 D L
100000 1.07 / 1.03 0.98 / 0.92 0.70 / 0.69
1000000 1.03 / 1.01 0.99 / 1.00 0.90 / 0.90

string, 1 % of the elements chosen

n A: mean us (± spread %), KB, gen2/1000 calls, pause us/call A2: mean us (± spread %), KB, gen2/1000 calls, pause us/call D: mean us (± spread %), KB, gen2/1000 calls, pause us/call L: mean us (± spread %), KB, gen2/1000 calls, pause us/call
100000 2607.8 (±5.2) 63 0.0 0.0 2694.3 (±17.1) 63 0.0 0.0 2680.1 (±14.5) 47 0.0 0.0 1926.5 (±6.4) 47 0.0 1.1
1000000 44408.1 (±2.8) 725 0.0 0.0 43784.0 (±8.8) 725 0.0 0.0 44169.3 (±1.4) 469 0.0 0.0 37688.7 (±8.2) 469 0.0 0.0

Ratio to A, mean / median of the rounds (A2 is A again):

n A2 D L
100000 1.03 / 0.96 1.03 / 1.05 0.74 / 0.77
1000000 0.99 / 1.00 0.99 / 1.03 0.85 / 0.84

🤖 Generated with Claude Code

@xperiandri

Copy link
Copy Markdown
Collaborator Author

Measurements 4 of 4: ofArray, the temporary arrays and unfold

The three builds side by side (one build per process)

base is the library with the first four commits of this pull request (ofList, Walk, choose); A adds the commit with the span build and Array.Fill; B adds the commit with the pooled temporary arrays. Protocol 2 for 100 000 and a million elements, and the same one-build-per-process arrangement with batches of 60 ms for 100 to 10 000. Each cell is the median of three replicate processes per build, run in interleaved orders, all on runtime 10.0.13. The columns are the time per call of base with the scatter of its replicates (half the range, in percent), and the time of A and B relative to it; B / A is the effect of the last commit alone. The last column is the collections of the second generation per 1000 calls.

Where the scatter of base is large (above ±10 %) the ratios of that row are not claimed. The calls of 100 elements take 1 to 4 microseconds, and the replicates scatter by up to ±34 % there.

fresh, int

n base (us) A / base B / base B / A gen2 per 1000 calls: base / A / B
100 1.24 us (±34%) 1.58 1.76 1.11 0.0 / 0.0 / 0.0
1000 10.10 us (±8%) 1.08 1.06 0.98 0.0 / 0.0 / 0.0
10000 126.06 us (±1%) 1.08 1.03 0.95 0.0 / 0.0 / 0.0
100000 1.64 ms (±7%) 0.93 0.96 1.03 0.0 / 0.0 / 0.0
1000000 18.68 ms (±4%) 0.96 1.01 1.05 0.0 / 0.0 / 0.0

fresh, string

n base (us) A / base B / base B / A gen2 per 1000 calls: base / A / B
100 1.70 us (±2%) 0.86 0.82 0.95 0.0 / 0.0 / 0.0
1000 16.23 us (±0%) 0.85 0.81 0.95 0.0 / 0.0 / 0.0
10000 198.29 us (±4%) 0.79 0.77 0.98 0.0 / 0.0 / 0.0
100000 2.02 ms (±5%) 0.85 0.83 0.98 0.0 / 0.0 / 0.0
1000000 24.75 ms (±6%) 0.90 0.88 0.98 0.0 / 0.0 / 0.0

temp-sub, int

n base (us) A / base B / base B / A gen2 per 1000 calls: base / A / B
100 2.34 us (±15%) 0.86 0.81 0.95 0.0 / 0.0 / 0.0
1000 16.82 us (±2%) 1.01 1.04 1.02 0.0 / 0.0 / 0.0
10000 199.18 us (±16%) 1.08 0.98 0.91 0.0 / 0.0 / 0.0
100000 2.75 ms (±38%) 1.07 0.93 0.87 0.0 / 0.0 / 0.0
1000000 50.07 ms (±21%) 0.90 0.89 0.99 0.0 / 0.0 / 0.0

temp-sub, string

n base (us) A / base B / base B / A gen2 per 1000 calls: base / A / B
100 3.90 us (±4%) 0.86 0.89 1.03 0.0 / 0.0 / 0.0
1000 37.86 us (±7%) 0.86 0.85 0.99 0.0 / 0.0 / 0.0
10000 417.90 us (±4%) 0.99 0.96 0.98 0.0 / 0.0 / 0.0
100000 5.77 ms (±5%) 1.16 1.05 0.90 0.3 / 5.9 / 0.0
1000000 74.56 ms (±5%) 0.95 0.90 0.94 0.0 / 0.0 / 0.0

temp-rev, int

n base (us) A / base B / base B / A gen2 per 1000 calls: base / A / B
100 1.84 us (±4%) 1.00 1.03 1.03 0.0 / 0.0 / 0.0
1000 17.81 us (±7%) 1.01 0.96 0.95 0.0 / 0.0 / 0.0
10000 203.49 us (±11%) 1.00 0.99 0.99 0.0 / 0.0 / 0.0
100000 2.57 ms (±3%) 1.01 1.06 1.05 0.0 / 0.0 / 0.0
1000000 43.12 ms (±4%) 1.01 0.99 0.98 0.0 / 0.0 / 0.0

temp-rev, string

n base (us) A / base B / base B / A gen2 per 1000 calls: base / A / B
100 4.27 us (±2%) 0.93 0.86 0.93 0.0 / 0.0 / 0.0
1000 39.91 us (±5%) 0.84 0.90 1.08 0.0 / 0.0 / 0.0
10000 430.28 us (±0%) 0.98 0.96 0.98 0.0 / 0.0 / 0.0
100000 6.39 ms (±8%) 0.93 0.86 0.92 6.2 / 4.1 / 0.0
1000000 78.18 ms (±4%) 0.96 0.91 0.94 0.0 / 0.0 / 0.0

temp-mapi, int

n base (us) A / base B / base B / A gen2 per 1000 calls: base / A / B
100 2.30 us (±1%) 1.00 0.97 0.96 0.0 / 0.0 / 0.0
1000 20.97 us (±10%) 1.03 0.97 0.94 0.0 / 0.0 / 0.0
10000 238.53 us (±2%) 1.04 1.07 1.02 0.0 / 0.0 / 0.0
100000 3.29 ms (±23%) 1.03 0.98 0.96 0.0 / 0.0 / 0.0
1000000 48.05 ms (±4%) 0.95 1.05 1.10 0.0 / 0.0 / 0.0

temp-mapi, string

n base (us) A / base B / base B / A gen2 per 1000 calls: base / A / B
100 3.46 us (±7%) 0.87 0.90 1.03 0.0 / 0.0 / 0.0
1000 32.31 us (±5%) 0.95 0.89 0.94 0.0 / 0.0 / 0.0
10000 368.99 us (±5%) 0.90 0.93 1.02 0.0 / 0.0 / 0.0
100000 6.02 ms (±5%) 0.86 0.82 0.95 6.3 / 7.7 / 0.0
1000000 68.69 ms (±9%) 0.94 0.94 1.01 0.0 / 0.0 / 0.0

temp-init, int

n base (us) A / base B / base B / A gen2 per 1000 calls: base / A / B
100 2.95 us (±3%) 1.03 1.00 0.98 0.0 / 0.0 / 0.0
1000 28.55 us (±7%) 1.23 1.04 0.85 0.0 / 0.0 / 0.0
10000 323.75 us (±9%) 0.97 0.98 1.01 0.0 / 0.0 / 0.0
100000 3.74 ms (±12%) 0.99 0.90 0.91 0.0 / 0.0 / 0.0
1000000 37.59 ms (±3%) 1.00 1.14 1.14 0.0 / 0.0 / 0.0

temp-init, string

n base (us) A / base B / base B / A gen2 per 1000 calls: base / A / B
100 2.00 us (±6%) 0.84 0.90 1.08 0.0 / 0.0 / 0.0
1000 20.17 us (±8%) 0.78 0.80 1.03 0.0 / 0.0 / 0.0
10000 212.96 us (±4%) 0.91 0.89 0.98 0.0 / 0.0 / 0.0
100000 3.49 ms (±4%) 0.90 0.61 0.68 10.5 / 9.9 / 0.0
1000000 36.73 ms (±12%) 0.92 0.72 0.78 32.3 / 34.9 / 0.0

temp-replicate, int

n base (us) A / base B / base B / A gen2 per 1000 calls: base / A / B
100 1.12 us (±2%) 1.08 1.20 1.10 0.0 / 0.0 / 0.0
1000 10.32 us (±19%) 0.96 0.94 0.98 0.0 / 0.0 / 0.0
10000 126.40 us (±7%) 1.01 0.97 0.96 0.0 / 0.0 / 0.0
100000 1.93 ms (±12%) 1.06 0.75 0.71 5.8 / 5.3 / 0.0
1000000 23.42 ms (±4%) 1.01 0.79 0.79 32.5 / 38.2 / 0.0

temp-replicate, string

n base (us) A / base B / base B / A gen2 per 1000 calls: base / A / B
100 1.95 us (±1%) 0.79 0.82 1.03 0.0 / 0.0 / 0.0
1000 20.21 us (±5%) 0.75 0.75 1.00 0.0 / 0.0 / 0.0
10000 203.63 us (±18%) 0.84 0.88 1.05 0.0 / 0.0 / 0.0
100000 3.25 ms (±1%) 0.92 0.61 0.67 9.8 / 11.0 / 0.0
1000000 35.38 ms (±5%) 0.96 0.70 0.72 33.7 / 45.9 / 0.0

Reading:

  • ofArray (the fresh table): strings take 14 to 21 % less for 100 to 100 000 elements and 10 to 12 % less for a million; integers take the same time within the noise.
  • The first commit (A) also helps init, sub, rev and mapi of strings of up to 10 000 elements (0.78 to 0.99 of the time) and replicate of strings with Array.Fill (0.75 to 0.84 for 100 to 10 000 elements, 0.92 to 0.96 above). Integers: no change. sub of strings at 100 000 elements reads 1.16 for A; there A had 5.9 collections of the second generation per 1000 calls against 0.3 of base for the same allocation, which is the uneven fall of the collections described in the first comment, and the neighbouring sizes read 0.99 and 0.95.
  • The last commit (B, the pool) pays in replicate (0.71 and 0.79 of the time of A for integers, 0.67 and 0.72 for strings of 100 000 and a million elements) and in init of strings (0.68 and 0.78), where it also removes the collections of the second generation (5 to 46 per 1000 calls). For rev and mapi of strings the gain is small (0.92 to 1.01), and for sub and the integer init, mapi and rev it is inside the noise; init and mapi of a million integers read 1.14 and 1.10 against a scatter of ±12 and ±3 %, so the pool may cost a little there. A list of 100 strings pays for the call of the helper and its closure, up to 8 % of a call of 2 to 4 microseconds. scan and mapFold use the same helper and were not measured separately.
  • Below 1024 elements B allocates an array of its own as before; from 1024 it rents. Between 1000 and 10 000 elements B / A ranges from 0.85 to 1.08, inside the scatter of the replicates.

An exact array against a rented one, against the old code (protocol 1, 10 to 10 000 elements)

Copies of the algorithm as a script, relative to a copy of the old code in the same process: an array of the exact length allocated anew (exact), and an array rented from the pool (pool). This is what the choice between them rested on. Above 10 000 elements the table before is the evidence, because a new array above the 85 000-byte limit goes to the large object heap and its garbage causes the collections that the pool avoids.

Tables

sub, int

n exact pool
10 1.00 0.93
100 1.00 1.00
1 000 0.85 0.92
10 000 0.96 1.00

rev, int

n exact pool
10 0.81 0.84
100 0.90 0.95
1 000 0.83 0.85
10 000 0.93 0.85

mapi, int

n exact pool
10 0.91 0.89
100 0.94 0.93
1 000 0.89 0.85
10 000 0.98 1.00

init, int

n exact pool
10 1.00 0.86
100 1.00 0.96
1 000 0.82 0.81
10 000 1.00 1.00

replicate, int

n exact pool
10 0.74 0.68
100 0.73 0.85
1 000 0.74 0.78
10 000 0.88 0.91

sub, string

n exact pool
10 0.92 0.93
100 0.95 0.94
1 000 0.90 0.93
10 000 0.96 1.00

rev, string

n exact pool
10 0.90 0.93
100 0.92 0.95
1 000 0.86 0.91
10 000 1.00 1.00

mapi, string

n exact pool
10 0.90 0.91
100 1.00 1.02
1 000 0.92 1.00
10 000 0.93 0.92

init, string

n exact pool
10 0.80 0.85
100 0.85 0.82
1 000 0.82 0.88
10 000 0.83 0.90

replicate, string

n exact pool
10 0.74 0.79
100 0.76 0.78
1 000 0.75 0.85
10 000 0.80 0.82

unfold (protocol 1, 10 to 10 000 elements)

Variants of the buffer of unfold, where the number of elements is neither known nor bounded: C0 the ResizeArray with the span build, S1 and S2 chunks of 1024 and 8192 elements joined as subtrees, A2 the old code again. The chunks read 0.65 to 1.00 of the time in this range (A2 reads 0.88 to 1.02) and C0 reads 0.91 to 1.10. The chunks were not taken: the tree built from joined chunks has a slower indexer (see the shape table in the third comment), and the other variants gain nothing. unfold is unchanged.

int

n C0 S1 S2 A2
10 1.00 0.92 0.86 1.01
100 1.00 1.00 0.90 1.00
1 000 0.91 0.87 0.82 1.02
10 000 1.06 0.98 1.00 1.00

string

n C0 S1 S2 A2
10 1.00 0.77 0.81 1.00
100 1.00 1.00 1.00 1.00
1 000 1.10 0.98 0.93 1.00
10 000 1.00 0.65 0.75 0.88

🤖 Generated with Claude Code

@xperiandri

Copy link
Copy Markdown
Collaborator Author

Measurements 5 of 5: the other collections

The Walk idea (the interface for a reference type, the struct enumerator for a value type) was checked for the immutable collections of the other modules, which have their own branches. Only SortedSet and SortedMap gain from it. Nothing of this section is in this pull request; the two changes that follow from it belong to feat/sorted-set and feat/sorted-map.

The struct enumerator against the interface (protocol 1, two runs of 15 rounds, light cells)

Time of a walk through IEnumerable<T> (boxed enumerator) relative to for item in collection do (the struct enumerator): the median of the rounds of two runs, and the 10th to 90th percentile in brackets. Values below 1 mean that the interface is faster. The same loop written twice (struct2) reads 0.95 to 1.07 in every cell, which is the noise.

collection type 100 1 000 10 000 100 000
hashset string 0.94 [0.82-1.17] 1.11 [0.84-1.41] 1.15 [0.95-1.27] 1.12 [0.81-1.55]
hashset int 1.73 [1.46-2.18] 1.88 [1.53-2.37] 1.76 [1.55-2.05] 1.70 [1.57-2.10]
sortedset string 0.56 [0.47-0.68] 0.53 [0.46-0.62] 0.57 [0.43-0.68] 0.58 [0.52-0.66]
sortedset int 1.65 [1.49-1.96] 1.53 [1.43-1.98] 1.54 [1.21-1.71] 1.32 [1.12-1.49]
queue string 2.81 [2.41-3.35] 2.44 [1.88-2.95] 2.07 [1.74-2.32] 2.10 [1.83-2.34]
queue int 2.54 [2.07-2.86] 1.97 [1.53-2.25] 1.89 [1.48-2.37] 1.67 [1.51-1.89]
stack string 2.16 [1.79-2.61] 1.81 [1.49-2.32] 1.65 [1.40-2.03] 1.62 [1.39-2.04]
stack int 1.83 [1.57-2.41] 1.55 [1.26-1.99] 1.51 [1.17-1.98] 1.43 [1.29-1.62]
hashmap string 0.93 [0.72-1.22] 0.93 [0.76-1.13] 0.87 [0.77-1.02] 0.87 [0.68-1.15]
hashmap int 1.38 [1.23-1.63] 1.42 [1.26-1.78] 1.57 [1.30-2.05] 1.49 [1.28-1.77]
sortedmap string 0.61 [0.46-0.67] 0.62 [0.48-0.75] 0.56 [0.48-0.67] 0.58 [0.46-0.75]
sortedmap int 1.62 [1.37-1.88] 1.57 [1.37-1.78] 1.58 [1.31-1.80] 1.36 [1.19-1.56]
frozenset string 0.88 [0.77-0.96] 0.81 [0.73-0.93] 0.80 [0.74-0.89] 0.81 [0.68-1.00]
frozenset int 1.54 [1.37-1.79] 1.62 [1.42-1.84] 1.56 [1.34-1.90] 1.62 [1.39-1.75]
frozenmap string 0.95 [0.80-1.12] 0.94 [0.80-1.04] 0.89 [0.78-1.07] 0.89 [0.77-1.02]
frozenmap int 1.45 [1.22-1.72] 1.53 [1.33-1.78] 1.49 [1.27-1.69] 1.40 [1.25-1.61]

Per element at 100 000 elements:

collection type struct, ns per element interface, ns per element interface / struct
hashset string 73.0 75.6 1.04
hashset int 14.5 24.5 1.69
sortedset string 23.2 13.4 0.58
sortedset int 9.1 11.8 1.30
queue string 1.6 3.4 2.10
queue int 1.5 2.5 1.66
stack string 1.6 2.6 1.62
stack int 1.5 2.1 1.41
hashmap string 85.4 74.2 0.87
hashmap int 19.9 28.5 1.44
sortedmap string 29.3 16.4 0.56
sortedmap int 8.2 10.9 1.33
frozenset string 2.5 2.0 0.81
frozenset int 0.8 1.3 1.61
frozenmap string 3.5 3.1 0.88
frozenmap int 1.5 2.0 1.40

Reading:

  • Reference types: the interface is faster only for SortedSet (0.53 to 0.58) and SortedMap (0.56 to 0.62). For FrozenSet, FrozenMap and HashMap it is 0.80 to 0.95, which is small, and for HashSet it is equal or slower (0.94 to 1.15): a walk over 100 000 strings takes 73 ns per element there, and it waits for memory, not for the enumerator. For Queue and Stack the interface is 1.6 to 2.8 times slower, so their struct enumerators stay.
  • Value types: the interface is slower everywhere (1.3 to 2.5 times), so the struct enumerator stays for them in all of the collections.

exists and forall of SortedSet and SortedMap, in the library (protocol 1)

These four functions walk with the struct enumerator (the others walk through Seq already or build with a builder, which costs more than the walk). The change picks the enumerator with typeof of the type argument (for the map: the struct enumerator when both the key and the value are value types). Library function against a copy of its old loop in the same process, built from the base branch and from the changed one; the ratio is of the medians of 15 rounds, in brackets the 10th to 90th percentile; the base build must read 1.00 (the check of the script).

collection elements base build changed build, run 1 changed build, run 2
SortedSet string, 100 to 100 000 0.94 to 1.08 0.47 to 0.57 0.47 to 0.56
SortedSet int, 100 to 100 000 1.00 to 1.07 0.94 to 1.03 0.93 to 1.05
SortedMap string, string 0.90 to 1.10 0.56 to 0.68 0.55 to 0.84 (one cell 1.11)
SortedMap string, int 0.94 to 1.06 0.62 to 0.66 0.61 to 0.66
SortedMap int, string 0.98 to 1.10 0.60 to 0.69 0.62 to 0.66
SortedMap int, int 0.95 to 1.07 0.90 to 1.13 0.96 to 1.09

The cost of the interface is the boxed enumerator: 64 bytes per call for the set, 56 for the map. The largest map (100 000 bindings) is the noisiest cell of the second run (0.82 and 0.84).

Every cell of the changed builds, run 1

SortedSet

function, type, n old loop library / old [10th-90th] old again / old
exists, string, 100 1.74 us 0.51 [0.45-0.63] 0.93 [0.71-1.10]
forall, string, 100 1.81 us 0.53 [0.46-0.62] 1.03 [0.91-1.20]
exists, string, 1000 17.38 us 0.51 [0.39-0.66] 0.95 [0.71-1.14]
forall, string, 1000 18.11 us 0.49 [0.46-0.64] 0.98 [0.91-1.24]
exists, string, 10000 197.04 us 0.49 [0.42-0.60] 1.00 [0.78-1.08]
forall, string, 10000 202.97 us 0.47 [0.37-0.60] 0.99 [0.77-1.35]
exists, string, 100000 2.57 ms 0.57 [0.45-0.81] 1.03 [0.80-1.37]
forall, string, 100000 2.39 ms 0.57 [0.44-0.68] 0.95 [0.83-1.24]
exists, int, 100 0.51 us 0.94 [0.82-1.21] 1.00 [0.83-1.21]
forall, int, 100 0.51 us 1.03 [0.74-1.14] 1.01 [0.78-1.31]
exists, int, 1000 5.78 us 0.97 [0.55-1.16] 0.95 [0.79-1.10]
forall, int, 1000 5.22 us 1.03 [0.92-1.12] 0.95 [0.85-1.23]
exists, int, 10000 65.74 us 0.96 [0.77-1.17] 0.93 [0.79-1.21]
forall, int, 10000 58.64 us 1.01 [0.90-1.18] 0.97 [0.84-1.20]
exists, int, 100000 772.86 us 1.02 [0.86-1.22] 1.05 [0.88-1.28]
forall, int, 100000 811.97 us 0.98 [0.87-1.21] 1.01 [0.89-1.22]

SortedMap

function, key and value, n old loop library / old [10th-90th] old again / old
exists, string, string, 100 2.43 us 0.56 [0.50-0.74] 0.91 [0.66-1.10]
forall, string, string, 100 2.37 us 0.58 [0.44-0.67] 0.98 [0.70-1.11]
exists, string, string, 1000 23.50 us 0.62 [0.44-0.70] 1.09 [0.79-1.25]
forall, string, string, 1000 29.86 us 0.56 [0.44-0.60] 0.96 [0.85-1.14]
exists, string, string, 10000 332.78 us 0.63 [0.48-0.81] 1.08 [0.77-1.22]
forall, string, string, 10000 265.38 us 0.63 [0.47-0.73] 0.99 [0.85-1.13]
exists, string, string, 100000 2.79 ms 0.68 [0.49-0.82] 1.17 [0.86-1.47]
forall, string, string, 100000 3.21 ms 0.68 [0.51-0.87] 1.06 [0.69-1.31]
exists, int, int, 100 1.02 us 0.90 [0.77-0.96] 0.99 [0.87-1.16]
forall, int, int, 100 0.95 us 0.96 [0.84-1.13] 1.00 [0.88-1.12]
exists, int, int, 1000 10.18 us 0.96 [0.83-1.10] 1.03 [0.80-1.25]
forall, int, int, 1000 9.75 us 1.01 [0.93-1.08] 1.05 [0.87-1.26]
exists, int, int, 10000 100.84 us 1.03 [0.83-1.42] 0.99 [0.86-1.14]
forall, int, int, 10000 107.00 us 1.04 [0.87-1.42] 1.18 [0.82-1.32]
exists, int, int, 100000 1.31 ms 0.96 [0.85-1.08] 0.93 [0.82-1.06]
forall, int, int, 100000 2.22 ms 1.13 [0.90-1.34] 1.09 [0.98-1.18]
exists, string, int, 100 3.93 us 0.62 [0.56-0.75] 0.99 [0.88-1.17]
forall, string, int, 100 3.86 us 0.62 [0.52-0.77] 1.01 [0.87-1.14]
exists, string, int, 1000 28.25 us 0.65 [0.55-0.79] 1.02 [0.86-1.24]
forall, string, int, 1000 29.97 us 0.65 [0.47-0.73] 0.94 [0.80-1.11]
exists, string, int, 10000 231.23 us 0.64 [0.50-0.75] 1.04 [0.92-1.16]
forall, string, int, 10000 224.15 us 0.63 [0.56-0.72] 1.06 [0.90-1.17]
exists, string, int, 100000 2.78 ms 0.62 [0.49-0.71] 0.99 [0.70-1.22]
forall, string, int, 100000 2.88 ms 0.66 [0.59-0.76] 0.94 [0.72-1.16]
exists, int, string, 100 2.19 us 0.62 [0.54-0.72] 1.02 [0.85-1.32]
forall, int, string, 100 2.13 us 0.63 [0.56-0.78] 1.01 [0.90-1.15]
exists, int, string, 1000 22.23 us 0.60 [0.51-0.65] 0.98 [0.81-1.20]
forall, int, string, 1000 21.09 us 0.67 [0.59-0.78] 0.99 [0.86-1.14]
exists, int, string, 10000 225.21 us 0.63 [0.49-0.71] 0.98 [0.86-1.27]
forall, int, string, 10000 222.56 us 0.61 [0.52-0.70] 1.02 [0.67-1.13]
exists, int, string, 100000 2.40 ms 0.69 [0.55-0.81] 1.03 [0.85-1.19]
forall, int, string, 100000 2.25 ms 0.68 [0.59-0.79] 1.05 [0.77-1.09]

🤖 Generated with Claude Code

@xperiandri xperiandri changed the title perf: faster IndexedList for large lists and reference types, and a linear ofList perf: ofArray builds the tree from a span, and the temporary arrays of long lists come from the pool Oct 21, 2026
@xperiandri
xperiandri changed the base branch from feat/indexed-list to perf/indexed-list October 21, 2026 09:54

This branch has not been deployed

No deployments
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