Repository navigation
fix(jq): make join linear in the number of elements - #2637
Conversation
`join` used jq's own fold shape, `(if . == null then "" else . + $x end) + $elem`, which keeps the accumulator reachable while the right operand is evaluated. `Val::add` reuses the left operand's buffer through `BytesMut::from(Bytes)`, and that is zero-copy only while the `Bytes` is unique, so a shared accumulator was copied on every element and `join` grew with N^2: 300k elements cost 68 s in an unoptimized build. The definition now coerces the elements first and folds with the accumulator as the left operand of a single add, `. + ($x + $s)`, then slices off the separator the fold prepends. The same 300k costs 14 s and scales linearly (100k -> 300k: 3.15x, where linear is 3.0x). Observable behaviour is unchanged. Arrays of 0 or 1 element never reach the fold, so `["a"] | join(1)` still succeeds, and `$p[0] + $x` reproduces jq's error and its operand order once a second element exists. Nightly #247 (AddressSanitizer) was the report: `memory_growth_security_tests::jq::ordinary_workloads_still_fit` hit its 600 s budget. That test exists to catch a quadratic `join` and had been passing only because 600 s is generous without instrumentation -- 68 s fits natively, the same work under ASAN does not. The jq module of that file now runs in 31 s instead of 80 s. `join_follows_jq` pins 14 cases captured from jq 1.7, error wording and operand order included; three `no_leak_*` cases cover the new failure paths and `def _bk_joinstr` joins the TM-INF-022 banned list.
Deploying with
|
| Status | Name | Latest Commit | Preview URL | Updated (UTC) |
|---|---|---|---|---|
| ✅ Deployment successful! View logs |
bashkit | 25409e5 | Commit Preview URL Branch Preview URL |
Oct 08 2026, 09:35 AM |
|
Reproduced the nightly job locally to confirm the headroom rather than infer it from the ~15x figure in Same configuration as the ASAN job — All 7 jq tests in that module pass under ASAN, 235.6 s for the module. 157 s against a 600 s budget — 3.8x headroom. That also pins the instrumentation multiplier for this workload at ~10.5x, which reconciles with nightly #247: the quadratic
Generated by Claude Code |
What changed
jq'sjoinis linear in the number of elements again. It was quadratic: everyelement copied the whole accumulated result, so
joinover 300k elements cost68 s in an unoptimized build and now costs 14 s, scaling linearly.
Observable behaviour is unchanged. 14 cases captured from real jq 1.7 — including
the wording and operand order of both error messages — are pinned by a new
join_follows_jqtest.Why
Nightly run #247 (AddressSanitizer) failed on
memory_growth_security_tests::jq::ordinary_workloads_still_fit, which hit its600 s budget. That test exists to catch exactly this, and it had been passing
only because 600 s is generous on an uninstrumented build: 68 s fits, the same
work under ASAN's instrumentation does not. The red nightly was a true
positive, not a slow-runner artifact.
The copy happens in
Val::add, which reuses the left operand's buffer throughBytesMut::from(Bytes)— zero-copy only while thatBytesis unique. jq's ownfold shape,
(if . == null then "" else . + $x end) + $elem, keeps theaccumulator reachable while the right operand is evaluated, so the inner add
sees a shared buffer and copies it.
Measured at 100k → 300k iterations, where linear is 3.0x:
. + "x". + ("," + "x"). + "," + "x". + "x" + "y"The rule that falls out: the accumulator appears once, as the left operand of
a single add, with the right operand fully parenthesized.
joinnow coercesthe elements first and folds with
. + ($x + $s), slicing off the separator thefold prepends.
Before / After
jq -n '[range(N)] | map(tostring) | join(",") | length', unoptimized build:Ratio 100k → 300k: 5.3x before (quadratic) vs 3.15x after (linear, 3.0x
is perfect). Isolating the stages confirmed
joinwas the only superlinear one —[range(300000)]is 57 ms,map(tostring)0.98 s, andreduce . + "x"0.96 swere all already linear.
Under the nightly ASAN configuration the failing test now finishes in 157 s
against its 600 s budget (3.8x headroom); see the measurement comment below.
The jq module of
memory_growth_security_testsdrops from 80 s to 31 s; the wholebashkitintegration binary runs in 22.5 s.Differential check against the
jq1.7 binary — all 14 identical, before andafter:
Risk
joinis defined in the compat prelude, so@csv,@tsvand@shgo throughit too; all 343 jq unit tests, the jq spec cases and all 1488 integration tests
pass. The fold now materializes the coerced elements as an array first, which
charges roughly
N * size_of(Val)extra againstmax_live_intermediate_bytes(~7 MB at 300k, against a 32 MB default) — a verylarge
joincan therefore reach the value-size cap marginally sooner, with thenormal
value size limit (N bytes) exceededdiagnostic.cargo fmt --check,cargo clippy --all-targets -D warnings,343 jq unit tests, 24 jq
no_leaktests, 1488 integration tests,check_okf.py, plus the ASAN run of the previously failing test.Checklist