-
Notifications
You must be signed in to change notification settings - Fork 0
docs(notes): measured .NET optimization limits and GC/LOH observability #327
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
c94f419
docs(notes): measured feasibility of LLVM-grade codegen for .NET, plu…
claude 5a7e4dc
docs(notes): measure the 1x-to-1092x spread that makes the runtime la…
claude c6578d2
docs(notes): name the missing summary domain precisely
claude 0cd4311
docs: address review — scope the claims to what was measured, and mak…
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,120 @@ | ||
| # GC observability and the LOH — what is worth adopting, and what stays runtime-only | ||
|
|
||
| Working note. **Trigger:** the *GCExperiment* write-up ("Making .NET GC | ||
| behaviour observable"), proposed as material to fold into our runtime layer. | ||
| This note records what is genuinely new in it, corrects one number our own docs | ||
| imply, and pins the boundary that must not move. | ||
|
|
||
| Companion to [`llvm-codegen-feasibility.md`](llvm-codegen-feasibility.md) (the | ||
| other half of the same discussion) and to | ||
| [P-034](../proposals/P-034-runtime-lifetime-guard.md), whose "ClrMD-free | ||
| complement" argument this reuses verbatim. | ||
|
|
||
| ## The one factual correction worth taking | ||
|
|
||
| Our docs treat the Large Object Heap threshold as **85,000 bytes** of payload. | ||
| That is the documented **default** constant, but it is not the predicate. Two | ||
| things are wrong with the folklore reading: | ||
|
|
||
| 1. **The threshold is configurable.** `System.GC.LOHThreshold` | ||
| (`runtimeconfig.json`) and `DOTNET_GCLOHThreshold` (environment, hex) raise | ||
| it, so 85,000 describes a default configuration, not a law. | ||
| 2. **The comparison is against full object size**, not payload — payload + | ||
| object header + method-table pointer + the array length field + alignment | ||
| padding — so an array whose *payload* sits comfortably under the limit can | ||
| still land on the LOH. | ||
|
|
||
| Worked on the environment this was checked against (**.NET CoreCLR, x64, | ||
| default GC configuration**), where that overhead is 24 bytes: | ||
|
|
||
| > `byte[84_999]` → 85,023 bytes → rounds to 85,024 → **allocated on the LOH.** | ||
|
|
||
| The header size is a **platform and runtime detail**, not a portable constant — | ||
| object layout and alignment differ by architecture and runtime version, so the | ||
| 24 above should not be copied into another context as a given. | ||
|
|
||
| The practical consequence, and the reason it is worth writing down: the familiar | ||
| "keep buffers under 85,000" folklore is **off by roughly one header**, and a | ||
| pool sized to exactly `85_000 - 1` is on the wrong heap under the default | ||
| configuration. The portable advice is not a corrected arithmetic constant — it | ||
| is **measure on your target runtime**, since both the threshold and the overhead | ||
| can move. | ||
|
|
||
| Where this touches our docs: [`ROADMAP.md`](../ROADMAP.md) and | ||
| [`Plan.md`](../../Plan.md) both list LOH fragmentation in the detectability | ||
| matrix. Neither states a threshold, so **neither is wrong** — but if a threshold | ||
| is ever quoted in a rule, a diagnostic message, or a talk, it must be the | ||
| full-object-size version, not the payload one. | ||
|
|
||
| ## What GCExperiment is, and what is actually adoptable | ||
|
|
||
| Four self-contained experiments (LOH placement; generation promotion; | ||
| allocation pressure; LOH fragmentation) built on ordinary public APIs — | ||
| `GC.GetGeneration`, `GC.Collect` with forced modes, | ||
| `GC.WaitForPendingFinalizers`, `GCSettings.LargeObjectHeapCompactionMode`, | ||
| plus small `GCMonitor`/`GCInfo` helpers for snapshots and size estimation. It | ||
| also flags a real measurement trap: without `GC.KeepAlive`, the JIT can shorten | ||
| an object's lifetime and skew the result. | ||
|
|
||
| The adoptable idea is **not** the GC content, which is well-trodden. It is the | ||
| **delivery shape**, and it is the same shape P-034 already argued for from a | ||
| different direction: | ||
|
|
||
| > a lifetime/GC observation that runs in an ordinary `dotnet test`, on any OS, | ||
| > with no PerfView, no ETW, no Windows stand, and no ClrMD heap walk. | ||
|
|
||
| Today our runtime layer ([`Plan.md`](../../Plan.md) §2, category 12) routes | ||
| *everything* GC-shaped to PerfView + ETW. That is correct for **evidence** and | ||
| badly overweight for **orientation** — it means no GC fact can be established in | ||
| CI, on Linux, or in a unit test. A `GC.GetGCMemoryInfo` / | ||
| `GC.CollectionCount(n)` snapshot around a scenario costs nothing and needs no | ||
| stand — with one caveat that has to travel with it: **both are process-wide, not | ||
| scenario-scoped.** `CollectionCount(n)` counts every collection since process | ||
| start (and a higher-generation collection bumps the lower ones too); | ||
| `GetGCMemoryInfo()` describes the *last* collection and returns an all-zero | ||
| struct with `Index == 0` when none of the requested kind has happened. Parallel | ||
| tests or background GC move both from outside the scenario. So a probe used as | ||
| an assertion has to record counts at the scenario boundary and compare | ||
| `GetGCMemoryInfo()` only when the GC index matches — otherwise it is a heuristic | ||
| wearing an assertion's clothes. | ||
|
|
||
| So: a cheap in-test GC probe is a reasonable sibling to P-034's disposal | ||
| quarantine, under the same honest caveat P-034 already states — it proves *what | ||
| the counters did during this test*, bounded by test coverage, and it is blind to | ||
| *why*. It is a debug assertion, not an auditor. | ||
|
|
||
| ## The boundary that does not move | ||
|
|
||
| **None of this makes LOH fragmentation statically detectable.** The matrix rows | ||
| stay exactly as written: | ||
|
|
||
| - `ROADMAP.md`: LOH fragmentation → ❌ **impossible** (depends on runtime data | ||
| volume / GC timing) | ||
| - `Plan.md` category 12: heavy dictionaries / LOH fragmentation / Gen2 bloat → | ||
| **impossible** static → **RUNTIME** | ||
|
|
||
| A GC probe is a **runtime witness**, and it lives on the runtime side of the | ||
| line the detectability matrix draws. The matrix exists specifically to stop | ||
| runtime-shaped problems from being hung on a static checker | ||
| ([`Plan.md`](../../Plan.md) §1: it "*forbids*" exactly that, killing a class of | ||
| false positives in advance). Making GC behaviour cheaper to *observe* is not an | ||
| argument for making it *inferred*, and this note must not be cited as one. | ||
|
|
||
| The one thing genuinely on the static side is unchanged and already ours: the | ||
| `ArrayPool`/`Span` misuse family (`POOL001`–`003`, P-007). Rent/return balance | ||
| is structurally visible; fragmentation is not. | ||
|
|
||
| ## Status | ||
|
|
||
| **Recorded, not scheduled.** Per the `research-landscape-2026.md` discipline, | ||
| notes record and the ROADMAP schedules. Concretely, if anything is ever picked | ||
| up from here: | ||
|
|
||
| 1. **The LOH threshold correction** — free, and the only item with a | ||
| correctness argument behind it. Applies wherever a number gets quoted. | ||
| 2. **A GC-counter probe as a P-034 sibling** — small, attaches to the open | ||
| question P-034 already asks (a new `Own.Diagnostics` package vs living in | ||
| OwnAudit's `runtime/`). Do not file it separately; it is the same decision. | ||
| 3. **Nothing else.** The generation-promotion and allocation-pressure | ||
| experiments are educational rather than diagnostic, and we do not need to | ||
| re-derive published GC behaviour to ship a checker. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,131 @@ | ||
| using System; | ||
| using System.Collections.Generic; | ||
| using System.Diagnostics; | ||
| using System.Linq; | ||
| using System.Runtime.CompilerServices; | ||
|
|
||
| // Question: a static rule can find "loop-invariant query evaluated inside a | ||
| // loop". Can it tell you how much it COSTS? Every case below is the SAME | ||
| // syntactic shape -- an invariant call in a loop body -- so a static rule sees | ||
| // them as identical. Measure the actual penalty. | ||
| static class Program | ||
| { | ||
| static long sink; | ||
|
|
||
| // ---- shape A: Any() over a List, predicate over a captured local ---- | ||
| [MethodImpl(MethodImplOptions.NoInlining)] | ||
| static long A_Inline(List<int> data, int threshold, int iters) | ||
| { | ||
| long acc = 0; | ||
| for (int i = 0; i < iters; i++) | ||
| if (data.Any(x => x > threshold)) acc += i; // loop-invariant | ||
| return acc; | ||
| } | ||
|
|
||
| [MethodImpl(MethodImplOptions.NoInlining)] | ||
| static long A_Hoisted(List<int> data, int threshold, int iters) | ||
| { | ||
| long acc = 0; | ||
| bool inv = data.Any(x => x > threshold); // hoisted by hand | ||
| for (int i = 0; i < iters; i++) if (inv) acc += i; | ||
| return acc; | ||
| } | ||
|
|
||
| // ---- shape B: Count() on a Select-wrapped sequence (O(n) here; Count() | ||
| // is O(1) when the source implements ICollection<T>) vs .Count property ---- | ||
| [MethodImpl(MethodImplOptions.NoInlining)] | ||
| static long B_Inline(IEnumerable<int> data, int iters) | ||
| { | ||
| long acc = 0; | ||
| for (int i = 0; i < iters; i++) acc += data.Count(); | ||
| return acc; | ||
| } | ||
|
|
||
| [MethodImpl(MethodImplOptions.NoInlining)] | ||
| static long B_Hoisted(IEnumerable<int> data, int iters) | ||
| { | ||
| long acc = 0; int c = data.Count(); | ||
| for (int i = 0; i < iters; i++) acc += c; | ||
| return acc; | ||
| } | ||
|
|
||
| // ---- shape C: OrderBy().First() -- repeated linear key scanning every | ||
| // iteration (.NET 9 takes a specialized TryGetFirst path rather than | ||
| // materializing and sorting a buffer) ---- | ||
| [MethodImpl(MethodImplOptions.NoInlining)] | ||
| static long C_Inline(List<int> data, int iters) | ||
| { | ||
| long acc = 0; | ||
| for (int i = 0; i < iters; i++) acc += data.OrderBy(x => x).First(); | ||
| return acc; | ||
| } | ||
|
|
||
| [MethodImpl(MethodImplOptions.NoInlining)] | ||
| static long C_Hoisted(List<int> data, int iters) | ||
| { | ||
| long acc = 0; int v = data.OrderBy(x => x).First(); | ||
| for (int i = 0; i < iters; i++) acc += v; | ||
| return acc; | ||
| } | ||
|
|
||
| // ---- shape D: a trivially cheap invariant -- the control ---- | ||
| [MethodImpl(MethodImplOptions.NoInlining)] | ||
| static long D_Inline(List<int> data, int iters) | ||
| { | ||
| long acc = 0; | ||
| for (int i = 0; i < iters; i++) acc += data.Count; // O(1) property | ||
| return acc; | ||
| } | ||
|
|
||
| [MethodImpl(MethodImplOptions.NoInlining)] | ||
| static long D_Hoisted(List<int> data, int iters) | ||
| { | ||
| long acc = 0; int c = data.Count; | ||
| for (int i = 0; i < iters; i++) acc += c; | ||
| return acc; | ||
| } | ||
|
|
||
| static double Bench(Func<long> f, int reps) | ||
| { | ||
| for (int i = 0; i < 20; i++) sink += f(); // warm | ||
| var sw = Stopwatch.StartNew(); | ||
| for (int i = 0; i < reps; i++) sink += f(); | ||
| sw.Stop(); | ||
| return sw.Elapsed.TotalMilliseconds / reps; | ||
| } | ||
|
|
||
| static void Row(string shape, int n, Func<long> inline, Func<long> hoisted, int reps) | ||
| { | ||
| // Correctness gate FIRST, and it must fail the run: timing two | ||
| // non-equivalent implementations produces a meaningless ratio, and a | ||
| // printed warning would still exit 0 and look like a good measurement. | ||
| long a = inline(), b = hoisted(); | ||
| if (a != b) | ||
| throw new InvalidOperationException($"{shape} n={n}: MISMATCH {a} vs {b}"); | ||
|
|
||
| double ti = Bench(inline, reps), th = Bench(hoisted, reps); | ||
| Console.WriteLine($"{shape,-38} n={n,-7} inline={ti,9:F4} ms hoisted={th,9:F4} ms penalty = {ti / th,8:F1}x"); | ||
| } | ||
|
|
||
| static void Main() | ||
| { | ||
| const int Iters = 1000; | ||
| Console.WriteLine("Same syntactic shape everywhere: a loop-invariant call in a loop body."); | ||
| Console.WriteLine($"Inner loop = {Iters} iterations. 'penalty' = how much the un-hoisted version costs.\n"); | ||
|
|
||
| foreach (int n in new[] { 4, 64, 4096, 100_000 }) | ||
| { | ||
| var list = Enumerable.Range(0, n).ToList(); | ||
| IEnumerable<int> seq = list.Select(x => x); // hides ICollection fast path | ||
| int th = -1; // Any() hits on the FIRST element | ||
|
|
||
| Row("A: Any(x => x > t) [hit@0]", n, () => A_Inline(list, th, Iters), () => A_Hoisted(list, th, Iters), 20); | ||
| Row("A: Any(x => x > t) [miss]", n, () => A_Inline(list, int.MaxValue, Iters), () => A_Hoisted(list, int.MaxValue, Iters), 5); | ||
| Row("B: Count() on Select-wrapped IEnumerable", n, () => B_Inline(seq, Iters), () => B_Hoisted(seq, Iters), 5); | ||
| Row("C: OrderBy().First()", n, () => C_Inline(list, Iters), () => C_Hoisted(list, Iters), n > 10000 ? 1 : 3); | ||
| Row("D: .Count property (cheap)", n, () => D_Inline(list, Iters), () => D_Hoisted(list, Iters), 50); | ||
| Console.WriteLine(); | ||
| } | ||
| Console.WriteLine($"(sink={sink})"); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk"> | ||
| <PropertyGroup> | ||
| <OutputType>Exe</OutputType> | ||
| <TargetFramework>net9.0</TargetFramework> | ||
| <Nullable>disable</Nullable> | ||
| <Optimize>true</Optimize> | ||
| </PropertyGroup> | ||
| </Project> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| #!/usr/bin/env bash | ||
| # Reproduces the table in ../invariant-cost-static-vs-runtime.md. | ||
| # Requires a .NET 9 SDK. The published numbers were taken on 9.0.316; any 9.0.x | ||
| # should reproduce the ORDER and the spread, but exact multipliers will differ | ||
| # by machine and patch level -- the selected SDK/runtime is printed below so a | ||
| # rerun is self-describing rather than merely claiming reproduction. | ||
| set -euo pipefail | ||
| HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
|
|
||
| # Only clean up a workdir we created ourselves; never delete a caller's. | ||
| if [[ $# -ge 1 ]]; then | ||
| WORK="$1" | ||
| else | ||
| WORK="$(mktemp -d)" | ||
| trap 'rm -rf -- "$WORK"' EXIT | ||
| fi | ||
|
|
||
| mkdir -p "$WORK"; cp "$HERE"/{Program.cs,linq.csproj} "$WORK/" | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| cd "$WORK" | ||
|
|
||
| echo "== toolchain actually used ==" | ||
| dotnet --version | ||
| dotnet --list-runtimes | grep -E '^Microsoft\.NETCore\.App 9\.0\.' || true | ||
| echo | ||
|
|
||
| dotnet build -c Release -v q --nologo | ||
| # Tiering disabled: every method is compiled straight to FullOpts, so the | ||
| # measurement is steady-state code. (This is non-tiered mode -- "tier 1" is a | ||
| # tiering concept and does not apply when tiering is off.) | ||
| DOTNET_TieredCompilation=0 dotnet bin/Release/net9.0/linq.dll | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.