diff --git a/docs/PERF.md b/docs/PERF.md new file mode 100644 index 0000000..5b84238 --- /dev/null +++ b/docs/PERF.md @@ -0,0 +1,71 @@ +# Switcher performance — measured bottlenecks + +Benchmark harness: `src/project/bench_switcher.cpp` (manual target `bench_switcher`, +not a ctest). Run: + +```sh +cmake -S . -B build-rel -DCMAKE_BUILD_TYPE=Release +cmake --build build-rel --target bench_switcher +./build-rel/bin/bench_switcher /tmp/switcher_metrics.txt +``` + +Reference project: `~/prj/r7.20/mobydick` — **12,234 files**, **17,184 ctags +symbols**, 6.3 GB tree. + +## One-time cost when the switcher opens (UI thread, synchronous) + +| Stage | Release | Debug (shipped) | +|----------------------------|--------:|----------------:| +| `ProjectIndex::listFiles` | ~84 ms | ~78 ms | +| `SymbolIndex::listSymbols` | ~974 ms | ~499 ms | +| build items + `setItems` | ~5 ms | ~45-70 ms | + +- `listFiles` is a `git ls-files` subprocess — acceptable but blocks the UI. +- `listSymbols` is a **ctags subprocess (~0.5-1 s)** — the dominant open-cost, + and it blocks the UI thread. (Cost is in the external binary, not our parse.) + +## Per-keystroke cost — the interactive bottleneck + +`PaletteModel::setQuery` → re-rank. Originally O(N) every keystroke; the cost +*grew* with query length even as the result set shrank. + +Full-scan (every keystroke re-scores all N), Release: + +| Query | files | symbols | +|------------------|---------:|---------:| +| `server` | ~14 ms | ~30 ms | +| `src server go` | ~17 ms | ~36 ms | + +Debug (what ships): **270-342 ms (files)**, **459-598 ms (symbols)** per +keystroke — unusable. + +## Fix 1 — incremental narrowing (done) + +When a query only **appends** to the previous one, re-score only the currently +visible subset (the match set is monotonic under lengthening/adding a needle), +turning later keystrokes from O(N) into O(previous matches). Exactness is +guarded by `test_palettemodel::incrementalMatchesFullScan` (incremental == +full-scan, incl. the typo tier). + +Effect once the set narrows (Release, type-forward): + +| Query | full-scan | incremental | +|----------|----------:|------------:| +| files `server` | ~14 ms | **~6.6 ms** | +| symbols `server` | ~30 ms | **~9.0 ms** | + +Early keystrokes (`s`, `se`) remain full-N because almost everything matches — +correct and unavoidable without a prefix index. + +## Fix 2 — build the installed plugin optimized (recommended) + +The shipped `olliepalette.so` is a **Debug** build (`-g`, no `-O`). Release is +~5-8× faster on every hot path above. Install a `Release` or `RelWithDebInfo` +build. + +## Open follow-up — move indexing off the UI thread + +`listFiles` (~85 ms) and especially `listSymbols` (~0.5-1 s) run synchronously +when the switcher opens. For large projects they should run on a worker thread +(or be cached + refreshed on `KateProjectBridge::projectChanged`) so the palette +appears instantly and fills in as results arrive. Not yet done. diff --git a/src/palette/palettemodel.cpp b/src/palette/palettemodel.cpp index 69fba99..379e098 100644 --- a/src/palette/palettemodel.cpp +++ b/src/palette/palettemodel.cpp @@ -29,8 +29,18 @@ void PaletteModel::setQuery(const QString &query) return; } beginResetModel(); - m_query = query; - rebuild(); + // Fast path: when the new query only appends to the old one, the match set + // can only shrink (every FuzzyRanker tier is monotonic under lengthening a + // 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); + if (pureAppend) { + rebuildFromVisible(query); + } else { + m_query = query; + rebuild(); + } endResetModel(); } @@ -51,6 +61,33 @@ void PaletteModel::rebuild() m_visible.push_back({i, r.score + item.frecency, r.ranges}); } + sortVisible(); +} + +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; + } + m_visible[write] = {src, res.score + m_items.at(src).frecency, res.ranges}; + ++write; + } + m_visible.resize(write); + + sortVisible(); +} + +void PaletteModel::sortVisible() +{ // 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; }); diff --git a/src/palette/palettemodel.h b/src/palette/palettemodel.h index 7a203fd..266e275 100644 --- a/src/palette/palettemodel.h +++ b/src/palette/palettemodel.h @@ -68,6 +68,11 @@ private: }; 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(); QList m_items; QList m_visible; diff --git a/src/palette/test_palettemodel.cpp b/src/palette/test_palettemodel.cpp index 76a580b..a067cad 100644 --- a/src/palette/test_palettemodel.cpp +++ b/src/palette/test_palettemodel.cpp @@ -158,6 +158,47 @@ private Q_SLOTS: QCOMPARE(m.index(0, 0).data(PaletteModel::LabelRole).toString(), QStringLiteral("src/palette/palettemodel.cpp")); } + + // Incremental narrowing (type-forward append) must produce exactly the same + // visible rows, in the same order, as a single full-scan query — including + // across the typo-tolerant tier. Guards the fast path in setQuery(). + void incrementalMatchesFullScan() + { + QList items; + const QStringList labels = { + QStringLiteral("src/server/main.cpp"), + QStringLiteral("src/service/handler.cpp"), + QStringLiteral("docs/SERVER.md"), + QStringLiteral("README"), + QStringLiteral("esrv/weird.txt"), // transposition bait + QStringLiteral("server_test.go"), + QStringLiteral("lib/observer.h"), + }; + for (int i = 0; i < labels.size(); ++i) { + items.push_back({QString::number(i), labels.at(i), QString(), 0}); + } + + auto rowsFor = [&](const QStringList &keystrokes) { + PaletteModel m; + m.setItems(items); + for (const QString &k : keystrokes) { + m.setQuery(k); // incremental path when each extends the last + } + QStringList ids; + for (int r = 0; r < m.rowCount(); ++r) { + ids << m.index(r, 0).data(PaletteModel::IdRole).toString(); + } + return ids; + }; + + // Type-forward "server" one char at a time vs. one-shot full scan. + const QStringList incremental = + rowsFor({QStringLiteral("s"), QStringLiteral("se"), QStringLiteral("ser"), + QStringLiteral("serv"), QStringLiteral("serve"), + QStringLiteral("server")}); + const QStringList full = rowsFor({QStringLiteral("server")}); + QCOMPARE(incremental, full); + } }; QTEST_MAIN(TestPaletteModel) diff --git a/src/project/CMakeLists.txt b/src/project/CMakeLists.txt index 74aca30..3566559 100644 --- a/src/project/CMakeLists.txt +++ b/src/project/CMakeLists.txt @@ -24,3 +24,9 @@ if(Qt6Test_FOUND) target_link_libraries(test_kateprojectbridge PRIVATE project_lib Qt6::Core Qt6::Test) add_test(NAME kateprojectbridge COMMAND test_kateprojectbridge) endif() + +# Manual benchmark (not a ctest): times the Go-to-File / Go-to-Symbol pipeline +# against a real project root. Build with `cmake --build build --target +# bench_switcher`, run `./build/bin/bench_switcher [metrics-out]`. +add_executable(bench_switcher bench_switcher.cpp) +target_link_libraries(bench_switcher PRIVATE project_lib palette Qt6::Core) diff --git a/src/project/bench_switcher.cpp b/src/project/bench_switcher.cpp new file mode 100644 index 0000000..b60a673 --- /dev/null +++ b/src/project/bench_switcher.cpp @@ -0,0 +1,209 @@ +/* + * SPDX-License-Identifier: LGPL-2.0-or-later + * + * Benchmark harness for the Go-to-File / Go-to-Symbol hot paths. NOT a unit + * test — it is excluded from ctest and run manually against a real project to + * measure where time goes at large N. It times each stage of the real pipeline + * and appends a metrics report to a file. + * + * test_switcher_bench [metrics-out] + * + * Stages timed: + * - ProjectIndex::listFiles (git ls-files / walk) + * - building file PaletteItems (label + frecency bonus) + * - PaletteModel::setItems (files) (initial rebuild, empty query) + * - PaletteModel::setQuery xN (per-keystroke re-rank at several query + * lengths — this is the interactive cost) + * - SymbolIndex::listSymbols (ctags over the file list) + * - building symbol PaletteItems + * - PaletteModel::setItems (symbols) + * - PaletteModel::setQuery xN (symbols) + */ +#include "projectindex.h" +#include "symbolindex.h" +#include "palettemodel.h" +#include "frecencystore.h" + +#include +#include +#include +#include +#include +#include + +#include + +using namespace katecustom; + +namespace +{ +struct Timer { + QElapsedTimer t; + void start() { t.start(); } + qint64 us() const { return t.nsecsElapsed() / 1000; } +}; + +// Representative queries at growing lengths. Short queries are the worst case: +// almost everything matches, so the result set and per-item work are largest. +const QStringList kQueries = { + QStringLiteral("s"), + QStringLiteral("se"), + QStringLiteral("ser"), + QStringLiteral("serv"), + QStringLiteral("server"), + QStringLiteral("src server go"), +}; + +QString buildFileLabel(const QString &rel) +{ + return rel; // matches the plugin: full project-relative path as the label +} + +QString buildSymbolLabel(const Symbol &s) +{ + QString label = s.name; + if (!s.scope.isEmpty()) { + label = s.scope + QStringLiteral("::") + s.name; + } + QString suffix = s.kind; + if (!s.file.isEmpty()) { + suffix = suffix.isEmpty() ? s.file : (s.kind + QStringLiteral(" ") + s.file); + } + if (!suffix.isEmpty()) { + label += QStringLiteral(" — ") + suffix; + } + return label; +} +} // namespace + +int main(int argc, char **argv) +{ + QCoreApplication app(argc, argv); + if (argc < 2) { + std::fprintf(stderr, "usage: %s [metrics-out]\n", argv[0]); + return 2; + } + const QString root = QString::fromLocal8Bit(argv[1]); + const QString out = argc >= 3 ? QString::fromLocal8Bit(argv[2]) + : QStringLiteral("/tmp/switcher_metrics.txt"); + + QString report; + QTextStream rs(&report); + rs << "# switcher benchmark\n"; + rs << "root\t" << root << "\n"; + + FrecencyStore frecency; // empty store; bonus() is still called per item + const qint64 now = 1700000000; + + Timer t; + + // --- Go to File ------------------------------------------------------- + t.start(); + const QStringList files = ProjectIndex::listFiles(root); + const qint64 listFilesUs = t.us(); + rs << "files.count\t" << files.size() << "\n"; + rs << "files.listFiles_us\t" << listFilesUs << "\n"; + + t.start(); + QList fileItems; + fileItems.reserve(files.size()); + for (const QString &rel : files) { + PaletteItem item; + item.id = QStringLiteral("ollie:file:") + rel; + item.label = buildFileLabel(rel); + item.frecency = frecency.bonus( + QStringLiteral("ollie:file:") + root + QLatin1Char('\t') + rel, now); + fileItems.push_back(item); + } + rs << "files.buildItems_us\t" << t.us() << "\n"; + + { + PaletteModel model; + t.start(); + model.setItems(fileItems); + rs << "files.setItems_us\t" << t.us() << "\n"; + rs << "files.setItems_rows\t" << model.rowCount() << "\n"; + + // Independent (reset-before-each) full-scan cost per query: + for (const QString &q : kQueries) { + model.setQuery(QString()); // reset so each measurement is independent + t.start(); + model.setQuery(q); + rs << "files.fullQuery[" << q << "]_us\t" << t.us() + << "\trows\t" << model.rowCount() << "\n"; + } + + // Realistic type-forward sequence (exercises incremental narrowing): + // type "server" one character at a time without resetting. + model.setQuery(QString()); + const QString word = QStringLiteral("server"); + for (int n = 1; n <= word.size(); ++n) { + const QString q = word.left(n); + t.start(); + model.setQuery(q); + rs << "files.typeFwd[" << q << "]_us\t" << t.us() + << "\trows\t" << model.rowCount() << "\n"; + } + } + + // --- Go to Symbol ----------------------------------------------------- + if (SymbolIndex::ctagsAvailable()) { + t.start(); + const QList symbols = SymbolIndex::listSymbols(root, files); + const qint64 listSymbolsUs = t.us(); + rs << "symbols.count\t" << symbols.size() << "\n"; + rs << "symbols.listSymbols_us\t" << listSymbolsUs << "\n"; + + t.start(); + QList symItems; + symItems.reserve(symbols.size()); + for (const Symbol &s : symbols) { + PaletteItem item; + const QString loc = s.file + QLatin1Char('\t') + QString::number(s.line); + item.id = QStringLiteral("ollie:sym:") + loc; + item.label = buildSymbolLabel(s); + item.frecency = frecency.bonus( + QStringLiteral("ollie:sym:") + root + QLatin1Char('\t') + loc, now); + symItems.push_back(item); + } + rs << "symbols.buildItems_us\t" << t.us() << "\n"; + + PaletteModel model; + t.start(); + model.setItems(symItems); + rs << "symbols.setItems_us\t" << t.us() << "\n"; + rs << "symbols.setItems_rows\t" << model.rowCount() << "\n"; + + for (const QString &q : kQueries) { + model.setQuery(QString()); + t.start(); + model.setQuery(q); + rs << "symbols.fullQuery[" << q << "]_us\t" << t.us() + << "\trows\t" << model.rowCount() << "\n"; + } + + model.setQuery(QString()); + const QString word = QStringLiteral("server"); + for (int n = 1; n <= word.size(); ++n) { + const QString q = word.left(n); + t.start(); + model.setQuery(q); + rs << "symbols.typeFwd[" << q << "]_us\t" << t.us() + << "\trows\t" << model.rowCount() << "\n"; + } + } else { + rs << "symbols.ctags\tUNAVAILABLE\n"; + } + + QFile f(out); + if (!f.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) { + std::fprintf(stderr, "cannot write %s\n", qPrintable(out)); + return 1; + } + f.write(report.toUtf8()); + f.close(); + + // Also echo to stdout for convenience. + std::fputs(report.toUtf8().constData(), stdout); + return 0; +}