Course outline

C# / Advanced: Lifetimes, Concurrency, and Runtime Design / Lesson 17

Profiling and Measured Performance

Optimize a correct bounded workload using evidence about CPU, allocation, waiting, and retention rather than source-level guesses.

About 5 min readContent reviewed
On this page

Lesson goals

  • Choose a representative benchmark and correctness oracle.
  • Separate JIT warmup, allocation, GC, and wall-clock effects.
  • Preserve semantic contracts when reducing costs.

Define the resource and workload first

Performance work begins with a measurable goal: latency distribution, throughput, CPU time, allocation rate, retained memory, or startup cost under a stated workload. Record runtime, architecture, build configuration, input distribution, and concurrency. A toy program’s elapsed time is not a universal property of a language feature.

Verify correctness before timing. A candidate returning fewer records or sharing data where copying was promised is not a valid faster replacement. Use profiling to identify costly paths and distinguish useful computation from lock contention, I/O waiting, repeated enumeration, and excessive allocation. Different symptoms need different diagnostic instruments.

Keep a simple oracle for an optimized candidate

The first program compares a direct loop with a LINQ sum on fixed small data. It establishes a bounded correctness check, not a timing claim. Both produce ten. For arbitrary inputs, review overflow behavior and other semantic differences before asserting equivalence; a convenient operator can have a different checked arithmetic contract from a handwritten unchecked loop.

A simple independent oracle helps test later algorithm changes. Add empty, boundary, and representative irregular inputs. When benchmarking, consume results so the optimizer has an observable purpose, and ensure candidates perform the same required work. Do not introduce volatile everywhere as a universal way to control optimization.

csharp Check equivalent results before measuring
using System;
using System.Linq;

int[] values = { 1, 2, 3, 4 };
int direct = 0;
foreach (int value in values) direct += value;
int queried = values.Sum();
if (direct != queried) throw new InvalidOperationException("sum mismatch");
Console.WriteLine($"equivalent={direct}");
Expected output
equivalent=10

The fixed safe values give ten through both implementations; no timing result is fabricated.

Warmup and runtime policy affect measurements

JIT compilation, tiered compilation, dynamic profile-guided optimization, and garbage collection can affect different phases of execution. Measure a release configuration representative of the target and separate startup-sensitive questions from steady-state throughput. Runtime implementation choices evolve, so record versions and avoid universal claims about inlining or allocation elimination.

A rigorous benchmark needs repeated samples and uncertainty reporting. Specialized benchmark tooling can help, but adding an external package is a separate trusted dependency decision. This course uses dependency-free correctness demonstrations and does not claim a benchmark framework or performance experiment was executed when it was not.

Compare construction strategies without inventing a speedup

The second example builds a fixed string with StringBuilder and checks the exact expected content. It demonstrates a construction strategy while leaving performance evaluation to a representative workload. For small known strings, interpolation or string.Join may be clearer and can be efficient; StringBuilder is not automatically the best choice everywhere.

Repeated immutable-string concatenation in a large loop can create substantial intermediate work, but compiler optimizations and input shape matter. Choose an algorithm and data structure that remove unnecessary work first. Avoid replacing clear code with unsafe buffer tricks before profiling establishes a need and a separate lifetime argument justifies them.

csharp A verified string-construction strategy
using System;
using System.Text;

var builder = new StringBuilder();
builder.Append("A");
builder.Append("-");
builder.Append("B");
builder.Append("-");
builder.Append("C");
string result = builder.ToString();
if (result != "A-B-C") throw new InvalidOperationException("text mismatch");
Console.WriteLine(result);
Expected output
A-B-C

The output is checked exactly; the example claims no universal allocation count or speedup.

Measure allocation and retention separately

dotnet-counters, dotnet-trace, and dump/GC tools answer different runtime questions on supported environments. A high allocation rate can coexist with a small live heap; a low allocation rate can still retain a huge cache. Process memory also includes stacks, native allocations, mappings, and runtime overhead. Name the metric and interval instead of saying only memory improved.

Collect diagnostics locally with synthetic or approved workloads, matching symbols, and an intentional access policy. Profiles and dumps can contain sensitive data, so artifact handling belongs to the application’s operational contract. Retain correctness regressions and workload metadata after an optimization; rerun relevant measurements when runtime or input changes materially.

Common errors and fixes

An optimization returns an alias where callers expect a copy.

Why it happens
A lower allocation count was prioritized over semantics.
How to fix it
Preserve ownership or explicitly redesign the public contract.
Prevent it next time
Test mutation and concurrency after optimization.

A debug run is used as a production speed claim.

Why it happens
Build and runtime phases differ from the target.
How to fix it
Measure representative release conditions with repeated trials.
Prevent it next time
Record configuration and warmup policy.

A smaller live heap is reported as lower total process memory.

Why it happens
Different metrics were conflated.
How to fix it
Report allocation, retained heap, and process memory separately.
Prevent it next time
State each tool’s measurement scope.

Check your understanding

1. A LINQ query is enumerated three times and profiling shows repeated expensive projections. What change could help, and what must be checked?

Show worked solution

A bounded materialization can compute the projection once if callers require the same captured result. Check source lifetime, memory cost, ordering, exception timing, and reference-copy depth. If callers require live observations, caching may change semantics. Verify the result contract before comparing the new workload and retain cases for source changes between uses.

2. Why is fewer allocations per operation insufficient evidence that pooling is a net improvement?

Show worked solution

Pooling can increase retained memory, synchronization, cleanup complexity, and data-leak risk. It also introduces a lease that must outlive every borrower. Measure representative throughput/latency and retention while preserving correctness and security. A pool that reuses a buffer still in use is invalid regardless of its benchmark allocation count.

Key takeaways

  • Correctness and workload equivalence precede performance comparison.
  • Runtime version, warmup, and build configuration are measurement inputs.
  • Allocation, retention, waiting, and CPU require different evidence.

Official references