A jq-style JSON query tool, written in kanso. Output
is byte-identical to jq -S on every query below — verified with diff, not
claimed.
Speed
Interleaved runs (kq and jq alternate, so machine state hits both alike),
whole-process wall time (startup + read + parse + query + print), nine rounds
per row with enough repetitions in each to amortise process startup, median of
the nine. Byte-identity verified before any timing. Apple M-series, quiet box,
2026-08-09. The 1.9 MB fixture is ten flat copies of bench/large.json's
elements, so it is reproducible from what the repo already carries.
kq against jq 1.7.1. Round-to-round spread was 3.7% on the path queries and 8.7% on the full print of the 1.9 MB document, so the last digit of each figure carries no information.
These rows used to be regenerated by CI on every pull request. Two consecutive runs of identical code moved every one of them by nine to twenty-two per cent and reversed the direction of the largest, because a shared runner is a different physical machine each time and alternating the two programs cannot cancel that. Interleaving handles the noise within a run, which is not where the error was.
| workload | kq | jq 1.7.1 | |
|---|---|---|---|
| path query, 188 KB | 2.8 ms | 4.7 ms | kq 1.7x faster |
| path query, 1.9 MB | 11.8 ms | 24.4 ms | kq 2.1x faster |
| full pretty-print, 188 KB | 5.2 ms | 12.5 ms | kq 2.4x faster |
| full pretty-print, 1.9 MB | 147 ms | 99.5 ms | jq 1.5x faster |
The large row used to read 1113 ms, ten times jq's. kanso #639 changed when the
copy walk may share a node and made the walk quadratic in document size; the
counter behind it read 2,721 on the 188 KB fixture and 286,401 on ten times the
data, and now reads 69 and 717. bench/scale_gate.kso is the gate that refuses
its return, and bench/scale_exceptions.txt is empty again.
Deterministic allocator counters, regenerated by CI on every commit. Every
figure reproduces on any machine, and ratio is ten times the document over
the 188 KB fixture — work that is linear in the input lands near 10.
| counter | 188 KB | 2.1 MB | ratio |
|---|---|---|---|
allocs |
98310 | 982696 | 9.9x |
alloc_bytes |
5757232 | 57492036 | 9.9x |
arena_peak_bytes |
3145728 | 26004240 | 8.2x |
carry_dedup |
69 | 717 | 10.3x |
view_allocs |
2761 | 27610 | 10.0x |
view_frees |
0 | 0 | — |
| full pretty-print, 1.9 MB | kq | jq 1.7.1 |
|---|---|---|
| wall clock, best of seven | 140.06 ms | 100.59 ms |
| peak footprint | 28.8 MB | 29.9 MB |
Measured in one interleaved sitting on 2026-08-05, alternating the two binaries so neither gets a warm machine the other did not. kq holds less memory and takes about a third longer. Retired instructions and cycles are the honest way to say this without a clock in it, and they want a box that can read the hardware counters; they are not in the table rather than stale in it.
The machinery under those rows is kanso's, and it is worth a paragraph. The arena rewinds between loop iterations when the compiler proves the iteration keeps nothing across the line, and a byte accumulator earns that proof by pointer identity: it is the very object that arrived at the loop's entry, threaded through appends the uniqueness analysis showed in place, with its growth outside the arena where a rewind cannot reach. Every loop on kq's print path qualifies, so each element's temporaries die the moment the next element begins. That mechanism still holds; what regressed is a separate one, the walk that decides when a node may be shared rather than copied.
What remains of kq's footprint is the decoded document — live across the whole run by construction — plus the decode-phase transients the region holds until exit. The output no longer contributes: it streams.
Absolutes here carry the load; a quiet box brings every row down.
The path-query gap grows with document size: kq decodes, walks to the subtree, and prints only that — the win compounds as the part you didn't ask for gets bigger. Pretty-printing is jq's board at scale again while the copy-walk regression stands: the byte builder in the encode path (one accumulator threaded through the whole tree, escape scanning proven clean in one SIMD pass) still wins the smaller documents and loses the large ones to the walk.
One deliberate difference: on a path that doesn't exist, jq prints
null; kq reports an error naming the missing key. kanso treats a missing
index as a failure to surface, not a nothing to pass along — if you want
jq's silence, query a path that exists. The race harness verifies
byte-identity per query before timing anything, which is exactly how this
difference was caught.
Use
kq <path> [file.json] # or pipe json on stdin
kq .users[3].name data.json
Why it's fast
kq is ~400 lines of kanso sharing its decoder with the standard library — the same decoder that outruns hand-tuned serde_json on the language's json gauntlet. No hand-written parser tricks live in this directory; the speed is the compiler's. The story: kanso-lang.dev/compiler.html.
Specs
sh spec.sh (with KANSO pointing at a kanso build) runs the unit tests,
then eleven fixture cases over non-trivial JSON — unicode/CJK/emoji and
escapes, precision-edge numbers, deep nesting and empty containers, and the
188 KB nested document — each checked against a committed golden AND against
live jq -S byte-for-byte. CI gates on all of it.
Intel macs: no GitHub runners exist for that target anymore; build from
source (kanso build .) or use Rosetta until a cross-build lands.