diff --git a/core/diagnostics/PerformanceScenarios/BlockingScenarios.cs b/core/diagnostics/PerformanceScenarios/BlockingScenarios.cs new file mode 100644 index 00000000000..e79f411369a --- /dev/null +++ b/core/diagnostics/PerformanceScenarios/BlockingScenarios.cs @@ -0,0 +1,113 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +internal static class BlockingScenarios +{ + public static Task SyncOverAsyncAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + int taskCount = Math.Min(64, Math.Max(16, Environment.ProcessorCount * 2)); + Task[] tasks = Enumerable.Range(0, taskCount) + .Select(_ => Task.Run(() => DelayedOperationAsync().Result)) + .ToArray(); + Task.WaitAll(tasks); + } + + return Task.CompletedTask; + } + + public static async Task AsyncDelayAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + await Task.Delay(250, token); + } + } + + public static Task ReaderWriterContentionAsync(CancellationToken token) + { + using ReaderWriterLockSlim gate = new(); + int readerCount = Math.Min(32, Math.Max(8, Environment.ProcessorCount * 2)); + Thread[] readers = Enumerable.Range(0, readerCount) + .Select(_ => new Thread(() => + { + while (!token.IsCancellationRequested) + { + gate.EnterReadLock(); + Thread.Sleep(20); + gate.ExitReadLock(); + } + })) + .ToArray(); + Thread writer = new(() => + { + while (!token.IsCancellationRequested) + { + gate.EnterWriteLock(); + Thread.Sleep(5); + gate.ExitWriteLock(); + } + }); + + foreach (Thread reader in readers) + { + reader.Start(); + } + + writer.Start(); + foreach (Thread reader in readers) + { + reader.Join(); + } + + writer.Join(); + return Task.CompletedTask; + } + + public static async Task DeadlockAsync(CancellationToken token) + { + object first = new(); + object second = new(); + using ManualResetEventSlim firstHeld = new(); + using ManualResetEventSlim secondHeld = new(); + + new Thread(() => + { + lock (first) + { + firstHeld.Set(); + secondHeld.Wait(); + lock (second) + { + } + } + }) + { + IsBackground = true, + }.Start(); + + new Thread(() => + { + lock (second) + { + secondHeld.Set(); + firstHeld.Wait(); + lock (first) + { + } + } + }) + { + IsBackground = true, + }.Start(); + + await Task.Delay(Timeout.Infinite, token); + } + + private static async Task DelayedOperationAsync() + { + await Task.Delay(500); + return 42; + } +} diff --git a/core/diagnostics/PerformanceScenarios/CpuScenarios.cs b/core/diagnostics/PerformanceScenarios/CpuScenarios.cs new file mode 100644 index 00000000000..12cc373eec8 --- /dev/null +++ b/core/diagnostics/PerformanceScenarios/CpuScenarios.cs @@ -0,0 +1,83 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; + +internal static class CpuScenarios +{ + public static Task HotspotAsync(CancellationToken token) + { + long result = 0; + while (!token.IsCancellationRequested) + { + result += Fibonacci(36); + } + + GC.KeepAlive(result); + return Task.CompletedTask; + } + + public static Task InliningAsync(CancellationToken token) + { + long result = 0; + while (!token.IsCancellationRequested) + { + result += CallerOne(); + result += CallerTwo(); + result += CallerThree(); + } + + GC.KeepAlive(result); + return Task.CompletedTask; + } + + public static Task NativeAsync(CancellationToken token) + { + const int BufferSize = 1024 * 1024; + IntPtr source = Marshal.AllocHGlobal(BufferSize); + IntPtr destination = Marshal.AllocHGlobal(BufferSize); + try + { + byte[] initial = new byte[BufferSize]; + Random.Shared.NextBytes(initial); + Marshal.Copy(initial, 0, source, initial.Length); + + while (!token.IsCancellationRequested) + { + Memcpy(destination, source, BufferSize); + } + } + finally + { + Marshal.FreeHGlobal(destination); + Marshal.FreeHGlobal(source); + } + + return Task.CompletedTask; + } + + private static long Fibonacci(int value) + { + return value <= 1 ? value : Fibonacci(value - 1) + Fibonacci(value - 2); + } + + private static long CallerOne() => SharedCompute(4_000); + + private static long CallerTwo() => SharedCompute(5_000); + + private static long CallerThree() => SharedCompute(6_000); + + private static long SharedCompute(int iterations) + { + long value = 17; + for (int index = 0; index < iterations; index++) + { + value = unchecked((value * 31) ^ index); + } + + return value; + } + + [DllImport("libc", EntryPoint = "memcpy")] + private static extern IntPtr Memcpy(IntPtr destination, IntPtr source, nuint count); +} diff --git a/core/diagnostics/PerformanceScenarios/IoScenarios.cs b/core/diagnostics/PerformanceScenarios/IoScenarios.cs new file mode 100644 index 00000000000..66b07e9be3a --- /dev/null +++ b/core/diagnostics/PerformanceScenarios/IoScenarios.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +internal static class IoScenarios +{ + public static Task TinyWritesAsync(CancellationToken token) + { + string path = Path.Combine(Path.GetTempPath(), $"tiny-writes-{Environment.ProcessId}.dat"); + try + { + using FileStream stream = new(path, FileMode.Create, FileAccess.Write, FileShare.Read, 1); + byte[] value = [42]; + while (!token.IsCancellationRequested) + { + stream.Write(value); + stream.Flush(); + } + } + finally + { + File.Delete(path); + } + + return Task.CompletedTask; + } + + public static Task SyncIoThreadPoolAsync(CancellationToken token) + { + string path = Path.Combine(Path.GetTempPath(), $"sync-io-{Environment.ProcessId}.dat"); + byte[] data = new byte[4096]; + try + { + while (!token.IsCancellationRequested) + { + Task[] tasks = Enumerable.Range(0, 64).Select(_ => Task.Run(() => + { + using FileStream stream = new(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite); + stream.Write(data); + stream.Flush(flushToDisk: true); + })).ToArray(); + Task.WaitAll(tasks); + } + } + finally + { + File.Delete(path); + } + + return Task.CompletedTask; + } +} diff --git a/core/diagnostics/PerformanceScenarios/MemoryScenarios.cs b/core/diagnostics/PerformanceScenarios/MemoryScenarios.cs new file mode 100644 index 00000000000..90f82cefc3c --- /dev/null +++ b/core/diagnostics/PerformanceScenarios/MemoryScenarios.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Runtime.InteropServices; + +internal static class MemoryScenarios +{ + public static Task AllocationGcAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + string value = string.Empty; + for (int index = 0; index < 2_000; index++) + { + value += index.ToString(); + } + + GC.KeepAlive(value.Split('7')); + } + + return Task.CompletedTask; + } + + public static Task LohGcAsync(CancellationToken token) + { + List retained = []; + while (!token.IsCancellationRequested) + { + retained.Add(new byte[200_000]); + if (retained.Count > 200) + { + retained.RemoveRange(0, 100); + } + } + + GC.KeepAlive(retained); + return Task.CompletedTask; + } + + public static async Task ManagedGrowthAsync(CancellationToken token) + { + List retained = []; + while (!token.IsCancellationRequested) + { + retained.Add(new byte[512 * 1024]); + await Task.Delay(100, token); + } + + GC.KeepAlive(retained); + } + + public static Task InducedGcAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + byte[] data = new byte[1_000_000]; + GC.KeepAlive(data); + GC.Collect(2, GCCollectionMode.Forced, blocking: true); + } + + return Task.CompletedTask; + } + + public static async Task NativeGrowthAsync(CancellationToken token) + { + List allocations = []; + try + { + while (!token.IsCancellationRequested) + { + IntPtr memory = Marshal.AllocHGlobal(256 * 1024); + for (int offset = 0; offset < 256 * 1024; offset += 4096) + { + Marshal.WriteByte(memory, offset, 1); + } + + allocations.Add(memory); + await Task.Delay(100, token); + } + } + finally + { + foreach (IntPtr allocation in allocations) + { + Marshal.FreeHGlobal(allocation); + } + } + } +} diff --git a/core/diagnostics/PerformanceScenarios/PerformanceScenarios.csproj b/core/diagnostics/PerformanceScenarios/PerformanceScenarios.csproj new file mode 100644 index 00000000000..dfb40caafcf --- /dev/null +++ b/core/diagnostics/PerformanceScenarios/PerformanceScenarios.csproj @@ -0,0 +1,10 @@ + + + + Exe + net10.0 + enable + enable + + + diff --git a/core/diagnostics/PerformanceScenarios/Program.cs b/core/diagnostics/PerformanceScenarios/Program.cs new file mode 100644 index 00000000000..d5865eab19c --- /dev/null +++ b/core/diagnostics/PerformanceScenarios/Program.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +Dictionary scenarios = new(StringComparer.OrdinalIgnoreCase) +{ + ["cpu-hotspot"] = new("One CPU core remains saturated.", CpuScenarios.HotspotAsync), + ["inlining"] = new("CPU is high, but logical helper frames might be optimized away.", CpuScenarios.InliningAsync), + ["native-cpu"] = new("One core is busy, but little work appears in managed methods.", CpuScenarios.NativeAsync), + ["allocation-gc"] = new("A small text transformation consumes excessive CPU and allocation bandwidth.", MemoryScenarios.AllocationGcAsync), + ["loh-gc"] = new("Full collections occur despite a modest object count.", MemoryScenarios.LohGcAsync), + ["managed-memory-growth"] = new("Managed memory rises instead of reaching a steady state.", MemoryScenarios.ManagedGrowthAsync), + ["induced-gc"] = new("Stop-the-world pauses occur more often than allocation warrants.", MemoryScenarios.InducedGcAsync), + ["native-memory-growth"] = new("RSS rises while the managed heap remains nearly flat.", MemoryScenarios.NativeGrowthAsync), + ["sync-over-async"] = new("Async-looking work stalls while the ThreadPool worker count grows.", BlockingScenarios.SyncOverAsyncAsync), + ["async-delay"] = new("Operations take about 250 milliseconds despite negligible CPU use.", BlockingScenarios.AsyncDelayAsync), + ["lock-contention"] = new("Writes are delayed while read traffic continues.", BlockingScenarios.ReaderWriterContentionAsync), + ["deadlock"] = new("The application stops making progress with near-zero CPU.", BlockingScenarios.DeadlockAsync), + ["tiny-writes"] = new("Writing little data produces an unexpectedly high syscall rate.", IoScenarios.TinyWritesAsync), + ["sync-io-threadpool"] = new("File activity delays unrelated queued work.", IoScenarios.SyncIoThreadPoolAsync), + ["jit-startup"] = new("Startup consumes CPU before the process becomes idle.", RuntimeScenarios.JitStartupAsync), + ["swallowed-exceptions"] = new("Throughput falls even though the application reports no errors.", RuntimeScenarios.SwallowedExceptionsAsync), + ["cpu-competition"] = new("The target receives little CPU despite doing no blocking.", SystemScenarios.CpuCompetitionAsync), + ["process-churn"] = new("The process launch rate is unexpectedly high.", SystemScenarios.ProcessChurnAsync), + ["cpu-and-contention"] = new("CPU is high and throughput is low, but one cause might not explain both.", SystemScenarios.CpuAndContentionAsync), + ["healthy"] = new("CPU, memory, and latency remain healthy.", SystemScenarios.HealthyAsync), +}; + +if (args.Length == 0 || args[0] is "--list" or "-l") +{ + Console.WriteLine("Usage: dotnet run -- [duration-seconds]"); + Console.WriteLine(); + foreach ((string name, Scenario scenario) in scenarios) + { + Console.WriteLine($" {name,-24} {scenario.Symptom}"); + } + + return; +} + +if (args[0] == SystemScenarios.WorkerArgument) +{ + int workerDuration = args.Length > 1 ? int.Parse(args[1]) : 30; + using CancellationTokenSource workerCancellation = new(TimeSpan.FromSeconds(workerDuration)); + await SystemScenarios.CpuWorkerAsync(workerCancellation.Token); + return; +} + +if (!scenarios.TryGetValue(args[0], out Scenario? selected)) +{ + Console.Error.WriteLine($"Unknown scenario '{args[0]}'. Run with --list to see the available scenarios."); + Environment.ExitCode = 1; + return; +} + +int durationSeconds = args.Length > 1 ? int.Parse(args[1]) : 30; +using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(durationSeconds)); + +Console.WriteLine($"Process ID: {Environment.ProcessId}"); +Console.WriteLine($"Symptom: {selected.Symptom}"); +Console.WriteLine($"Duration: {durationSeconds} seconds"); + +try +{ + await selected.RunAsync(cancellation.Token); +} +catch (OperationCanceledException) when (cancellation.IsCancellationRequested) +{ +} + +internal sealed record Scenario(string Symptom, Func RunAsync); diff --git a/core/diagnostics/PerformanceScenarios/RuntimeScenarios.cs b/core/diagnostics/PerformanceScenarios/RuntimeScenarios.cs new file mode 100644 index 00000000000..4c78ca6a08f --- /dev/null +++ b/core/diagnostics/PerformanceScenarios/RuntimeScenarios.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Reflection.Emit; + +internal static class RuntimeScenarios +{ + public static async Task JitStartupAsync(CancellationToken token) + { + for (int index = 0; index < 10_000 && !token.IsCancellationRequested; index++) + { + DynamicMethod method = new($"Generated{index}", typeof(int), [typeof(int)]); + ILGenerator generator = method.GetILGenerator(); + generator.Emit(OpCodes.Ldarg_0); + generator.Emit(OpCodes.Ldc_I4_1); + generator.Emit(OpCodes.Add); + generator.Emit(OpCodes.Ret); + Func callback = method.CreateDelegate>(); + GC.KeepAlive(callback(index)); + } + + await Task.Delay(Timeout.Infinite, token); + } + + public static Task SwallowedExceptionsAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + try + { + throw new InvalidOperationException("Expected and ignored."); + } + catch (InvalidOperationException) + { + } + } + + return Task.CompletedTask; + } +} diff --git a/core/diagnostics/PerformanceScenarios/SystemScenarios.cs b/core/diagnostics/PerformanceScenarios/SystemScenarios.cs new file mode 100644 index 00000000000..f5805d9d786 --- /dev/null +++ b/core/diagnostics/PerformanceScenarios/SystemScenarios.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +internal static class SystemScenarios +{ + internal const string WorkerArgument = "--cpu-competition-worker"; + + public static async Task CpuCompetitionAsync(CancellationToken token) + { + string processPath = Environment.ProcessPath ?? throw new InvalidOperationException("Unable to locate the workload executable."); + List competitors = []; + try + { + int competitorCount = Math.Min(32, Math.Max(2, Environment.ProcessorCount * 2)); + for (int index = 0; index < competitorCount; index++) + { + ProcessStartInfo startInfo = new(processPath, $"{WorkerArgument} 60") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + competitors.Add(Process.Start(startInfo) ?? throw new InvalidOperationException("Unable to start a competing process.")); + } + + await CpuWorkerAsync(token); + } + finally + { + foreach (Process competitor in competitors) + { + if (!competitor.HasExited) + { + competitor.Kill(entireProcessTree: true); + } + + competitor.Dispose(); + } + } + } + + public static Task ProcessChurnAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + using Process process = Process.Start(new ProcessStartInfo("/bin/true") + { + UseShellExecute = false, + }) ?? throw new InvalidOperationException("Unable to start /bin/true."); + process.WaitForExit(); + } + + return Task.CompletedTask; + } + + public static async Task CpuAndContentionAsync(CancellationToken token) + { + object gate = new(); + Task cpu = Task.Run(() => CpuWorkerAsync(token), token); + int contenderCount = Math.Min(32, Math.Max(8, Environment.ProcessorCount)); + Task[] contenders = Enumerable.Range(0, contenderCount) + .Select(_ => Task.Run(() => + { + while (!token.IsCancellationRequested) + { + lock (gate) + { + Thread.Sleep(20); + } + } + }, token)) + .ToArray(); + await Task.WhenAll(contenders.Append(cpu)); + } + + public static async Task HealthyAsync(CancellationToken token) + { + while (!token.IsCancellationRequested) + { + long result = 17; + for (int index = 0; index < 1_000; index++) + { + result = unchecked((result * 31) ^ index); + } + + GC.KeepAlive(result); + await Task.Delay(20, token); + } + } + + internal static Task CpuWorkerAsync(CancellationToken token) + { + long result = 0; + while (!token.IsCancellationRequested) + { + for (int index = 0; index < 10_000; index++) + { + result = unchecked((result * 31) ^ index); + } + } + + GC.KeepAlive(result); + return Task.CompletedTask; + } +} diff --git a/core/diagnostics/PerformanceScenarios/readme.md b/core/diagnostics/PerformanceScenarios/readme.md new file mode 100644 index 00000000000..ed7130a6891 --- /dev/null +++ b/core/diagnostics/PerformanceScenarios/readme.md @@ -0,0 +1,63 @@ +--- +languages: +- csharp +products: +- dotnet +page_type: sample +name: "dotnet-trace collect-linux performance scenarios" +urlFragment: "dotnet-trace-collect-linux-performance-scenarios" +description: "Runnable .NET performance scenarios for practicing Linux investigation with dotnet-trace collect-linux." +--- +# `dotnet-trace collect-linux` performance scenarios + +This console application creates 20 distinct CPU, memory, garbage collection, blocking, contention, I/O, exception, startup, process, mixed-cause, and healthy-control scenarios. Use it with the [`dotnet-trace collect-linux` performance investigation tutorial](https://learn.microsoft.com/dotnet/core/diagnostics/dotnet-trace-collect-linux-scenarios) to practice selecting trace data and reaching a conclusion from the evidence. + +## Download the source + +Select **Browse code** at the top of this page to open the repository, or clone the [dotnet/samples](https://github.com/dotnet/samples) repository and navigate to `core/diagnostics/PerformanceScenarios`. + +## Build and list the scenarios + +The sample requires the .NET 10 SDK: + +```dotnetcli +dotnet build -c Release +dotnet run -c Release --no-build -- --list +``` + +## Run a scenario + +Pass the scenario name and an optional duration in seconds: + +```dotnetcli +dotnet run -c Release --no-build -- cpu-hotspot 45 +``` + +The application prints its process ID, the symptom to investigate, and the configured duration. The tutorial groups the scenarios by the CPU, GC, thread-time, syscall, exception, startup, or machine-wide trace strategy needed to diagnose them. + +## Scenario matrix + +Each scenario exercises a materially different diagnostic question, evidence source, collection timing requirement, or limitation. Scenarios that use the same trace configuration remain separate when the investigator must interpret the evidence differently or switch to a different diagnostic artifact. + +| Scenario | Distinct investigation question | Differentiating evidence or next step | +| --- | --- | --- | +| `cpu-hotspot` | Which managed method directly consumes the CPU? | Exclusive CPU samples identify one expensive application method and its callers. | +| `inlining` | What can a CPU trace prove when optimization removes logical methods from physical stacks? | Samples localize the expensive surviving frame, but source or disassembly is required to divide cost among inlined methods. | +| `native-cpu` | Did CPU consumption move from managed code into a native library? | Symbolized stacks cross the P/Invoke boundary and attribute samples to `memcpy`. | +| `allocation-gc` | Is CPU cost driven by a high rate of small managed allocations and collections? | Allocation types and call stacks correlate with GC frequency, pauses, and CPU samples. | +| `loh-gc` | Are large objects causing full collections despite a modest object count? | Large `System.Byte[]` allocations, large object heap growth, and generation 2 collections distinguish large-object pressure from small-object churn. | +| `managed-memory-growth` | Is rising memory caused by managed objects that remain reachable? | Managed heap growth and allocation stacks identify the types and creation sites; a GC dump or process dump is required to prove retention roots. | +| `induced-gc` | Are explicit `GC.Collect` calls causing otherwise unexplained pauses? | GC start events report the `Induced` reason and connect collections to the application call site. | +| `native-memory-growth` | Is process memory rising outside the managed heap? | RSS rises while managed heap metrics remain stable, requiring a native memory profiler or operating-system memory map instead of more managed allocation data. | +| `sync-over-async` | Are synchronously blocked ThreadPool workers causing starvation? | Worker growth, cooperative-blocking events, task-wait stacks, and low CPU distinguish starvation from ordinary asynchronous waiting. | +| `async-delay` | Can physical thread stacks identify the logical operation that initiated an asynchronous delay? | Thread-time data proves timer waiting, but activities or application instrumentation are needed when the initiating request isn't preserved on the physical stack. | +| `lock-contention` | Which lock acquisition path is delayed, and is reader activity delaying writers? | Separate reader and writer stacks plus contention duration identify the affected acquisition path. | +| `deadlock` | How did a deadlock form, and what artifact proves the final ownership cycle? | Pre-reproduction contention events preserve opposing acquisition paths; a process dump is still required to prove current owners and the complete wait cycle. | +| `tiny-writes` | Is poor I/O efficiency caused by excessive syscalls for very little data? | `write` event stacks and external operation counts reveal syscall amplification and expose trace-event loss when counts disagree. | +| `sync-io-threadpool` | Is synchronous durable I/O tying up workers and delaying unrelated work? | `fsync` stacks identify the operation while thread-time data measures worker unavailability, requiring both I/O and scheduling evidence. | +| `jit-startup` | Is startup CPU dominated by JIT compilation before the process becomes idle? | Collection must begin before launch so early CPU samples can be correlated with JIT method events and `libclrjit` stacks. | +| `swallowed-exceptions` | Are caught exceptions reducing throughput without producing error logs? | First-chance exception events count every throw and identify repeated throw sites even when exceptions are handled. | +| `cpu-competition` | Is the target slow because other processes are consuming the machine? | Machine-wide CPU and scheduling data show overlapping competitors and runnable target threads receiving less CPU. | +| `process-churn` | Is repeated creation of short-lived processes causing overhead? | Process lifecycle and `execve` events preserve child launches that are too brief to receive CPU samples. | +| `cpu-and-contention` | Are multiple independent causes required to explain the symptom? | The trace contains both a CPU-intensive path and lock contention, preventing the investigation from stopping at the first plausible finding. | +| `healthy` | What does normal activity look like, and can the workflow avoid inventing a problem? | Stable CPU, memory, GC, and latency provide a negative control with no dominant anomalous stack, wait, pause, or event rate. |