| schema_version | 1 | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| name | code-performance-reviewer | ||||||||||||||
| description | Use when reviewing Rust code for micro-optimizations and performance — hot-path audits, allocation/cache footprint review, API design for speed, Criterion-backed regressions, or flat profiles with no obvious hotspot. Inspired by Abseil Performance Hints (Jeff Dean & Sanjay Ghemawat). Pair with rust-backend-engineer (standards), code-review (general audit method), rust-criterion-benchmarking, and rust-concurrency-patterns. | ||||||||||||||
| tags |
|
||||||||||||||
| triggers |
|
Use when reviewing Rust code for micro-optimizations and performance — hot-path audits, allocation/cache footprint review, API design for speed, Criterion-backed regressions, or flat profiles with no obvious hotspot. Inspired by Abseil Performance Hints (Jeff Dean & Sanjay Ghemawat). Pair with rust-backend-engineer (standards), code-review (general audit method), rust-criterion-benchmarking, and rust-concurrency-patterns.
Disciplined performance review of Rust code through the lens of Jeff Dean & Sanjay Ghemawat's Performance Hints (Abseil, 2025 — https://abseil.io/fast/hints.html), translated to idiomatic Rust.
Scope: single-binary / library micro-performance — CPU, memory, cache, allocations, locks, API shape. Not distributed systems or ML hardware tuning.
Core principle (Knuth, full quote): forget about small efficiencies ~97% of the time — but do not pass up the critical 3%. A 12% easily-obtained gain is never marginal in quality software. Prefer the faster alternative when it does not significantly hurt readability.
Evidence before claims. Every finding cites file:line, states whether the
code is on a hot path, and proposes one concrete fix. Unmeasured
"this looks slow" off a cold path is Minor at most.
Companion skills:
code-review— severity taxonomy, general method, security/reliabilityrust-backend-engineer— idioms, types, security, workflowrust-criterion-benchmarking— how to measurerust-concurrency-patterns— locks, DashMap, WAL, throughput
- User asks for performance review, micro-optimizations, or "make it faster"
- Profiling / Criterion shows a hotspot or a flat profile after low-hanging fruit
- Designing library APIs that will be called from many call sites
- Pre-merge audit of hot request/scrape/parse/encode paths
- Investigating high RSS, allocator pressure, or cache-miss-bound code
Do not micro-optimize test-only code beyond asymptotic complexity, or one-shot scripts. Do not sacrifice correctness or introduce dual mechanisms.
- Think about performance while writing, not only after profiles go flat. Flat profiles mean cost is smeared everywhere — hard to start.
- Library code is high leverage. Callers often cannot fix your internals.
Choose good defaults (
SmallVec,&str, bulk APIs) with low local complexity. - Estimate before implementing. Back-of-envelope with known costs; discard alternatives that cannot win.
- Measure before claiming. Criterion for micro;
perf/samplyCPU profiles for systems; allocation profiles when the allocator is suspect. - Many 1% wins compound. Twenty small clean improvements beat one heroic rewrite — needs stable benches.
- Deep modules. Keep public interfaces narrow so layout/alloc changes stay inside encapsulation boundaries.
- Don't pay for what you don't use. Thread-safety, generality, stats, and logging on hot paths have real costs.
| Operation | ~Cost |
|---|---|
| L1 reference | 0.5 ns |
| L2 reference | 3 ns |
| Branch mispredict | 5 ns |
| Uncontended mutex lock/unlock | 15 ns |
| Main memory reference | 50 ns |
| Compress 1KB (Snappy-class) | 1 µs |
| SSD 4KB read | 20 µs |
| Same-DC network RTT | 50 µs |
| 1MB sequential DRAM | 64 µs |
| 1MB over 100Gbps net | 100 µs |
| Disk seek | 5 ms |
| Transcontinental RTT | 150 ms |
Track your higher-level costs too: SQL point read, HTTP hop, HTML render, serde of a typical payload. Without numbers you cannot estimate.
Worked example (Abseil method, Rust framing): scanning 1M strings for a
prefix. Data ~50MB ⇒ memory-bound floor ≈ 50MB / 16GB/s ≈ 3ms. If the compare
branch is data-dependent, ~50% of 1M branches mispredict ⇒ 0.5M × 5ns ≈ 2.5ms
extra. Conclusion: bandwidth and mispredicts are the same order — so (a) keep
the data compact (arena/Box<str>, not Vec<String> with capacity slack), and
(b) a SIMD/memchr-style scan that removes the branch can roughly halve the
time. That estimate decides the fix before writing any code.
- Scope. Whole crate, module, hot function, or diff? Hot path vs init/setup?
- Classify code
- Test-only → asymptotics + test runtime only
- App-specific → is it per-request / per-item?
- Library / multi-caller → apply techniques aggressively when cheap
- Estimate dominant ops (allocs, copies, syscalls, locks, branches, bytes).
- Measure when tradeoffs are non-obvious (Criterion;
perf record/samply;dhator alloc counters if available). - Apply lenses below in order: algorithmic → memory representation → allocations → avoid work → compiler help → code size → concurrency → serialization → Rust containers.
- Report with severity, evidence, estimated impact class, one fix.
- Gate claims with a bench or profile delta when Important+.
- Critical — pathological complexity on production path (O(n²) per request),
unbounded memory (
posts_per_page => -1class), lock held across network I/O, allocator thrash that OOMs / freezes host (this repo: uncappedcargo testfroze the host 2026-07-29; FPM OOM 2026-06-23 — both real). - Important — measurable hot-path waste: needless alloc/copy per request,
wrong container, missing
reserve, regex where prefix match suffices, missing bulk API forcing N crossings, contended global lock, false sharing. - Minor — cold-path micro-opts, style-level
clonecleanup, layout polish with tiny footprint, speculative SIMD. Batch only with approval.
- Structural / algorithmic — often 2–10×+
- Representation / cache — often 10–50% system-wide when data is large
- Allocation reduction — often 10–30% on alloc-heavy paths
- Avoided work / fast path — highly variable; can be huge
- Lock / parallel — throughput-bound multi-core wins
- Death by 1000 cuts — many 1% changes after profile is flat
Highest leverage. Prefer these over micro-opts.
Checklist:
- Wrong asymptotics? (nested loops, repeated full scans, sort when hash works)
- Sorted intersection → hash set lookup
- Ordered map used only for equality lookups →
HashMap/HashSet - Graph/build done incrementally with per-edge checks → one-shot construction
- Hash quality so expected O(1) is not accidentally O(n)
Rust notes:
- Hot maps with internal (non-adversarial) keys: swap the default SipHash
hasher for
rustc_hash::FxHashMap(rustc-hash 2.x) orfoldhash(hashbrown 0.15's fast default). Keep SipHash for attacker-controlled keys. HashMap::entry()/get_or_insert_withinstead ofcontains_key+insert(double hash + double lookup).Vec::swap_remove(O(1)) instead ofremove(O(n)) when order doesn't matter.sort_unstableinstead ofsortwhen stability isn't required;sort_by_cached_keywhen the key is expensive to compute.VecDequefor ring-buffer / pop-front patterns, notVec::remove(0).- Ordered lookups on moderate N: sorted
Vec+binary_searchoften beatsBTreeMapon cache behavior.blart(ART) for large ordered string keys. - Build structures in bulk (
extend,from_iter,collect) instead of repeated insert with rebalancing/rehash churn when input is known.
Touch fewer cache lines; cut memory bus traffic (helps neighbors on the machine).
- Smaller integer types when domain fits (
u8/u16/u32vsusize/u64) #[repr(u8)]enums instead of pointer-sized discriminants when it matters- Bitflags /
bitvec/ manual bitsets instead ofHashSet<SmallId> - Dense arrays indexed by small IDs instead of maps
Box<str>/Box<[T]>for immutable stored data (drops the capacity field, 8 bytes per value) —String::into_boxed_str,Vec::into_boxed_sliceArc<str>/Arc<[T]>for shared immutable data — cheap clone, no double indirection ofArc<String>- Enum size = largest variant + discriminant:
Boxlarge cold variants so the common path stays small
- Field order: group by access (hot together); put cold fields last or behind
Box/separate SoA arrays - Separate hot read-mostly from hot mutable to reduce false sharing / invalidation
- Use
#[repr(C)]+ manual packing only inside well-tested modules; validate with benches — packing can hurt if it causes under-aligned hot loads - Check sizes with
static_assertions::assert_eq_size!in tests andcargo +nightly rustc -- -Zprint-type-sizesfor full layout dumps #[repr(align(64))]for false-sharing fixes — easy to bloat; measure
- On 64-bit, rich pointer graphs waste memory and scatter cache lines
- Store
u32indices into an arena/Vec<T>when cardinality fits slab/slotmap(generational indices) are the canonical crates for this- Contiguous
T[]beats pointer soup for traversal
- Avoid per-element heap nodes (
LinkedList, naive tree maps) on hot data - Prefer
Vec, slab/arena, chunked structures, flat hash maps - Partition into fixed-size chunks when you need both locality and growth
When N is usually small:
smallvec::SmallVec<[T; N]>(spills to heap past N) /arrayvec::ArrayVec(fallible push past N) /tinyveccompact_str/smartstringfor short strings (inline ≤ 24 bytes)- Caveat: large
Tor large inline N bloats every instance (stack and struct size)
HashMap<A, HashMap<B, V>>→ oftenHashMap<(A,B), V>; keep nested only if the outer key is huge and shared- Measure both; Abseil notes nested can win when the first key is large
bumpalo,typed-arena, or domain-specificVec+indices for many short-lived related objects: fewer allocs, better locality, cheap bulk drop- Caveat: long-lived arena holding short-lived junk → memory bloat
- Size the arena / pre-reserve when possible
- Small integer or enum domain →
[V; N],Vec<Option<V>>, or bitset - Set ops become word-parallel AND/OR
Alloc cost = allocator time + init/drop + cache footprint (each alloc tends toward a new cache line in long-running programs).
Checklist:
- Avoid alloc when static/empty sentinel works (
&[],Cow::Borrowed("")) - Prefer stack (
ArrayVec, small arrays) when lifetime is scoped -
Vec::with_capacity/reservewhen size known — never grow one-by-one in a loop without reserve - Prefer
reserve+pushoverresize+ assign when element construct is dear - Move (
std::mem::take,swap, ownership transfer) instead ofclone - Store
&T/ indices /Arcin transient structures instead of deep clones - Hoist buffers out of loops; reuse
Vec/String/BytesMutwithclear - Caveat: reused buffers retain high-water capacity — periodically
shrink_to/ recreate every N uses if sizes vary wildly -
Bytes/BytesMutfor shared I/O buffers; avoid copy intoVecthen again - Iterator pipelines without intermediate
collectwhen a single pass works -
Cow<'_, str>/Cow<'_, [T]>when sometimes borrowed, sometimes owned -
Rcinstead ofArcin provably single-threaded code (atomic refcount traffic is not free)
Size-hint nuance: iter.map(f).collect::<Vec<_>>() already reserves via
size_hint; iter.filter(f).collect() does not — add with_capacity or
collect_into a reused buffer when the filtered count is predictable.
Rust-specific smells (each is a finding when on a hot path):
.clone()onString/Vec/HashMapin a per-item loopformat!/to_string()just to pass to an API that takesimpl AsRef<str>format!chains in a loop →write!into one reusedString(use std::fmt::Write),String::with_capacityto_lowercase() == other→eq_ignore_ascii_case(no alloc, ASCII) — flag the Unicode caveat if input is non-ASCIIregex::Regex::newinside a function body → compile once inOnceLock; better:starts_with/split_once/memchrwhen a regex isn't needed- Building
PathBufrepeatedly from the same prefix serde_json::to_vec+ parse cycles when a typed value already exists- Number formatting via
format!on ultra-hot paths →itoa/ryu s.as_bytes().to_vec()wheres.into_bytes()moves the allocationRc<RefCell<T>>/Arc<Mutex<T>>for a plain counter →Cell<u64>/AtomicU64
For alloc-heavy services, swapping the global allocator is often a bigger win
than any single code change: mimalloc or tikv-jemallocator via
#[global_allocator]. Measure on the real workload — gains are largest under
multi-threaded alloc churn; it also changes RSS behavior. One-line change,
benchmark before and after.
Often the biggest win: don't do it.
- Structure code so the common case is branch-predictable and allocation-free
- e.g. push when capacity remains; 1-byte varint; empty error-free stats skip
- Keep slow path cold (
#[cold], separate function) so it does not pollute I-cache
- Expensive properties, lookup tables, fingerprints of large blobs
- Validate inputs at module boundaries; don't re-check deep inside
OnceLock/LazyLockfor process-wide immutable tables (regexes, maps)
- Bounds, date formatting, config flags, logger enabled checks
- Don't compute stats/sharding/subtrees until a consumer needs them
- Hot call site may not need full generality:
starts_with/split_oncevs full regex;memchr/memchr::memmemfor byte/substring search;aho-corasickfor many patterns at once; manual format vsformat!
- Fingerprint-keyed caches for large serialized inputs
- Even disabled logging can cost a load+branch and block inlining/opts
- Precompute
log::log_enabled!/ a cached bool outside nested loops - Compile-time level stripping:
log/tracingmax_level_*/release_max_level_*cargo features make disabled calls zero-cost - Sample stats (1/N requests) instead of every event
- Counters:
AtomicU64relaxed increments, notMutex<u64> - Drop useless counters entirely
Only when profiles show pain — rustc/LLVM are often already good. Inspect
assembly (cargo-show-asm, perf annotate, Godbolt) for critical functions.
Techniques:
- Avoid
dyn Traiton hottest loops when monomorphization is OK - Split slow path into
#[cold] fn/#[inline(never)]so hot path stays lean - Iterate over slices instead of indexing (
for x in s,chunks_exact) — elides bounds checks and enables autovectorization - Copy small hot data into locals to end aliasing doubts (aids vectorization)
- Explicit SIMD:
std::simd(nightly portable SIMD),wide, orcore::archintrinsics for bulk byte/number work (packed_simdis deprecated — don't recommend it) - Process chunks of 4/8/16 items with
chunks_exactwhen alignment allows - Careful
#[inline]— helps tiny hot getters; hurts when code size explodes #[inline(never)]on rare error paths- Limit monomorphization blowup: convert type params to trait objects or function pointers for cold bulky code; keep hot kernels monomorphized
[profile.release] lto = "thin"(or"fat"for the final binary),codegen-units = 1,panic = "abort"where acceptableRUSTFLAGS="-C target-cpu=native"on controlled deploy targets (unlocks autovectorization; not for distributed binaries)- PGO via
cargo-pgofor mature hot binaries — typically 5–15% debug = 1(line tables) in the release profile used for profiling, soperf/samplyattribute correctly
Large code → longer builds, fatter binaries, I-cache pressure, worse predictors. Especially important for widely used generics and macros.
- Measure monomorphization bloat with
cargo llvm-lines; binary size withcargo bloat - Trim code that ends up inlined at many call sites
- Avoid heavy
format!/Display machinery in tiny inlined helpers - Collapse repeated map-insert initialization into one
from/extend - Share non-generic bulky logic in a non-generic inner function
(
fn inner(x: &str)called fromfn api<S: AsRef<str>>(s: S)) impl AsRef<str>on a widely-called API monomorphizes per caller; sometimes&str+ one conversion at the edge is smaller- Watch proc-macro / serde derives on huge types pulled into hot crates
See also rust-concurrency-patterns.
- Parallelize independent items (
rayonfor CPU-bound,tokiotask batches for I/O-bound) when spare cores/memory bandwidth exist — measure; bandwidth saturation can make parallel slower - Amortize lock acquisition (one lock for a batch, not per item)
- Keep critical sections short — never hold a lock across await/RPC/disk
- Shard contended maps (
DashMap) or use concurrent maps; be careful which hash bits pick the shard (don't skew the inner table) - False sharing:
crossbeam_utils::CachePadded<T>around per-thread counters instead of hand-rolled#[repr(align(64))] - Counters/flags: atomics (
AtomicU64,Ordering::Relaxed) instead ofMutex<u64>;parking_lotfor smaller/faster sync locks in sync code - Prefer bounded channels / batching over per-item task spawn (context switches)
- Lock-free only via proven structures (
crossbeam,arc-swap) — not hand-rolled atomics unless expert + tested - Thread-compatible (external sync) by default for library types; internal sync only when typical use needs it (so uncontended callers don't pay)
Protobufs / heavy schema frameworks are convenient and expensive. Abseil
example: list of 1000 points ~20× faster as Vec<Struct> than protobuf.
Rust equivalents — beware of:
prost/protobuf/ largeserdegraphs on hot inner loops- Deep message hierarchies and map fields
- Re-encoding/decoding the same blob repeatedly
Prefer:
- Plain structs +
Vecon internal hot paths; serialize at boundaries bytes::Bytesto avoid copy of large fields- Reuse parse buffers / message objects across loop iterations when APIs allow
simd-jsonfor proven hot JSON parsing (needs a mutable input buffer)itoa/ryufor fast number formatting- Compact encodings (
rkyv, flatbuffers, custom packed) only when measured need - Avoid serde_json on hot paths when a binary format or manual parser suffices
| Abseil / C++ idea | Rust default | Faster / denser alternatives |
|---|---|---|
std::vector |
Vec<T> |
pre-reserve; SmallVec/ArrayVec if small |
absl::InlinedVector |
— | smallvec, arrayvec, tinyvec |
std::unordered_map |
HashMap (hashbrown) |
FxHashMap (rustc-hash 2.x) / foldhash for internal keys |
absl::flat_hash_map |
hashbrown (open addressing) | same; good hasher; bulk insert; entry API |
std::map |
BTreeMap |
sorted Vec + binary_search; blart for string keys |
| bit sets | HashSet of ids |
bitvec, fixedbitset, integer bitmasks |
gtl::small_map |
— | SmallVec of pairs linear scan; heapless::FnvIndexMap |
| intrusive list | LinkedList (rare) |
intrusive-collections; usually better as Vec+indices |
| arenas | — | bumpalo, typed-arena, slab, slotmap |
| indices vs pointers | — | slab / slotmap with u32 keys |
| Need | Reach for |
|---|---|
| Shared immutable string | Arc<str> (not Arc<String>) |
| Stored immutable string | Box<str> via into_boxed_str |
| Short strings | compact_str, smartstring |
| Conditional ownership | Cow<'_, str> |
| Substring / byte search | memchr, memchr::memmem |
| Many patterns | aho-corasick |
| Shared I/O buffers | bytes::Bytes / BytesMut |
| Number formatting | itoa, ryu |
| Abseil / C++ idea | Rust default | Alternative |
|---|---|---|
string_view / Span |
&str, &[T] |
always prefer views in APIs |
FunctionRef |
impl Fn / fn() |
avoid Box<dyn Fn> on hot calls |
alignas(64) false sharing |
— | crossbeam_utils::CachePadded |
| faster mutex | std::sync::Mutex |
parking_lot (sync code) |
| stats counters | Mutex<u64> |
AtomicU64 (relaxed) |
Status/StatusOr tax |
Result<T,E> |
on ultra-hot infallible paths, don't force Result |
| allocator | system malloc | mimalloc / tikv-jemallocator |
API design (Abseil "API considerations"):
- Bulk APIs —
lookup_many,delete_many, batch encode/decode; amortize locking and boundary costs. If callers can't change, bulk internally + cache. - View types — accept
&str,&[T],impl AsRef<Path>, not ownedStringunless transferring ownership. - Pre-allocated / precomputed args — let callers pass clocks, buffers, scratch space they already have.
- Thread-compatible vs thread-safe — default externally synchronized.
When CPU profile has no tall towers:
- Take the many 1% wins (layout, reserve, hoist, cold paths) with benches
- Flame graph: find loops near the top of stacks; restructure whole loop
- Step up a level — structural/API changes beat instruction tweaks
- Replace overly general code (regex → prefix; serde → manual; generic → special)
- Allocation profile — cut top alloc sites (allocator time + cache)
- Hardware counters — cache misses, branch misses (
perf stat,perf mem) - I-cache: check binary growth and over-inlining (
cargo bloat,cargo llvm-lines)
| Need | Tool |
|---|---|
| Microbench | criterion (see rust-criterion-benchmarking); divan as lighter alternative |
| CI-stable regression gate | iai-callgrind (instruction counts, noise-immune) |
| CPU profile | samply record, perf record + cargo flamegraph |
| Assembly | cargo-show-asm, Godbolt |
| Allocations | dhat, heaptrack |
| Cache/branch | perf stat -e cache-misses,branches,branch-misses |
| Lock contention (async) | tokio-console |
| Binary size | cargo bloat (twiggy is wasm-only) |
| Monomorphization bloat | cargo llvm-lines |
| Type layouts | cargo +nightly rustc -- -Zprint-type-sizes |
| PGO | cargo-pgo |
Rules:
- Bench with
--release(and same RUSTFLAGS as prod) black_boxinputs/outputs- Prefer throughput metrics for batch work
- Distrust single-run deltas; use Criterion CI and baselines
- Microbenches can lie — validate important wins on realistic workloads
Production host constraint (diariobitcoin): never uncapped
cargo test/cargo build on the live box — use nice -n 19 + --jobs 4,
prefer a dev machine. See AGENTS.md.
## Performance review: <scope>
### Summary
- Hot paths identified: ...
- Profile/bench evidence: ... (or "static review only")
- Top opportunities: ...
### Findings
#### [Critical|Important|Minor] <title>
- **Where:** `path/file.rs:LINE`
- **Evidence:** quote + why hot
- **Mechanism:** (alloc / cache / algorithm / lock / work-avoidance / ...)
- **Impact class:** structural | representation | allocation | ...
- **Fix:** minimal concrete change (code sketch OK)
- **Validate:** criterion case / perf command / reason estimate suffices
### Non-findings / accepted costs
- ... (generality, readability tradeoffs explicitly OK)
### Suggested bench plan
- ...Push back on premature micro-opts that hurt clarity with no hot-path evidence. Push back on "optimize everything" — prioritize the critical 3%.
- Per-item or per-request? If no → deprioritize.
- Any alloc/copy that could be borrow, move, or reuse?
- Any
HashMap/BTreeMap/Regex/format!that could be simpler/denser? - Capacity reserved? Buffers hoisted out of loops?
entrynot double lookup? - Locks: scope minimal? sharded? held over I/O?
AtomicU64for counters? - API: bulk + views possible without breaking encapsulation?
- Logging/stats on the path — compile-time levels, sampling, hoisted checks?
- Layout: hot fields together; cold behind
Box; enums not bloated by cold variants? - Compiler: cold paths marked; inlining not exploding size; iterators not indexing?
- Measured or estimated before recommending Important+ changes?
Principles and structure adapted from:
Jeffrey Dean & Sanjay Ghemawat, Performance Hints, 2025, https://abseil.io/fast/hints.html
Rust mappings, severity taxonomy, and review workflow are project-specific for agent use in this workspace and related Rust codebases.