Use when diagnosing slow operations, CPU saturation, or hangs in the Tauri app. Covers CPU profiling setup for the renderer (Chromium DevTools / Safari Web Inspector against the system webview), the Rust host binary (cargo flamegraph / samply), cpuprofile + Firefox-Profiler analysis, and the known SQLite bottleneck patterns in this codebase.
インストール
Codex または Claude でインストール この Prompt をコピーして Codex、Claude、または他のアシスタントに貼り付けると、Skill ページを確認してインストールできます。
Use when diagnosing slow operations, CPU saturation, or hangs in the Tauri app. Covers CPU profiling setup for the renderer (Chromium DevTools / Safari Web Inspector against the system webview), the Rust host binary (cargo flamegraph / samply), cpuprofile + Firefox-Profiler analysis, and the known SQLite bottleneck patterns in this codebase.
Performance Profiling for Släktforskning
Rule Zero
Do not guess. Do not change code. Get a profile first.
CPU profiles tell you exactly which functions consumed the time, with sample counts and call chains. Without one, you are guessing — and the bottleneck is almost never where you expect.
Where bottlenecks live
Layer
Tooling
Renderer (Vue, JS, layout, paint)
Chromium DevTools (Linux/Windows) or Safari Web Inspector (macOS WKWebView) — Performance tab against the running webview
Host process (DB, fs, native dialogs)
Rust async commands in src-tauri/src/ — profile the Rust binary with cargo flamegraph or samply
SQLite
rusqlite (full native) — EXPLAIN QUERY PLAN + the slow-pattern catalog below
The Rust core's spawn_blocking keeps SQL off the renderer thread, so renderer-side CPU is almost always Vue/layout/paint; host-side CPU is almost always SQL or serde_json serialisation. Profile the two layers independently.
Step 1: Instrument the Suspect Operation
Rust host (src-tauri/src/*.rs)
Build the app with debug symbols in a release-class profile, then drive it with native sampling profilers. Two options on macOS / Linux:
cargo install samply
# Build a release-with-debug binarycd src-tauri && cargo build --release --features tauri/devtools
# Run under samply (replace path with the actual binary)
samply record ./target/release/slaktforskning
# When the operation completes, samply opens https://profiler.firefox.com with the trace loaded.
cargo flamegraph (one PNG/SVG, simple):
cargo install flamegraph
# macOS needs DTrace permissions; Linux needs perfcd src-tauri && cargo flamegraph --release --bin slaktforskning
# Outputs flamegraph.svg in the current directory. Open in a browser.
For both: rebuild with [profile.release] debug = true in Cargo.toml if symbols are missing — the default release strip leaves nothing useful in the trace.
What you're looking for in the Rust profile:
Wide rusqlite::Statement::execute / query_map frames → SQL bottleneck (use EXPLAIN QUERY PLAN to confirm + add an index in src/api/schema.ts).
Wide serde_json frames in db_run / db_all / db_get → row payloads are large; consider a more selective SELECT from the api/-layer caller.
Wide tokio::fs frames in import paths → bulk file copies; switch to media_bulk_copy Rust command (planned, see Cluster M).
Wide xcap::Window::capture_image → screenshot endpoint hot-path; not a normal-app bottleneck (only fires from the dev MCP).
The renderer-side profile (next section) is the same regardless of runtime — Chromium DevTools attached to whichever webview is running the Vue app.
Step 2: Analyze the Profile
Option A — Chrome DevTools MCP (recommended — no manual steps):
Use the chrome-devtools-mcp plugin tools to profile the renderer when it's running in a Chromium-CDP-attached webview (dev mode under macOS Safari Web Inspector or Linux WebKitGTK — Tauri's WebView2 on Windows does not expose CDP). For the default Tauri runtime, use the system WebView's own inspector instead — see Step 1 above:
1. performance_start_trace() → start recording
2. Trigger the slow operation (import, quality checks, etc.)
3. performance_stop_trace() → stop and save trace
4. performance_analyze_insight() → get analysis of the trace
These tools capture V8 CPU profiles, network timing, and rendering metrics. The performance_analyze_insight tool provides automated bottleneck identification.
Open Chrome → F12 → Performance tab → click the upload icon (⬆) → load the .cpuprofile
Look at the flamegraph: wide bars are hot. Narrow bars are fast.
Option C — Agent analysis (when the file is too large to open):
Hand the .cpuprofile path to an Explore agent with this instruction: "Parse the V8 cpuprofile JSON. Find the top 30 nodes by hitCount. For each hot node walk up the parent chain. Report total samples, top functions by hitCount with functionName/url/lineNumber, and the call chains. Identify the bottleneck."
Key metrics:
Total samples = profile duration × sample rate (1000 Hz)
hitCount on a leaf node = % of total time executing that code
High hitCount in WASM frames = the SQL query itself is the bottleneck
High hitCount in JS = the JS logic is the bottleneck
Step 3: Interpret What You Find
Pattern: rusqlite execution dominates (80%+ of samples)
The SQL query is slow. Look at the call chain to find which check / api function is calling it. The fix is always one of:
4-way event_participants self-join → 2-query + JS join (see below)
Correlated NOT EXISTS subquery → Set membership (see below)
N+1 queries in a loop → single bulk query + JS grouping
Pattern: High hitCount in a specific check function
The function name and line number tell you exactly where to look. Read that function, identify which query pattern it uses, apply the appropriate fix.
Pattern: read() syscalls (10–20% of samples)
Normal — this is SQLite doing WAL file I/O. Not actionable.
Pattern: High hitCount in getPersonDisplayNames
The name-resolution query after checks.runAllChecks() is slow. Cap the result set before calling it (already done with 500-result cap per notice-severity check code in checks:runAll handler).
Pattern: Renderer freezes during a long DB op
In Tauri this is rare: rusqlite calls go through spawn_blocking, so the Rust side never blocks the renderer's tokio executor or the WebView event loop. If you do see a freeze, the suspect is renderer-side JS (a synchronous loop in Vue / a layout-thrash render pass) — profile the renderer with DevTools, not the Rust binary. The classic Electron-era "yield inside the worker loop" fix does not apply here.
Known Slow Patterns in This Codebase
1. The 4-Way event_participants Self-Join (the #1 killer)
-- SLOW: Cartesian product explosion with 20k personsSELECT e.id, ep.person_id, b.date_value
FROM events e
JOIN event_participants ep ON ep.event_id = e.id
JOIN event_participants epb ON epb.person_id = ep.person_id -- self-joinJOIN events b ON b.id = epb.event_id AND b.event_type ='birth'WHERE e.event_type ='marriage'
Fix: Two queries + JS join
// Load all events of each type separately — two simple index seeksconst marriages = loadPersonEvents(db, 'marriage', ['exact', 'calculated']);
const births = loadPersonEvents(db, 'birth', ['exact', 'calculated']);
// Join in JS — O(n) with Map lookupfor (const [personId, personMarriages] of marriages) {
const personBirths = births.get(personId);
if (!personBirths) continue;
for (const m of personMarriages) {
for (const b of personBirths) {
// compare dates here
}
}
}
The loadPersonEvents helper already exists in src/api/checks.ts:
-- SLOW: one subquery per row → O(n) queriesSELECT id FROM persons p
WHERENOTEXISTS (
SELECT1FROM event_participants ep
JOIN events e ON e.id = ep.event_id
WHERE ep.person_id = p.id AND e.event_type ='birth'
)
Fix: Set membership
// One query to get all person_ids that HAVE the eventconst withBirth = personIdsWithEvent(db, 'birth');
// Filter in JSconst allPersons = queryAll<{ id: string }>(db, 'SELECT id FROM persons');
for (const p of allPersons) {
if (!withBirth.has(p.id)) { /* flag it */ }
}
The personIdsWithEvent helper exists in src/api/checks.ts.
3. N+1 Queries in a Loop
// SLOW: one SQL query per relationshipfor (const rel of relationships) {
const person = queryOne(db, 'SELECT ... FROM persons WHERE id = ?', [rel.person_id]);
}
Fix: Bulk query + JS Map
const allPersons = queryAll<{ id: string }>(db, 'SELECT id, ... FROM persons');
const personMap = newMap(allPersons.map(p => [p.id, p]));
for (const rel of relationships) {
const person = personMap.get(rel.person_id);
}
Three checks (checkGazetteerMatchQuality, checkPlaceMissingComma, checkPlaceNameNoRegion) each call loadGazetteersForChecks(db) independently. That helper deep-clones ~42 MB of bundled gazetteer data (JSON.parse(JSON.stringify(g)) in src/api/place-gazetteers/merge.ts) and merges language translations on every call. Three calls per runAll = three full clones.
Worse, the resolver's getGlobalNameDepth cache in src/api/place-gazetteers/resolver.ts was previously array-identity-keyed. Each fresh clone produced new identities → cache miss → full tree walk to rebuild a depth map across ~27 gazetteers.
Fix shape (caller-side): Hoist the load to a closure variable in getAllCheckFunctions():
letcachedGazetteers: ReturnType<typeof loadGazetteersForChecks> | null = null;
letcachedGazDb: Database | null = null;
functiongazetteersFor(db: Database) {
if (cachedGazetteers && cachedGazDb === db) return cachedGazetteers;
cachedGazetteers = loadGazetteersForChecks(db);
cachedGazDb = db;
return cachedGazetteers;
}
// Then all three checks call gazetteersFor(db) instead of loadGazetteersForChecks(db) directly.
Fix shape (resolver-side): Two-tier WeakMap, NOT array-identity. Per-root WeakMap<GazetteerNode, Map<string, number>> so the heavy walk runs once per root identity even if the surrounding array changes. The depth-map values are normalizeUniversal-keyed (language-agnostic), so root-keying is safe regardless of the gazetteer's per-locale normalize rules. The OTHER cache (nameIndexCache) does need full-Gazetteer keying because it uses normalizeForGazetteer.
When changing the resolver's caching, lock the new behavior in with the tests already in tests/unit/checks-perf.test.ts:
Spy on getImportedGazetteers from gazetteersApi; assert ≤1 call per runAllChecks (catches re-introducing fan-out).
Wrap a synthetic gazetteer's children accessors with a Proxy counter; first resolvePlace builds (high count), second on the same root or in a different array hits the cache (low count).
Indexes That Matter
The schema already has these covering the common check queries:
CREATE INDEX idx_events_event_type ON events(event_type);
CREATE INDEX idx_events_type_datetype ON events(event_type, date_type);
CREATE INDEX idx_event_participants_event_id ON event_participants(event_id);
CREATE INDEX idx_event_participants_person_id ON event_participants(person_id);
If a new query filters on a column not listed here, add the index in src/api/schema.ts (inside the final db.exec(...) block, using CREATE INDEX IF NOT EXISTS).
Per-Check Timing (Debug Mode)
runAllCheckFunctions in src/api/checks.ts already logs per-check timing to stdout:
Use these to narrow down which check to profile before capturing a full CPU profile.
Profiling Checklist
When a user reports CPU saturation or slowness:
Check per-check timing logs first ([checks] checkX: Nms → M result(s)) — identify the slow check by name
Decide host vs renderer. If wall-clock dominates a Rust command (DB / fs / import), profile the Rust binary with samply record. If the UI is sluggish without a corresponding host-side cost, profile the renderer with DevTools (Chromium on Linux/Windows, Safari Web Inspector on macOS).
Trigger the operation with the actual large dataset
Collect the profile (samply opens Firefox Profiler automatically; .cpuprofile exports from DevTools)
Analyze: widest frames → identify bottleneck function + line number
Read that function in the source; identify the slow query pattern
Apply the appropriate fix (4-way JOIN → 2-query+JS, NOT EXISTS → Set, N+1 → bulk)
npm test — all tests must pass before declaring done
Re-run to verify the hot function is gone from the profile
Baseline Storage Convention
Before-and-after CPU profiles for any refactor with a perf claim live at
docs/baseline-perf/YYYY-MM-DD/. Each dated directory contains:
When closing out a perf refactor that claims to speed up workload X:
Capture an after-trace into docs/baseline-perf/<after-date>/ using the same
capture command listed in the prior summary.md.
Compare wall-clock + top-3 self-time deltas in the close-out commit message.
The plan's Verification section must name the specific row(s) it expects to move.
Renderer-side capture on macOS Tauri requires Safari Web Inspector (WKWebView; no
Chromium DevTools). On Linux/Windows, the renderer is a Chromium-family webview
and the standard .cpuprofile workflow applies.