Skip to content

perf: net10.0 zero-alloc hot paths in NamespaceTree + BenchmarkDotNet project#13

Open
Rolling2405 wants to merge 5 commits intoNeVeSpl:mainfrom
Rolling2405:feat/ns21-span-perf
Open

perf: net10.0 zero-alloc hot paths in NamespaceTree + BenchmarkDotNet project#13
Rolling2405 wants to merge 5 commits intoNeVeSpl:mainfrom
Rolling2405:feat/ns21-span-perf

Conversation

@Rolling2405
Copy link
Copy Markdown

Summary

This PR adds tiered, backward-compatible performance optimizations to NamespaceTree — the core hot path called for every scanned type during dependency search — taking full advantage of .NET 8/9/10 APIs while keeping
etstandard2.0 entirely unchanged.

Approach: #if NET*_OR_GREATER tiers, no new TFMs

No new TFMs are added. The library already targets
etstandard2.0;net10.0. All new code is gated with #if NET8_0_OR_GREATER, #if NET9_0_OR_GREATER, or #if NET10_0_OR_GREATER so:

  • netstandard2.0 consumers: zero changes, zero risk
  • net10.0 consumers: all tiers active automatically
  • net8.0/net9.0 consumers: respective tiers active (since NET8_0_OR_GREATER is defined for all of 8, 9, and 10)

Optimizations by tier

Unconditional (all TFMs)

  • NormalizeString fast-path: str.IsNormalized(FormC) is allocation-free; Normalize() always allocates. .NET type names are virtually always ASCII → NFC, so this is a near-100% zero-allocation fast-path.

#if NET8_0_OR_GREATER

  • FrozenDictionary build→freeze pattern: Node builds child nodes into a Dictionary<string, Node> during NamespaceTree construction, then Freeze() is called at end of constructor, converting every node's dictionary to a FrozenDictionary. Read path gets SIMD-accelerated lookups; write path (construction only) stays a regular Dictionary. StringComparer.Ordinal used explicitly to enable GetAlternateLookup later.
  • SearchValues: Created once as static readonly, replaces char[] in IndexOfAny — SIMD-accelerated character scan on modern runtimes.

#if NET9_0_OR_GREATER

  • CollectionsMarshal.GetValueRefOrAddDefault on GetOrAddNode write path: single hash probe for insert-or-get instead of TryGetValue + Add (2 probes).
  • FrozenDictionary.GetAlternateLookup<ReadOnlySpan>() on TryGetNode read path: zero-allocation span lookup against the frozen dictionary — avoids Substring allocation AND the NormalizeString check on the hot read path.
  • Inline-span GetAllMatchingNames for both the string and TypeReference overloads: ullName.AsSpan(start, len) passed inline as a method argument (not stored as a local) to TryGetNode(ReadOnlySpan), which is CS4007-safe in iterator methods. Eliminates Substring allocations on the critical read hot path.

Files changed

  • sources/NetArchTest/NetArchTest.csproj — LangVersion made conditional: latest for net10.0 (needed for C# 13 �llows ref struct in GetAlternateLookup), 11 for other TFMs.
  • sources/NetArchTest/Dependencies/DataStructures/NamespaceTree.cs — tiered optimizations as described above.
  • ests/NetArchTest.Benchmarks/ — new BenchmarkDotNet project targeting
    et8.0;net10.0. [MemoryDiagnoser] included. Two public-API benchmarks over the dependency search hot path. [SimpleJob(RuntimeMoniker.Net80)] + [SimpleJob(RuntimeMoniker.Net100)] so the difference is directly measurable.

Test results

353 passed, 0 failed, 2 skipped on both
et8.0 and
et10.0.

Backward compatibility

etstandard2.0 compilation: zero changes (all new code is inside #if NET*_OR_GREATER blocks).

Copilot AI and others added 5 commits April 30, 2026 20:39
Adds .NET 10 to library and test multi-targets while preserving
netstandard2.0 (library) and net8.0 (tests). Fixes packaging icon
declaration to use <None Include> (was <None Update>) so it resolves
correctly under multi-targeting on newer SDKs.

Workflow:
- Bump actions/checkout v3 -> v4
- Bump actions/setup-dotnet v3 -> v4
- Add 10.0.x to dotnet-version (alongside 3.1.x and 8.0.x)

Verified locally: 353 passed (2 skipped) on both net8.0 and net10.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…AOT smoke test

Add [RequiresUnreferencedCode] and [RequiresDynamicCode] annotations to the
dependency-search (Tier 2) public API surface, gated #if NET10_0_OR_GREATER, so
that consumers using NativeAOT or the trimmer receive clear, actionable warnings
at their own call sites rather than opaque linker errors.

Changes
-------
sources/NetArchTest/NetArchTest.csproj
- Add net10.0 TFM alongside existing netstandard2.0
- Add IsTrimmable/IsAotCompatible guards (NET10_0_OR_GREATER only)

sources/NetArchTest/NetArchTestAotMessages.cs (new)
- Shared const message strings used by all Requires* attributes

sources/NetArchTest/Condition_Dependencies.cs
sources/NetArchTest/Predicate_Dependencies.cs
sources/NetArchTest/Slices/SliceCondition.cs
sources/NetArchTest/Functions/FunctionDelegates_Dependencies.cs
- [RequiresUnreferencedCode] + [RequiresDynamicCode] on every public/internal
  method that drives the dependency-search pipeline (Tier 2 surface)

sources/NetArchTest/Dependencies/TypeParser.cs
- Type-level [UnconditionalSuppressMessage] for IL2057/IL2077/IL2080 covering
  Mono.Cecil private-field reflection in the static cctor
- [RequiresUnreferencedCode] + [RequiresDynamicCode] on Parse() and
  ParseReflectionNameToRuntimeName()

sources/NetArchTest/Dependencies/DataStructures/NamespaceTree.cs
- Extract parseNames=true branch into private ParseTokensRequiringReflection()
  annotated with Requires*; Add() gets [UnconditionalSuppressMessage] with
  justification (runtime-guarded; Tier 1 callers pass false)

sources/NetArchTest/Extensions/Mono.Cecil/TypeDefinitionExtensions.cs
- [RequiresUnreferencedCode] + [RequiresDynamicCode] on ToType()

sources/NetArchTest/Assemblies/PublicUse/TypeContainer.cs
- Extract Lazy<Type> initialiser lambda into private ResolveReflectionType()
  annotated with Requires*; constructor gets [UnconditionalSuppressMessage]
  (Tier 1 paths never read ReflectionType)

tests/NetArchTest.AotSmoke/ (new)
- NativeAOT publish smoke that exercises Tier 1 only
  (Types.FromPath + name/namespace/structural predicates)
- PublishAot=true, TreatWarningsAsErrors=true, NoWarn IL2104
  (Mono.Cecil 0.11.x is not trim-annotated upstream — IL2104 is its aggregate
  warning; Tier 2 callers still get the proper annotations at their call sites)
- Prints 'OK' and exits 0 when the AOT image is correct

tests/NetArchTest.AotSmoke.Fixture/ (new)
- Minimal test-assembly DLL published alongside the smoke binary

Result: dotnet publish -r win-x64 -c Release produces a 4.3 MB native exe with
zero IL warnings and zero errors; the exe prints 'OK' and exits 0. All 353x2
unit tests continue to pass.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Publishes tests/NetArchTest.AotSmoke with PublishAot=true on win-x64 and
asserts the produced native exe exits 0. Runs on windows-latest since
NativeAOT win-x64 cross-compilation requires the MSVC toolchain.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Node: dual-mode FrozenDictionary (after Freeze) / mutable Dictionary
  (NET8_0_OR_GREATER) with CollectionsMarshal.GetValueRefOrAddDefault
  insert path (NET9_0_OR_GREATER)
- SearchValues<char> SIMD separator scan in GetSubnameEndIndex (NET8_0_OR_GREATER)
- Zero-allocation ReadOnlySpan<char> FrozenDictionary.GetAlternateLookup
  in TryGetNode overload and GetAllMatchingNames(string) inline-span path
  (NET9_0_OR_GREATER; avoids CS4007 iterator restriction)
- NormalizeString: IsNormalized() fast-path to skip allocation for ASCII names
- Constructor calls _root.Freeze() to lock the tree after build (NET8_0_OR_GREATER)
- netstandard2.0 path byte-for-byte unchanged; all guards use NET*_OR_GREATER
- Add NetArchTest.Benchmarks project (BenchmarkDotNet 0.14.0, net8.0;net10.0)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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.

2 participants