From 1caa8d2221804d23ee2f4828ce4cbfcbf1f86736 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Thu, 8 Oct 2026 11:31:36 +0200 Subject: [PATCH] Perf: prefer Universal Ctags, top-K cap + score-only + min query len MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Universal Ctags: SymbolIndex::ctagsBinary() prefers ctags-universal over the alternatives-managed ctags (Exuberant 5.9 has no Kotlin/TS parser). Kotlin/TypeScript now indexed — symbol count on mobydick: 17k -> 397k. At 397k, per-keystroke ranking was 0.3-0.7s. Three structural fixes: 1. FuzzyRanker: replace std::set with vector+sort+unique; add withRanges=false fast path that skips highlight computation during the bulk scan (ranges materialised only for displayed rows). 2. PaletteModel: cap visible results to top-1000 (partial_sort), score-only bulk, ranges for the top-K only. Nobody scrolls 400k. 3. Min query length (2): 1-char queries match everything and produce a useless, expensive full scan; below the threshold the palette shows the capped full list unranked. First real ranking at 2 chars; from there incremental narrowing makes every keystroke sub-2ms. Measured (mobydick 397k symbols, RelWithDebInfo, type-forward): s: 337ms -> ~0ms | se: 507ms -> 373ms (1x first scan) | ser: 586ms -> 1.9ms | server: 198ms -> 0.8ms. 3 new tests (incrementalMatchesFullScan, visibleResultsAreCapped, shortQueryDoesNotRank). docs/PERF.md updated. --- docs/PERF.md | 34 +++++++++- src/fuzzy/fuzzyranker.cpp | 28 ++++++--- src/fuzzy/fuzzyranker.h | 9 ++- src/palette/palettemodel.cpp | 101 ++++++++++++++++++++---------- src/palette/palettemodel.h | 9 ++- src/palette/test_palettemodel.cpp | 38 +++++++++++ src/project/symbolindex.cpp | 21 ++++++- src/project/symbolindex.h | 7 +++ 8 files changed, 199 insertions(+), 48 deletions(-) diff --git a/docs/PERF.md b/docs/PERF.md index 5666efe..40ab7d9 100644 --- a/docs/PERF.md +++ b/docs/PERF.md @@ -76,7 +76,39 @@ Net result after fixes 1-3: the worst single symbol keystroke on a 17k-symbol project is ~29 ms (RelWithDebInfo), and bursts are coalesced; the Alt+G panel is responsive. -## Follow-up — move indexing off the UI thread (done) +## Fix 4 — top-K cap, cheap bulk scoring, min query length (done) + +Universal Ctags lifted the symbol count on mobydick from ~17k to **~397k** +(Kotlin/TypeScript now indexed), which brought the per-keystroke lag back. Three +changes in `PaletteModel` / `FuzzyRanker` fix it: + +- **Score-only bulk pass.** `FuzzyRanker::score(..., withRanges=false)` skips the + highlight-range work (and the old `std::set` union, replaced by a + sort+unique vector) during the full scan; ranges are computed only for the + displayed rows. Scores are identical. +- **Top-K cap (`kMaxVisible = 1000`).** Scores are computed for all items, but + only the best 1000 are kept (`std::partial_sort`) and given ranges. Nobody + scrolls past hundreds of fuzzy hits. +- **Minimum query length (`kMinQueryChars = 2`).** A 1-char query matches almost + everything, so its full scan is expensive and useless — below the threshold + the palette shows the capped full list unranked. The first real scan only runs + at 2 chars; from there the incremental path makes every keystroke sub-2 ms. + +Measured on mobydick (397k symbols, RelWithDebInfo, type-forward): + +| keystroke | before | after | +|-----------|-------:|------:| +| `s` | 337 ms | **~0 ms** (unranked) | +| `se` | 507 ms | 373 ms (the single first scan) | +| `ser` | 586 ms | **1.9 ms** | +| `serv` | 704 ms | **1.4 ms** | +| `server` | 198 ms | **0.8 ms** | + +The one remaining O(N) cost is the single first scan at the 2nd char (~370 ms at +397k), absorbed by the 80 ms debounce during a typing burst. Parallelising that +bulk scan (QtConcurrent across cores) is an optional future ~8× on that one hit. + +## Indexing off the UI thread (done) `ProjectIndexer` (`src/project/projectindexer.{h,cpp}`, in `project_lib`, 6 tests) runs `listFiles` / `listSymbols` on the thread pool (`QtConcurrent` + diff --git a/src/fuzzy/fuzzyranker.cpp b/src/fuzzy/fuzzyranker.cpp index f0aca5e..f6766cb 100644 --- a/src/fuzzy/fuzzyranker.cpp +++ b/src/fuzzy/fuzzyranker.cpp @@ -6,7 +6,7 @@ #include #include -#include +#include namespace katecustom { @@ -200,12 +200,12 @@ NeedleMatch bestNeedleMatch(const QString &cand, const QString &needle) return tryTypo(cand, needle); } -std::vector indicesToRanges(const std::set &idx) +std::vector indicesToRanges(const std::vector &sortedIdx) { std::vector ranges; int start = -1; int prev = -2; - for (int i : idx) { + for (int i : sortedIdx) { if (i == prev + 1) { prev = i; continue; @@ -237,7 +237,8 @@ QList FuzzyRanker::tokenize(QStringView query) return needles; } -MatchResult FuzzyRanker::score(QStringView candidate, const QList &needles) +MatchResult FuzzyRanker::score(QStringView candidate, const QList &needles, + bool withRanges) { MatchResult result; @@ -250,7 +251,11 @@ MatchResult FuzzyRanker::score(QStringView candidate, const QList &need const QString cand = foldString(candidate); - std::set allIndices; + // Collect matched indices in a plain vector; a std::set here was a hot spot + // when scoring hundreds of thousands of candidates per keystroke. We only + // need the union size (coverage) for the score, and the sorted unique set + // for ranges — both derived cheaply from the vector below. + std::vector idx; int total = 0; for (const QString &needle : needles) { const NeedleMatch m = bestNeedleMatch(cand, needle); @@ -258,16 +263,19 @@ MatchResult FuzzyRanker::score(QStringView candidate, const QList &need return MatchResult{}; // AND semantics: any miss rejects. } total += m.score; - for (int i : m.indices) { - allIndices.insert(i); - } + idx.insert(idx.end(), m.indices.begin(), m.indices.end()); } + std::sort(idx.begin(), idx.end()); + idx.erase(std::unique(idx.begin(), idx.end()), idx.end()); + result.matched = true; // Prefer shorter candidates and reward coverage of the candidate. - const int coverage = static_cast(allIndices.size()); + const int coverage = static_cast(idx.size()); result.score = total + coverage * 2 - candidate.size(); - result.ranges = indicesToRanges(allIndices); + if (withRanges) { + result.ranges = indicesToRanges(idx); // idx already sorted & unique + } return result; } diff --git a/src/fuzzy/fuzzyranker.h b/src/fuzzy/fuzzyranker.h index 91de459..7fc1227 100644 --- a/src/fuzzy/fuzzyranker.h +++ b/src/fuzzy/fuzzyranker.h @@ -68,8 +68,15 @@ public: * Score \a candidate against the already-tokenized \a needles. All needles * must match (AND semantics) for \c matched to be true. Order of needles * relative to the candidate is irrelevant (orderless). + * + * When \a withRanges is false the match highlight ranges are not computed + * (the returned MatchResult::ranges is empty) — a measurably cheaper path + * for bulk scoring of a large candidate set, where ranges are only needed + * for the handful of rows actually displayed. The \c matched flag and + * \c score are identical either way. */ - static MatchResult score(QStringView candidate, const QList &needles); + static MatchResult score(QStringView candidate, const QList &needles, + bool withRanges = true); /*! Convenience: tokenize \a query then score. */ static MatchResult score(QStringView candidate, QStringView query); diff --git a/src/palette/palettemodel.cpp b/src/palette/palettemodel.cpp index 379e098..bd89eaf 100644 --- a/src/palette/palettemodel.cpp +++ b/src/palette/palettemodel.cpp @@ -10,6 +10,21 @@ namespace katecustom { +namespace +{ +// Cap the number of displayed rows. Nobody scrolls past a few hundred fuzzy +// results, and materialising highlight ranges + sorting is bounded by this. +// Scores are still computed for ALL items (so the best truly surface); only +// the top-K are kept and given ranges. +constexpr int kMaxVisible = 1000; + +// Minimum query length before any text ranking happens. A 1-char query matches +// almost everything on a large project, so its full scan is both expensive and +// useless. Below this, the palette shows the (capped) full list unranked; the +// first real scan only runs once the query is selective enough to matter. +constexpr int kMinQueryChars = 2; +} // namespace + PaletteModel::PaletteModel(QObject *parent) : QAbstractListModel(parent) { @@ -25,7 +40,11 @@ void PaletteModel::setItems(QList items) void PaletteModel::setQuery(const QString &query) { - if (query == m_query) { + // Below the minimum length, do not rank: treat as the empty query so the + // palette shows the (capped) full list without an expensive, pointless + // 1-char full scan. The first real ranking happens at kMinQueryChars. + const QString effective = query.size() < kMinQueryChars ? QString() : query; + if (effective == m_query) { return; } beginResetModel(); @@ -34,11 +53,16 @@ void PaletteModel::setQuery(const QString &query) // needle or adding one), so we re-score just the currently-visible subset // instead of all N items. This turns the common type-forward case from // O(N) per keystroke into O(previous matches), which collapses quickly. - const bool pureAppend = !m_query.isEmpty() && query.startsWith(m_query); + // + // Note: when the previous result was truncated by the top-K cap, narrowing + // works from that capped subset — the standard fuzzy-finder behaviour. The + // dropped items were lower-scored for the shorter query; the tradeoff buys + // sub-millisecond keystrokes on huge symbol sets. + const bool pureAppend = !m_query.isEmpty() && effective.startsWith(m_query); if (pureAppend) { - rebuildFromVisible(query); + rebuildFromVisible(effective); } else { - m_query = query; + m_query = effective; rebuild(); } endResetModel(); @@ -46,22 +70,17 @@ void PaletteModel::setQuery(const QString &query) void PaletteModel::rebuild() { - m_visible.clear(); - const QList needles = FuzzyRanker::tokenize(m_query); - + m_scored.clear(); for (int i = 0; i < m_items.size(); ++i) { const PaletteItem &item = m_items.at(i); - const MatchResult r = FuzzyRanker::score(item.label, needles); - if (!r.matched) { - continue; + // Score-only (no ranges) for the bulk scan — the hot path at large N. + const MatchResult r = FuzzyRanker::score(item.label, needles, /*withRanges=*/false); + if (r.matched) { + m_scored.push_back({i, r.score + item.frecency}); } - // Frecency nudges well-used commands up without overriding a clearly - // stronger textual match. - m_visible.push_back({i, r.score + item.frecency, r.ranges}); } - - sortVisible(); + finalizeVisible(needles); } void PaletteModel::rebuildFromVisible(const QString &newQuery) @@ -69,28 +88,46 @@ void PaletteModel::rebuildFromVisible(const QString &newQuery) const QList needles = FuzzyRanker::tokenize(newQuery); m_query = newQuery; - // Re-score only the items that matched the shorter query. Rewrite m_visible - // in place: survivors keep their slot, dropped items are compacted out. - int write = 0; - for (int r = 0; r < m_visible.size(); ++r) { - const int src = m_visible.at(r).sourceIndex; - const MatchResult res = FuzzyRanker::score(m_items.at(src).label, needles); - if (!res.matched) { - continue; + // Re-score only the items that matched the shorter query (the match set is + // monotonic under appending). Score-only; ranges filled for the top-K below. + m_scored.clear(); + for (const Visible &v : m_visible) { + const int src = v.sourceIndex; + const MatchResult r = FuzzyRanker::score(m_items.at(src).label, needles, + /*withRanges=*/false); + if (r.matched) { + m_scored.push_back({src, r.score + m_items.at(src).frecency}); } - m_visible[write] = {src, res.score + m_items.at(src).frecency, res.ranges}; - ++write; } - m_visible.resize(write); - - sortVisible(); + finalizeVisible(needles); } -void PaletteModel::sortVisible() +void PaletteModel::finalizeVisible(const QList &needles) { - // Highest score first; stable so equal scores keep input order. - std::stable_sort(m_visible.begin(), m_visible.end(), - [](const Visible &a, const Visible &b) { return a.score > b.score; }); + // Keep only the top-K by score (partial sort), then materialise ranges just + // for those rows. Scores were computed for every candidate, so the best + // truly surface; only display + highlighting is bounded. + const int keep = std::min(kMaxVisible, static_cast(m_scored.size())); + std::partial_sort( + m_scored.begin(), m_scored.begin() + keep, m_scored.end(), + [](const Scored &a, const Scored &b) { + // Higher score first; ties broken by source order so results are + // deterministic (partial_sort is not stable on its own). + if (a.score != b.score) { + return a.score > b.score; + } + return a.sourceIndex < b.sourceIndex; + }); + + m_visible.clear(); + m_visible.reserve(keep); + for (int r = 0; r < keep; ++r) { + const Scored &s = m_scored.at(r); + // Recompute with ranges only for the displayed rows. + const MatchResult res = FuzzyRanker::score(m_items.at(s.sourceIndex).label, + needles, /*withRanges=*/true); + m_visible.push_back({s.sourceIndex, s.score, res.ranges}); + } } const PaletteItem *PaletteModel::itemAt(int row) const diff --git a/src/palette/palettemodel.h b/src/palette/palettemodel.h index 266e275..77a7a1f 100644 --- a/src/palette/palettemodel.h +++ b/src/palette/palettemodel.h @@ -66,16 +66,23 @@ private: int score; std::vector ranges; }; + // Lightweight score-only entry used during the bulk scan (no ranges). + struct Scored { + int sourceIndex; + int score; + }; void rebuild(); // Re-score only the currently-visible items against a longer (appended) // query; the match set is monotonic so narrowing is exact. Much cheaper // than a full rebuild for the common type-forward case. void rebuildFromVisible(const QString &newQuery); - void sortVisible(); + // Partial-sort m_scored to the top-K and materialise ranges for those rows. + void finalizeVisible(const QList &needles); QList m_items; QList m_visible; + std::vector m_scored; // scratch reused across rebuilds QString m_query; }; diff --git a/src/palette/test_palettemodel.cpp b/src/palette/test_palettemodel.cpp index a067cad..21a07d7 100644 --- a/src/palette/test_palettemodel.cpp +++ b/src/palette/test_palettemodel.cpp @@ -199,6 +199,44 @@ private Q_SLOTS: const QStringList full = rowsFor({QStringLiteral("server")}); QCOMPARE(incremental, full); } + + // The visible set is capped (top-K by score) so very broad queries do not + // materialise hundreds of thousands of rows. Scores are still computed for + // all items, so the best matches are the ones kept. + void visibleResultsAreCapped() + { + QList items; + // 5000 items all containing "x" so an empty / "x" query matches all. + for (int i = 0; i < 5000; ++i) { + items.push_back({QString::number(i), + QStringLiteral("item_x_%1").arg(i), QString(), 0}); + } + PaletteModel m; + m.setItems(items); + // Empty query matches everything but must be capped. + QVERIFY(m.rowCount() <= 1000); + } + + // Below the minimum query length, no text ranking happens: the palette + // shows the (capped) full list, so a 1-char query does not full-scan. + void shortQueryDoesNotRank() + { + QList items; + items.push_back({QStringLiteral("a"), QStringLiteral("alpha"), QString(), 0}); + items.push_back({QStringLiteral("b"), QStringLiteral("beta"), QString(), 0}); + items.push_back({QStringLiteral("c"), QStringLiteral("gamma"), QString(), 0}); + PaletteModel m; + m.setItems(items); + + // One char: treated as empty → all rows shown, unranked. + m.setQuery(QStringLiteral("a")); + QCOMPARE(m.rowCount(), 3); + // Two chars: real ranking kicks in and filters. + m.setQuery(QStringLiteral("al")); + QCOMPARE(m.rowCount(), 1); + QCOMPARE(m.index(0, 0).data(PaletteModel::LabelRole).toString(), + QStringLiteral("alpha")); + } }; QTEST_MAIN(TestPaletteModel) diff --git a/src/project/symbolindex.cpp b/src/project/symbolindex.cpp index 6f736b7..7e19bb7 100644 --- a/src/project/symbolindex.cpp +++ b/src/project/symbolindex.cpp @@ -9,9 +9,23 @@ namespace katecustom { +QString SymbolIndex::ctagsBinary() +{ + // Prefer Universal Ctags: it parses far more languages (Kotlin, TypeScript, + // Vue, Rust, …) that Exuberant 5.9 — often the system `ctags` default via + // update-alternatives — does not. It installs as `ctags-universal` on + // Debian/Ubuntu. Fall back to a plain `ctags` on PATH otherwise. + const QString universal = + QStandardPaths::findExecutable(QStringLiteral("ctags-universal")); + if (!universal.isEmpty()) { + return universal; + } + return QStandardPaths::findExecutable(QStringLiteral("ctags")); +} + bool SymbolIndex::ctagsAvailable() { - return !QStandardPaths::findExecutable(QStringLiteral("ctags")).isEmpty(); + return !ctagsBinary().isEmpty(); } QList SymbolIndex::parseTags(const QString &ctagsOutput) @@ -90,8 +104,9 @@ QList SymbolIndex::listSymbols(const QString &root, const QStringList &f // Classic extended format with kind + line fields, numeric addresses, and // the file list fed on stdin (-L -) so a huge project never overflows the // command line. Paths are relative to the working directory, so the parsed - // Symbol::file is already project-relative. - ctags.start(QStringLiteral("ctags"), + // Symbol::file is already project-relative. The format flags are accepted by + // both Exuberant and Universal Ctags. + ctags.start(ctagsBinary(), {QStringLiteral("-f"), QStringLiteral("-"), QStringLiteral("-L"), QStringLiteral("-"), QStringLiteral("--fields=+nK"), diff --git a/src/project/symbolindex.h b/src/project/symbolindex.h index 940bc3f..a0f0460 100644 --- a/src/project/symbolindex.h +++ b/src/project/symbolindex.h @@ -51,6 +51,13 @@ public: /*! True if a ctags binary is usable. */ static bool ctagsAvailable(); + /*! + * Path to the ctags binary to use: prefers Universal Ctags + * (`ctags-universal`, which parses Kotlin/TypeScript/… that Exuberant 5.9 + * cannot), falling back to a plain `ctags` on PATH. Empty if none found. + */ + static QString ctagsBinary(); + /*! * Parse ctags extended tab-separated output into a symbol list. Pure: no * process, no filesystem. Lines that are comments (starting with "!_TAG_")