--- name: blazing-fast-dotnet description: High-performance .NET guidance for writing and tuning C# apps and benchmarks (BenchmarkDotNet, ASP.NET, challenges). Use when asked to make .NET code faster, reduce allocations or GC overhead, fix lock contention or thread pool starvation, inspect JIT codegen, profile apps, or tune runtime/NativeAOT settings. --- # Blazing Fast .NET Performance > [!WARNING] > Much of the advice here might not age well: as .NET evolves, old hacks and custom knobs may become obsolete or even detrimental. Apply only after careful consideration and testing. Revisit periodically to ensure continued relevance. This skill focuses on optimizing .NET applications for maximum performance, providing tips, techniques, and best practices to achieve blazing-fast execution times. It targets .NET 10.0 and 11.0. Older versions have not been tested. **Contents** - [Profiling and Performance Analysis](#profiling-and-performance-analysis) - [dotnet-counters](#dotnet-counters) - [dotnet-trace](#dotnet-trace) - [Low-level profiling with Linux `perf`](#low-level-profiling-with-linux-perf) - [Popular low-level problems](#popular-low-level-problems) - [Inspecting JIT codegen](#inspecting-jit-codegen) - [Best practices for high-performance C# code](#best-practices-for-high-performance-c-code) - [Quick tips](#quick-tips) - [Strings and globalization](#strings-and-globalization) - [Contention on shared state](#contention-on-shared-state) - [False sharing](#false-sharing) - [Safe vectorization (SIMD or SWAR)](#safe-vectorization-simd-or-swar) - [Generic specialization via static abstract members](#generic-specialization-via-static-abstract-members) - [ASP.NET Core](#aspnet-core) - [Magic knobs to Tune .NET Performance](#magic-knobs-to-tune-net-performance) - [NativeAOT-specific tuning](#nativeaot-specific-tuning) - [General notes](#general-notes) # Profiling and Performance Analysis Start cheap and go deeper only when needed: counters (what kind of bottleneck?) → traces (where?) → `perf` and JIT disassembly. Install the tools with: ```sh dotnet tool install -g dotnet-counters dotnet tool install -g dotnet-trace dotnet tool install -g dotnet-symbol ``` ## dotnet-counters [dotnet-counters](https://learn.microsoft.com/dotnet/core/diagnostics/dotnet-counters) is a low-overhead, first-level triage tool. Run it while the app is under load: ```sh dotnet-counters monitor -p # System.Runtime counters by default dotnet-counters monitor -p --counters System.Runtime,Microsoft.AspNetCore.Hosting,System.Net.Http dotnet-counters collect -p --format csv -o counters.csv # save for post-processing ``` See the `System.Runtime` [built-in metrics](https://learn.microsoft.com/dotnet/core/diagnostics/built-in-metrics-runtime) (.NET 9+) for what each counter means. ## dotnet-trace [dotnet-trace](https://learn.microsoft.com/dotnet/core/diagnostics/dotnet-trace) collects EventPipe traces on any OS without root or a native profiler (managed frames only): ```sh dotnet-trace collect -p --duration 00:00:00:10 # default profiles: dotnet-common + dotnet-sampled-thread-time dotnet-trace collect -p --profile gc-verbose # GCs + sampled allocations: which types/stacks allocate dotnet-trace collect -p --clrevents contention # lock contention events with stacks dotnet-trace collect -- ./MyApp arg1 # trace from startup dotnet-trace list-profiles dotnet-trace report trace.nettrace topN -n 20 # top methods (exclusive) as plain text dotnet-trace report trace.nettrace topN -n 20 --inclusive dotnet-trace convert trace.nettrace --format Speedscope # open in https://www.speedscope.app ``` For more tips on using `dotnet-trace`, refer to the [dotnet-trace collection skill](https://raw.githubusercontent.com/dotnet/skills/refs/heads/main/plugins/dotnet-diag/skills/dotnet-trace-collect/SKILL.md). ## Low-level profiling with Linux `perf` The instructions below are mostly for CoreCLR/JIT scenarios on Linux with the native `perf` tool. NativeAOT/R2R scenarios should presumably work as is, without `perf inject`. Typically, the following environment variables need to be set in the context of the running application: ```sh DOTNET_PerfMapEnabled=1 DOTNET_PerfMapStubGranularity=3 DOTNET_JitFramed=1 DOTNET_EnableWriteXorExecute=0 ``` What to profile depends on the scenario. Typically it's steady-state performance, so wait until the process is properly warmed up (e.g. 1 minute), then attach and collect traces: ```sh perf record -k 1 -g -F 3999 -p $PROCESS_PID -o perf.data -- sleep 5 ``` Next, symbolize the collected JIT frames using `perf inject`: ```sh perf inject --input perf.data --jit --output perfjit.data ``` Then use the resulting `perfjit.data` file as needed, e.g. to generate flame graphs or annotated asm: ```sh # annotated asm & report: perf report --input perfjit.data --no-children --percent-limit 1 --stdio > perf_report.txt perf annotate --stdio2 -i perfjit.data --percent-limit 1 -M intel > perf.asm # Generate flamegraph (svg) git clone --depth 1 --quiet https://github.com/brendangregg/FlameGraph || true perf script -i perfjit.data | ./FlameGraph/stackcollapse-perf.pl | ./FlameGraph/flamegraph.pl > flamegraph.svg ``` Now `perf.asm` contains the annotated assembly output for the hottest methods in your application. Keep in mind that sample attribution is imprecise ("skid"): the cost is often reported on an instruction shortly after the one that actually stalls (e.g. right after a slow load or division), so inspect the neighboring instructions too. In case of missing symbols you may need to use `dotnet-symbol` to download the necessary debug symbols for .dll/.dylib/.so: ```sh dotnet-symbol --symbols $PUBLISH_FOLDER/*.dll dotnet-symbol --symbols $PUBLISH_FOLDER/*.so ``` All other `perf`-related techniques (e.g. c2c, branch profiling, cache misses) can be applied similarly, depending on the performance aspects you want to analyze. For simple functions, create a self-contained console application that calls the function in an infinite loop. For BenchmarkDotNet, use the in-process toolchain (`-i` or `[InProcess]`) with a long iteration duration and a very large iteration count so it is clear which process to attach `perf` to. ## Popular low-level problems - Cache contention and false sharing in multi-threaded code. Fix with per-thread data and padding (see [False sharing](#false-sharing)). - Bounds checks not being elided by the JIT. - Store-to-load forwarding stalls, e.g. writing a struct field by field and reading it back with a wider load. - Bad data locality causing cache misses. - Code alignment noise. Loop or branch placement, Intel JCC erratum - not something we can control, just to keep in mind when analyzing performance variations. - GC's alignment is 8 bytes (64-bit), which might not be suitable for SIMD optimizations (e.g. AVX512 might penalize every load/store if misaligned since the cache line is 64 bytes). ## Inspecting JIT codegen These are diagnostic switches (not tuning knobs) and work with the shipped Release runtime: ```sh DOTNET_JitDisasm="Test" # method list: 'Test', 'Program:Test', '*Test*', 'Program:*' (space-separated) DOTNET_JitDisasmSummary=1 # list every jitted method with its tier DOTNET_JitStdOutFile=/tmp/jit.txt # write JIT output to a file instead of stdout DOTNET_JitDisasmDiffable=1 # stable output (no addresses) for before/after diffs DOTNET_JitDisasmOnlyOptimized=1 # show only Tier-1/FullOpts code ``` ^ might require `MethodImplOptions.NoInlining` on the method to ensure it gets JIT-compiled and not inlined away. > [!TIP] > Set them only for the app process, not for `dotnet run`, which would also dump the SDK's own methods. - A method is compiled several times as it tiers up. Judge the last version: header `; Tier1 code` (or `; Tier1-OSR code` / `; FullOpts code`), ideally with `; optimized using Dynamic PGO`. `; Tier0 code` and `; Instrumented Tier0 code` are unoptimized. - BCL methods precompiled with ReadyToRun show up only once they get hot and are re-jitted at Tier1. - NativeAOT: pass the same options to ILC, e.g. `` and ``. # Best practices for high-performance C# code > [!IMPORTANT] > Only safe C# code is considered in these best practices. Unsafe code is highly discouraged and not covered here. ## Quick tips - Use [source-generated System.Text.Json](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/source-generation): pass generated `JsonTypeInfo` to avoid reflection-based metadata discovery and enable supported serialization fast paths. Preserve existing serialization options and behavior. - Use [`[LoggerMessage]`](https://learn.microsoft.com/dotnet/core/extensions/logging/source-generation) source-generated logging to avoid template parsing, boxing, and temporary allocations. Still guard expensive argument computation with `IsEnabled`. - Use [`[GeneratedRegex]`](https://learn.microsoft.com/dotnet/standard/base-types/regular-expression-source-generators) for compile-time-known patterns instead of runtime pattern compilation. Preserve options, culture, and match timeouts. - Prefer optimized bulk APIs over handwritten loops: `Span.CopyTo`, `Clear`, `Fill`, `SequenceEqual`, and `IndexOf` already have specialized implementations, including SIMD where appropriate. - Use RVA-backed spans `static ReadOnlySpan RVA => new T[CNS]` for all kinds of immutable data (only primitives, e.g., `long`, `byte`, `char`) - the data will be embedded into metadata, and the JIT will be able to perform constant folding on it. - Prefer static lambdas and state-passing overloads in hot paths to avoid capturing closures. - Parse and format UTF-8 directly: numeric `TryParse(ReadOnlySpan, ...)` and `TryFormat(Span, ...)` avoid intermediate strings and UTF-8/UTF-16 transcoding. Preserve culture and formatting semantics. - Cache `SearchValues` / `SearchValues` for repeated searches against fixed sets; use `SearchValues` for multi-substring searches. Construction has a cost, so do not create these per call. - Consider `FrozenDictionary` / `FrozenSet` for build-once, read-many data. Their construction cost must be amortized over enough lookups; preserve the intended comparer. - Use dictionary alternate lookups from spans: `dictionary.GetAlternateLookup>()` avoids allocating temporary string keys when the comparer supports it. Use `TryGetAlternateLookup` when comparer support is not known. - Implement `IEquatable` and a good `GetHashCode` for struct dictionary/set keys. This can avoid expensive default equality paths, boxing, and collision-heavy hashes. - Try [`TensorPrimitives`](https://learn.microsoft.com/dotnet/api/system.numerics.tensors.tensorprimitives) before hand-vectorizing numeric operations. Its span-based implementations handle SIMD and tails; prefer it over manual SIMD loops or LINQ for things like finding min/max, sums, etc. - Prefer [`[LibraryImport]`](https://learn.microsoft.com/dotnet/standard/native-interop/pinvoke-source-generation) over `[DllImport]` for supported signatures to generate marshalling at build time; both `DllImport` and `LibraryImport` require `AllowUnsafeBlocks=true` under unsafe-v2, but if interop is not avoidable, `LibraryImport` is generally preferred. - Use `static readonly` flags for genuinely immutable process-wide configuration. Optimized JIT code can fold their values and eliminate disabled branches. - Cache fields in locals in hot loops (`var items = _items;`). The JIT often can't do it for you: calls and stores through references may modify the field, and the memory model restricts hoisting reads. Locals also enable bounds check elimination for arrays/spans. - Consider `Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping` for UTF-8 JSON-only responses ([documentation](https://learn.microsoft.com/dotnet/standard/serialization/system-text-json/character-encoding#serialize-all-characters)). Never embed its raw output in HTML or `