The count changed every time.
Same file, same command, same machine. Three runs in a row: 356,940 hits, then 356,356, then 357,803. None of them failed. Each exited cleanly and handed back a number.
This is not a performance story. It is about the one thing an investigation tool cannot afford to get wrong.
Up front: we traded 21.9× of speed for a repeatable answer
The counts moved because the old default cap — stop at one million hits — kept whichever million the parallel shards happened to fill first. Which shard wins depends on disk mood and thread scheduling, so the surviving subset was non-deterministic. We made the default unlimited, and a cap now only applies when you ask for one, in which case the command still returns results but exits 2 (incomplete). The bill: the same 50 GB investigation goes from 1.24 s with the cap to 27.12 s without it (ripgrep, same run: 60.35 s). We chose repeatability over speed. Mac M4, 32 GB RAM, external USB SSD, OpenStreetMap XML; one setup, results vary. Details at the end.
About this series. "First Light" is the pet name of UwView v1.6.6 — the release where both the GUI and the command line
started opening and searching a huge file in a single pass. This series follows what that took, one fix at a time.
Part 5 is not about speed: it is a CLI specification we fixed along the way, in v1.6.2.
The problem: three runs, three answers
This surfaced while chasing mismatches in the harness that diffs our output against ripgrep's, line by line.
The query is a two-stage narrow over a 50 GB OpenStreetMap XML file: collect lines containing k="amenity", then find parking among those. Ordinary investigation shape. The correct answer, counted without a cap, is 460,467.
What came back:
- Run 1: 356,940
- Run 2: 356,356
- Run 3: 357,803
All short. And all different from each other.
The mismatch itself had an explanation. At 50 GB, stage one exceeds a million hits, so it tripped the then-default "stop at one million." Stage two then ran over a truncated population, which of course cannot reach 460,467. The exit code was a correct 2 (cap reached). Working as specified.
The trouble was how it truncated. Which raises the question: if the cap is behaving as specified, why is a different million left over every time?
The cause: not "the first million" but "the first million to arrive"
Searching a huge file means splitting it and scanning the pieces in parallel (shards). uvp does this too.
The old cap watched a shared collector that every shard fed, and stopped when it reached a million. Reasonable at a glance — except that time has crept into the rule:
- How fast each shard progresses depends on disk behaviour at that moment, cache hits, and thread scheduling
- Shards that get ahead claim more of the budget
- Where each shard had reached when the millionth hit landed is different on every run
So what survived was never "the first million hits in the file." It was whichever million won that particular race. Stage two therefore ran over a different population each time, and out came 356,940, 356,356 and 357,803.
The same root produced a second surprise, with -limit 10:
3 GB, -limit 10 | Line of the first hit returned |
|---|---|
| `rg \ | head -10` |
uvp -limit 10 (then) | line 71,004,000 |
Both return exactly ten. The contents have nothing to do with each other. Our first hit came from a late shard that happened to finish early. The old -limit meant "maximum hits retained" — memory protection — and never promised "the first N," so this too was as specified. But every user's intuition sits with head. The person who wrote that test expected head semantics, which is about as direct a piece of evidence as you get.
"Working as specified" is not a reason to leave it
This is the part that took the most thought, so it gets a heading.
Everything we found was working as specified. Capping at a million, -limit not promising the first N — both matched decisions made in advance. Cases where the search result itself was wrong: zero.
We changed it anyway, because of what this tool is for. Investigation means running the same command many times, varying one thing, and reading the movement in the numbers. If the numbers move when you did not vary anything, you can no longer tell what you are looking at.
Repeatability — same command, same answer — is not a feature sitting alongside speed and functionality. It is the floor that makes every other number trustworthy. A specification that is internally consistent but takes out the floor is the specification that has to change.
The fix: uncap by default, report truncation in the exit code
Three changes went into v1.6.2.
1. -limit defaults to unlimited. Say nothing and you get every hit. With the magic million gone, nobody gets silently truncated. The two-stage narrow above now returns 460,467 every time.
2. Hitting a cap is reported through the exit code. Pass -limit N, reach N, and the command returns its results and exits 2. uvp's exit codes follow grep's contract:
| Exit code | Meaning |
|---|---|
| 0 | Found |
| 1 | Not found |
| 2 | Error, or the result is incomplete (a cap was hit) |
grep's 2 nominally means "error," but for the question that matters — can I trust this output? — truncation is the same kind of answer as an error. It keeps if uvp huge.log 'ERROR'; then working as written, while refusing to pass off a short result as a success. There was no other slot that did both.
3. -head was rebuilt. "Show me the first N" is a legitimate request, so it got its own flag, separate from -limit. The old implementation searched everything and then sliced, costing 7.01 s for -head 10 on 50 GB. The new one reads forward on a single thread and stops at N. 7.01 s → 0.32 s, deterministic, byte-identical to rg | head (9 of 9 matching, exit 0).
We also wrote -limit's contract down: it is a retention cap, it does not promise the first N, and it exits 2 when reached — with "if you want the first N, use -head" printed right next to it. Once you remove an implicit default, the remaining behaviour has to be explicit or you get the same bug wearing a different hat.
The numbers: 21.9× slower, and worth it
50 GB, .uwvz reused (Mac M4, 32 GB RAM, external USB SSD, ripgrep 15.2.0, measured 2026-09-15)
| Same 50 GB investigation | uvp | ripgrep |
|---|---|---|
| Old default (cap at 1,000,000) | 1.24 s | 60.35 s |
-limit 1000 (explicit) | 0.52 s | — |
| New default (unlimited, 29,114,490 hits) | 27.12 s | 60.35 s |
1.24 s to 27.12 s. 21.9× slower — an unusual direction for a series about improvements, and not something to hide. Truncating is fast. "It is quick if you are allowed to stop early" is trivially true; the problem was that where it stopped moved.
What the 27 seconds bought:
- The same command returns the same count. 460,467, every time
- The ripgrep diff passes on the defaults. 81 checks that previously needed an explicit
-limit nonenow match with nothing added: 81 matching, 0 mismatching -
-headbecame both fast and deterministic: 7.01 s → 0.32 s
For what it is worth, the uncapped 27.12 s is still ahead of ripgrep's 60.35 s, so buying determinism cost time without costing the comparison. That part was luck. We do not know whether we would have made the same call if it had gone the other way.
Honest notes
-
-headis deterministic now, but still loses torg | head: 0.32 s against 0.20 s at 50 GB. Most of what is left is .NET taking 0.24 s to start, not the search. Sub-second against sub-second is a tie in practice — but a loss is a loss. - The timings above are second-and-later queries, with the
.uwvzalready built. The first query is about 6–7% behind ripgrep at 50 GB (59.0 s vs. 54.9–55.8 s, v1.6.6.1). - 356,940 / 356,356 / 357,803 were observed on v1.6.1. That is three runs. Enough to establish that the non-determinism was real; nowhere near enough to characterise its spread.
- Using exit 2 for "incomplete" bends grep's convention slightly.
rg | headexits 0 even though it truncates, and matching that was an option. We weighed script ergonomics against never handing over a quietly short result, and took the latter.
The general lesson: "fast" and "same every time" are often the same trade
Fan out, and stop when enough has arrived. It is a fast shape, and it mixes time into the result. The parallelism you added for speed is the doorway the non-determinism walks through.
The tell is simple: does the stopping condition depend on how fast things happen to go? If it does, the output changes run to run. Caps on count, on wall time, on memory — same trap in three costumes.
And: broken things usually show up as unstable, not as wrong. Nothing here was found by a failing test. The test failures already had an explanation — "it was truncated" — and stopping there was entirely available. What found it was running the same investigation a few times and noticing the numbers would not line up.
Which makes one cheap piece of insurance worth adding to any harness: run the same input twice and diff the outputs. You do not even need an expected value. That single check costs nothing to write and catches designs with time mixed into them across the board.
Finally: defaults are specification. A well-meant "cap it at a million for safety" reads, from the outside, as an unexplained gap in the data. A cap you have to ask for is safer than a cap that just happens — as long as, when it fires, you are told.
The tools behind this
UwView is a viewer for huge text and huge logs (free: UwView; paid: UwView Pro).
The same things, from the command line
# Ch.2: narrow with a second word (searched within the first word's hits) uvp huge.osm 'k="amenity"' 'parking' # Ch.4: ask for a retention cap explicitly (exits 2 when reached) uvp huge.log 'ERROR' -limit 1000 # Ch.4: take the first N (deterministic, stops early) uvp huge.log 'ERROR' -head 10 # Ch.4: branch on the exit code (0 = found / 1 = not found / 2 = error or incomplete) if uvp huge.log 'FATAL'; then echo "found"; fi # Ch.5: count by value / see surrounding context uvp huge.log -uniq 'status=([0-9]+)' uvp huge.log 'FATAL' -C 5 # Ch.5: write results to a file / hand them to the GUI uvp huge.log 'FATAL' -out fatal.txt uvp huge.log 'FATAL' -open Exit codes match grep: 0 = found, 1 = not found, 2 = error or incomplete result, so if uvp huge.log 'FATAL'; then works as written.
Honestly: the first query with uvp is a little slower than ripgrep, because it builds the .uwvz (compression + index) first — at 50 GB, 59.0 s vs. 54.9–55.8 s = 6–7% (v1.6.6.1). It pays off from the second query on: the same 50 GB search returns in 6.56 s, about 8.4× ripgrep. On small files like 3 GB it is about even — the index buys you less. The free uvf is on par with ripgrep at any size (measured article; Mac M4, 32 GB RAM, external USB SSD, OpenStreetMap XML — one setup, results vary).
Links
- Part 1 — the day we wrote "this is not a speed tool": https://uvp.y42u.net/en/blog/uwview-fl01-v160-confession-en/
- Part 2 — folding two and a half passes into one: https://uvp.y42u.net/en/blog/uwview-fl02-one-pass-rawgrep-en/
- Part 3 — folding case in raw bytes: https://uvp.y42u.net/en/blog/uwview-fl03-ignorecase-u212a-en/
- We shipped a uvp command (measured against ripgrep): https://uvp.y42u.net/en/blog/uvp-cli-release-vs-ripgrep-en/
- 81 checks diffed against ripgrep and sed: https://uvp.y42u.net/en/blog/uvp-vs-ripgrep-sed-81-checks-en/
- Source (GitHub): https://github.com/amru195704/UwView
And if huge logs are eating your storage and you want them kept compressed and still searchable, try UwView Pro: a persistent index, search inside the compressed cache, and about 1/9 the storage, with reopening and searching both a step faster (all OSes; one-time purchase or subscription).
From the developer: a list of my apps, Kindle books, and open source is at GitHub: amru195704.