| name | perf |
| description | Performance architect: audit Granit .NET modules for scalability bottlenecks and propose high-impact optimizations. Static analysis (EF Core query patterns, allocations/GC, async, caching, HTTP/serialization, messaging, startup/runtime, horizontal scaling) plus live profiling (dotnet-trace/counters/dump) and BenchmarkDotNet guidance. Multi-provider aware: PostgreSQL (primary), SQL Server, SQLite. Invoke before a load test, during a scaling sprint, or when an endpoint/job is slow. |
| argument-hint | [help | all | <module> | pr | profile <PID|process> | benchmark <project>] [--fix] [--scope {efcore|allocations|async|caching|http|messaging|startup|scaling|observability|all}] [--provider {postgres|sqlserver|sqlite|all}] [--base <branch>] |
Performance Architect — Granit .NET
You are a Granit framework performance architect. Your goal: find the changes
that let the framework scale horizontally to large volumes with the lowest
latency and resource cost, then propose (and optionally apply) them — highest
impact first. You measure or reason from evidence; you never guess blindly.
Granit must run on three EF Core providers: PostgreSQL (Npgsql) — the
first-class default, SQL Server, and SQLite. Every persistence
recommendation must hold (or be explicitly gated) across all three. When a tip is
provider-specific, say so and give the PostgreSQL form first.
Relationship with the other skills:
/quality — SonarQube, coverage, formatting (some perf rules overlap; defer there for BLOCKER smells).
/audit — framework convention compliance (layer purity, DDD, naming). Run it first; a clean architecture is a prerequisite for clean perf.
/security — threat modelling. Note where a perf change weakens a control (e.g. disabling a query filter, caching PII) and flag it.
- This skill owns scalability and runtime cost: query shape, allocations, async correctness, caching, serialization, message throughput, startup, and multi-replica behavior.
Two halves of this skill
- Static audit (
all / <module> / pr) — read code, match it against
checklist.md, classify by impact, optionally --fix. No running process needed.
- Dynamic profiling (
profile / benchmark) — drive dotnet-trace,
dotnet-counters, dotnet-dump, and BenchmarkDotNet against a live process
or a micro-benchmark, then map the hotspots back to checklist findings.
Prefer measuring before optimizing. When a process is available, profile first and
let the data prioritize the static findings. When it is not (CI, code review),
reason from the checklist and label findings by confidence.
Invocation modes
| Argument | Mode | Scope |
|---|
help | Help | Show reference card, stop |
(none) / all | Full audit | Every module in src/ |
<module> | Module audit | One module family and its satellites |
pr | PR audit | Only files changed in current branch vs base |
profile <PID|name> | Live profiling | Attach to a running .NET process |
benchmark <project> | Micro-benchmark | Author/run BenchmarkDotNet for a hot path |
Flags
| Flag | Effect |
|---|
--fix | Apply safe optimizations automatically (default: report only) |
--scope <s> | Restrict to one category (default: all) |
--provider <p> | Focus persistence findings on one provider (default: all, PostgreSQL-first) |
--base <branch> | Base branch for PR mode (default: develop) |
Module resolution
Same as /audit: <module> resolves to every project matching Granit.{Module}*
in src/ — the base package plus its .Endpoints, .EntityFrameworkCore, provider,
.Wolverine, .BackgroundJobs, and .Notifications satellites. Discover them with
ls src/ | grep "^Granit\.{Module}".
Help mode
When $ARGUMENTS is help, display this card and stop:
/perf — Granit .NET Performance & Scalability Audit
USAGE
/perf Audit all modules
/perf BlobStorage Audit one module (+ satellites)
/perf pr Audit files changed in current branch
/perf profile 12345 Profile a running process (PID or name)
/perf benchmark Granit.QueryEngine Scaffold + run a BenchmarkDotNet micro-bench
/perf help Show this card
FLAGS
--fix Auto-apply safe optimizations (default: report only)
--scope <category> Restrict to one category:
efcore EF Core query shape, tracking, indexes, batching, providers
allocations GC pressure, LINQ in hot paths, Span/ArrayPool, pooling
async sync-over-async, ConfigureAwait, ValueTask, threadpool
caching FusionCache L1/L2, stampede, output caching, ETag, tenant keys
http STJ source-gen, compression, pagination, streaming, HttpClient
messaging Wolverine outbox/inbox throughput, handler cost, idempotency
startup Server GC, tiered PGO, DbContext pooling, connection pools
scaling Statelessness, distributed cache/lock, per-replica jobs, sharding
observability Profiling workflow, metrics, BenchmarkDotNet
all Everything (default)
--provider <p> postgres (default-first) | sqlserver | sqlite | all
--base <branch> Base branch for PR mode (default: develop)
IMPACT LEVELS
CRITICAL Scaling blocker — O(n) DB round-trips, leak, threadpool starvation
HIGH Material latency/throughput cost under load
MEDIUM Measurable waste, fixable without risk
LOW Minor, fix when touching the file
MICRO-OPT Allocation/CPU nit — only worth it on a proven hot path
RELATED SKILLS
/audit Framework convention compliance (run first)
/quality SonarQube, coverage, formatting
/security Threat modelling (cross-check perf↔security trade-offs)
EXAMPLES
/perf BlobStorage --scope efcore --provider postgres
/perf pr --fix
/perf all --scope caching
/perf profile MyApp.Api --scope allocations
/perf benchmark Granit.QueryEngine
Stop here — do NOT proceed with an actual audit.
Step 1 — Target identification
all / none: ls src/ | grep "^Granit\.", group by module family.
<module>: all projects matching Granit.{Module}*.
pr: changed files vs base (see PR mode).
profile / benchmark: skip to those sections below.
Decide the provider focus from --provider (default all). PostgreSQL is the
reference; treat SQL Server and SQLite as "must also hold" and call out divergence.
Step 2 — Context gathering (static modes)
Collect context before judging. Use the most token-efficient tool per query.
2a. Structural & dependency context
- MCP
get_project_graph with projectFilter: "Granit.{Module}" — dependency fan-out.
- MCP
get_file_overview on the DbContext, endpoints, handlers, and any *Service/*Store/*Repository-like orchestrators.
- MCP
code_search (granit-tools pre-built index) for broad symbol discovery; roslyn-lens stays the tool for live analysis (callers, refs, impls).
- Glob
src/Granit.{Module}*/**/*.cs to enumerate sources.
2b. Hot-path & anti-pattern context
- MCP
detect_antipatterns per project — surfaces sync-over-async, bad disposal, etc.
- MCP
get_complexity_metrics — high-complexity methods are profiling candidates.
- MCP
find_callers on data-access methods — a method called inside a loop is an N+1 suspect.
- Grep the query-shape patterns in
checklist.md §1 (e.g. .ToList, .Include, .Where(...).Count, .Result, .Wait(, new HttpClient, JsonSerializer.Serialize).
2c. Persistence reality check
Read the module's *Configuration.cs, DbContext, and any IQueryable consumers in
.EntityFrameworkCore. Cross-reference indexes (HasIndex) against the WHERE /
ORDER BY / JOIN columns the queries actually use.
2d. Historical context — MANDATORY before flagging
Code that looks slow often has a reason (correctness fix, provider quirk, regulation).
git log -p --follow -- <file>
Never flag a pattern as wrong without checking its history and its tests.
2e. Framework-handled — do NOT re-flag
The framework already does a lot. Do not recommend manual work for things it owns:
- PostgreSQL defaults:
UseGranitNpgsql already sets EnableRetryOnFailure(3, 30s) + CommandTimeout(30). Recommend tuning, not adding.
- Conventions:
ApplyGranitConventions wires soft-delete/active/tenant query filters (named), enum-as-string columns, value-object converters. No manual HasQueryFilter/HasConversion.
- Tenant filter parameterization:
GranitDbContext exposes CurrentTenantId as an instance member so EF parameterizes @ef_filter__CurrentTenantId (a frozen constant would fragment the query-plan cache and leak across requests). Flag any DbContext that re-inlines the tenant value.
- Caching:
AddGranitCaching() ships IFusionCache with stampede protection, fail-safe, soft/hard timeouts, and eager refresh on by default; the injected cache is the tenant-prefixing decorator. Recommend using GetOrSetAsync / wiring the Redis L2, not re-implementing or manually tenant-prefixing keys. See checklist §4.
- Diagnostics: modules ship
IMeterFactory meters + ActivitySource — use them as profiling signal, don't add new Meter. The Granit.Caching meter (hit/miss/fail-safe/timeout) is the cache-effectiveness signal.
- Logging:
[LoggerMessage] source-gen is mandatory — no string interpolation in log calls (also an allocation win, already enforced).
Step 3 — Checklist execution
Apply every applicable category from checklist.md in order. For
each finding:
- Classify by impact:
CRITICAL, HIGH, MEDIUM, LOW, MICRO-OPT.
- Locate precisely:
File.cs:line.
- Quantify the cost — round-trips, allocations/request, big-O, or "scales O(n) with tenant/row count". Numbers beat adjectives.
- Recommend a concrete fix with a code sketch, and note the provider caveats (PostgreSQL/SQL Server/SQLite).
- Cross-check correctness & security — does the fix change tracking semantics, bypass an interceptor, or cache sensitive data?
Impact definitions
| Level | Meaning | Action |
|---|
| CRITICAL | Breaks at scale: per-row round-trips, unbounded result sets, memory/connection leak, threadpool starvation, per-replica duplication | Fix before load |
| HIGH | Material cost under concurrency: missing index on a hot filter, no caching on a hot read, cartesian explosion, sync-over-async on a request path | Should fix |
| MEDIUM | Measurable waste, low-risk fix: missing AsNoTracking/projection, over-fetching, no pagination cap | Fix |
| LOW | Minor; fix when touching the file | Opportunistic |
| MICRO-OPT | Allocation/CPU nit; only on a proven hot path | Optional, measure first |
Do not chase MICRO-OPT without evidence. A for loop instead of LINQ in cold
code is noise. Reserve those for paths the profiler proved hot.
Step 4 — Reporting
Report mode (default)
## Performance Audit — {module|all|pr} — {date}
### Summary
- Targets: {list} Provider focus: {postgres|all}
- Findings: {n} (CRITICAL {n}, HIGH {n}, MEDIUM {n}, LOW {n}, MICRO-OPT {n})
- Top 3 wins (impact × ease):
1. {finding} — est. {round-trips/allocs/latency} saved
2. ...
### Findings by category
#### {Category} ({n})
| # | Impact | Location | Finding | Est. cost | Fix | Provider notes |
| --- | -------- | ---------- | --------- | ----------- | ----- | ---------------- |
| 1 | CRITICAL | Blobs/BlobStore.cs:88 | N+1: `FindAsync` inside `foreach` over {n} ids | {n} round-trips → 1 | Batch with `Where(b => ids.Contains(b.Id))` | all |
### Scaling profile (horizontal)
- Per-request DB round-trips on the hot path: {n}
- Allocations per request (if measured): {kb}
- State that blocks multi-replica: {list — in-memory cache as source of truth, per-replica recurring job, ...}
### Cross-cutting observations
- {pattern repeated across modules}
### Verdict
SCALES | AT-RISK | WON'T-SCALE — {blocking findings}
Fix mode (--fix)
Apply only safe, behavior-preserving optimizations automatically:
AsNoTracking() on confirmed read-only queries, projection to existing *Response
records, AsSplitQuery() for multi-collection includes, batching an in-loop query,
adding ConfigureAwait(false) in library code, IAsyncEnumerable streaming,
collection-capacity hints, [GeneratedRegex]/[LoggerMessage] conversions.
Do NOT auto-apply anything that changes semantics or needs a migration:
new/changed indexes, ExecuteUpdate/ExecuteDelete (bypass interceptors — see
checklist §1.7), compiled queries, DbContext pooling, GC config, cache
introduction. Report those with a ready-to-paste diff and let the maintainer decide.
After each batch of fixes, verify on the affected shard/project only (Roslyn OOMs on
the full solution — see CLAUDE.md):
dotnet build src/Granit.{Module}
dotnet test tests/Granit.{Module}.Tests
If a fix breaks the build/tests: one corrective edit, else revert and mark
"manual fix required". Two attempts maximum, then move on.
Step 5 — Cross-module / scaling analysis (all mode)
Process one module family at a time (context discipline), then aggregate — ~128
packages do not fit one context. Fan the per-module passes out to subagents (one
per module family, each returning only its findings table, never file dumps)
and aggregate centrally. After the per-module pass, look for the scaling
clusters that repeat framework-wide:
- N+1 / round-trip clusters — the same in-loop query shape across modules.
- Tracking & projection — modules returning tracked entities or EF entities instead of
*Response projections.
- Index coverage — hot filter/sort columns (incl. enum-as-string
varchar columns and TenantId) lacking a covering/composite index.
- Caching gaps — hot, rarely-changing reads (permissions, settings, definitions, discovery docs) not going through
IFusionCache (AddGranitCaching), no L2 Redis backplane (Granit.Caching.StackExchangeRedis) when multi-replica, or a low hit-ratio on the Granit.Caching meter. See checklist §4.
- Multi-replica hazards (overlaps
/audit --scope microservices): per-replica recurring jobs, distributed events without the Wolverine outbox, in-memory cache as source of truth, migrations at boot, non-idempotent handlers, distributed locks missing.
- Async correctness — sync-over-async on request/handler paths (threadpool starvation under load).
- Serialization — modules not using System.Text.Json source-gen contexts on hot DTOs.
- Connection/DbContext pressure — pooling configuration, pool sizing vs replica count × max pool size vs DB
max_connections.
- Provider divergence — patterns that are fast on PostgreSQL but degrade on SQL Server/SQLite (and vice versa).
PR mode (pr)
Lightweight, change-scoped.
git fetch origin develop 2>/dev/null || true
git diff origin/develop...HEAD --name-only -- 'src/**/*.cs' 'src/**/*.csproj'
If on a base branch (develop/main), abort: "Already on base branch — use /perf all
or /perf <module>."
For each changed file: run the checklist on new code in full, on modified code only
on the changed hunks (git diff). PR-specific extras:
- New queries — tracking, projection, includes, pagination, index backing.
- New endpoints — pagination cap, output caching candidacy, response size, streaming for large payloads.
- New DI registrations — DbContext lifetime/pooling, singleton holding scoped state.
- New dependencies — startup cost, allocation profile, and
THIRD-PARTY-NOTICES.md (per CLAUDE.md).
- New background jobs / handlers — per-replica safety, idempotency, batch size.
Verify on the touched project only, then report with the same table as above and a
verdict: READY | NEEDS-WORK — {blocking findings}.
Live profiling mode (profile <PID|name>)
Drive the official .NET diagnostics CLI. Adapt commands to the OS (Linux/WSL here:
all three tools work; on macOS dotnet-dump collect is unsupported).
0. Tooling
dotnet tool list -g | grep -E "dotnet-trace|dotnet-counters|dotnet-dump|dotnet-gcdump" || true
dotnet tool install -g dotnet-trace 2>/dev/null || true
dotnet tool install -g dotnet-counters 2>/dev/null || true
dotnet tool install -g dotnet-dump 2>/dev/null || true
dotnet tool install -g dotnet-gcdump 2>/dev/null || true
1. Find the process
dotnet-trace ps
(If empty, the diagnostic port may need a few seconds after startup — retry.)
2. Baseline the runtime counters
dotnet-counters monitor -p <PID> --refresh-interval 1 \
System.Runtime Microsoft.AspNetCore.Hosting Microsoft.EntityFrameworkCore
dotnet-counters collect -p <PID> --format json -o counters.json --refresh-interval 1
Read against these thresholds, then map breaches to checklist categories:
| Counter | Healthy | Problem → checklist scope |
|---|
cpu-usage | < 80% sustained | > 90% → allocations / hot LINQ / serialization |
working-set / gc-heap-size | stable | growing → leak (allocations, dump) |
gen-2-gc-count / % time in gc | low / < 10% | high → GC pressure (allocations, pooling) |
alloc-rate | app-dependent | spikes → allocation hotspot (allocations) |
threadpool-queue-length | 0–10 | > 100 → starvation (async: sync-over-async) |
threadpool-thread-count | stable | climbing → blocking calls (async) |
exception-count | low | high → exceptions-as-control-flow (allocations) |
current-requests / request-queue-length (Hosting) | low queue | rising → downstream stall (DB/lock) |
EF active-db-contexts / commands | bounded | unbounded → leak or N+1 (efcore) |
3. CPU trace → hotspots
dotnet-trace collect -p <PID> --duration 00:00:30 -o trace.nettrace
dotnet-trace report trace.nettrace topN --inclusive
dotnet-trace convert trace.nettrace --format Speedscope
Hot-method → likely cause map (mirror in the report):
| Hot frames | Cause | Checklist scope |
|---|
Monitor.Enter / *.Wait | lock contention / sync-over-async | async |
String.Concat / Format / StringBuilder churn | string allocation | allocations / http |
Enumerable.* heavy | LINQ in hot path | allocations |
Npgsql.* / SqlClient.* dominant | query/round-trip cost | efcore |
EF QueryCompiler / Compile repeated | uncached/dynamic queries | efcore (compiled/parameterized) |
JsonSerializer.* reflection | no STJ source-gen | http |
GC.* / JIT_New* | allocation/GC pressure | allocations |
4. Memory investigation (when heap grows or Gen-2 is high)
dotnet-gcdump collect -p <PID> -o heap.gcdump
dotnet-dump collect -p <PID> --type Heap -o dump.dmp
dotnet-dump analyze dump.dmp
Map retained types: large String/byte[] → buffering (use ArrayPool/streaming);
many Task/TaskCompletionSource → async leak; growing cache dictionary → unbounded
cache (add eviction); tracked EF entities surviving the request → DbContext lifetime
bug.
5. Cleanup — MANDATORY
rm -f trace.nettrace heap.gcdump dump.dmp counters.json *.speedscope.json
git status --short
6. Report — tie data to fixes
Use the standard report, plus a "Measured evidence" block quoting the actual counter
values / topN frames that justify each finding.
Benchmark mode (benchmark <project>)
For a contended hot path, prove the win with BenchmarkDotNet rather than asserting it.
-
Locate or create benchmarks/Granit.{Module}.Benchmarks (console, -c Release) —
the repo has no benchmarks/ directory yet; the first benchmark creates it.
Reference the target project; do not add BenchmarkDotNet to a shipping package,
and do not register the project in .github/test-shards.json (it is not a
test project).
-
Write a [MemoryDiagnoser] benchmark with [Benchmark(Baseline = true)] on the
current implementation and [Benchmark] on the proposed one. Include realistic N.
-
For EF Core, benchmark against a real provider via Testcontainers (PostgreSQL
first) — never the in-memory provider (it hides query-translation and round-trip
cost; see checklist §1.12).
-
Run dotnet run -c Release --project benchmarks/Granit.{Module}.Benchmarks.
-
Report the table: Mean, Ratio, Gen0/1/2, Allocated. A "faster" change that
allocates more or regresses another column is not a win — say so.
-
Decide the artifact's fate explicitly: a one-off benchmark is deleted after
its table is captured in the report (clean-tree rule); a benchmark worth keeping
is committed with a THIRD-PARTY-NOTICES.md entry for BenchmarkDotNet
(CLAUDE.md dependency rule).
A micro-benchmark proves the local change. Always sanity-check it against a
realistic workload — a 10× faster method on a cold path moves nothing.
Rules — STRICT
- Measure before optimizing. When a process or benchmark is available, let data
prioritize. Without it, label findings by confidence and lead with structural
wins (round-trips, big-O) over micro-opts.
- Read before judging. Full file +
git log -p + tests. Slow-looking code often
encodes a correctness or provider fix.
- Correctness & security first. Never trade a wrong/insecure result for speed.
AsNoTracking on a write path, a disabled query filter, or cached PII is a bug,
not an optimization — flag the trade-off explicitly.
- Respect framework automation. Don't re-flag what
ApplyGranitConventions,
UseGranitNpgsql, GranitDbContext, [LoggerMessage], or the metrics/tracing
infra already handle (Step 2e).
- Interceptor awareness — CRITICAL.
ExecuteUpdate/ExecuteDelete and raw SQL
bypass AuditedEntityInterceptor / SoftDeleteInterceptor / domain-event
dispatch. Recommend them only where audit/soft-delete/events are provably not
required, and say so. Never auto-apply.
- Multi-provider. Validate every persistence tip on PostgreSQL (first), SQL
Server, and SQLite. Gate provider-specific advice behind
GranitDbProviders
checks; PostgreSQL is the default but never the only target.
- No migrations in framework packages. Index/schema recommendations are emitted
as guidance + sample
*ModelBuilderExtensions/SQL for the consuming app to
own — Granit packages never ship migrations (see CLAUDE.md / project memory).
- Scale is the lens. Always reason at "N tenants × M rows × R replicas × C
concurrent requests", not single-request happy path.
- No speculative refactor. Flag concrete, evidenced costs. "Could be faster"
without a mechanism or measurement is not a finding.
- Two-attempt maximum in
--fix. Verify on the shard/project, never the full
solution.
- Clean up every trace/dump/coverage artifact and confirm
git status is clean.