The large-repo benchmarks measure search time only — the trigram index is built before timing starts.
tgrep runs in client/server mode: tgrep serve runs in the background, and the tgrep client connects via TCP.
Every query is run through a fresh tgrep client process, so each measurement includes process
startup and the TCP round trip, exactly as a shell user or editor integration would pay them.
ripgrep is capped at 120s per query; a query that hits the cap still contributes the full 120s to
ripgrep's total, so a non-zero Timeouts column means the ripgrep total is a lower bound and the
real speedup is larger than shown. All runs below reported zero timeouts.
The core benchmark suite also includes Criterion microbenchmarks for index building, query execution, and trigram extraction. These are useful for tracking lower-level performance changes that may not show up in end-to-end search latency.
Speedup = ripgrep total / tgrep total across the whole query suite for that repo. Values below 1.0 mean ripgrep won.
| Repo | Files | Queries | Windows | macOS | Linux |
|---|---|---|---|---|---|
| chromium/chromium | 504,351 | 30 | 17.6x | 15.8x | 3.81x |
| mozilla/gecko-dev | 387,841 | 122 | 38.6x | 51.9x | 7.36x |
| torvalds/linux | 95,831 | 102 | 34.8x | 21.0x | 9.38x |
| rust-lang/rust | 62,326 | 102 | 7.69x | 2.69x | 1.61x |
| kubernetes/kubernetes | 31,300 | 97 | 7.08x | 2.81x | 0.93x |
| golang/go | 15,833 | 103 | 7.53x | 3.12x | 1.29x |
All 18 cells are from the complete 24 August 2026 sweep at 82b88a1, measured on
GitHub-hosted runners (windows-latest, macos-latest, ubuntu-latest). Every
headline latency, ratio, file count, and primary run link in the repo sections
below comes from that same sweep rather than mixing the latest totals with minima
from older runs. Older runs are cited only to show the kernel suite's variance.
Geometric mean speedup across the six repos is 14.6x on Windows, 8.61x on macOS,
and 2.82x on Linux. The largest margin is Gecko on macOS at 51.9x.
Kubernetes on Linux is the one cell ripgrep wins, at 0.93x — a near-tie
(101.8 ms versus 94.4 ms per query) on a warm page cache, where match volume costs
tgrep more in delivery than the index saves in file selection. See
What decides the margin.
Shared-runner results are not controlled-machine measurements. Compare tgrep with ripgrep within a row, not absolute milliseconds between workflow runs: CPU, storage and page-cache variance move both tools, particularly on macOS.
The benchmark logs also record the indexer's peak memory, in MiB:
| Repo | Windows | macOS | Linux |
|---|---|---|---|
| chromium/chromium | 402.9 | 462.6 | 332.2 |
| mozilla/gecko-dev | 347.8 | 416.3 | 256.9 |
| torvalds/linux | 135.4 | 216.7 | 129.6 |
| rust-lang/rust | 114.7 | 150.8 | 109.4 |
| kubernetes/kubernetes | 109.1 | 145.3 | 110.7 |
| golang/go | 108.0 | 137.2 | 108.7 |
These are not comparable to figures published before this sweep, which were
peak working set / RSS and so counted mapped file pages the OS can reclaim.
index now reports private bytes — PagefileUsage on Windows, RssAnon on Linux
— and names the working set separately only when it is materially larger. None of
these six repos holds a file big enough to make the two diverge, so the numbers
land close to the old ones by coincidence; they measure the memory tgrep owns
rather than what the OS happened to have resident.
Peak memory tracks corpus size rather than query count, and stays bounded: the largest here is Chromium at 504K files and 2.6 GB of index, which builds in under 470 MiB on every platform.
Local Criterion run on Windows from the perf-benchmarks branch. The short
measurement windows below are intended for PR validation; use larger sample sizes
and measurement windows for publication-quality comparisons.
These microbenchmark numbers come from a local
cargo bench -p tgrep-corerun, not from the CI benchmark workflows, so they are not refreshed by the large-repo sweep above and are only comparable to other runs on the same machine.
cargo bench -p tgrep-core --bench trigram_extraction -- --sample-size 30 --warm-up-time 1 --measurement-time 2
cargo bench -p tgrep-core --bench query_execution -- --sample-size 30 --warm-up-time 1 --measurement-time 2
cargo bench -p tgrep-core --bench index_build -- --sample-size 10 --warm-up-time 0.5 --measurement-time 1
cargo bench -p tgrep-core --bench index_build -- --peak-memory 5000
cargo bench -p tgrep-core --bench index_build -- --peak-memory-high-diversity 1000| Case | Mean | Throughput | Peak working set |
|---|---|---|---|
| 100 files | 29.611ms | 1.6490 MiB/s | - |
| 500 files | 100.77ms | 2.4228 MiB/s | - |
| 2,000 files | 322.78ms | 3.0255 MiB/s | - |
| 5,000 files | 776.27ms | 3.1450 MiB/s | 16.47 MiB |
| 1,000 high-diversity files | 372.62ms | 2.6208 MiB/s | 43.68 MiB |
The high-diversity case stresses the number of distinct trigrams and posting-list serialization. The flat sorted-posting writer reduced this case from roughly 1.47s and 98.74 MiB peak working set to roughly 0.37s and 43.68 MiB in local runs.
The default build strategy, external, replaces the single in-heap posting
vector with an external merge sort: postings fill a fixed-size arena that spills
sorted, delta+varint encoded segments to disk, which are then k-way merged
directly into index.bin / lookup.bin. --index-strategy=memory selects the
older sort-everything-in-RAM path, retained as an escape hatch and as the
reference implementation for differential tests.
The peak-memory probe runs both strategies back to back on the same fixture. Each of these runs uses a deliberately small 4 MB arena so bench-scale fixtures spill; the shipped default is 64 MB.
cargo bench -p tgrep-core --bench index_build -- --peak-memory-high-diversity 16000Peak working set (high-diversity fixture, Windows, local run):
| Files | memory |
external |
Reduction |
|---|---|---|---|
| 2,000 | 66.93 MiB | 32.85 MiB | −51% |
| 4,000 | 88.98 MiB | 34.29 MiB | −61% |
| 8,000 | 185.21 MiB | 37.92 MiB | −80% |
| 16,000 | 342.98 MiB | 50.30 MiB | −85% |
The memory column grows roughly linearly with file count while external
stays close to flat (+17 MiB across an 8x increase in files), which is the
point: peak heap becomes a function of the arena budget rather than of repo
size. The remaining growth in the external column is the file table and walk
results, not postings.
Real-world scale: the Linux kernel. 94,634 indexed files / 446,892 trigrams
/ 990 MiB index (C:\repos\linux, v7.2-rc7, warm page cache, peak working set
sampled from the child process, under the 1 MiB indexing cap that was the
default at the time):
| Strategy | Spill segments | Peak working set | Build |
|---|---|---|---|
memory |
- | 2.20 - 3.76 GiB | 23 - 32 s |
external --index-buffer 256 |
8 | 430.6 MiB | ~24 s |
external (64 MiB, default) |
31 | 151 - 160 MiB | ~23 s |
external --index-buffer 16 |
122 | 109.6 MiB | ~23 s |
external --index-buffer 4 |
487 | 102.0 MiB | ~24 s |
external --index-buffer 1 |
1,946 | 98.9 MiB | ~29 s |
At the default budget that is a ~17x reduction in peak memory for no time
cost — build time is flat within run-to-run noise for any budget at or above
4 MiB, and only degrades at 1,946-segment fan-in. This is why external is the
default. In several runs it was measurably faster than memory (22.6 s vs
31.6 s), because sorting one ~2 GiB posting vector, and the doubling
reallocations that grow it, cost more than encoding and merging spill segments.
Two things worth noting beyond the headline. First, the memory figure is a
range because Vec growth doubles: peak lands wherever the final reallocation
happens to fall, and during that realloc both the old and new buffers are
resident. It varied by over a gigabyte across identical runs. The external
column varied by 8 MiB. Bounded memory is also predictable memory, which
matters more than the average when the question is whether a machine can finish
the build at all.
Second, peak decreases monotonically as the budget shrinks, which is the whole point of the knob — but that property had to be fixed, not assumed. See below.
All configurations produce identical results. 15 literal and regex queries over
190,000+ matches returned byte-identical output against the memory index,
including at 1,946-segment fan-in; re-verified after external became the
default with 7 queries over 100,130 matches.
Peak figures here are the OS process high-water mark (PeakWorkingSetSize on
Windows, VmHWM on Linux), sampled externally. Since large files became
memory-mapped, that counter also counts mapped file pages, so it overstates what
the process holds — where that matters below, private bytes are reported
alongside it. tgrep index and tgrep serve now lead with private bytes for
that reason; see "What peak memory reports" below.
What a file-size cap costs. The table above holds the arena fixed and varies the budget. Raising the indexing cap from 1 MiB moved the floor under all of it, because the builder read each file whole and in parallel, so several 20 MB generated headers were resident at once. Same host and repo, default arena:
--max-filesize |
Files indexed | Skipped as too large | Peak working set | Build |
|---|---|---|---|---|
| 1 MiB (oldest default) | 94,637 | 110 | 151.2 MiB | 25.5 s |
| 8 MiB | 94,736 | 11 | 304.1 MiB | 24.4 s |
| 16 MiB | 94,745 | 2 | 341.2 MiB | 24.5 s |
| 64 MiB | 94,747 | 0 | 338.8 MiB | 25.5 s |
The cost is a step, not a slope: almost all of it is paid on the first megabyte
past the old cap, and 16 MiB and 64 MiB are within noise of each other. So a cap
between the two gives up files without buying memory back — 8 MiB still drops 11
kernel files for 89% of the peak. Build time is flat throughout, and the index
grew 990.4 MB to 996.7 MB (+0.6%) for 110 more files totalling 437.9 MB, because
the files a size cap catches are generated and repetitive: dcn_3_2_0_sh_mask.h
is 24 MB and contributes 7,263 distinct trigrams, fewer than the 10,936 of the
224 KB fs/ext4/super.c.
Bounding what the cap admits. Those peaks are what the cap cost before the builder was taught to bound it. Two changes: trigram extraction no longer materialises a lowercased copy of each file, and batches are bounded by cumulative bytes rather than file count, so the raw bytes in flight are capped however large the files are. Same host and repo, minimum of three runs:
| Peak working set | Build | |
|---|---|---|
| Before | 291.5 MiB | 36.2 s |
| After | 198.1 MiB (−32%) | 37.8 s (+4%) |
The variance matters as much as the mean. Across those runs the old path ranged 291.5–400.5 MiB while the new one ranged 198.1–203.5 MiB, so a 109 MiB spread became a 5 MiB one: bounding the bytes in flight makes peak memory repeatable, not just smaller. The budget is deliberately a flat 64 MiB rather than something that scales with the thread pool — a larger, pool-scaled budget was measured and was both slower and hungrier here (38.4 s, 254.0 MiB), because the kernel's large files are a rare minority and the extra headroom bought no parallelism.
Removing the cap. Bounding the bytes in flight made the peak predictable but
left it proportional to the largest files, which is why a cap still looked
necessary. Mapping files past 1 MiB instead of reading them removes that link,
and with it the memory argument for a cap. ripgrep has no default
--max-filesize either, and this is the mechanism that lets it not need one.
(A 64 MiB default was later reinstated for a different reason: not build memory, which mapping had solved, but repeated scan time on the pathological tail. See "File size limits" in the README.)
From here the two metrics diverge and have to be reported separately. Resident set counts mapped file pages, so it barely moves; private bytes — the memory the process actually holds and cannot hand back — is what the change targets. Interleaved A/B runs on the kernel, alternating binaries to keep the page cache from favouring whichever ran second:
| Private bytes | Resident set | Build | |
|---|---|---|---|
| 64 MiB cap, heap reads | 197.2 / 199.5 MiB | 202.8 / 205.1 MiB | 41.1 / 42.2 s |
| No cap, mapped over 1 MiB | 152.4 / 152.4 MiB (−23%) | 192.2 / 195.7 MiB | 27.0 / 27.0 s (−36%) |
Both produce the same index (447,080 trigrams over 94,744 files), because the kernel has nothing above 64 MiB — the time and memory come from the mapping, not from indexing more. The effect is far larger where the files are: on a single 192 MB file, private bytes are 67.1 MiB, and searching one fell from 291.3 MiB to 58.2 MiB.
The threshold is 1 MiB because that is where the measurement pointed. In the AMD register headers, the worst case in the tree, an 8 MiB threshold mapped only 11 of 488 files and left 69% of the bytes on the heap, while 1 MiB maps 102 files covering 87%. It stays cheap on ordinary trees because only 110 of the kernel's 94,747 files reach it at all.
What removing the cap still costs. A file that is neither valid UTF-8 nor
detectably binary cannot be mapped: the index must hold the same U+FFFD-repaired
bytes a search will match against, so it is decoded onto the heap. Two fixes keep
that proportional rather than multiplied — the repaired buffer is now sized
exactly instead of doubling out of a bytes.len() guess, and indexing skips the
per-repair offset table it never reads. On a 135 MB Latin-1 file that took
private bytes from 504.2 MiB (3.7x the file) to 204.6 MiB (1.5x).
Indexing large files at speed. Admitting large files exposed a throughput problem the cap had been hiding. Two causes, both only visible once a tree is mostly large files:
The byte budget charged mapped bytes it never allocated. A mapped file costs no heap, but it was charged its full length against the 64 MiB budget, so a batch of 32 MiB files held two — and a batch is a barrier, so a 16-core pool ran two files at a time. Mapped files are now charged a saturating stand-in for the one thing they do put on the heap, their extracted trigram map, against a second budget that caps mapped bytes in flight at 256 MiB.
Extraction hashed every byte twice with SipHash. A trigram key is its own 24-bit hash, so the collision resistance was paying for nothing, and the lowercase pass re-walked the whole file even though most windows in real source lower to the same key. Extraction now uses a multiply-xorshift hasher and folds both cases into one pass, doing real work only for windows that actually contain an uppercase byte.
Interleaved A/B runs, 512 MiB per corpus, minimum of alternating runs:
| Corpus | Before | After | |
|---|---|---|---|
| 16 x 32 MiB, mixed case | 24.1 s | 1.7 s | 14.2x |
| 16 x 32 MiB, lowercase | 9.4 s | 1.0 s | 9.4x |
| 512 x 1 MiB, lowercase | 2.7 s | 1.9 s | 1.4x |
| 20,000 x 8 KiB, mixed case | 12.5 s | 12.6 s | — |
The last row is the point: ordinary source trees are bound by file I/O and the external sort, not by extraction, so they neither gain nor lose. The gain is concentrated exactly where the old cap used to hide the cost.
This is what the reported case was made of. A tgrep serve opening an index
built under the old cap sees every formerly-oversized file as new and absorbs
them in one stale merge; on a 40,300-file index absorbing 16 x 32 MiB of new
mixed-case files, that merge went from 26.3 s to 3.4 s (7.7x), producing the same
79,078 trigrams over 40,316 files.
Memory moves the way mapping implies. On the mixed-case corpus, private bytes went 71.4 MiB to 78.1 MiB while resident set went 59.3 MiB to 228.1 MiB: the increase is entirely file-backed pages the kernel can reclaim, held by the 256 MiB mapped budget, and the heap the process actually owns barely moved.
What peak memory reports. Mapping large files made the number tgrep printed
wrong. It was PeakWorkingSetSize / VmHWM, which counts resident mapped file
pages, so once indexing mapped files it tracked the size of the files rather than
the memory tgrep held. A tgrep serve bootstrap over a 290k-file internal
monorepo reported peak memory 13.49 GiB, which prompted this investigation.
Reproducing at that scale showed the build itself was never the problem. On a generated 290,700-file / 4.2 GB corpus:
| Reported peak | Private bytes | Wall | |
|---|---|---|---|
tgrep index |
305.1 MiB | 318.0 MiB | 816 s |
tgrep serve bootstrap |
382.3 MiB | 386.8 MiB | 609 s |
The builder is bounded by construction — batches cap at 1,024 files and 64 MiB of
heap, the sort arena is 64 MiB, and the merge reads through buffers sized from
that same budget — so scale alone cannot produce gigabytes. Isolating a single
oversized file does, because batch_ranges gives a file larger than a budget a
batch of its own and the whole file is then mapped and scanned:
| Corpus | Reported peak (old) | Private bytes | Overstatement |
|---|---|---|---|
| 1 x 2 GiB file | 1.99 GiB | 77.8 MiB | 26x |
| 200 x 8 MiB files | 470.2 MiB | 365.4 MiB | 1.3x |
| 290,700 files, 4.2 GB | 305.1 MiB | 318.0 MiB | none |
So the metric, not the build, is what scales with file size. Two consequences were fixed:
Reporting. Both commands now lead with private bytes — PagefileUsage on
Windows, RssAnon on Linux — and name the working set separately only when it is
both 25% and 64 MiB larger, so the mapped-page component is visible instead of
being folded into a figure that reads as tgrep's own use. The 2 GiB case now
prints 77.8 MiB private, 1.99 GiB working set incl. memory-mapped files.
Linux has no kernel high-water mark for anonymous memory alone, so the peak is
sampled on a background thread for the duration of a build; without it the
reported peak would be whatever survived the build, which is a small fraction of
the true high point once the sorter drops its arena. macOS exposes the split only
through Mach TASK_VM_INFO, which libc does not surface, so it still reports
resident set.
The memory cap. It was enforced against the working set too. Mapped pages are file-backed and reclaimable, so charging them against a heap budget fires the cap on memory no flush can return — the build pays for a full overlay flush and is still over budget on the next check, which is exactly the "flush did not reclaim memory" path the code warns about. The cap now charges private bytes.
Why the merge shares one read budget. The first implementation gave every
open segment a fixed 256 KiB read-ahead buffer, which made merge memory
segments * 256 KiB — unbounded in fan-in. Since a smaller arena spills more
segments, asking for less memory delivered more of it, and peak traced a U:
| Arena | Segments | Peak, fixed buffers | Peak, shared budget |
|---|---|---|---|
| 16 MiB | 122 | 114.7 MiB | 109.6 MiB |
| 4 MiB | 487 | 195.8 MiB | 102.0 MiB |
| 1 MiB | 1,946 | 565.8 MiB | 98.9 MiB |
At a 1 MiB budget the read buffers alone were 486 MiB. Sizing them as
budget / segments (floored at 4 KiB) removes the U entirely. This only
reproduces at real scale — every synthetic fixture in this file spills too few
segments to reach the crossover.
tgrep serve on an empty index used to build through a different path than
tgrep index: it walked the repo and accumulated every posting in the live
in-memory overlay, flushing to disk once at the end. The soft memory cap that
was supposed to bound this only trips above --memory-cap (16 GB by default),
so on anything smaller than that the whole index simply stayed in heap.
A cold start now delegates to the same memory-bounded builder as tgrep index.
On the Linux kernel tree (94,181 files, same host as above, peak sampled
externally, timed to the point the server reports the index ready):
| Bootstrap path | Peak working set | Wall |
|---|---|---|
| in-heap overlay (before) | 1,569.7 MiB | 73.6 s |
| external builder (after) | 148.6 MiB | 28.7 s |
That is a 10.6x reduction in peak memory and a 2.6x speedup. The overlay path was slower because it pays twice: it builds the posting map in memory and then rewrites the whole thing through the append-overlay flush.
Two behavioural notes. The server no longer answers from a partially built index during a cold start — queries see an empty index until the build finishes, which is a deliberate trade, since results from a fraction of the repo are misleading. And an interrupted bootstrap now leaves a usable index: the old path wrote nothing until its single end-of-build flush, so killing it at 99% left an empty index behind.
The watcher's ignore matcher is built from the .gitignore paths the build's
walk already collected, not by gitignore::build_matcher, which repeats the
entire walk single-threaded. That distinction is worth more than it sounds: on
a 289k-file repository the rewalk took 48.9 s, longer than indexing the
repo. Reusing the walk's results makes it 0.07 s on the Linux tree.
Resuming a partial index still uses the incremental path, which can skip the files already on disk.
Smaller fixtures. 2,500 files / 37.5 MiB built from tgrep's own .rs
sources, timed end to end through the release binary (median of 3 runs):
| Strategy | Spill segments | Peak working set | Median build | Delta |
|---|---|---|---|---|
memory |
- | 144.08 MiB | 0.725 s | - |
external --index-buffer 64 |
2 | 118.75 MiB | 0.791 s | +9.1% |
external --index-buffer 8 |
9 | 58.38 MiB | 0.789 s | +8.8% |
All three produce the same 14,379 trigrams over 2,500 files. Note that shrinking the arena 8x costs nothing in time — going from 2 segments to 9 is free, because merge fan-in is not the bottleneck at this scale, the spill encode/decode round-trip is. The arena budget is therefore a fairly clean dial on peak memory.
Criterion microbenchmarks bracket that number from both sides:
| Case | memory |
external |
Delta | Spills? |
|---|---|---|---|---|
| 5,000 files | 1.2819 s | 1.2917 s | +0.8% | no |
| 1,000 high-diversity files | 464.42ms | 540.11ms | +16% | yes |
The 5,000-file case is the no-spill floor: its fixture repeats one line, so the whole repo is only 67 distinct trigrams and the arena never fills, which is why the overhead is indistinguishable from noise. The high-diversity case is the worst-case ceiling: random-byte content makes nearly every trigram unique, so segment groups hold a single posting each and per-group header overhead and merge fan-in work are both maximal. Real code sits between them, at roughly +9%.
Total sort work is unchanged — k sorts of n/k elements plus an n·log k
merge is still O(n·log n) — so the delta is purely the spill write plus the
merge read.
| Case | Mean |
|---|---|
| AND intersection, 100 files | 816.79ns |
| AND intersection, 1,000 files | 3.7150us |
| AND intersection, 10,000 files | 29.260us |
| OR union, 4 terms | 6.4297us |
| OR union, 16 terms | 29.912us |
| OR union, 64 terms | 122.23us |
| On-disk common literal, 1,000 files | 63.806us |
| On-disk common literal, 5,000 files | 475.88us |
The on-disk common-literal cases exercise a real built index through
IndexReader::lookup_trigram_with_masks. Keeping on-disk posting lists sorted by
file ID lets query execution skip redundant sorting and deduplication for these
already-normalized posting lists.
Interleaved A/B on one machine, before and after replacing SipHash with a multiply-xorshift hasher over the 24-bit trigram key and fusing the case-folding pass into the main window loop:
| Case | 1 KiB | 16 KiB | 256 KiB |
|---|---|---|---|
| Extract masks, lowercase ASCII | 18.860us → 6.7010us | 240.73us → 73.384us | 3.7818ms → 1.1429ms |
| Extract merged masks, lowercase ASCII | 37.896us → 9.2151us | 530.06us → 124.74us | 8.4003ms → 2.0132ms |
| Extract merged masks, mixed case | 38.483us → 9.9965us | 536.00us → 139.44us | 8.4413ms → 2.2813ms |
A trigram packs three bytes into 24 bits, so the key is already collision-free and SipHash's collision resistance bought nothing while running once per input byte. Merged extraction previously walked the file a second time whenever it held any uppercase byte; folding that into one pass means a window costs extra work only if it actually lowers to a different trigram, which closes most of the gap between the mixed-case and lowercase rows.
Benchmark Run (Windows) Benchmark Run (Linux/macOS)
- Repo: chromium/chromium (504,351 files)
- Queries: 30 (mix of literals, multi-word, and regex)
- Index build time: ~52s (Linux), ~73s (Windows), ~248s (macOS)
- Index size: ~2,584 MB
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 737,275 | 24,575.8 | 0 |
| tgrep (client → serve) | 41,884 | 1,396.1 | — |
tgrep is ~17.6x faster
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 1,254,185 | 41,806.2 | 0 |
| tgrep (client → serve) | 79,293 | 2,643.1 | — |
tgrep is ~15.8x faster
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 72,127 | 2,404.2 | 0 |
| tgrep (client → serve) | 18,942 | 631.4 | — |
tgrep is ~3.81x faster
Benchmark Run (Windows) Benchmark Run (Linux/macOS)
- Repo: mozilla/gecko-dev (387,841 files)
- Queries: 122 (mix of C++, JavaScript, and Python patterns)
- Index build time: ~35s (Linux), ~58s (Windows), ~165s (macOS)
- Index size: ~1,952 MB
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 2,176,629 | 17,841.2 | 0 |
| tgrep (client → serve) | 56,435 | 462.6 | — |
tgrep is ~38.6x faster
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 4,075,017 | 33,401.8 | 0 |
| tgrep (client → serve) | 78,447 | 643.0 | — |
tgrep is ~51.9x faster
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 145,774 | 1,194.9 | 0 |
| tgrep (client → serve) | 19,815 | 162.4 | — |
tgrep is ~7.36x faster
Benchmark Run (Windows) Benchmark Run (Linux/macOS)
- Repo: torvalds/linux (95,831 files)
- Queries: 102 (mix of literals, multi-word, and regex)
- Index build time: ~21s (Linux), ~26s (Windows), ~37s (macOS)
- Index size: ~1,000 MB
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 334,555 | 3,280.0 | 0 |
| tgrep (client → serve) | 9,612 | 94.2 | — |
tgrep is ~34.8x faster
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 549,813 | 5,390.3 | 0 |
| tgrep (client → serve) | 26,124 | 256.1 | — |
tgrep is ~21.0x faster
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 43,542 | 426.9 | 0 |
| tgrep (client → serve) | 4,643 | 45.5 | — |
tgrep is ~9.38x faster
This suite has now been measured five times on separate runner sessions. Linux came out at 5.2x, 5.5x, 5.7x, 6.2x and 9.38x; macOS at 21.0x, 22.4x, 24.7x, 27.3x and 31.7x (32481331031, 32456274534, 32457317972). Only the latest run carries this branch's read-once and lazy-line-index work, so its Linux figure is not purely runner variance; the earlier four are. The tables report the linked latest run consistently, and the spread is retained to show why a shared-runner ratio should not be read as a controlled benchmark.
Benchmark Run (Windows) Benchmark Run (Linux/macOS)
- Repo: rust-lang/rust (62,326 files)
- Queries: 102 (mix of Rust patterns, macros, traits, and regex)
- Index build time: ~4s (Linux), ~6s (Windows), ~8s (macOS)
- Index size: ~199 MB
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 151,921 | 1,489.4 | 0 |
| tgrep (client → serve) | 19,759 | 193.7 | — |
tgrep is ~7.69x faster
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 66,769 | 654.6 | 0 |
| tgrep (client → serve) | 24,849 | 243.6 | — |
tgrep is ~2.69x faster
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 14,708 | 144.2 | 0 |
| tgrep (client → serve) | 9,115 | 89.4 | — |
tgrep is ~1.61x faster
Benchmark Run (Windows) Benchmark Run (Linux/macOS)
- Repo: kubernetes/kubernetes (31,300 files)
- Queries: 97 (mix of Go patterns, Kubernetes API types, and regex)
- Index build time: ~4s (Linux), ~7s (Windows), ~5s (macOS)
- Index size: ~215 MB
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 130,200 | 1,342.3 | 0 |
| tgrep (client → serve) | 18,381 | 189.5 | — |
tgrep is ~7.08x faster
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 27,731 | 285.9 | 0 |
| tgrep (client → serve) | 9,881 | 101.9 | — |
tgrep is ~2.81x faster
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 9,158 | 94.4 | 0 |
| tgrep (client → serve) | 9,878 | 101.8 | — |
ripgrep is ~1.08x faster — tgrep is 0.93x
Benchmark Run (Windows) Benchmark Run (Linux/macOS)
- Repo: golang/go (15,833 files)
- Queries: 103 (mix of Go stdlib patterns, testing, and regex)
- Index build time: ~2s (Linux), ~3s (Windows), ~3s (macOS)
- Index size: ~113 MB
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 60,949 | 591.7 | 0 |
| tgrep (client → serve) | 8,093 | 78.6 | — |
tgrep is ~7.53x faster
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 21,075 | 204.6 | 0 |
| tgrep (client → serve) | 6,753 | 65.6 | — |
tgrep is ~3.12x faster
| Tool | Total (ms) | Avg per query (ms) | Timeouts (120s) |
|---|---|---|---|
| ripgrep | 4,541 | 44.1 | 0 |
| tgrep (client → serve) | 3,516 | 34.1 | — |
tgrep is ~1.29x faster
- Repo size is the strongest predictor. The trigram index eliminates scanning files that can't match, so the advantage grows with the corpus. On the two largest repos (Chromium 504K files, gecko-dev 388K files) tgrep wins on every platform, by 3.81–51.9x. On the smallest (Go, 16K files) the Linux margin narrows to 1.29x.
- Windows benefits most consistently — geometric mean 14.6x, and never below 7.08x in any cell. Windows per-file open/read overhead is high, so skipping 90%+ of the files pays off everywhere.
- macOS has the highest peak but more spread — geometric mean 8.61x, range 2.69–51.9x.
- Linux is the weakest case — geometric mean 2.82x, range 0.93–9.38x, and it holds the matrix's only loss. Linux's page cache plus ripgrep's parallel scan make brute force genuinely cheap on a warm repo, so the index buys less.
- ripgrep never hit the 120s per-query cap in any run (Timeouts = 0 in every table), so every ratio here is a true measured value, not a censored one.
- Index build is a one-time cost — ~3s for Go on macOS, ~248s for Chromium there — and the server then watches for file changes and updates incrementally.
tgrep loses exactly one of the eighteen cells — Kubernetes on Linux, at 0.93x — and the measured margin elsewhere ranges from 1.29x to 51.9x. Two things move it.
Repo size, as above: more files means more files the index can skip.
Match volume, which is easy to miss. tgrep's cost per delivered match — serialize, ship over IPC, deserialize, print — is higher than ripgrep's, because ripgrep writes straight to stdout from the scanning thread. A query returning tens of thousands of matches can spend more on delivery than the index ever saved on file selection.
The kernel suite used to demonstrate this the hard way. Its queries were generic
tokens — read, write, ^#define\s+[A-Z_]+ — that matched most of the tree:
5,398,512 matches across 102 queries, with a single query returning 2,089,941.
On that query set kernel Linux lost every run: 0.81x, 0.74x and 0.95x, the closest
being 48.6s against ripgrep's 46.4s. Index pruning was not the problem; measured
directly on a local kernel checkout, the index still narrowed to 4–12% of the
95K files.
The suite now uses queries a kernel developer would actually run
(devm_platform_ioremap_resource, netif_napi_add, blk_mq_alloc_tag_set):
a different set of 102 queries returning 188,862 matches, none above 6,000.
ripgrep's Linux total barely moved — 33–46s across the six most recent runs
spanning both query sets, with a single 103s outlier in the earliest, because it
scans every file whichever pattern it gets — while tgrep's fell from 49–128s on
the old set to 4.6–8.1s on the new one. That difference is the delivery cost,
isolated.
A caution on reading the ratios: the ripgrep baselines themselves move between runner sessions, and macOS moves most. Five runs of the identical kernel suite measured macOS ripgrep at 385s, 388s, 495s, 500s and 550s — a 1.4x spread from runner variance alone — so treat a single macOS column as an order of magnitude rather than a precise figure. The Linux column is the stable one, and it is also the least flattering.
Practical guidance: tgrep is a strong replacement for ripgrep on large repos. Expect the biggest wins on Windows and macOS, and on selective queries anywhere. A repo-wide search for an extremely common token is the one case where it can lose to ripgrep, and it is also the case where neither tool gives you a useful answer.