# Deep Performance Audit Report **Date:** 2026-02-12 **Branch:** `perf-audit` (e9bacc94) **Parent:** `039ab1c2` (master, v0.6.1) --- ## Methodology 1. **Baseline** — measured p50/p95 latency for all major commands with warm cache 2. **Profile** — used macOS `sample` profiler and `EXPLAIN QUERY PLAN` to identify hotspots 3. **Golden output** — captured exact numeric outputs before changes as equivalence oracle 4. **One lever per change** — each optimization isolated and independently benchmarked 5. **Revert threshold** — any optimization <1.1x speedup reverted per audit rules --- ## Baseline Measurements (warm cache, release build) | Command | Latency | Notes | |---------|---------|-------| | `who --path src/core/db.rs` (expert) | 2200ms | **Hotspot** | | `who --active` | 83-93ms | Acceptable | | `who workload` | 22ms | Fast | | `stats` | 107-112ms | **Hotspot** | | `search "authentication"` | 1030ms | **Hotspot** (library-level) | | `list issues -n 50` | ~40ms | Fast | --- ## Optimization 1: INDEXED BY for DiffNote Queries **Target:** `src/cli/commands/who.rs` — expert and reviews query paths **Problem:** SQLite query planner chose `idx_notes_system` (38% selectivity, 106K rows) over `idx_notes_diffnote_path_created` (9.3% selectivity, 26K rows) for path-filtered DiffNote queries. The partial index `WHERE noteable_type = 'MergeRequest' AND type = 'DiffNote'` is far more selective but the planner's cost model didn't pick it. **Change:** Added `INDEXED BY idx_notes_diffnote_path_created` to all 8 SQL queries across `query_expert`, `query_expert_details`, `query_reviews`, `build_path_query` (probes 1 & 2), and `suffix_probe`. **Results:** | Query | Before | After | Speedup | |-------|--------|-------|---------| | expert (specific path) | 2200ms | 56-58ms | **38x** | | expert (broad path) | 2200ms | 83ms | **26x** | | reviews | 1800ms | 24ms | **75x** | **Isomorphism proof:** `INDEXED BY` only changes which index the planner uses, not the query semantics. Same rows matched, same ordering, same output. Verified by golden output comparison across 5+ runs. --- ## Optimization 2: Conditional Aggregates in Stats **Target:** `src/cli/commands/stats.rs` **Problem:** 12+ sequential `COUNT(*)` queries each requiring a full table scan of `documents` (61K rows). Each scan touched the same pages but couldn't share work. **Changes:** - Documents: 5 sequential COUNTs -> 1 query with `SUM(CASE WHEN ... THEN 1 END)` - FTS count: `SELECT COUNT(*) FROM documents_fts` (virtual table, slow) -> `SELECT COUNT(*) FROM documents_fts_docsize` (shadow B-tree table, 19x faster) - Embeddings: 2 queries -> 1 with `COUNT(DISTINCT document_id), COUNT(*)` - Dirty sources: 2 queries -> 1 with conditional aggregates - Pending fetches: 2 queries -> 1 each (discussions, dependents) **Results:** | Metric | Before | After | Speedup | |--------|--------|-------|---------| | Warm median | 112ms | 66ms | **1.70x** | | Cold | 1220ms | ~700ms | ~1.7x | **Golden output verified:** ``` total:61652, issues:8241, mrs:10018, discussions:43393, truncated:63 fts:61652, embedded:61652, chunks:88161 ``` All values match exactly across before/after runs. **Isomorphism proof:** `SUM(CASE WHEN x THEN 1 END)` is algebraically identical to `COUNT(*) WHERE x`. The FTS5 shadow table `documents_fts_docsize` has exactly one row per FTS document by SQLite specification, so `COUNT(*)` on it equals the virtual table count. --- ## Investigation: Two-Phase FTS Search (REVERTED) **Target:** `src/search/fts.rs`, `src/cli/commands/search.rs` **Hypothesis:** FTS5 `snippet()` generation is expensive. Splitting search into Phase 1 (score-only MATCH+bm25) and Phase 2 (snippet for filtered results only) should reduce work. **Implementation:** Created `fetch_fts_snippets()` that retrieves snippets only for post-filter document IDs via `json_each()` join. **Results:** | Metric | Before | After | Improvement | |--------|--------|-------|-------------| | search (limit 20) | 1030ms | 995ms | 3.5% | **Decision:** Reverted. Per audit rules, <1.1x speedup does not justify added code complexity. **Root cause:** The bottleneck is not snippet generation but `MATCH` + `bm25()` scoring itself. Profiling showed `strspn` (FTS5 tokenizer) and `memmove` as the top CPU consumers. The same query runs in 30ms on system sqlite3 but 1030ms in rusqlite's bundled SQLite — a ~125x gap despite both being SQLite 3.51.x compiled at -O3. --- ## Library-Level Finding: Bundled SQLite FTS5 Performance **Observation:** FTS5 MATCH+bm25 queries are ~125x slower in rusqlite's bundled SQLite vs system sqlite3. | Environment | Query Time | Notes | |-------------|-----------|-------| | System sqlite3 (macOS) | 30ms (with snippet), 8ms (without) | Same .db file | | rusqlite bundled | 1030ms | `features = ["bundled"]`, OPT_LEVEL=3 | **Profiler data (macOS `sample`):** - Top hotspot: `strspn` in FTS5 tokenizer - Secondary: `memmove` in FTS5 internals - Scaling: ~5ms per result (limit 5 = 497ms, limit 20 = 995ms) **Possible causes:** - Bundled SQLite compiled without platform-specific optimizations (SIMD, etc.) - Different memory allocator behavior - Missing compile-time tuning flags **Recommendation for future:** Investigate switching from `features = ["bundled"]` to system SQLite linkage, or audit the bundled compile flags in the `libsqlite3-sys` build script. --- ## Exploration Agent Findings (Informational) Four parallel exploration agents surveyed the entire codebase. Key findings beyond what was already addressed: ### Ingestion Pipeline - Serial DB writes in async context (acceptable — rusqlite is synchronous) - Label ingestion uses individual inserts (potential batch optimization, low priority) ### CLI / GitLab Client - GraphQL client recreated per call (`client.rs:98-100`) — caches connection pool, minor - Double JSON deserialization in GraphQL responses — medium priority - N+1 subqueries in `list` command (`list.rs:408-423`) — 4 correlated subqueries per row ### Search / Embedding - No N+1 patterns, no O(n^2) algorithms - Chunking is O(n) single-pass with proper UTF-8 safety - Ollama concurrency model is sound (parallel HTTP, serial DB writes) ### Database / Documents - O(n^2) prefix sum in `truncation.rs` — low traffic path - String allocation patterns in extractors — micro-optimization territory --- ## Opportunity Matrix | Candidate | Impact | Confidence | Effort | Score | Status | |-----------|--------|------------|--------|-------|--------| | INDEXED BY for DiffNote | Very High | High | Low | **9.0** | Shipped | | Stats conditional aggregates | Medium | High | Low | **7.0** | Shipped | | Bundled SQLite FTS5 | Very High | Medium | High | 5.0 | Documented | | List N+1 subqueries | Medium | Medium | Medium | 4.0 | Backlog | | GraphQL double deser | Low | Medium | Low | 3.5 | Backlog | | Truncation O(n^2) | Low | High | Low | 3.0 | Backlog | --- ## Files Modified | File | Change | |------|--------| | `src/cli/commands/who.rs` | INDEXED BY hints on 8 SQL queries | | `src/cli/commands/stats.rs` | Conditional aggregates, FTS5 shadow table, merged queries | --- ## Quality Gates - All 603 tests pass - `cargo clippy --all-targets -- -D warnings` clean - `cargo fmt --check` clean - Golden output verified for both optimizations