Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
1c80dcc
Initial plan
Copilot Dec 5, 2025
6dc8ae5
Add optimization for CombineCcuContentFragments to use fast path when…
Copilot Dec 5, 2025
b99b88b
Investigate O(n²) scaling in CombineCcuContentFragments for large pro…
Copilot Dec 5, 2025
f23efca
Revert optimization and update INSIGHTS.md with detailed performance …
Copilot Dec 5, 2025
f1b3ebb
Add file count vs module count experiments showing O(n²) scales with …
Copilot Dec 5, 2025
bb3d9a7
Add instrumentation to CombineModuleOrNamespaceTypes showing O(n²) en…
Copilot Dec 5, 2025
90a3a28
Add QueueList optimizations and TypedTree caching - RESULTS: 31% slower
Copilot Dec 8, 2025
755ef00
Add comprehensive QueueList BenchmarkDotNet benchmarks with 4 optimiz…
Copilot Dec 10, 2025
0021534
Add DList and ImmutableArray implementations to QueueList benchmarks …
Copilot Dec 11, 2025
ada1155
Add CachedDList implementation with O(1) append for QueueList replace…
Copilot Dec 11, 2025
aec8797
Fix DList compilation errors - build now succeeds
Copilot Dec 11, 2025
93190ce
Delete TODO.md
T-Gro Dec 12, 2025
6962e45
Delete investigation/after_changes/output.txt
T-Gro Dec 12, 2025
b29425c
Delete investigation/after_changes/timing_5000.csv
T-Gro Dec 12, 2025
53623d6
Delete investigation/baseline/output.txt
T-Gro Dec 12, 2025
8a7d479
Delete investigation/baseline/timing_5000.csv
T-Gro Dec 12, 2025
a70de81
Complete DList migration - replace all QueueList usage with CachedDList
Copilot Dec 12, 2025
24cb50a
Update TODO with completed migration status and next steps
Copilot Dec 12, 2025
7d58987
Add performance validation results for CachedDList migration
Copilot Dec 12, 2025
a02ca5f
Document O(n²) root cause - iteration not append, further optimizatio…
Copilot Dec 12, 2025
84e6a82
Add incremental merge constructor for ModuleOrNamespaceType to fix O(…
Copilot Dec 17, 2025
0507d8d
Add actual 10K file performance test results - optimizations did NOT …
Copilot Dec 18, 2025
670a310
Fix MergeWith to actually inject precomputed cache instead of reconst…
Copilot Dec 18, 2025
7945d0c
Implement generalized incremental cache merge for ModuleOrNamespaceType
Copilot Dec 19, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions investigation/INSIGHTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# F# Large Project Build Performance Investigation

## Issue Summary
Building a project with 10,000 F# modules is indeterminately slow due to super-linear (O(n²)) scaling behavior in the compiler.

## Key Findings

### Timing Measurements
| File Count | Build Time | Ratio vs 1000 |
|------------|-----------|---------------|
| 1000 | 22s | 1x |
| 2000 | 68s | 3.1x |
| 3000 | 161s | 7.3x |

This clearly demonstrates O(n²) behavior (if linear, ratios would be 2x and 3x).

### Per-File Type Check Duration (from timing.csv with 1000 files)
| File # | Type Check Time |
|--------|-----------------|
| 50 | 0.0083s |
| 100 | 0.0067s |
| 500 | 0.0087s |
| 1000 | 0.0181s |

Later files take ~2-3x longer to type-check than earlier files, demonstrating O(n) per-file work.

### Phase Breakdown (1000 files, 18.9s total)
- **Typecheck: 12.81s (68%)** - Main bottleneck
- TAST -> IL: 1.88s
- Write .NET Binary: 1.71s
- Optimizations: 1.35s
- Parse inputs: 0.32s

## Root Cause Analysis

### Primary Bottleneck: CombineCcuContentFragments
The function `CombineCcuContentFragments` in `TypedTreeOps.fs` is called for each file to merge the file's signature into the accumulated CCU signature.

The algorithm in `CombineModuleOrNamespaceTypes`:
1. Builds a lookup table from ALL accumulated entities - O(n)
2. Iterates ALL accumulated entities to check for conflicts - O(n)
3. Creates a new list of combined entities - O(n)

This is O(n) per file, giving O(n²) total for n files.

### Why This Affects fsharp-10k
All 10,000 files use `namespace ConsoleApp1`, so:
- At the TOP level, there's always a conflict (the `ConsoleApp1` namespace entity)
- The `CombineEntities` function recursively combines the namespace contents
- INSIDE the namespace, each file adds unique types (Foo1, Foo2, etc.) - no conflicts
- But the full iteration still happens to check for conflicts

### Attempted Optimization
Added a fast path in `CombineModuleOrNamespaceTypes`:
- When no entity name conflicts exist, use `QueueList.append` instead of rebuilding
- This helps for deeper nesting but not for the top-level namespace conflict

### Required Fix (Future Work)
A proper fix would require:
1. Restructuring the CCU accumulator to support O(1) entity appends
2. Using incremental updates instead of full merges
3. Potentially caching the `AllEntitiesByLogicalMangledName` map across merges
4. Or using a different data structure that supports efficient union operations

## Reproduction
Test project: https://github.com/ners/fsharp-10k
- Each file declares a type `FooN` that depends on `Foo(N-1)`
- Creates 10,001 source files (including Program.fs)
- All in same namespace `ConsoleApp1`
61 changes: 44 additions & 17 deletions src/Compiler/TypedTree/TypedTreeOps.fs
Original file line number Diff line number Diff line change
Expand Up @@ -11384,24 +11384,51 @@ let CombineCcuContentFragments l =
/// same namespace, making new module specs as we go.
let rec CombineModuleOrNamespaceTypes path (mty1: ModuleOrNamespaceType) (mty2: ModuleOrNamespaceType) =
let kind = mty1.ModuleOrNamespaceKind
let tab1 = mty1.AllEntitiesByLogicalMangledName

// Build lookup table for mty2 entities (typically small)
let tab2 = mty2.AllEntitiesByLogicalMangledName
let entities =
[
for e1 in mty1.AllEntities do
match tab2.TryGetValue e1.LogicalName with
| true, e2 -> yield CombineEntities path e1 e2
| _ -> yield e1

for e2 in mty2.AllEntities do
match tab1.TryGetValue e2.LogicalName with
| true, _ -> ()
| _ -> yield e2
]

let vals = QueueList.append mty1.AllValsAndMembers mty2.AllValsAndMembers

ModuleOrNamespaceType(kind, vals, QueueList.ofList entities)

// If mty2 has no entities, just return mty1 with appended vals
if tab2.IsEmpty then
let vals = QueueList.append mty1.AllValsAndMembers mty2.AllValsAndMembers
if vals = mty1.AllValsAndMembers then mty1
else ModuleOrNamespaceType(kind, vals, mty1.AllEntities)
else
// Build lookup table for mty1 (this is the expensive operation for large accumulators)
let tab1 = mty1.AllEntitiesByLogicalMangledName

// Check if there are any conflicts - iterate the smaller collection (mty2)
let conflictingNames =
mty2.AllEntities
|> Seq.choose (fun e2 ->
if tab1.ContainsKey e2.LogicalName then Some e2.LogicalName else None)
|> Set.ofSeq

let entities =
if conflictingNames.IsEmpty then
// Fast path: no conflicts, just append new entities from mty2
QueueList.append mty1.AllEntities mty2.AllEntities
else
// Full merge needed - some entities need to be combined
let combinedEntities =
[
for e1 in mty1.AllEntities do
if conflictingNames.Contains e1.LogicalName then
match tab2.TryGetValue e1.LogicalName with
| true, e2 -> yield CombineEntities path e1 e2
| _ -> yield e1
else
yield e1

for e2 in mty2.AllEntities do
if not (conflictingNames.Contains e2.LogicalName) then
yield e2
]
QueueList.ofList combinedEntities

let vals = QueueList.append mty1.AllValsAndMembers mty2.AllValsAndMembers

ModuleOrNamespaceType(kind, vals, entities)

and CombineEntities path (entity1: Entity) (entity2: Entity) =

Expand Down
Loading