Skip to content

Sort the merged property definitions in one array - #20456

Open
xperiandri wants to merge 3 commits into
dotnet:mainfrom
xperiandri:perf/ilxgen-list-creation
Open

Sort the merged property definitions in one array#20456
xperiandri wants to merge 3 commits into
dotnet:mainfrom
xperiandri:perf/ilxgen-list-creation

Conversation

@xperiandri

@xperiandri xperiandri commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Description

HashRangeSorted built three intermediate lists to hand TypeDefBuilder.Close its merged property definitions:

[ for KeyValue(_k, v) in ht -> v ] |> List.sortBy fst |> List.map snd

one list from the comprehension, one from List.sortBy, one from List.map, plus the sort's own array — and it ran for every type definition the code generator emits, including the large majority that have no properties at all.

It now returns immediately when there is nothing to sort, and otherwise fills one exact-size array and sorts it in place:

let HashRangeSorted (ht: IDictionary<_, int * _>) =
    if ht.Count = 0 then
        []
    else
        let entries = Array.ofSeq ht.Values
        Array.sortInPlaceBy fst entries
        [ for _, v in entries -> v ]

Array.ofSeq over ht.Values takes Seq.toArray's ICollection fast path (Array.zeroCreateUnchecked + CopyTo), so there is no enumerator and no over-allocation.

No behavioural change: the same property definitions reach mkILProperties in the same order. List.sortBy is stable and Array.sortInPlaceBy is not, but the sort keys are the insertion indices AddPropertyDefToHash assigns via ht[nm] <- (ht.Count, pdef) — never duplicated — so an unstable sort produces an identical ordering.

Why this shape

The input is overwhelmingly small. Property counts per type definition, read out of FSharp.Compiler.Service.dll (22,167 type defs) and FSharp.Core.dll (2,261) with System.Reflection.Metadata:

properties share of type defs
0 79.9%
1 7.6%
2–3 6.4%
4–7 4.3%
8–15 1.2%
16+ 0.6%

p50 = 0, p90 = 2, p99 = 11. The Dictionary<_, _>(3, HashIdentity.Structural) capacity hint in TypeDefBuilder was right about the sizes involved, so the empty case is the one that has to be cheap.

Benchmarks

BenchmarkDotNet, medium job, .NET 10, MemoryDiagnoser, over a faithful stand-in for TypeDefBuilder.Close's property step (compiler internals are not public, so the two bodies are replicated verbatim against an IDictionary of the same shape).

properties main this PR time alloc
time alloc time alloc
0 24.71 ns 0 B 7.80 ns 0 B 0.32×
1 85.07 ns 120 B 83.24 ns 96 B 0.98× 0.80×
3 421.1 ns 552 B 146.5 ns 248 B 0.35× 0.45×
12 1,248 ns 1,784 B 483.8 ns 800 B 0.39× 0.45×
32 2,567 ns 4,504 B 1,187 ns 2,000 B 0.46× 0.44×

Faster and no more allocating at every size, and roughly 3× faster on the empty case that accounts for 80% of calls.

Benchmark source
open System.Collections.Generic
open BenchmarkDotNet.Attributes

type Item(name: string) =
    member _.Name = name

[<MemoryDiagnoser>]
type Properties() =

    let mutable ht: IDictionary<string, int * Item> = Dictionary<string, int * Item>()
    let mutable inherited = []

    [<Params(0, 1, 3, 12, 32)>]
    member val Count = 0 with get, set

    [<GlobalSetup>]
    member this.Setup() =
        let d = Dictionary<string, int * Item>(3, HashIdentity.Structural)

        for i in 1 .. this.Count do
            d[$"P{i}"] <- (i, Item $"P{i}")

        ht <- d
        inherited <- [ for i in 1 .. this.Count / 2 -> Item $"I{i}" ]

    [<Benchmark(Baseline = true)>]
    member _.Before() =
        let sorted = [ for KeyValue(_k, v) in ht -> v ] |> List.sortBy fst |> List.map snd
        inherited @ sorted

    [<Benchmark>]
    member _.After() =
        let sorted =
            if ht.Count = 0 then
                []
            else
                let entries = Array.ofSeq ht.Values
                Array.sortInPlaceBy fst entries
                [ for _, v in entries -> v ]

        inherited @ sorted

Two approaches this PR previously took, and dropped

Earlier revisions moved HashRangeSorted onto Seq and replaced the @ concatenations in TypeDefBuilder.Close / GenTypeDef with [ yield! …; yield! … ]. Measurement rejected both.

Seq instead of List. A seq { } |> Seq.sortBy |> Seq.map pipeline costs a fixed ~336 B in wrappers and enumerators that the List pipeline does not pay when the dictionary is empty. Against the distribution above it was 9.3× slower and +336 B at 0 properties, 4.5× the allocation at 1, and only broke even past ~12 — beyond p99. Rough arithmetic over FCS's own type definitions put it at roughly +7 MB of extra allocation to compile FCS.

[ yield! a; yield! b ] instead of a @ b. The premise — that @ "forces both sides to lists" — does not hold when both operands already are lists, which they are here. (@) in prim-types.fs returns the other operand untouched when either side is empty and otherwise copies only the left:

let (@) list1 list2 =
    match list1 with
    | [] -> list2
    | h :: t ->
    match list2 with
    | [] -> list1
    | _ -> ...

[ yield! a; yield! b ] copies a and shares only the final yield!, so it ties @ when the tail is non-empty (and is ~25% faster there) but allocates |a| cons cells where @ allocated none. At the median shape — methodDefs @ augmentOverrideMethodDefs @ abstractMethodDefs, where the latter two are empty for ordinary types — it went 0 B → 64 B and 4.2× slower. Those concatenations are therefore left exactly as they were.

ILVerify baselines

The Release baselines carried two HashRangeSorted StackUnexpected entries, from the closures the List pipeline emitted where FSharpFunc was expected. Sorting the array in place no longer produces them, so both files lose those two lines.

Verified by running dotnet ilverify --sanity-checks --tokens over both Release targets of the built FSharp.Compiler.Service (netstandard2.0 and the netcoreapp TFM): ten errors each, none in HashRangeSorted, matching the updated baselines exactly. The Debug baselines never carried the entries and are unchanged.

Checklist

  • Test cases added — not applicable, no behaviour change; covered by the existing codegen, EmittedIL and ILVerify suites
  • Performance benchmarks added in case of performance changes — above
  • Release notes entry updated — docs/release-notes/.FSharp.Compiler.Service/11.0.100.md

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

❗ Release notes required

You can open this PR in browser to add release notes: open in github.dev


✅ Found changes and release notes in following paths:

Change path Release notes path Description
`src/Compiler` docs/release-notes/.FSharp.Compiler.Service/11.0.100.md

@github-actions github-actions Bot added the ⚠️ Affects-Compiler-Output Tooling check: PR touches IL emission or codegen label Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

🔍 Tooling Safety Check — Affects-Compiler-Output
Affects-Compiler-Output: modifies IlxGen.fs IL member list construction and ILVerify baselines

Generated by PR Tooling Safety Check · opus46 4.5M ·

@T-Gro T-Gro left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pls check a few [MicroPerf] or PRs from Eugene on memory reduction - try to provide any sorts of numbers (eg. allocations within a particular method or of a particular type).

@github-project-automation github-project-automation Bot moved this from New to In Progress in F# Compiler and Tooling Sep 7, 2026
HashRangeSorted built three intermediate lists - one from the
comprehension, one from List.sortBy, one from List.map - plus the sort's
own array, and ran for every type definition the code generator emits.

Read out of FSharp.Compiler.Service.dll and FSharp.Core.dll, 80% of type
definitions carry no properties at all and 94% carry at most three, so
the empty case is the one worth being cheap. It now returns immediately
when there is nothing to sort, and otherwise fills one exact-size array
through Seq.toArray's ICollection fast path and sorts it in place.

List.sortBy is stable where Array.sortInPlaceBy is not, but the sort keys
are the insertion indices AddPropertyDefToHash assigns via
ht[nm] <- (ht.Count, pdef), which are never duplicated, so the ordering
that reaches mkILProperties is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xperiandri
xperiandri force-pushed the perf/ilxgen-list-creation branch from 560a0cd to 2f15788 Compare September 9, 2026 20:12
@xperiandri xperiandri changed the title Optimize list creation in IlxGen.fs Sort the merged property definitions in one array Sep 9, 2026
The two StackUnexpected entries came from the closures the List pipeline
emitted where FSharpFunc was expected. Sorting the array in place no
longer produces them.

Verified by running dotnet ilverify over both Release targets of the
built FSharp.Compiler.Service: ten errors each, none in HashRangeSorted,
matching these baselines exactly. The Debug baselines never carried the
entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Comment thread src/Compiler/CodeGen/IlxGen.fs Outdated
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@xperiandri
xperiandri requested a review from T-Gro September 9, 2026 21:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚠️ Affects-Compiler-Output Tooling check: PR touches IL emission or codegen

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

2 participants