# Performance Optimization

> The instruction to profile before optimizing survives because intuition about

- Skill: `snoodleboot-io/performance-optimization` (Agent Skill)
- Install (CLI): `npx skillmds@latest add snoodleboot-io/performance-optimization`
- Raw SKILL.md: https://api.skillmd.com/api/skills/snoodleboot-io/performance-optimization/raw
- Safety review: PASS (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: snoodleboot-io (https://skillmd.com/u/snoodleboot-io)
- Updated: 2026-08-19
- Page: https://skillmd.com/skills/snoodleboot-io/performance-optimization

---


# Performance Optimization (Verbose)

## Core Patterns

### Measure, Don't Guess

The instruction to profile before optimizing survives because intuition about
performance is unusually bad — worse than intuition about correctness. Code that
*looks* expensive (a nested loop, a regex, a hand-rolled parser) is often trivial
next to code that looks free (an ORM attribute access that lazily fires a query, a
logging call that formats a large object at DEBUG level even though DEBUG is off).

Profiling does more than rank candidates: it changes the plan. A meaningful share
of investigations end with "the fix is a connection-pool setting" or "we call this
three times and only need it once," making the week of rewriting you had scheduled
unnecessary.

```bash
# Sampling profiler — low overhead, attaches to a running production process
py-spy record --pid 4821 --duration 60 --output flame.svg

# Deterministic, high overhead, good for a single hot function
python -m cProfile -s cumtime script.py | head -40

# Line-level, once you know the function
kernprof -l -v hot_module.py
```

Read a flame graph by width, not by height. Width is time spent; depth is only call
nesting. The widest plateau is your target.

| Tool class | Use when | Cost |
|---|---|---|
| Sampling profiler (py-spy, async-profiler, perf) | Production, unknown hotspot | 1–5% |
| Deterministic profiler (cProfile) | Local, need exact call counts | 2–10× slowdown |
| Distributed tracing | Latency spread across services | Per-request span overhead |
| `EXPLAIN ANALYZE` | Time is inside the database | Runs the query |

If tracing shows most of the request budget inside a single query, this skill is
the wrong one — go to `sql-optimization`.

### Define the Target

Optimization without a target never terminates. State the goal as a percentile, a
load level, and a workload:

> p99 latency of `POST /checkout` under 300 ms at 500 rps with the production
> catalog size, measured at the load balancer.

Attach a budget breakdown so you know when a component is out of line:

| Stage | Budget | Actual |
|---|---|---|
| TLS + routing | 15 ms | 12 ms |
| Auth lookup | 10 ms | 45 ms ← |
| Cart read | 40 ms | 38 ms |
| Pricing service | 120 ms | 110 ms |
| Payment authorize | 100 ms | 96 ms |

The over-budget line is where you start, and you stop when it fits. Establishing
the load level matters as much as the latency number — see `load-testing` for
generating the traffic honestly.

### Amdahl's Law and Where to Aim

Overall speedup from improving one component is bounded by that component's share
of total time:

```
speedup_total = 1 / ((1 - p) + p / s)

p = fraction of time in the component
s = speedup of that component
```

| Component share | 2× faster | 10× faster | Infinitely faster |
|---|---|---|---|
| 5% | 1.03× | 1.05× | 1.05× |
| 20% | 1.11× | 1.22× | 1.25× |
| 50% | 1.33× | 1.82× | 2.00× |
| 80% | 1.67× | 3.57× | 5.00× |

Two consequences. First, compute the ceiling before you commit — a heroic 10×
rewrite of a 5% component buys 4.5% and costs you readability permanently. Second,
re-profile after every fix: once you remove the top contributor, the ranking is
different, and the second-largest item from the old profile is frequently not the
new top.

### The Latency Hierarchy

| Operation | Time | Relative to L1 |
|---|---|---|
| L1 cache reference | 1 ns | 1× |
| Branch mispredict | 3 ns | 3× |
| L2 cache reference | 4 ns | 4× |
| Main memory reference | 100 ns | 100× |
| SSD random read | 100 µs | 100,000× |
| Round trip in datacenter | 500 µs | 500,000× |
| Spinning disk seek | 10 ms | 10,000,000× |
| Round trip California→Netherlands | 150 ms | 150,000,000× |

Read this as a strategy, not trivia: the ratio between a memory access and a
network hop is so large that eliminating a single remote call dominates any amount
of CPU tuning. Most real wins are structural — batch N calls into one, cache the
result, move the computation next to the data, or stop doing the work at all.

### Algorithmic Complexity Before Constants

```python
# Accidental quadratic: `in` on a list is O(n)
def find_dupes(new_ids, existing_ids):          # 41 s at n = 50_000
    return [x for x in new_ids if x in existing_ids]

# Linear: hash membership
def find_dupes(new_ids, existing_ids):          # 0.02 s, ~2000× faster
    existing = set(existing_ids)
    return [x for x in new_ids if x in existing]
```

Other quadratics that hide well: string concatenation in a loop (each `+=` copies
the whole string), `list.pop(0)` in a loop (each pop shifts every element),
repeated `DataFrame.append` or `pd.concat` inside a loop (each call copies the
frame), and re-sorting inside a loop that could sort once outside it.

A change in complexity class is the only optimization that keeps paying as data
grows. Constant-factor work is worth doing after the class is right, not before.

### Tail Latency

```python
lat = sorted(samples)
p50 = lat[int(0.50 * len(lat))]
p95 = lat[int(0.95 * len(lat))]
p99 = lat[int(0.99 * len(lat))]
```

Averages are actively misleading: a mean of 60 ms is consistent with everything
being 60 ms, and equally consistent with 99% at 40 ms and 1% at 2.5 s. The second
system feels broken and the first feels fine.

Tail amplification is why this matters more than it seems. If a page makes 100
independent backend calls, the probability at least one hits the p99 path is
`1 − 0.99^100 ≈ 63%`. A one-in-a-hundred backend event becomes a two-in-three page
event. Fan-out systems live and die on p99, not p50.

Common tail causes: garbage collection pauses, cold caches, connection pool
exhaustion, lock contention, a retry storm, and one slow shard in a scatter-gather.

### Caching — After Measurement, Not Before

Caching is the most-reached-for and least-analyzed optimization. It is right when
reads greatly outnumber writes, the computation is expensive, and staleness is
tolerable. It is wrong when it hides an O(n²) you could have fixed, because now you
have a quadratic *and* an invalidation problem.

| Layer | Latency | Invalidation difficulty | Good for |
|---|---|---|---|
| Local in-process (LRU) | ~100 ns | Hard — per-instance drift | Small, hot, tolerant of staleness |
| Redis / Memcached | ~0.5 ms | Manageable, centralized | Shared derived data |
| CDN / edge | ~10 ms to origin-free | Purge APIs, TTL | Static and semi-static responses |
| Materialized view | Query-time | Refresh schedule | Expensive aggregates |

Always measure the hit rate. A cache at 40% hit rate on an expensive path is often
worse than no cache once you count the added latency, memory, and the class of bugs
where two instances disagree.

### Benchmark Honestly

```python
import statistics, time

def bench(fn, n=50, warmup=5):
    for _ in range(warmup):
        fn()                                   # let JIT/caches/pools settle
    times = []
    for _ in range(n):
        t0 = time.perf_counter()
        fn()
        times.append(time.perf_counter() - t0)
    return {
        "median": statistics.median(times),
        "p95": sorted(times)[int(0.95 * n)],
        "stdev": statistics.stdev(times),
    }
```

Use production-shaped data volume. Behavior is not linear in size: performance is
often fine until the working set stops fitting in memory or the index stops fitting
in the buffer pool, and then the curve bends sharply. A benchmark at 1,000 rows
predicts nothing about 10 million.

## Common Anti-Patterns

❌ **Optimizing from intuition** — a week spent on a 3% component.
✅ Profile first; let the flame graph pick the target.

❌ **"Make it faster" with no number** — no way to know when you are done.
✅ A percentile, a load level, and a workload, agreed up front.

❌ **Reporting averages** — hides the tail that users actually experience.
✅ p50/p95/p99, and alert on p99.

❌ **Caching before profiling** — masks the real defect and adds invalidation bugs.
✅ Find the hot path first; cache only what measurement justifies.

❌ **Micro-optimizing inside a loop that makes a network call per iteration.**
✅ Batch or eliminate the I/O; the loop body is noise next to it.

❌ **Benchmarking on toy data, once, cold.**
✅ Production-shaped volume, warmed, repeated, reported as a distribution.

❌ **Landing an unreadable optimization with no recorded before/after.**
✅ Record the measurement in the PR; if the gain is not measurable, revert it.

❌ **Assuming a rewrite in a faster language fixes it** — a 3× constant factor does
not rescue the wrong complexity class or an N+1 pattern.
✅ Fix the algorithm and the I/O shape first; the language rarely turns out to be
the constraint.

## Performance Optimization Checklist

- [ ] Target stated as percentile + load + workload before work starts
- [ ] Baseline measured and recorded
- [ ] Profile captured under realistic load, not synthetic ideal traffic
- [ ] Top contributor identified from the profile, not from a hunch
- [ ] Amdahl ceiling computed before committing to a rewrite
- [ ] Algorithmic complexity checked for accidental quadratics
- [ ] I/O count per request measured; N+1 patterns eliminated
- [ ] p50/p95/p99 reported; averages not used alone
- [ ] Tail causes investigated (GC, pools, locks, cold cache, slow shard)
- [ ] Cache hit rate measured where caching was added
- [ ] Benchmarks warmed, repeated, and run at production data volume
- [ ] Re-profiled after each change; ranking re-derived
- [ ] Before/after numbers recorded in the change description
- [ ] Regression guard in place so the win does not silently erode

