Skip to content

perf: a linear ofList, interface walks for reference types and a pooled buffer in choose - #49

Open
xperiandri wants to merge 3 commits into
feat/indexed-listfrom
perf/indexed-list
Open

xperiandri wants to merge 3 commits into
feat/indexed-listfrom
perf/indexed-list

Conversation

@xperiandri

Copy link
Copy Markdown
Collaborator

Proposed Changes

Builds on #31. Fixes IndexedList.ofList and ofSeq of an F# list, which built the tree in O(n²), and makes the walking functions and choose of IndexedList (and IIndexedList.choose) faster for reference types and lighter for long lists. No public signature changes.

This is the lower pull request of a stack: #45 (ofArray from a span, and the pooled temporary arrays) is on top of it. The measurements are in the comments of #45, because they were made for the whole stack: the first gives the method and which numbers to trust, the second ofList and the Walk loops, the third choose, the fifth the other collections (1, 2, 3, 5). Every number in the commit messages and below comes from them.

Commit What it does Measured effect
4d98858 fix: ofList and ofSeq of an F# list copy the list before they build the tree ImmutableList.CreateRange indexes an F# list, whose indexer is O(i) 100 000 elements: 6.6 s -> 1.6 ms (integers), 6.4 s -> 1.9 ms (strings)
849d6b6 perf: IndexedList walks a list of reference types through the interface an internal module Walk picks the enumerator with typeof<'T>.IsValueType, because the struct enumerator is slow in the code shared by reference types strings: iter 0.6, fold 0.7, tryPick 0.6, mapi and scan 0.7 to 0.9 of the time; integers unchanged. mapFold keeps its loop (the Walk loop took 1.1 to 1.4 times as long)
d381e3f perf: choose collects the elements in an array that is rented from the pool for a long list Buffers.ChosenBuffer: a new array below 1024 elements, ArrayPool above, capped at 1 048 576 elements (a longer result moves into a list) allocation 15 to 21 % (integers) and 25 to 35 % (strings) lower and no collections of the second generation from the buffer; at 100 000 and a million elements the time is equal for integers (0.90 to 1.03) and 0.65 to 0.89 for strings

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)

Checklist

  • Build and tests pass locally (the whole test project: 16 710 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 ofList, ofSeq and choose (100, 50 and 1 % chosen); the walking functions on strings; the buffer around the length where the pool starts, its spill and the clearing of the rented array
  • I have added necessary documentation: the remarks of ofList and ofSeq in the signature file, and the doc comments of the helpers (they carry the measured numbers, as the comments of Collectors do)

Further comments

Why the pool, and not something simpler. The elements of choose are not counted in advance, and the tree is built once from them. Four buffers were measured for it, with capacity, with a span, with an exact array and with a rented one, and chunks joined as subtrees and the builder as the extremes (third comment of #45). The rented array wins on allocation (the tree needs 46 875 KB for a million elements; the ResizeArray allocated 17 % more for integers and 35 % more for strings, in arrays that go to the large object heap and cause collections of the second generation) and is not slower beyond the noise; chunks give a tree whose indexer is slower, an array of the length of the source allocates 7 to 11 times as much as the old code when 1 % is chosen, and the builder is 1.2 to 3.3 times slower.

What is not here. ofArray and replicate, and the pool for the temporary arrays of init, sub, rev, mapi and the others, are in #45. The other collections were measured for the same interface walk (fifth comment of #45); only SortedSet and SortedMap gain, in #46 and #47. exists2, forall2 and compareWith of IndexedList still walk two struct enumerators in step. unfold is unchanged.

🤖 Generated with Claude Code

xperiandri and others added 3 commits October 21, 2026 03:10
…ore they build the tree

An F# list implements `IReadOnlyList`, and its indexer walks the list from its head. `ImmutableList.CreateRange` builds
the tree by indexing a list that implements `IReadOnlyList`, so the build took time that grows with the square of the
length: 0.43 ms for 1000 elements, 41 ms for 10 000 and 5.9 s for 100 000. The copy in an array takes 8 µs, 90 µs and
1.4 ms, and the tree is built from the array.

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

`ImmutableList<T>.GetEnumerator` returns a struct, which is the fastest way to walk a list of a value type. 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: walking the list through `IEnumerable<T>` took 0.56 to 0.70 times as long for strings.

The internal module `Walk` has the loops `iter`, `iteri`, `fold` and `tryPick`; `typeof<'T>.IsValueType` picks the
enumerator, and the body of a loop is a lambda that the compiler inlines into both loops. `iter`, `iteri`, `fold`,
`mapi`, `scan`, `collect`, `choose` and `pick`/`tryPick` use them. For strings of 100 to 100 000 elements `iter` and
`fold` took 0.6 to 0.7 times as long, `tryPick` 0.6, `mapi` and `scan` 0.7 to 0.9; nothing changed for integers.

`mapFold` keeps its loop: compiled into the library, the loop of `Walk` took 1.1 to 1.4 times as long there for strings,
with machine code that differs from the faster loop only in the order of a copy of a struct.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…the pool for a long list

The elements that `IndexedList.choose` and `IIndexedList.choose` choose waited in a `ResizeArray` that doubles its array
as it fills. For a million elements it allocated 18 % (integers) to 35 % (strings) more than the tree needs, and the
arrays of its growth in the large object heap made the collections of the second generation more frequent.

The result is at most as long as the list, so the elements now wait in an array of that length: a new one below 1024
elements, where the array is cheap, and one rented from `ArrayPool<T>.Shared` above, which allocates nothing in the
steady state. The rented array is at most 1 048 576 elements long, so that a sparse selection from a huge list does not
rent an array of the length of the list; a longer result moves into a list. A rented array is cleared as far as it was
written when it holds references, and is not returned when the chooser throws.

A million integers allocate 46 875 KB, the size of the tree, where 55 067 KB were allocated before, and a million strings
46 875 KB where 63 259 KB were. Measured with one variant in a process, in a loop that calls the function again and
again, the list caused up to 11 collections of the second generation per 1000 calls for 100 000 elements chosen out of
100 000, and the array none. The time did not change for integers of up to 10 000 elements and for a million, and was up
to 10 % less for 100 000; with the enumerator of the previous commit strings took 0.65 to 0.9 times as long. A buffer of
chunks joined to the result as subtrees was not taken: it took no less time than the array, and the indexer of its tree
was 6 to 10 % slower (the average of two runs, 100 to 100 000 elements).

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

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