v1.2.4 managed views: scoped searches and globs on a ready view are slower than ripgrep; idle verification re-reads the whole checkout every ~4 minutes
I maintainer di solito rispondono entro 1 giorno
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 5/5
- Tempo stimato
- Più di una settimana
- Idoneità per principianti
- 25/100
- Tipo di issue
- Bug
- Chiarezza
- Abbastanza chiara
- Stato di attività
- Attiva
- Stack tecnologico
- rust
- Ambito
- performance
Direzione di ricerca
Start with the query and verification paths in tgrep-core/src/worktrees.rs and tgrep-cli/src/serve/shared/server.rs, then trace managed query handling in tgrep-cli/src/serve/shared/managed.rs. The report identifies full-index filtering, sequential path requests and full reads during idle verification; compare those paths with the reported timings and readiness drops. Done means scoped searches and file globs are faster, idle verification avoids repeated full reads, and readiness remains stable when no changes are detected.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
Summary
We run tgrep v1.2.4 managed views (serve --shared) inside the GitHub Copilot runtime. On a large Windows repository (≈293k indexed files), repository-wide selective searches were much faster than ripgrep (0.53 s against ≈13 s). But most coding-agent searches are scoped to one or a few directories. On a ready view those took seconds, where ripgrep takes tens of milliseconds. The idle daemon also re-reads the whole checkout every ≈4 minutes. Twice, the view left readiness for 2–3 minutes although we found nothing in the checkout or its Git directory, other than tgrep's own files, modified after the daemon started.
From the v1.2.4 source, we think five structural costs explain the latency:
- A scoped content search resolves candidates over the whole index, then applies the directory scope, glob and type filters.
- Shared
--filesreturns every listed path, and the client applies--glob. - The client sends one request per path argument, in sequence.
- The daemon has two fixed query threads, and every query reads the policy and commits a catalog write.
- Verification of a healthy native view is a full content read every
--poll-interval, on the same rayon pool that serves queries.
Separately, readiness closes on every path hint, and Git metadata writes force a full content pass. Because every view also watches the repository's common Git directory, a ref update in any worktree forces a full pass on every view of that repository. Together these can leave a view unavailable for minutes at a time.
Environment
- tgrep v1.2.4 (
0be7a76) on Windows 11 Enterprise, 16 logical cores. The checkout is on a ReFS Dev Drive that isn't marked trusted, with ReFS last-access updates disabled. Shared storage is on NTFS. - One managed view with a native watcher. The checkout has ≈1.93M files in ≈168k directories, including ignored ones; the view indexes ≈293k files in a 3.15 GB generation.
- Daemon:
serve --shared <root> --shared-storage <dir> --shared-policy <file>with the default--poll-interval(120 s). Policy:work.workers=1,queue_items=256. - Clients:
tgrep --shared --content-eligibility content …with ripgrep-style flags, one process per agent tool call. Agents often issue calls in parallel.
Observations
1. Scoped searches on a ready view
These figures come from about 75 minutes of normal agent use. The ready view served every tgrep call below.
| Calls | n | p50 | p90 | max |
|---|---|---|---|---|
| Content search, one directory | 45 | 0.80 s | 6.43 s | 14.54 s |
| Content search, several directories | 22 | 3.44 s | 15.58 s | 20.07 s |
--files --glob at the repository root |
17 | 2.70 s | 6.66 s | 7.07 s |
| ripgrep, other agent calls in the same window | 23 | ≈0.14 s | — | 1.18 s |
We re-ran the slowest calls with ripgrep and the same arguments. The ripgrep runs came later, on a warm cache and without concurrency, so they overstate the gap somewhat.
| tgrep (ms) | ripgrep (ms) | Output lines | Mode | Path arguments |
|---|---|---|---|---|
| 20,074 | 89–91 | 85 | -l |
4 |
| 15,698 | 34–39 | 22 | -c |
2 |
| 15,584 | 41–43 | 135 | -c |
2 |
| 14,542 | 37 | 38 | -c |
1 |
| 11,712 | 115–144 | 9 | -l |
7 |
| 8,456 | 53–59 | 428 | -c |
12 |
| 7,498 | 42–44 | 84 | -c |
4 |
| 6,913 | 44–59 | 95 | -c |
2 |
- The first four rows were one parallel burst, while ripgrep calls running at the same time took 0.1–0.3 s. Together the four calls carried 9 path arguments. Each call sends its paths one at a time and the daemon has 2 query threads, so the ≈20 s burst is consistent with 9 requests of ≈4.4 s each. We didn't trace individual requests.
- An 8-call burst took 5.1–7.5 s per call. Similar calls at quieter times took 0.4–3.7 s.
- The slow patterns were regex alternations of 3–6 identifier-like branches with
\s*,.*or anchors. Results were small (9–428 files), so this isn't the broad-pattern-l/-ccost from #210.
2. Where the index wins
- A selective literal at the repository root took 0.53 s with tgrep and ≈13 s with ripgrep. A cold ripgrep run at the repository root hit our 20 s tool timeout.
- A broad pattern at the root took 48.2 s with tgrep and 37.2 s with ripgrep.
3. Globs
Agent glob calls run --files --hidden --glob <pattern> --glob '!**/.git' at the repository root. tgrep took 2.0–7.1 s, and a full ripgrep walk took 0.95–1.9 s. Each call returned 0–284 files.
4. Idle verification
We measured this with passive OS process counters and didn't query the daemon.
- A pass starts ≈120 s after the previous one completes and lasts ≈110 s:
- ≈5–10 s of metadata work at ≈3.4 cores, with no reads;
- ≈80 s of reads;
- ≈20 s at ≈2.5 cores, with no reads.
- One pass made 6.45 GiB of logical reads in ≈657k read operations. It used ≈3.7 cores above baseline on average, with a peak of ≈5.2 cores. Between passes the daemon used ≈0.2 cores.
- Extrapolated, with no file changes: a ≈230 s cycle (≈48% duty), ≈100 GiB/hour of logical reads and ≈1.8 cores on average, per repository. We didn't measure physical reads.
5. Readiness drops with no detected changes
- The view reported
ready: falsetwice, for 2m14s and ≈2m54s. The daemon wrote nothing to stderr. Our client falls back to ripgrep while the view isn't ready. - The first drop came during normal agent use, which was searches only. The second started after the agent session had gone idle, while we re-ran its searches with ripgrep (reads only).
- A stat-only walk of the whole checkout, ignored files included, found no file or directory modified or created after the daemon started, 78 minutes before the first drop. The walk compared modification and creation times only, so it can't rule out attribute or ACL changes, and the volume has no active change journal to check them.
- Under
.git, nothing changed after the daemon started except tgrep's owntgrep-daemon-*.jsonandtgrep-view-*.jsonfiles, written at startup. There were no ref,packed-refs,HEADorindexwrites. - Each drop ended ≈5 s after a new generation, the same size as the previous one, appeared in shared storage: one for the first drop, two (87 s apart) for the second. We read this as one or two reconcile passes that found nothing to change. We couldn't tell what started them; see the questions below.
Code pointers (v1.2.4)
Query path
worktrees.rsL2226–L2240:candidatesrunsexecute_query_with_masksover the whole index and resolves every id before the prefix-visibility filter. Globs and types are applied afterwards (server.rsL1561–L1585).--filestakes three steps:server.rsL1526–L1559 returnssnapshot.files(prefix)without a glob.worktrees.rsL2243–L2254 iterates every listed path.search.rsL600–L640 fetches all pages and appliesglob_filterandtype_filteron the client.
main.rsL1611–L1618 (list_files) and L1665–L1671 (run_search) issue one request per path argument, in sequence.managed.rsL2297 starts two query threads.- In
managed.rsL1678–L1707, each query:- reads
policy()(catalog.rsL694–L706) through the single-connection mutex (L581–L591); - ends with
complete(), which commits atouch_objecttransaction (views.rsL2395–L2403).
- reads
server.rsL1332–L1355: query verification usespar_iteron the global rayon pool.- The window is
current_num_threads() × 4(L1599). - Each candidate gets its own
source.snapshotcall (L1696–L1720). - Rayon workers drain local and stealable work before injected jobs (rayon-core
registry.rsL833–L842), so a query's jobs can wait for the current verification batch.
- The window is
Verification
managed.rsL1868–L1896: a ready native view is verified wheneververification_elapsed >= interval.- The interval is
--poll-interval(main.rsL727–L735). - The only off switch is
--no-watch(L719–L721), which disables all refresh.
- The interval is
worktrees.rsL961–L998:verify_servingcallsprepare(true, …), so every pass is full. The unchanged-version shortcut is skipped whenfull(L1489–L1495).- Pass parallelism:
worktrees.rsL1276–L1314 and L1317–L1319;work.rsL781–L813 and L881–L906;- 64 MiB batches (
builder.rsL43).
- Verification and reconciliation of a view hold the same
reconcilemutex (worktrees.rsL920–L923 and L820–L823). A hint that arrives mid-pass waits for the pass to finish, and the pass then fails withChangedDuringReconcile(L980–L982). - A hinted reconcile still walks the tree and opens every listed file (L1248 and L1476–L1484). It skips only the content read.
Readiness
worktrees.rsL495–L509:ready = control.ready && !stale.- Every reconcile closes readiness first (L842), and so does every path hint (
invalidate_path, L560–L587). A reconcile with no hints is full (L843). - Any verification error except
ChangedDuringReconcileor a budget failure marks the view stale (L950–L956). So do detected differences (L1249–L1255, L1546–L1548, L1592–L1599). watch_eventforces a full invalidation forneed_rescan,AnyandOtherevents and events with no paths (managed.rsL822–L824), for Git metadata writes (L829–L855:HEAD,index,config,packed-refs,refs,info, …) and for watcher errors (L888–L897). Watcher errors are written to stderr, and we saw none. Non-ASCII paths, ignore files and hint overflow also force a full pass (worktrees.rsL574–L582).- The metadata set is the view's Git directory plus the common directory (
managed.rsL709). In recursive mode the root and every metadata directory not nested in another watched directory get a recursive subscription (L711–L737, L993), and the test at L2823–L2832 expects a linked view to watch the whole common directory. So for every view of a repository, a write under<common>/refsor to<common>/packed-refsforces a full pass. A linked view also reacts to the main worktree's<common>/HEADand<common>/index, which match the same names relative to the common directory. - Other notifications under the root become path hints unless they are a
Modifyof a file whose metadata matches the indexed evidence (L862–L875,worktrees.rsL521–L537). Directory notifications and notifications for files without evidence, such as ignored files, therefore close readiness. - Managed views use
stale_reads: true(managed.rsL2185–L2187).- A query's failed candidate read is requeued as a path hint (
server.rsL1687–L1690,managed.rsL691–L695), and only the non-eventual path logs (L677–L684). - The requeue closes
readywhile the view keeps serving (test atmanaged.rsL3726–L3738).
- A query's failed candidate read is requeued as a path hint (
watch_statusalready reportswatch_eventsandlast_verification(managed.rsL1010–L1041, L1668–L1673).
Impact
- Coding agents mostly search a few directories at a time, often in parallel. On this repository ripgrep answers those searches in tens of milliseconds and tgrep in seconds. We're therefore considering routing directory-scoped searches and globs to ripgrep and using tgrep only for repository-root content searches.
- Idle cost is ≈1.8 cores and ≈100 GiB/hour of logical reads per repository (extrapolated), multiplied by the number of repositories with a daemon.
- Readiness: from the code (not measured separately), any write to Git metadata forces a full content pass. That includes a
git add,commitorcheckout, or agit statusthat refreshes the index. On this repository each one means ≈2 minutes of fallback and another ≈6.45 GiB of reads, and agents run Git often. The common directory widens this: a commit or branch update in any worktree,git fetch(refs/remotes) or background maintenance prefetch (refs/prefetch) forces a full pass on every view of the repository, and linked views also react to staging and checkouts in the main worktree. Our agent sessions often run in separate linked worktrees of one repository, served by one daemon, so one commit can queue a full pass for every session's view.
Suggestions
- Make candidate resolution scope-aware. Restrict it to the requested prefix before resolving paths, for example with a path-id range per directory, and push glob and type filters in where possible. For
--files, apply--globand type filters on the server. - Accept several path arguments in one request, with one candidate resolution and one snapshot.
- Reduce per-query overhead:
- make the number of query threads configurable, or derive it from the core count;
- read the policy once rather than per query;
- batch or defer
touch_objectinstead of committing a transaction per query.
- Make verification cheaper:
- run it on a dedicated, bounded pool or at lower priority so it doesn't compete with queries;
- trust unchanged versions (the L1489 shortcut) and re-read only a rotating sample, or verify incrementally;
- give it its own longer or adaptive interval instead of
--poll-interval, or allow turning it off while the native watcher is healthy.
- Keep the view usable during reconciles:
- for hinted reconciles, process only the hinted paths and their parents instead of walking and opening every listed file;
- for Git metadata changes, recompute visibility with a metadata walk and keep the unchanged-version shortcut;
- report "ready except N pending paths" so clients can search those paths directly instead of losing the whole index.
- Narrow what starts a pass:
- scope Git metadata handling per view: react to the view's own
HEAD,indexand checked-out branch, and ignore other branches,refs/remotes,refs/prefetchand, for linked views, the main worktree'sHEADandindex; - in recursive mode, drop directory
Modifynotifications, or check them against the directory's recorded attributes first, since entry changes arrive as their own events; - detect
ReadDirectoryChangesWoverflow and rescan only then. notify 7 ignores each completion's byte count (windows.rsL312–L405), so the zero-byte completion that signals an overflow is dropped (notify-rs/notify#963, fixed by notify-rs/notify#964, first released in 9.0.0-rc.5; no stable release has it yet). If periodic verification exists to catch missed events, overflow detection could let it run much less often.
- scope Git metadata handling per view: react to the view's own
- Improve observability:
- record why readiness closed (trigger, error kind, first differing path), with a short history in
watch_status; - log full passes, verification differences and requeued candidate reads to stderr.
- record why readiness closed (trigger, error kind, first differing path), with a short history in
Questions
- What does a full content read on every verification pass protect against when size and modification time are unchanged? If it's there to catch events the watcher missed, would overflow detection be enough?
- Can a transient read error on an unchanged tree mark the view stale and force a full pass? An example is a sharing violation while antivirus or the search indexer holds a file open.
- Is closing
readyfor a single requeued candidate intended, given that the view keeps serving its publication? - What could close readiness when we found nothing modified in the checkout (Observation 5)? From the code we see three candidates: an unfiltered notification (a directory, a file without evidence, or an attribute or ACL change), a requeued candidate read, or a verification difference or error. If it helps, we can sample
watch_eventsandlast_verificationfrom view status around the next drop.
Related
- #210 covers cold readiness. Its §4 covers
-l/-ccosts for broad patterns and the response cap. - #212 covers reconciliation and periodic verification starving when the only work slot is reserved under storage pressure. This issue is about the cost of verification once it runs.
- #214 covers catalog WAL growth. The per-query
touch_objectcommits are also catalog writes.
We're happy to test a build or collect more data.
- Lingua principale
- Rust
- Stelle
- 3.5k
- Fork
- 140
- Merge medio
- 14h 59m
- PR unite (30g)
- 28
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Nessun modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di microsoft/tgrep
-
Difficoltà 5/5 Più di una settimana Idoneità per principianti 35/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 42/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 4/5 3-5 giorni Idoneità per principianti 45/100
I maintainer di solito rispondono entro 1 giorno
Tutte le issue di microsoft/tgrep
Issue simili
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
farion1231/cc-switch#8829 ·
I maintainer di solito rispondono entro 1 giorno
-
bug ci good first issue
Difficoltà 2/5 1-3 ore Idoneità per principianti 88/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 92/100
longbridge/gpui-kit#3677 ·
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 68/100
ilya-zlobintsev/LACT#1235 ·
I maintainer di solito rispondono entro 1 giorno
-
[Bug]: Shared Sink flush/close can complete without submitting buffered tasksForse già presa @RivTian l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
apalis-dev/apalis#797 ·
I maintainer di solito rispondono entro 7 giorni