Skip to content
← Back to explainers

Rust explainer

Profiling Rust: finding the hot spot before you optimise it

About 6 min to read. Allow extra time to work through examples.

Choose a representative workload, collect an optimized profile, and test whether a targeted change improves the result.

This article uses Rust. You can make it your practice mode to browse related content across the site.

A profile helps you locate expensive work. A benchmark helps you decide whether changing that work improved the result. Use both: a different-looking flamegraph is not, by itself, evidence that a program became faster.

Start with a measurable question

Choose the result you want to improve: startup time, request latency, throughput, CPU usage or peak memory. Record a baseline on a representative input before editing the code.

Then follow a short loop:

  1. Collect a profile for that workload.
  2. Form a specific hypothesis, such as “repeated string allocation is contributing to this parsing cost.”
  3. Make a focused change and check correctness.
  4. Repeat the measurement with the same input and build settings.
  5. Keep the change if the benefit justifies its complexity.

Record the compiler version, hardware, feature flags and relevant environment settings. Repeat runs to estimate noise. Decide whether startup, warm caches and input/output belong in the measurement, then keep that choice consistent.

Profile the build you intend to improve

To improve production performance, start with the optimized settings used in production. A development build can have different inlining, checks and generated code, so its hottest functions may not be the production hot spots.

A development profile can still help diagnose a slow development tool or repeated work. The mistake is assuming its timing predicts an optimized build, not collecting it at all.

Cargo's default release profile omits debug information. This is different from stripping every symbol; strip defaults to "none". Some function names may remain available without enough information for useful source attribution.

Add this profile to the workspace-root Cargo.toml:

[profile.profiling]
inherits = "release"
debug = "line-tables-only"
strip = "none"

Build with:

cargo build --profile profiling

The executable is normally under target/profiling/. A custom target directory or cross-compilation changes that path. This profile keeps release optimization settings and adds line information. If the profiler needs more detail, try debug = true. debug = 1 means limited debug information, not specifically line tables only.

Debug information increases build artifacts and can affect build time. Keep the corresponding symbol files available to the profiler, including any separate debug files produced on your platform. Do not assume that two differently built binaries are byte-for-byte identical.

If the profiler needs frame pointers for unwinding, rebuild with:

RUSTFLAGS="-C force-frame-pointers=yes" cargo build --profile profiling

Combine this with any existing required RUSTFLAGS. Frame pointers can affect generated code and performance, so use the same setting for the before-and-after comparison.

Collect a profile

Pick a tool available for your operating system and architecture. Replace app below with your executable's name and include its normal arguments.

On a supported system with samply installed:

samply record ./target/profiling/app

Samply presents recordings in the Firefox Profiler interface. Check its platform requirements and profiling permissions before using it.

On Linux with perf installed and permission to collect events:

perf record --call-graph dwarf ./target/profiling/app
perf report

Other options include Instruments' Time Profiler on macOS and cargo flamegraph for flamegraph output. A flamegraph's width represents the selected sample weight or event count, often used to estimate CPU time. Height shows stack depth, not how expensive a function is.

These tools run outside the site's submission interface. You can profile a standalone Rust executable locally; Cargo is not a prerequisite for every profiler.

Interpret a wide frame carefully

First check whether the view shows self cost, attributed directly to a frame, or inclusive cost, which includes its descendants. A small wrapper can have high inclusive cost because it calls expensive work.

Inlining and incomplete stack information can also affect attribution. Inspect source locations, available inline frames and callers before blaming the function whose name appears widest. Many calls to a cheap function can add up, even when each call is inexpensive.

Temporarily preventing inlining may help investigate a specific function, but it changes the program you are measuring. Remove diagnostic changes and measure the intended build again before accepting an optimization. Do not treat a changed call graph as proof of the original cause.

CPU samples also have limits. Time spent waiting for I/O, sleeping or blocked on a lock may require scheduling, tracing or waiting-time data rather than an on-CPU profile alone.

Choose evidence for the suspected cost

Question Useful evidence Limit
Where is CPU work concentrated? Sampling profile and call stacks Samples and attribution can be noisy
Are allocations frequent or long-lived? Allocation profiler, such as DHAT Instrumentation changes execution cost
Does instruction count change? Callgrind or Cachegrind Instruction count is not elapsed time
Could cache behavior matter? Hardware counters or cache simulation Aggregate counts do not identify a cause
Is generated code too large? Binary-size analysis or cargo bloat File size is not a runtime profile

Callgrind and Cachegrind instrument execution. Their counts can be useful for repeatable comparisons, but the workload and execution path must also be repeatable. Cachegrind's cache simulation is a model, not a measurement of the exact cache behavior on your machine.

A low instructions-per-cycle result or high cache-miss count suggests questions to investigate. It does not automatically mean that flattening data will fix the program. Compare a targeted change on the workload that matters.

Likewise, removing allocations has no universal conversion into a speedup. It may help latency, memory use or contention, but the effect depends on allocation sizes, lifetimes, allocator behavior and surrounding work.

When no function dominates

A flat profile can reflect work spread across many functions, insufficient samples, waiting outside the recorded events or an unsuitable workload. It does not, by itself, prove quadratic complexity.

Check the recording first, then compare larger inputs, group related work by subsystem and examine the algorithm's operation count. If one independent portion accounts for 2% of the measured time, eliminating only that portion can reduce total time by at most 2%. A change that affects shared data or many callers may have a broader effect.

Stop when the program meets its target and further changes do not justify their cost. Preserve the baseline and measurement recipe so future regressions can be investigated on the same terms.

step 2 of 20

Performance and Data Layout

Up next Where the allocations are: the counting allocator