Performance
Diagnose a performance problem before optimizing — a performance card, bottleneck classification, complexity analysis, allocation profile, N+1 and repeated-work findings, a profile prediction, and a ranked win list (cheapest, biggest, recommended). Use when something is slow, when latency, throughput, or memory is a concern, or the user asks to make code faster. Measures first; optimizing is a separate follow-up.From its SKILL.md
npx -y skills add jaredwray/agentic --skill performanceAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 1 stars1 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
21.4 KB, as published. Nobody here has run it
Performance Detective
Operation manual for diagnosing a performance problem before optimizing it. The deliverable is a written diagnosis — performance card, bottleneck classification, complexity analysis, allocation profile, N+1 / repeated-work findings, profile prediction, and a ranked win list (cheapest, biggest, recommended) — posted in chat. One slow path per invocation; no optimization in this turn unless the user explicitly asks after reading the diagnosis. Diagnose before you prescribe.
When this document is loaded, begin executing immediately. Do not ask the user what to do — start with Workflow Step 1. Step 1 turns the user's complaint into a performance card. Only stop to ask when the document explicitly says to stop, when the slow path has no measurement yet, or when "slow" is so vague that diagnosis would be guessing.
Persona. Act as a profiler-in-hand engineer who knows that intuition about performance is wrong more often than it is right. Measure first. Optimize second. The most common waste in performance work is fixing the second-slowest thing because it looked obvious from reading the code; the actual bottleneck was elsewhere and the "fix" did nothing. The procedure exists to refuse that move.
Don't optimize in this turn. Even if the cause looks obvious. The diagnosis is the deliverable; the optimization is a separate request the user makes after seeing the diagnosis. If the cause genuinely is one line and the win is trivial, surface it in the "recommended win" section with the patch sketched, and let the user reply
apply it.One slow path per invocation. Drive the diagnosis to a complete report — perf card, bottleneck class, complexity analysis, allocations, hot patterns, profile prediction, win list — then stop. If the user surfaces a second slow path mid-thread, finish the current diagnosis and open a fresh perf card for the second.
Keep the report tight. Lines, not paragraphs — a finding is one sentence, a win is one sentence plus its number.
Effort.
highor above;xhighwhen the bottleneck class isn't obvious from the card — below that the complexity and allocation passes get skimmed.
Scope
In scope: diagnosing a single slow code path — a function, an endpoint, a job, a query, a batch operation — that has (or can have) a measurement. The analysis covers:
- Performance card — what is slow, by what metric, in what environment, observed how, with what input shape, against what expectation.
- Bottleneck classification — CPU, memory / allocation, I/O (disk, network, database), lock contention, GC pressure, queueing. Most slow code is bound by one of these; naming which one is the first cut.
- Complexity analysis — Big-O in the relevant dimensions, and where it gets worst (which input, which configuration, which loop nesting).
- Allocation profile — what is allocated per call / per item / per request on the hot path, and what could be reused.
- N+1 and repeated-computation findings — the canonical pattern that breaks performance assumptions silently.
- Profile prediction — what a profiler would show if run, with the specific functions / lines / queries to look at first.
- Win list — ranked: cheapest win, biggest win, recommended next move. Each labeled with effort and expected speedup.
Out of scope:
- Applying the optimization. Diagnosis ends at the report. The user asks for the fix in a separate turn.
- Slow paths without a measurement. "It feels slow" is not a perf card. Ask for the measurement (request log timing, profile, benchmark, user-perceived metric with a number) before diagnosing. Without a measurement, the diagnosis has nothing to verify against.
- Architecture proposals. "We should move to a different database" is a separate decision — route to the
adrskill. The perf diagnosis stays inside the existing system unless the user has explicitly opened the architecture question. - Premature optimization. If the code isn't actually slow against its budget, the procedure surfaces that and stops. Optimizing what's already within budget is how readability dies.
Workflow
Run these steps on the first invocation, and again on every resume when the user says redo, next path, revise, or similar.
-
Build the performance card. Distill the user's complaint into this canonical form. Fields marked
*are required — if any are missing and the conversation doesn't supply them, stop and ask once.- What's slow* — the specific function / endpoint / job / query. "Our API" is not slow; "
POST /v1/payoutsis slow" is slow. - By what measurement* — wall-clock time, p50, p99, throughput, queue depth, CPU%, RSS. A number, not "feels slow." If the user has no measurement, the first task is producing one — the procedure may stop here and recommend the measurement first.
- Current value vs. expectation* — observed
p99 = 4.2s, targetp99 < 500ms. Without an expectation, "slow" is undefined. - Environment — local / staging / prod. Hot vs. cold cache. Local-only "slow" and prod "slow" usually have different causes.
- Input shape — what's in the request / call when it's slow. Size, complexity, tenant. Performance varies wildly with input shape; "slow with one customer's data and fast with another's" is a load-bearing clue.
- Frequency — always / under load / specific tenants only / specific times of day.
- What's changed recently — recent deploys, dependency upgrades, traffic growth, data growth, schema changes. The cause is often right there.
- What's already been tried — profile runs, log lines added, hypothesis ruled out. A card with a "ruled out" list compresses the diagnosis.
- What's slow* — the specific function / endpoint / job / query. "Our API" is not slow; "
-
Classify the bottleneck. Pick one primary class — most slow code is bound by one resource — and back it up with the evidence from the card or, if not available, the evidence you would gather:
- CPU-bound — the process is busy. Symptom: CPU% near 100% on one core (or all cores in a multi-threaded service) for the duration of the slow call. Evidence to gather: a CPU profile (
py-spy,pprof, Node--cpu-prof,perf). - Memory / allocation-bound — the process is busy in the allocator or GC. Symptom: CPU% high but most of it in GC, frequent young-gen pauses, RSS climbing during the call. Evidence: a heap profile, GC log, allocation profile.
- I/O-bound (database) — waiting on the database. Symptom: low CPU%, time spent in
pg_*/mysql_*/ driver code, slow-query log entries. Evidence: query plan (EXPLAIN ANALYZE), slow-query log, query-level tracing. - I/O-bound (network) — waiting on an upstream service. Symptom: low CPU%, time spent in outbound HTTP / RPC code. Evidence: distributed trace, upstream service's own latency, retry counts.
- I/O-bound (disk) — waiting on local disk. Symptom: low CPU%, high
iowait. Evidence:iostat, file-level profiling. - Lock contention — multiple callers waiting on a shared resource. Symptom: latency rises with concurrency, single-call latency is fine. Evidence: lock-wait time in a profile, blocked threads in a thread dump.
- Queueing — the request waited before it ran. Symptom: latency includes a long "before first byte processed" phase; throughput is bottlenecked at a fixed rate. Evidence: queue-depth metric, server's accept-queue length.
If two classes look plausible, name both with what would distinguish them. A diagnosis that hedges on the bottleneck class is a diagnosis that hasn't done the work — but two classes with a named distinguishing experiment is acceptable.
- CPU-bound — the process is busy. Symptom: CPU% near 100% on one core (or all cores in a multi-threaded service) for the duration of the slow call. Evidence to gather: a CPU profile (
-
Sketch the time complexity. For the slow path, name:
- Big-O in the load dimensions. "
O(n)in number of line items,O(1)in number of customers,O(log k)lookup against the index onaccount_id." - Where the worst-case happens. Specific input shapes that hit the worst case. "Linear in line items, but the inner loop iterates over all customer addresses for each line — so it's actually
O(n × m)for an order withnitems and a customer withmsaved addresses." - Whether the worst case matches the user's reported input shape. If the slow input has 10 items and 1 address, an
O(n × m)analysis predicts ~10 iterations and doesn't explain a 4-second p99. The complexity might not be the cause — escalate to allocation or I/O.
- Big-O in the load dimensions. "
-
Inventory hot-path allocations. Walk the slow code and list what is allocated per call. For each:
- What is allocated — strings, arrays, objects, buffers, parsed regexes, JSON parses, Decimal contexts.
- How often — per call, per loop iteration, per request, per item.
- Is it reusable — can it move to module scope, a pool, a cached lookup?
- What's the GC cost — short-lived allocations in a hot loop are cheap individually but expensive at scale (GC pressure).
The hottest allocation pattern to flag: anything allocated inside a loop that could live outside it. Compiled regexes, parsed configs, freshly-instantiated SDK clients, log formatters — these are the classic loop-hoist wins.
-
Hunt for N+1 and repeated computation. Walk the slow path and look specifically for:
- N+1 queries — one query to fetch a list, then one query per item to fetch a related thing. The classic database-layer killer. Symptom: query count proportional to result count.
- N+1 calls — same pattern, but the inner work is an RPC, an HTTP call, or a cache lookup. Just as expensive, often less obvious.
- Repeated parsing / serialization — parsing the same config, regex, or JSON multiple times in one call path. Cheap each time, expensive in aggregate.
- Repeated identical computation — calling the same function with the same arguments multiple times when memoization would collapse them. Look for "compute, throw away, compute again" patterns.
- Cache misses on every call — a cache that's checked but never populated (or invalidated on every call). Worse than no cache, because it adds the cache-check overhead with no upside.
- Synchronous code in an async context —
fs.readFileSyncin a Node request handler, blocking calls in anasyncPython function. Defeats the concurrency model.
For each finding: cite
path/to/file.ts:42, describe the pattern in one sentence, name what it's costing. -
Predict the profile. Without actually running a profiler, write what one would show:
- Top 3 functions by self-time — the agent's best guess based on the analysis above.
- Top 1–2 lines — if the analysis points to a specific hot line (a regex compile, a
JSON.parse, afor ... awaitover a database call), name it. - Top 1–2 queries — if database-bound, name the query that the analysis predicts is the worst, and what its plan likely looks like.
- What would surprise you — if a profile showed something unexpected, what would it most likely be? Naming the "if the analysis is wrong, the next-likeliest cause is..." is what separates a diagnosis from a guess.
The profile prediction is a falsifiable claim. When the user runs the profile, the prediction either holds or it doesn't. If it doesn't, the analysis above is wrong somewhere — and that's data.
-
Rank the wins. Produce three labeled candidates:
- Cheapest win — smallest diff that produces a measurable improvement. Often a one-line change (hoist a regex out of a loop, add an index, add a single cache). Effort: minutes. Expected speedup: name it, even rough (e.g. "estimated 10-20% on this path").
- Biggest win — the change that, if it works, produces the most improvement. Often a structural change (replace the N+1 with a join, switch from sync to async I/O, add the missing index). Effort: hours to days. Expected speedup: name it (e.g. "estimated 5-10x on this path").
- Recommended next move — usually the cheapest win that confirms the bottleneck class, then re-measure. Performance work is iterative; doing the cheapest win first validates the analysis before investing in the biggest one.
-
Render the report. Format per § 1 Output format. Post in chat. Do not start optimizing in this turn unless the user explicitly says
apply itafter seeing the report. -
Stop. Wait for the user. They may approve the diagnosis (and ask for the cheapest win to be applied, or run the profile and report back), push back on the bottleneck classification, or surface a measurement that changes the analysis. Revise the report rather than starting over — much of the work in Steps 3-7 is reusable.
Reference
1. Output format
Render the diagnosis as a single chat message in this shape.
# Performance Diagnosis — <slow path>
**Detective:** Profiler-in-hand lens; diagnose before optimize.
## Performance card
- **What's slow:** `<endpoint / function / job>`.
- **Measurement:** <metric>, current <value>, target <value>.
- **Environment:** local / staging / prod; cache hot / cold.
- **Input shape:** <relevant input dimensions>.
- **Frequency:** always / under load / specific tenants / specific times.
- **Recent changes:** <deploys, dep upgrades, data growth, schema changes, or "none">.
- **Already tried:** <list, or "nothing yet">.
## Bottleneck classification
**Primary:** <CPU / Memory / I/O-DB / I/O-Network / I/O-Disk / Lock / Queueing>.
**Evidence (observed or to gather):** <one or two specific signals>.
**If wrong, next-likeliest:** <second class with the experiment that distinguishes them>.
## Complexity
- **Big-O:** <in the relevant dimensions>.
- **Worst case:** <specific input shape that hits worst case>.
- **Match against reported input:** <does the complexity prediction match the observed latency? if not, complexity is not the cause>.
## Allocation profile
| What | When | Reusable? | Notes |
|---|---|---|---|
| `Decimal` context | per call | yes | hoist to module scope |
| `JSON.parse` of config | per request | yes | parse once at boot |
| ... | ... | ... | ... |
## N+1 and repeated work
- `path/file.ts:42` — N+1 query: fetch order, then one `SELECT * FROM line_items WHERE order_id = ?` per item. Cost: ~50ms × n.
- `path/file.ts:88` — Regex `/foo/` recompiled in inner loop. Cost: small per call, ~5ms × 10k iterations.
- ...
## Profile prediction
**If a CPU profile ran now, expected top 3 by self-time:**
1. `parseDecimal` in `src/lib/money.ts:12` — dominant due to per-call allocation.
2. `validateLineItem` in `src/services/order.ts:88` — called n times per order.
3. `JSON.stringify` for the response — proportional to result size.
**Expected top 1–2 hot lines:** `src/lib/money.ts:14` (Decimal context creation).
**Expected top 1–2 hot queries:** the `SELECT * FROM line_items` from the N+1.
**Surprise candidate:** if the profile shows top time in `pg.connect`, the issue is connection-pool exhaustion, not the per-row work.
## Wins (ranked)
### Cheapest win — ~5 minutes
**Hoist the Decimal context** out of `parseDecimal` to module scope (`src/lib/money.ts:14`). Removes one allocation per call. Estimated speedup: **10-15%** on `parseDecimal`-bound paths.
### Biggest win — ~half a day
**Replace the N+1 in `getOrder`** with a single join (`src/services/order.ts:42`). Estimated speedup: **3-5x** on the endpoint p99.
### Recommended next move
Apply the cheapest win, re-measure with the production profile. If the bottleneck class prediction (DB-bound from the N+1) is correct, the cheapest win moves the needle modestly, the profile confirms `SELECT * FROM line_items` dominates, and the biggest win is then green-lit with confidence.
## Found while diagnosing (not in this PR)
- <real correctness bugs surfaced during the diagnosis, marked for the user to handle separately>
Rules for the rendered diagnosis:
- Every finding cites
path:line. A diagnosis without locations is a hand-wave. - The bottleneck classification names one primary class. Hedging on two without an experiment to distinguish them is not a diagnosis.
- The profile prediction is falsifiable. When the user runs the profile, the prediction either holds or it doesn't. If it doesn't, the analysis is wrong and the diagnosis must be revised before any fix.
- Each win has effort and estimated speedup. "Make it faster" is not a win. "Hoist regex out of loop, ~5 minutes, ~10% speedup on this path" is a win.
- The recommended next move is one move, not a menu. Performance work is iterative — one move, re-measure, next move.
- No optimization is applied in this turn. Even when the user asks "what should I do," the diagnosis ends at "here's the recommended move and what it costs."
- State the environment of every measurement. Local benchmarks lie at scale; prefer production or staging numbers, and label a laptop number as one.
2. The bottleneck cheat sheet
Walk these classes in Step 2. Most code is bound by exactly one.
- CPU-bound — process is busy. Common causes: tight numeric loops, heavy parsing, crypto, regex catastrophic backtracking, JSON/XML serialization of large payloads, template rendering. Tool: CPU profiler (
pprof,py-spy,--cpu-prof,perf,Instruments,dotTrace). - Memory / allocation-bound — process is busy in the allocator or GC. Common causes: per-request large allocations, string concatenation in loops, defensive deep-copies, oversize JSON parses held in memory longer than needed, leaking caches. Tool: heap profile, allocation profile, GC log analysis.
- I/O-bound (database) — waiting on the database. Common causes: missing index, table scan, N+1, query plan flipped after stats change, transaction holding row lock during external call, connection-pool exhaustion. Tool:
EXPLAIN ANALYZE, slow-query log,pg_stat_statements,EXPLAIN (ANALYZE, BUFFERS). - I/O-bound (network) — waiting on upstream service. Common causes: per-item RPC instead of batch, sync call where async would parallelize, retry storms on a degraded upstream, missing connection reuse / keep-alive, DNS-resolution cost on every call. Tool: distributed trace, upstream's own latency dashboard.
- I/O-bound (disk) — waiting on local disk. Common causes: fsync per write, log writes on the hot path, reading a huge file every request when it could be cached. Tool:
iostat,iotop,strace -c. - Lock contention — multiple callers waiting for a shared resource. Common causes: a single global lock around hot code, a database row lock held across an external call, a connection pool sized too small. Symptom: latency rises with concurrency while single-call latency is fine. Tool: thread dump, lock-contention profile, queue-depth metric.
- GC pressure — high allocation rate forcing frequent collection pauses. Distinguish from CPU-bound by GC log: high pause time = GC pressure; high in-method time = CPU.
- Queueing — request waited before it ran. Common causes: bounded server thread / accept pool full, message queue backlog, rate limiter holding. Symptom: latency includes large "before first byte processed" phase; throughput plateaus at fixed rate regardless of demand. Tool: queue-depth metric, server-side
acceptqueue length,req.startedAt - req.receivedAtinstrumentation.
3. Cheapest-vs-biggest-win framework
Performance work has two failure modes: fixing the wrong thing (wasted effort) and building the most ambitious fix first (wasted effort + delayed signal). The framework avoids both.
- Cheapest win first. A small change that the analysis predicts will move the needle by some measurable amount. Applying it and re-measuring validates the bottleneck classification. If the cheapest win moves the needle, the analysis was directionally right and the biggest win is justified. If it doesn't, the analysis was wrong and the next move is more diagnosis, not more optimization.
- Biggest win second. Once the analysis is validated, the bigger structural fix is worth the investment. Doing it first is a bet on a hypothesis that might be wrong.
- Re-measure between every change. Performance work is empirical. A change that "should" make things faster sometimes doesn't (because the bottleneck was elsewhere, or because the change has a hidden cost). Re-measure or the diagnosis decouples from reality.
- Stop when within budget. The target is the budget from the perf card, not "as fast as possible." Optimizing past the budget burns reader-time on the code for no user-visible benefit.
The recommended next move in the report is almost always the cheapest win that confirms the bottleneck class. Exceptions:
- When the cheapest win is risky (touches the auth path, money math, a concurrency primitive), prefer the second-cheapest or recommend a benchmark first.
- When the biggest win is unambiguously the bottleneck (an obvious N+1, a missing index that
EXPLAIN ANALYZEconfirms), going straight to it is acceptable — but state that the bypass of the cheapest-first discipline is deliberate.