Skip to content

perf(query): compose safe nested selects before planning - #754

Merged
singaraiona merged 7 commits into
devfrom
perf/752-nested-select-merge
Oct 8, 2026
Merged

singaraiona merged 7 commits into
devfrom
perf/752-nested-select-merge

Conversation

@singaraiona

@singaraiona singaraiona commented Oct 8, 2026 •

Copy link
Copy Markdown
Collaborator

Nested selects materialize their inner result before the outer planner can combine row filters, reduce derived columns by group, or stop at take. Compose eligible literal nested selects before planning to avoid that intermediate.

Fixes #752.

The rewrite validates both schemas and expands projection aliases in order using a whitelist of typed row-local expressions. It targets bound tables with flat typed columns and at least 8,192 rows. Grouped reductions, row projections and early take remain eligible. All scalar reductions over a filtered inner select stay materialized, including those with an outer filter: composing those predicates can lose to compacting selective input, and this patch does not have a selectivity cost model.

Computed aggregate inputs are limited to literal scalar multiplications of an F64 column. Arithmetic offsets, integer intermediates (including a later F64 promotion) and column-by-column products are barriers. This preserves nulls created by overflow, including sum/avg (* f g), squares and nested column products: the existing flat aggregate paths do not all normalize overflowing lanes before reduction. Row projections containing products can still merge. Repeated uses of computed aliases decline to avoid duplicated computation.

For grouped aggregates crossing an inner filter, a probe of the first 64 source rows must establish a nonempty inner result. If it cannot, the rewrite declines without scanning the rest. This preserves empty-source semantics but adds fixed planning overhead to some declined queries (most visible near the 8,192-row cutoff). Small tables skip composition before duplicate-key validation. Clause IDs are cached per symbol-table lifetime; fixed-arity expression lists avoid fallible append chains.

Inner grouping/sorting/take, aggregates and windows, opaque calls, casts, missing names, shadowed expression bindings, unsafe alias capture, text outputs with take, and combining two filters under take remain barriers. The original AST is unchanged; expansion size and nesting are bounded. Named sources use ray_env_get → env_lookup_flat, which searches caller scopes before globals. Regression tests cover source shadowing with different sizes and numeric types.

No runtime flag is added. Benchmarks and differential fuzzing wrap every nested select in do to force materialization. Floating reductions can change addition order; fuzzing permits 1e-12 relative/absolute error only for F64 aggregate values. Row expressions, schema, order, null metadata and error codes compare exactly.

Validation:

  • Full ASan/UBSan suite: 4,110/4,110 passed (zero skips/failures).
  • Final differential campaigns: 2,359 serial executions in 61 seconds and 3,326 four-worker executions in 181 seconds, with no failures; all 21 saved inputs replayed successfully in both modes.
  • Regression checks cover F64 column products, squares, grouped/nested products, mixed I64 intermediates promoted to F64, scalar-reduction admission with two filters, empty grouped results, local source shadowing and unchanged ASTs.
  • Benchmark variants serialize identically across all three final matrices (5M dense, 20M selective with one/four workers). Workflow YAML parses successfully.
  • Independent recheck of dd9bd970: both reported bugs fixed, 125 differential queries checked, no wrong results or crashes.
  • The final CI-only follow-up starts each short fuzz mode from a separate fresh corpus plus the 21 committed seeds. Executing that workflow step locally passed: 2,976 serial executions and 573 four-worker executions, including 550 executions after parallel initialization. This fixes the cached corpus consuming the entire parallel mutation budget.
  • All CI checks passed on final head 4205f704, including audit, static analysis, Linux/macOS debug and release, NDEBUG, TSan, and fuzzing. CI run.
  • The hosted CI log confirms 1,011 serial executions and 1,021 four-worker executions, with 998 parallel executions after initialization; both modes began with exactly 21 committed seeds and an empty working corpus.

The differential generator includes independent F64 columns, column products, squares, nested products, scalar/grouped reductions, extreme values, and tables of 8,191, 8,192, 16,385 and 131,073 rows. PR CI runs select_merge fuzzing for one minute each in serial and four-worker modes, with separate fresh working corpora and committed seeds so cached replay cannot consume the mutation budget. The nightly campaign uses four workers and retains its growing corpus.

Benchmark: Intel Core i7-14700, GCC 11.4 release build, one warmup and interleaved runs. Five-million-row tables use a % 3, four workers and seven runs. Twenty-million-row tables use a % 1000, four columns and five runs. Values are medians in milliseconds. The final matrices ran after local tests/fuzzing completed.

Case Materialized ms Nested ms Speedup
5M / 4 cols / two filters + scalar sum 12.9319 12.9667 1.00x
5M / 4 cols / derived grouped sum 13.5247 7.8662 1.72x
5M / 40 cols / derived grouped sum 59.2813 7.5762 7.82x
5M / 40 cols / filter + take 10 53.9166 0.0210 2563.31x
5M / 40 cols / two filters + row output 54.6396 3.6602 14.93x
20M / 4 workers / selective equality + sum 10.5442 10.5489 1.00x
20M / 4 workers / selective range + sum 4.7213 4.7107 1.00x
20M / 4 workers / selective equality + avg 4.3925 4.4139 1.00x
20M / 4 workers / selective grouped sum 8.3302 6.0877 1.37x
20M / 4 workers / selective row output 8.0303 5.9634 1.35x
20M / 1 worker / selective equality + sum 14.7931 11.8379 1.25x
20M / 1 worker / selective range + sum 10.2400 10.2235 1.00x
20M / 1 worker / selective equality + avg 8.1654 8.0500 1.01x
20M / 1 worker / selective grouped sum 11.8847 9.6816 1.23x
20M / 1 worker / selective row output 11.6820 9.6829 1.21x

Filtered scalar reductions now use their existing materialized path; variations in their timing ratios are not optimization wins. This deliberately gives up the earlier dense two-filter scalar speedup until a selectivity-aware cost decision exists. Grouped reductions, row output and early take retain the measured wins. The 64-row prefix check still has a small fixed cost on declined grouped cases near the size cutoff.

Reproduce with make release and python3 bench/nested_select.py --binary ./rayforce --rows 5000000 --runs 7 --cores 4. Use --a-period 1000 --widths 4 --rows 20000000 for selective inner filters, and --cores 1 for the serial comparison. S14/S15 cover the review's selective two-filter sum/avg cases; S16 uses a dense inner filter and S17 combines filters for a row projection.

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Reviewed. About 300 differential probes compared merged against hook-disabled against do-forced, using ser bytes and error parity. They agree everywhere except one F64 edge case (item 5). What they covered:

  • table sizes from 6 rows to 20M;
  • renamed and computed filters, alias shadowing, dropped columns, duplicate keys;
  • by: on a computed column (declines);
  • desc/asc+take with ties, row and group emit order;
  • empty filters with and without by:;
  • nulls, I64 overflow, SYM equality and < (declines);
  • 3–4 layers;
  • hash/zone/sorted/bloom/sort indexes, splayed (merges) and parted (declines) tables;
  • lambda-local shadowing.

AST unchanged, no refcount growth over ~18k queries, ASan clean, full suite 4104/4104, make fuzz-select_merge ran 74k executions without a crash.

The performance case has two gaps the bench does not cover:

  1. A scalar aggregate over a filtered inner select gets slower. {from:(select {from:T where:P}) total:(sum p)} with no by: is rewritten into the flat where+aggregate path, which is already slow on scattered selections. On the same build, (select {from:T where:(== a 1) total:(sum p)}) takes 41 ms against 7 ms for (select {from:T where:(== a 1) p:p}). Release, 20M rows, -c 4, PR against the hook disabled, median ms:

    outer inner where PR hook disabled
    (sum p) (== b 1), 1/7 of rows 110 42
    (sum p) (> b 3) 158 72
    (count a) (> b 3) 160 67
    (sum p) (== a 1), 1/1000 of rows 31 10.7

    At 5M rows the single-predicate case is 41 vs 16 ms at -c 4 and 157 vs 39 ms at -c 1. S1 in the bench uses two predicates, which take the faster and path, and S4 is already on the slow path, so neither shows this. The wins hold: take 70 → 0.02 ms, filter+projection 85 → 36, by: 74 → 41.

  2. The take:1 existence probe (select_merge.c ~234, called at ~367) is not a cheap early stop. It runs when an outer sum/avg/min/max/count crosses an inner where. With a late first match it scans for ~26 ms, while the whole parallel filter takes 6–7 ms. 20M rows, -c 4, ms:

    inner where PR hook disabled
    late match, (> p 19999990.0), + sum 37–39 5.9
    single match at 75% 41.6 6.2
    same with by:b 34 6.1
    no match 14.9 6.5

    Three layers with an aggregate in the middle run the probe twice: 65 vs 6.0 ms. The description's "a no-match filter costs an extra scan" understates it. On a 10-row table the probe is a whole extra select: 11.9 vs 7.75 µs per query.

Smaller:

  1. Alias expansion duplicates work below the 1024-node bound. With a doubling chain q0:(* p 1.0001) q1:(+ q0 q0) … q5 on 5M rows, the outer where+projection takes 140 vs 53 ms. From q6 on, the bound declines.

  2. Declined nested queries pay about 7% on tiny tables: 8.45 vs 7.9 µs.

  3. BUG, edge values. The F64 affine lowering ignores nulls produced by overflow. merge_nullable (~165, used at ~200) looks only at HAS_NULLS on the base columns, but (+ f c) overflow becomes 0Nf per row, which the nested path skips and sum(f) + n*c does not. Repro: with D = (table [f g] (list [1.7976931348623157e308 -1.7976931348623157e308 1.0] [1.0 2.0 3.0])), (select {from:(select {from:D c:(+ f 1e300)}) total:(sum c)}) gives 3.00e300 merged and -1.80e308 when do-forced; avg gives 1.00e300 vs -8.99e307. The flat query has the same lowering, and this PR exposes nested queries to it. Either decline sum/avg with an offset or fix the lowering.

  4. F64 sum/avg bits change for plain columns too, not only derived ones: about 1e-16 relative at 100k rows. It is documented, mentioned only for completeness.

  5. Tests and code:

    • The fuzzer builds tables of at most 32 rows, so it never reaches multi-morsel or parallel paths.
    • The bench has no single-predicate scalar-aggregate shape and no rare-match shape.
    • merge_clause/merge_name (~40–49) string-compare clause names under the sym lock on every merge_get; interning them once would avoid that.
    • On OOM only, ray_list_append failure paths leak out/both (~156–158, ~298–300).

The 43-query workload on 100M rows, with this PR and #744 on top of dev, matches dev in timings and outputs. Those queries have no nested selects.

@github-actions

github-actions Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

Rayforce targeted audit passed

The required Rayforce audit gate passed on the latest run.

Workflow run: https://github.com/RayforceDB/rayforce/actions/runs/37853418614

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Re-reviewed at c12ed82 and re-checked at 6c6a32c. The only src/ change since is the comment in merge_pair.

Fixed:

  • The take:1 probe is now the 64-row prefix check. Late-match, rare-match, by: and three-layer cases cost the same as with the hook disabled (~6.2 ms).
  • A computed column used more than once is no longer inlined. The doubling chain is 54 vs 55 ms.
  • The size cutoff works, and +/- inside the aggregate now declines.
  • Clause sym ids are cached, the OOM leaks are gone, and the bench has the missing shapes.
  • One filter inside with a scalar aggregate outside now declines and costs the same as before (38 vs 38 ms).

Still open:

  1. Two levels of where plus a scalar aggregate still merge, and they regress when the inner filter is selective. Release build, 20M rows, -c 4, merged vs hook disabled, median ms:

    inner where outer merged hook disabled
    (== a 1) where:(== b 1), sum 17.8 10.5
    (< a 10) where:(== b 1), sum 45.9 11.6
    (== a 1) where:(> q 5), avg 24.3 10.9

    At -c 1 it is 1.5–2x slower. The bench's own two-filter case on 5M rows is 93 vs 77 ms. Only dense inner filters win (230 vs 300 ms).

  2. BUG: a product of two F64 columns. When the product overflows, the materialised column stores 0Nf and sum skips it. The fused flat path sums inf and -inf into NaN. Repro at 6c6a32c:

    (set E (take (table [f g] (list [1e300 1e10 -1e300 3.0] [1e300 2.0 1e300 4.0])) 10000))
    (select {from:(select {from:E c:(* f g)}) total:(sum c)})        ;; 0Nf
    (select {from:(do (select {from:E c:(* f g)})) total:(sum c)})   ;; 5.00e13
    

    avg gives 0Nf vs 1.00e10, and (* f f) gives 0Nf vs 2.50e23. The flat (select {from:E total:(sum (* f g))}) already gives 0Nf, so the root cause is older. The merge sends nested queries that used to be right into that path. Declining * between two F64 columns, or fixing the flat path, would close it.

  3. Tests. I traced the merge on every line of nested_select_merge.rfl at 6c6a32c. Only the two lines b2fbaa2 added on L752 merge: the combined filter with total:(sum x) and the empty grouped ser. These lines do not merge:

    • the take:3 line;
    • both lambda-shadow lines (expected);
    • the Int752 (* x 2) line, so that overflow check passes whether or not the merge runs.

    Nothing covers the F64 product case. The fuzzer builds large tables and runs in parallel only with RAY_FUZZ_CORES, which the CI fuzz pass does not set, and its generator never makes column × column products.

  4. Minor: when the 64-row prefix check fails, it costs about 9–19 µs per query. This only shows on tables around 10K rows.

At c12ed82 the full suite passes 4110/4110. Over 100 new merged-vs-unmerged queries agree, except for item 2. These cover empty inner results, grouping, I64 overflow and overflow inside filters.

@ser-vasilich

Copy link
Copy Markdown
Collaborator

Rechecked dd9bd97. No wrong results or crashes. Both earlier findings are fixed. What the fix gives up and the parallel fuzz step are below.

Fixed:

  • The admission rule (merge_scaled_f64). I compared merged against do-forced, by ser bytes, in 125 queries. Tables had 100K rows and 9000 rows, at -c 4 and -c 1.
    • Admitted: (* f 2), (* 2 f), (* (* f 2) 3), and literals ±1e300, 1e308, -2, ±0.0, 0, 2^62 and INT64_MAX. Each was aggregated with sum/avg/min/max/count, scalar and by:, with an outer where:, and three layers deep.
    • Overflowing lanes become 0Nf on both paths.
    • Correctly declined: (* f g), (* f f), I64/I32/I16 bases, offsets, and null literals.
    • The only differences are about 2 ulp of F64 sum order, which the do path alone shows from run to run.
    • The E754 lines fail on 6c6a32c, so they catch the old bug.
  • The three two-predicate scalar cases now cost the same as the unmerged path. 20M rows, -c 4, merged vs do: 11.6 vs 11.5, 12.4 vs 12.6, and 11.2 vs 11.2 ms. At -c 1 they also match.

What declining scalar reductions over an inner where: gives up. The merge loses only on narrow tables with a selective inner filter. As the inner filter gets less selective, or the table gets wider, it wins by a lot:

  • 20M rows, 4 columns, -c 4, outer (== b 1) and (sum p), ms:

    inner where 6c6a32c merged now (= unmerged)
    (== d 1), 33% of rows 81.1 120.1
    (< d 2), 67% 101.1 210.2
    (< a 800), 80% 112.5 239.0
    (> b 0) / (> q 0) 216.9 325.6
  • 5M rows, 40 columns, -c 4, ms. Other sessions were running on the box:

    inner selectivity 6c6a32c merged now (= unmerged)
    0.1% 9.1 8.8
    1% 15.9 34.1
    10% 18.6 36.8
    33% 26.2 123.1
    80% 35.3 309.5
  • The bench's own S1 at width 40 went from 27.6 merged vs 135.7 unmerged to 138.5 vs 135.1.

The decline ignores how many columns the inner select materializes. That number, together with the inner filter's selectivity, decides whether merging pays. Admitting the rewrite when the inner select carries more columns than the outer one reads (or when a cheap selectivity estimate is high) would keep the narrow-selective case unmerged and recover most of these. The other shapes still merge and keep their gains: by:, projections (p:p 26.0 vs 78.5), take:10 (0.040 vs 71.3), and S2/S3/S17.

Planning cost of the new checks: +0.6 µs per query when the scalar decline fires, +1.3 µs when merge_output declines (10K rows). On 6c6a32c these were +25 and +57 µs. The grouped merge is still about 30% slower than unmerged at 10K rows (30.5 vs 23.3 µs), as it was before this commit. Sparse grouped merges break even at about 200K rows on -c 4.

Tests. I reverted each hunk in turn:

  • The scalar decline: C cases 0 and review case 9 fail; the rfl file passes, since the rule is about speed only.
  • The admission rule (back to merge_has_offset, or admitting column × column): C review case 2 and rfl line 45 fail.
  • Accepting I64 bases: only C review case 7 fails. With that check removed, (* (* i 2) 1.0) gives [10000] merged against -4.61e22 materialized, so the check matters.
  • Dropping the !RAY_ATOM_IS_NULL literal check: nothing fails, because the results agree anyway. The check is defensive only.

The rfl file still merges on only two lines (33 and 34); every other line is below 8192 rows or declined.

Fuzzer:

  • The generator now builds column products, squares, mixed I64/F64 chains and scaled overflow chains. A fuzz build with the 6c6a32c rule fails during seed replay on mixed_integer_product within a second.
  • Serial 60 s: 1701 executions, 0 crashes, 198 MB.
  • The new parallel CI step (RAY_FUZZ_CORES=4 … FUZZ_RUNTIME=60) only replays the corpus. Replaying the ~400 units it had at that point took 86 s at 4 execs/s, so the 60 s run stopped at INITED with 0 mutations. With the CI corpus cache restored by prefix, the corpus keeps growing. That step will never mutate, and its runtime grows with the corpus. It needs either a seeds-only corpus, a separate corpus directory, or a bounded replay.

make test (ASan/UBSan): 4110/4110.

Unrelated to this PR (also on v2.11.0): an overflowing I64 vector multiply stores INT64_MIN without marking it null. (avg (* (take [4611686018427387904 1] 10) 2)) gives -4.61e18, and nil? on that vector is false. The atom (* 4611686018427387904 2) gives 0Nl.

@singaraiona
singaraiona merged commit 24c14e8 into dev Oct 8, 2026
10 checks passed
@ser-vasilich

Copy link
Copy Markdown
Collaborator

Found after merge, now on dev 24c14e8: an outer desc:/asc: that names a computed column of the inner select errors with nyi once the composition applies (≥ 8192 rows).

(set T (table [x] (list (til 8192))))
(select {from:(select {from:T q:(* x 2)}) desc:q})        ;; nyi
(select {from:(do (select {from:T q:(* x 2)})) desc:q})   ;; 8192 rows
  • With (til 8191), where the select is not composed, it works.
  • dev 57f5cfe, before this PR, gives 8192 rows.
  • It fails with and without take:, for I64 and F64, and with an inner where:.

The sort-visibility check in select_merge.c (~370–380) lets the outer sort name an inner computed column. The composed flat query {from:T q:(* x 2) desc:q} then hits a nyi that the flat path already has on dev. The fuzzer misses it because its generated sorts only name p. Declining composition when the outer sort names a computed inner column would avoid it until the flat path supports that sort.

singaraiona added a commit that referenced this pull request Oct 12, 2026
* fix(store): a replaced splayed table keeps one copy of itself (#713)

A table replaced twice or more held about three copies on disk: the first
write's root column files, never read again after the first publish, and
the previous generation, kept for a reader that resolved .current before
the swap.

- The first publish removes the columns the retired root schema names,
  with their .link sidecars, then .legacy.d.  Only those names go: the
  root also holds the symfile, the lock, and for a table written into a
  database root, the partitions.  A cleanup cut short is finished by the
  next publish, which also cleans stores written by earlier versions.
- Publication keeps only the selected generation.  Instead of the writer
  keeping the previous one, a reader whose generation is removed before it
  opened every file resolves again and reads the selected one
  (splay_load_impl, ray_read_splayed_dom, and ray_splay_has_schema for the
  partition checks, where a false "no table" would let .db.parted.fill
  write an empty table over a real one).  Mapped files stay readable.

No setting: the previous generation protected nothing the retry does not.

* fix(query): exclude null symbols from not-in (#739)

* perf(index): store hash slot table and group offsets as 32-bit

A hash index over a column below 2^32 rows keeps its slot table (group
id + 1) and its CSR group offsets as unsigned 32-bit values instead of
64-bit.  Both are bounded by the row count, so nothing is lost; the two
arrays take half the memory and half the bytes on disk.  Readers go
through one width-aware accessor; the append carry widens the source
arrays and re-narrows the result.

An index with a narrow array is persisted as layout generation 2.  The
mapper accepts generation 1 (all 64-bit, as written so far) and
generation 2, and rejects a generation-1 region that carries a narrow
array, so the column loads unindexed rather than misreading it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(heap): count off-heap allocations toward the spill watermark (#712)

The anonymous watermark that routes new memory to spill files saw only
the heap's pools and direct blocks.  Everything allocated with
ray_sys_alloc stayed outside it: the string arenas of the symbol table
and of the column domains, their hash tables, and index-build scratch.
During a load of a wide table with many distinct strings that is most of
the footprint.  Sampled in a container limited to 15 GB with -m 9G, the
watermark counter stayed at 1.9 GB while sys allocations grew to
15 GB, and the process was OOM-killed.

ray_sys_alloc now goes through ray_heap_sys_map: every block counts
toward the watermark, and a block of 1 MiB or more that would cross it
is mapped over an unlinked spill file instead, like a direct block.  The
file's descriptor is closed at once, so spilled blocks hold no fds.

Past the watermark, a pool or direct block that could not get a spill
file (no writable spill directory, disk full) failed the allocation.
With more counted and a lower default that point came much earlier; such
a block, like a sys block, now comes from RAM instead.

The default watermark was the whole RAM the process may use (physical
RAM, or a cgroup limit when that is smaller), so spilling began only once
nothing was left.  The page cache needs room from the same memory: the
mapped inputs, the dirty pages of the files being written and those of
the spill files, all charged to the limit under a cgroup.  The default now
stops a quarter below it, at least 3 GiB and at most half.  The budgets a
query sizes its strategies by (the direct-block cache, the dense and mode
grouping gates, the fused cumulative-distribution gate) keep the whole
limit: ray_heap_mem_budget is the -m value when one is set, else the RAM
the process may use.  Sized by the lowered watermark they picked slower
plans on a 32 GB machine.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(index): store hash index row ids as 32-bit

The CSR row-id array of a hash index (one entry per indexed row, the
largest of its arrays) is stored as unsigned 32-bit values below 2^32
rows, like the slot table and offsets.  Over a column of unique keys an
index now takes 16 bytes per row (row id, offset, key) plus 4 per slot,
instead of 24 plus 8.

Row slices leave the index as ray_idx_rows_t, a pointer with its width,
read through ray_idx_rows_at:

- ray_index_hash_group and ray_idx_slice_t carry it; the rowsel builder
  is specialised per width, and ray_index_rowsel_from_rows builds a
  selection straight from an index slice.
- The filter-plus-group kernel widens a narrow task chunk once into a
  per-worker buffer, so its accumulation loops are unchanged.
- The as-of join verify takes the slices directly instead of packing
  pointers into an int64 array.

Indexes mapped from generation-1 files keep their 64-bit rows and are
read through the same accessors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(arena): grow chunks so a large arena can spill (#712)

An arena allocated every chunk at its initial size: 64 KiB for a column
domain's string atoms.  A domain with millions of distinct strings was
then hundreds of thousands of small ray_sys_alloc blocks, and blocks
below 1 MiB are never mapped over a spill file, so the atoms of a large
load stayed in RAM past the watermark.  In a 15 GB container with -m 9G
the counted footprint reached 16.5 GB and the load stalled at the
cgroup limit.

Chunks now double from the initial size up to 16 MiB.  A large arena is
a few big chunks that spill past the watermark; a small one keeps its
small first chunk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(index): keep the wide arrays when narrowing is interrupted

Narrowing a just-built hash index's arrays runs as a pool dispatch, and
an interrupt (Ctrl-C, a client's cancel) makes a dispatch skip its
tasks.  Landing after the build's last interrupt check, it left the
32-bit copies unfilled and installed them; a load could then save that
index, and lookups returned wrong rows.  The narrowing now keeps the
64-bit array it was copying when the dispatch was cancelled.

The append carry built 64-bit arrays and narrowed them again on every
append; it now builds them in the width they end in and copies a narrow
source as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(heap): hand memory back between statements when it is short

A free block keeps its pages and a standard pool stays mapped after a
statement, so a repeated query reuses them without faulting; a server
gave them back only after ten idle seconds.  Where memory is short this
turned against the next query.  What the previous one left resident
starved the page cache, and a kept pool still counts toward the spill
watermark, so the next query's new memory went to spill files.  In a
container limited to 4 GB a heavy grouping ran in 22 s on a fresh server
and in 430 to 870 s on the second and third runs.

At a statement boundary of a server request or the REPL, once the
anonymous footprint is past the watermark (the next query's memory
would spill, and the room the watermark leaves the page cache is being
eaten), empty pools of every heap, standard ones included, are now
unmapped (their bytes leave the watermark count) and the pages of the
remaining free blocks released.  The repeated runs above take as long as
the first.  Below the watermark nothing changes: the kept memory is what
a repeated query reuses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(index): build hash indexes partition by partition within the memory budget

The hash index build kept every row's group id, key word, partition slot
and the output arrays live at once, about 58 bytes per row at random, so a
100M-row column needed ~6 GB of private memory and on a small machine paged
it through the spill file at random.

Numeric and SYM columns are now indexed by a partitioned builder.  Key
words and row ids are bucketed by the top bits of the key's mixed hash;
partitions are deduped in batches whose working set fits a quarter of the
heap watermark; their groups, row offsets and rows are written in order.
Groups are numbered in ascending order of the mixed hash and a key's home
slot is the top bits of that hash, so the slot table is written in one
forward sweep and every array follows from the data alone, whatever the
partition count, budget or core count.  Only a batch is accessed at
random; the bucketed input and the outputs are streamed, and past the
watermark their heap blocks are file-backed.  An interrupt anywhere in
the build leaves nothing allocated and the column unindexed.

The new tables are marked RAY_MARK_HASH_HIGH and persisted as layout
generation 3.  Readers take the home slot from the table's own rule, so
generation 1 and 2 tables keep loading; older binaries load a generation-3
column unindexed.  STR columns keep the serial walk.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(heap): relieve only when it brings the footprint back under the watermark

The statement-boundary relief scanned every freelist once per pool (its
cost grew with the square of the heap: hundreds of milliseconds per
statement on a fragmented heap of a few hundred MB), ran at every boundary
while live data held the footprint past the watermark (unmapping pools,
draining the direct cache and releasing pages that the next statement
mapped and faulted straight back), walked the heaps of threads that may
still be allocating, and ignored empty spill pools.

Empty pools are now found per heap with a cheap filter (a free block of
pool order or above must exist; then one header read per cascade offset)
and confirmed by one walk of the freelists that sums free bytes per
candidate.  Relief acts only when the next pool would not fit under the
watermark and handing back the empty pools and the direct cache brings it
under again; otherwise the memory stays for reuse.  When it acts it also
unmaps empty spill pools, whose page cache counts against a container's
limit.  Relief, the idle decay and gc's pool pass visit only the caller's
heap and the parked pool workers' heaps.  Without a file spill (Windows)
relief does nothing and memory is left to the idle decay.  The REPL's ^C
path, a script run with a file argument, timer callbacks and .sys.gc now
end at the same boundary.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(scan): read ahead the columns a pass reads on cold storage (#747)

* fix(store): a reader keeps up with a table that is being rewritten (#748)

* fix(store): a reader keeps up with a table that is being rewritten

Only the selected generation of a splayed table is kept, so a reader can
see the generation it resolved removed before it has opened all its
files.  It then resolves again and retries, but gave up after eight
attempts.  A reader slower to open its files than the writer is to
publish (many columns, slow storage, a busy CPU) failed with an I/O
error after eight saves, although every failure was only the table
moving on.  splay/generation_reader_races hit it: under a loaded CPU it
failed 29 times in 30.

Each retry follows a completed publish, so the reader is no longer
counted out: it retries while the table keeps resolving to a different
generation, at least eight times and for up to 60 seconds, and then
reports that the table kept changing rather than the error of its last
attempt.  An interrupt stops the retries with a cancel error.  The
symbol domain of the previous attempt stays open across a retry, so a
large symfile is extended rather than read again on every attempt; read
again each time, it could take longer than the writer's interval and
lock the reader out.  Under the CPU load above the race test passes 30
times in 30.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(store): a retry that finds no symfile lets go of the domain it held

A retry that found the symbol file missing or unreadable left the
domain an earlier attempt held in place and then overwrote the only
reference to it, so the domain and its mapping stayed cached for the
life of the process (a later save to that symfile path then failed).
Each attempt now releases the domain held from the one before it,
after reopening the symfile when there is one.

New test splay/generation_retry_releases_domain drives a read through a
FIFO .current served by a child process: the first attempt fails after
opening the domain, the symfile is removed before the retry, and the
domain must not stay cached.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* perf(parquet): import column by column when memory is short, through an import symbol dictionary (#753)

* perf(parquet): import a splayed table column by column when memory is short

The direct import ran one task per column chunk with row groups outermost,
so every worker wrote all of the table's column files at once and kept
every column's state live together, and the hash indexes waited for the
end and re-read their columns.  On a machine whose memory cannot hold the
output that is a scattered write pattern, and the re-read goes to disk.

The import now runs in passes.  When the process may use less than twice
the output's bytes it goes column by column (a pass is one column, or a
few when the file has fewer row groups than the pool has workers):
numeric and temporal columns first, each closed as soon as its pass ends
and its hash index built right away while the file is still in the page
cache; symbol columns after, smallest first, one by one below half the
output's bytes of memory and together above.  With more memory one pass
takes every column, as before.  RAY_PQ_PASS_COLS forces the columns per
pass.

Read-ahead follows the passes: a task requests its own chunk and the same
column's chunks of the next row groups (RAY_PQ_PREFETCH), up to a share of
the memory budget.  In one pass each written chunk's writeback starts as
it is written (ray_file_writeback_start), so the final sync finds little.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(sym): build the import's symbol file as the strings arrive

The import interned its strings into a file domain that keeps every one as
an arena atom (about a hundred bytes each with its index), behind one lock,
and wrote the symbol file only at the end: a rewrite of the whole
vocabulary through a temporary file, then a sync.  On large
vocabularies that is several gigabytes of anonymous memory, which a small
machine pages in and out, and tens of seconds of writing after the last
column.

A converter that builds its symbol file alone now uses an import domain
(ray_sym_domain_create_import, table/symimp.c): interned strings are
appended straight into the mapped symbol file as its records; in memory
there are only the records' offsets and a hash index in 1024 shards.  A
hit is found without a lock, and a batch's new strings are added together
under one, so they get neighbouring positions.  Position 0 stays "".

While the passes run, a background thread appends what is new to the
symbol file every quarter second and starts its writeback
(ray_sym_domain_flush_append writes the tail in place, and is what the
import domain's flush does), so the final flush only syncs.  The direct
Parquet import uses the import domain; the spooled path keeps the
ordinary one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(parquet): keep the one-pass import as it was; harden the import dictionary

One pass over every column (the layout with memory to spare) closed and
hash-indexed each numeric column on the main thread as the pass ended,
one after another, instead of finishing them on the pool and building
their indexes in one parallel wave after the symbol domain is released:
a table of wide numeric columns took twice as long.  That pass now leaves
the columns to the common finish, as the row-group import does; only the
column-by-column passes close and index each column as they go.  A file
with fewer row groups than the pool has workers, imported column by
column, put its symbol columns in the numeric pass; they get passes of
their own.  RAY_PQ_PASS_COLS sets the symbol passes too.  Output sizes
from the file's metadata are summed without overflow.

The symbol file appender slept a quarter second between flushes and the
import joined it at the end, so every import with a symbol column took a
quarter second longer: it now sleeps in slices that see the stop.  A
failed flush stops the import at the next pass instead of after the
last.

When the import dictionary could not be set up after creating its file,
the empty or zero-filled file stayed and the fallback to the ordinary
domain rejected it ("oom"): the file is removed first.  The file grows by
fallocate where the filesystem supports it, so a full disk fails the
intern instead of faulting a write through the mapping, and from 1 MiB
instead of 64; growth stops at the mapping's reserve rather than failing
short of it.  The file is created 0666 under the umask, as the ordinary
domain's is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(sym): free the import dictionary's replaced tables; look up a batch's repeats once

A shard table that grew was kept until the end of the import, since a
lookup without the lock might still be reading it, so the dictionary held
its tables about twice over.  On a machine with little memory that left
the page cache too small for the symbol file's records, which every
lookup reads to confirm a match, and the import of a large vocabulary
became a stream of random reads.

Lookups now register in the parity of an epoch for the span of their
lock-free probing; a replaced table is retired in the current epoch and
freed, under the lock, once no lookup of an older epoch is left.  Equal
strings of a batch are looked up once, compared with the batch's own
bytes, and the symbol file's mapping is marked random so a lookup that
misses the page cache reads one page rather than a read-ahead window.
Peak anonymous memory of a 2.5M-string import: 200 MB to 120 MB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(sym): the import dictionary gives back the tables it replaced

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* perf(parquet): finish the one-pass import's columns while the symbol file is still written

With memory to spare the import runs one pass over every column, then
stopped the symbol file appender and synced the file before closing and
indexing the columns.  On a disk busy writing back the column data the
appender's last writeback request waits behind it, and the import sat
idle for that wait, then for the symbol file's sync, before any column
work began.

The one-pass import now finishes its columns (on the pool) and builds
their hash indexes in waves before it stops the appender, so that wait
overlaps the work; a symbol column's header takes the domain's final
count, which no longer changes once the pass is done.  A column whose
hash index ray_col_stream_hash_all built no longer asks for one, so the
common finish after it has nothing left to do.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sym): random advice only on the written part of the import's symbol file

Marking the whole mapping random made every new page of the file being
appended a separate major fault, taken under the lock that serialises
the appends (a few percent slower with memory to spare).  The written
part is marked random at each background sync instead, and the tail
keeps the default.  Replaced tables are freed after the lock is dropped
rather than under it.  A batch's dedupe table is cleared for the batch's
size, not for the largest batch.  The reclaim test counts table sizes in
the 4 KiB units ray_sys_get_stat uses whatever the page size, so it holds
on 16 KiB pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(parquet): a chunk whose final flush fails fails the import

In the one-pass layout a chunk's stream is flushed before its writeback
is started, and a failed flush only skipped the writeback: the stream
had dropped the bytes it still buffered, the close after it succeeded,
and the import published a table with those rows zeroed.  A failed flush
is now the task's error, so nothing is published, and the writeback
starts only after a flush that succeeded.

New test parquet/chunk_flush_error (Linux): a file size limit inside the
buffered tail of the first chunk makes that flush fail; the import must
report it and publish nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

* perf(query): isolate sparse group-count worker slices (#741)

* perf(query): isolate sparse group counters by cache line

* test(query): cover sparse count stride through reachable queries

* fix(query): compare ordered symbols by lexical value (#743)

* fix(query): compare ordered symbols by lexical value (#738)

* perf(query): bound symbol ordering setup to scanned ids

* perf(query): compose safe nested selects before planning (#754)

* perf(query): compose safe nested selects before planning

* fix(query): bound nested select planning and preserve overflow semantics

* test(query): cover empty grouped nested selects and large script rewrites

* test(query): verify scoped sources shadow global tables

* fix(query): preserve materialized product and filtered reduction semantics

* fix(ci): start select merge smoke fuzzing from seeds

* fix(query): align membership, concat, and grouped fallback semantics (#744)

* fix(query): align collection membership across evaluation paths

* fix(query): address membership and concat review regressions

* fix(query): preserve concat aggregate slots and scalar broadcasting

* fix(query): close filtered count and concat fallback gaps

* fix(query): preserve per-row enlist constructors

* fix(query): preserve collection concat across query scopes

* test(pool): drain pending reads before cache eviction

* test(query): account for unmerged parallel aggregate variability

* fix(group): fold keyless reduction partials in chunk order

Scalar reductions and the keyless select scan kept one accumulator per
worker while the pool hands out tasks dynamically, so F64 sum, avg,
var and prod changed from run to run. Partials are now kept per chunk,
with chunk boundaries derived from the row count only, and folded in
chunk order, which gives the same bits at every core count.

* fix(query): preserve concat operands through helper fallbacks

* fix(query): preserve materialized columns for nested sort

* test(pool): batch page reads while settling mappings

* test(query): restore bounded aggregate comparison after reduction fix

* fix(query): preserve helper globals during fallback expansion

* fix(query): preserve helper scope across fallback aliases

* fix(query): preserve fallback text and grouped output layout

* fix(query): retain column provenance in opaque helpers

* test(pool): require worker participation before asserting reclaim

* fix(query): align helper literals, concat operands and grouped layout across paths

The collection-concat detector recursed without end on an opaque helper
around row text; it now inspects the unexpanded call's arguments. A
grouped output named like its key kept only the key; the layout pass
treats the last column of a repeated name as the output. Grouped
fallback outputs follow the requested order, as planned outputs do.

A concat operand the planner has no node for (a reordered, indexed or
cast column) is a whole column in projections, so select agrees with
update, where and by; a STR column's cell keeps its type row by row.
Helper purity covers every non-restricted, non-special-form builtin
outside system namespaces. A literal symbol is the symbol inside a named
helper's body on every path, while inline lambdas keep the query's
column rule; a named helper's free names never see its caller's formals
or a derived alias. Aggregate classification skips the trial compile for
bare columns and concat-free column expressions.

* fix(eval): run query helpers as bytecode with row provenance on the VM stack

A helper called from a query fallback ran on the tree walker so concat
could tell column parameters from bound collections. The tree walker
ignores return, binds lazy arguments it cannot read twice, stops near 62
scopes and is about three times slower on recursion.

Helpers run as bytecode again. Each VM stack slot carries a row flag:
parameter loads take it from the call, let stores and loads copy it,
builtins the planner lowers row by row keep it, collection verbs clear
it, nested compiled calls pass it on and return it with their result,
and concat consults it. A call frame marks the lexical boundary: the
query's aliases and locals are hidden, a free name that is a source
column binds on first use to the row's cell or the whole column, and
anything else is a global. The tree walker stays for closures and for
lambdas written inline in a query, whose literals follow the column
rule; it now forces lazy parameters at binding. An opaque helper under
a grouped aggregate evaluates per group. call_lambda is the direct
bytecode path again outside queries.

* fix(eval): key provenance caches on the symbol epoch and carry opaque helper results

The row-wise builtin table, the helper purity tables and the per-object
verdict cache are rebuilt when the symbol-table epoch changes, so a
runtime re-init cannot leave a stale id or address behind. The compiled
call path computes a result flag only for builtins, a scoped window
clears the flag of a local it rebound, and the call-frame binding comment
states that only an allocation failure leaves a name unbound.

At the query level an opaque helper given row data yields row data, so a
concat over its result keeps text semantics. A source column shadowing a
global helper of the same name is documented and tested.

* refactor(eval): drop the dead inline-lambda bytecode gate in query calls

A lambda written inline in a query captures the query's bindings and is
therefore a closure, which is never compiled; the tree walker handles it
with the query's literal rule regardless. Pin that rule with a lambda the
planner does not substitute.

* fix(eval): keep a helper's free source column on the query's rows

A source column bound on demand into a helper's call frame carried its
row flag only on the first read; the frame now records where those
bindings start and every lookup of one sets the flag. The binding itself
is the query's own: the row's cell, the group's slice or the whole
column, taken from the frames below the call frame, or sliced by the
group's rows when the per-group fallback did not bind that column for its
own expression. While a projection binds output aliases, the source table
decides, as before.

* feat(query): group on a helper the planner cannot lower

A by: key that is an opaque helper is evaluated once over the table,
where its column arguments keep their rows, and grouped as a hidden
column named after the expression, as other computed keys are.

* fix(eval): carry row provenance through the tree walker

Every evaluation publishes whether its value derives from the active
query's rows: a query frame's column binding, a helper's source column,
a row-wise builtin over such a value, or a compiled helper's result. The
concat hook and the helper call site read that flag instead of
re-deriving it from the syntax, which also removes a DAG compile per
row from the call site; the syntactic analysis remains for the result
of an interpreted closure.

Inside a function, concat joins text only when an operand carries row
provenance; otherwise it keeps collection semantics, whatever called
it. Helper purity is an allowlist again, and a name standing for a user
function is never pure. A helper's free source column is the query's
binding for it, never a caller's local, and a nested query inside a
helper restores the enclosing group's rows. Reordering and indexing
keep row provenance in the VM; a bound vector returned by a helper does
not. Every grouped result keeps the requested output order, and a
purity lookup no longer binds a column into a helper's call frame as a
side effect, which the planner then mistook for a lexical local.

* perf(string): build a two-cell text concat as an atom

Row-by-row text concat of two cells produced a one-row vector and
unpacked it; it now builds the SYM or STR atom directly.

* fix(eval): keep row provenance on every binding, through forms, and in by keys

Scope frames carry a row-provenance flag per binding, so a let local in
an interpreted lambda, a parameter, a scoped window's locals and a
helper's source column all report their own provenance; the separate
call-scope bookkeeping is gone, and a call frame records whether it was
made by name for the literal rule. Flags are tri-state in the VM and the
tree walker: a try handler's value or an interpreted closure's result is
unknown and falls back to the syntactic analysis, so a bound constant is
not row data and a row value through a handler still is.

Only control forms pass their operand's provenance through; a nested
select, update or join builds a new value. A null text atom follows the
kernel's null rule row by row. An opaque helper used as the by: key is
evaluated over the rows where: keeps, row by row when its body holds a
condition over one value, grouped under the planned key's name, and
with no outputs the key replaces the source column it is named after.

* fix(group): pad keyless chunk accumulators to 128-byte line pairs

Blocks of chunks scanned at the same time sat on neighbouring 64-byte
lines, which the adjacent-line prefetcher moves as a pair, so they still
bounced between cores on every row of the generic kernel. Five
aggregates over 70K rows on a two-socket box took 5.7 ms against 0.58 ms
before the chunked fold.

* fix(eval): scope provenance to the innermost query and carry it through closures

A lookup that passes a query frame reports the binding below as plain
data, so a nested query inside a helper no longer takes the caller's
row value as its own row data. A closure's captures keep their flags,
and a try handler's own evaluation reports its value's provenance, so a
row value through a handler used inside the helper stays row data. set
passes its value's provenance through; a lambda called inside a named
helper's body is that body's text, so its quoted symbols stay symbols.
A null cell is the empty text the vector kernel reads, while a null
literal still nulls the row. An opaque by: key runs row by row only for
a condition over one value, looking helpers up lexically before
globally; a let, a sequence or a loop keeps the key whole.

* fix(env): size a closure's capture for its provenance entry

The key vector of a captured scope was sized for the bindings alone,
and the provenance entry appended after them wrote one slot past it.
Regressions create closures inside a query with several live row
bindings, directly in a per-row expression and inside an inline lambda.

* fix(env): resolve a closure's capture provenance at call time

A closure stores no row provenance: it is relative to a query. When the
closure is called while the frames it captured from are live, each
capture takes the provenance its binding has at that moment, relative
to the innermost query, and a compiled frame publishes its slots for a
closure whose scoped window has already closed. An escaped closure's
captures are plain data. The stored entry, its spellable key, its
serialization and the intern per closure call are gone.

The opaque by: key gate leaves and/or to elementwise evaluation, treats
an if over an aggregate condition as one value for the column, and
resolves names inside a global helper's body globally. A null STR
literal follows the kernel's null rule on both paths.

* fix(env): keep a closure's capture flags in the query frame it was made under

The flags are recorded in the innermost query frame when the closure is
made, keyed by the capture object, and consulted only while that frame
is the innermost query; they are freed with the frame. Nothing about
provenance is stored in the closure, matched by object identity, or
limited in count, and the compiled frame publishes nothing.

An opaque by: key is evaluated over the whole column and again row by
row only if a conditional tested a vector derived from the rows during
that run. Two STR literals join as strings on both paths. A try fallback
value keeps the provenance its expression had, in both interpreters. A
closure is never compiled when called through another function, so it
keeps its environment.

* feat(eval): compile closures, loading captures as flagged constants

A closure compiles like any lambda; its captures are loaded by OP_LOADCAP
as fixed values. The capture row flags live on the lambda in two new
slots that are never serialized, together with the serial of the query
frame the closure was made under, and apply only while a frame with that
serial is the innermost query. No record is keyed by address, nothing is
kept per closure in a frame, lookups are constant time, and a recursive
closure called from a compiled helper keeps bytecode's depth. A lambda
made inside a query is query text and stays on the tree walker, which
keeps the query's literal rule; the tree walker reports its result's
provenance like the VM does, so a try handler of either kind agrees.

An opaque by: key is evaluated again row by row when any conditional
tested a vector during the whole-column run, or when that run failed.

* perf(eval): clear only the VM header per compiled call

Every stack, return and trap slot is written before it is read and the
locals are set explicitly, so zeroing the whole exec block per call was
wasted work that outweighed a small body.

* fix(eval): compile a closure only when its captures have the expected shape

A closure compiles only with SYM keys and a LIST of values of equal
length; anything else stays on the tree walker, whose capture binding
reports the type error. The deserializer rejects a lambda whose closure
is not such a dict, and the compiled capture loads check the list type.

* test(serde): pin the closure shape check and size lambdas by their slot layout

The deserializer's rejection of a closure whose values are not a LIST now
has a test that fails when the check is reverted. Three tests built lambdas
with seven pointer slots while the heap walks every slot of the current
layout on retain and free; they and the shallow object-size report use
LAMBDA_SLOTS.

* fix(mem): count a closure's captures in both object-graph walkers

.mem.objsize stopped at a lambda's debug slot and the heap's retained
count skipped the capture-flag vector; both now walk the closure and
capture-flag slots. objsize.rfl pins that a closure outweighs the same
lambda without one.

* fix(eval): bind a closure's captures as locals of its compiled body

A compiled closure resolved names differently from the tree walker: a
let of a captured name allocated a new slot, a capture named like a
builtin resolved to the builtin, a captured self was the closure's
maker, a caller's local of the same name stood in for a capture inside
a window, and a closure with more than 255 captures read globals. The
compiler now binds every capture to a local slot in a prologue, a local
shadows special forms and builtins, self is never captured and loads
through OP_LOADSELF (also for a bare self in any compiled body), and a
closure past the slot limit stays on the tree walker.

A closure compiles on its second call, so one made and called once keeps
no bytecode; capture row flags are kept only for lambdas made inside a
query. A by: key body with an effect is evaluated row by row from the
start instead of whole first, so the effect runs once per row; a failed
whole-column run clears the raise payload and trace it leaves behind.

* test(eval): pin the capture-flag release, the self exclusion and the query-text lambda rule

The flag-release probe makes a lambda per row; the compiler no longer
skips a self capture itself, so the env exclusion is the one mechanism;
a lambda with nothing to capture written in query text keeps the column
rule for its quoted symbols.

* perf(eval): bind a compiled closure's captures with one opcode

OP_BINDCAPS stores every capture into its slot from one constant map
instead of a load and a store per capture, so a closure call pays one
dispatch for its prologue. The flag-release probe's lambda now captures
a row-flagged local, so a leaked flag vector shows.

* fix(eval): count a closure call once when deciding to compile

The VM's call opcode decided not to compile a closure on its first call,
then fell back to the tree-walk entry, which decided again and compiled.
The entry now takes the decision from its caller.

* fix(eval): return ends a lambda on the tree walker too

A closure compiles on its second call, so a return in its body gave the
identity on the first (interpreted) call and the early exit on the
compiled one. Inside a lambda the tree walker's return now parks the
value on the VM and raises a return marker that every form propagates;
the lambda's call boundary turns it back into the value, try lets it
through, and the VM's error path treats a marker from a tree-walked
special form as the frame's return. Outside any lambda return is still
its value.

* fix(query): never inline a closure into a query

The fallback inliner skipped a closure only once it had compiled, and a
closure now compiles on its second call, so a query inlined an uncompiled
closure's body and a captured name read a source column or a global of
that name; the planner's own inlining had the same hole. Neither inlines
a lambda with a closure. The window sync covers every slot of a frame,
and a swallowed whole-column key failure drops a pending return value.

* test(query): a closure as the by: key, pure and with an effect

* fix(agg): flag a grouped key column as nullable only when a null key was emitted

A fused where: can remove every null row of the key column, yet the
grouped result inherited the source column's may-have-nulls flag, while
the same query over the filtered table materialized first carried none,
so one result had two spellings on the wire (the nested select
differential fuzzer caught it). The key gather and the dense key emit
keep the flag only when a gathered key is null; symbol keys keep it as
before.

* feat(eval): compile a fn literal once as a template and share it across its closures

A closure's first call ran on the tree walker and later calls compiled,
so every engine difference showed up as call-count dependence, and each
closure compiled its own copy of the body. A fn literal inside a compiled
body is now compiled once, as a template, when the enclosing function
compiles; OP_MKCLOSURE makes each closure share that bytecode and capture
only the enclosing slots the body mentions, with their row flags.
OP_BINDCAPS binds captures by name, so a let that had not run leaves its
slot empty, and a read of an empty let slot (OP_LOADENVG) falls back to
the global, as the tree walker does. Closures no longer compile on their
second call; a lazy value is materialized when captured.

The by: key purity check follows a callee wherever it is bound (a global,
a parameter or let of an enclosing frame, a capture, a lambda literal
bound earlier in the body); a computed or unknown callee is an effect,
and builtins are pure unless they are system, I/O or a few that read
outside their arguments.

* perf(agg): settle a grouped key's null flag with a vectorised, pool-split scan

The per-key scan behind the flag fix was serial and showed on large
groupings. The scan is now a block-wise sentinel compare the compiler
vectorises, split across the pool for a large column, and shared by the
three emitters and by the legacy wide-key path, which seeded the flag
from the source column and could only ever set it; an F64 key, alone or
beside another key, now agrees with the materialized reference too.

* fix(eval): materialize a lazy slot when a compiled closure captures it

A lazy handle is single-use: materializing frees its graph in place, so a
closure dict holding the handle would fail the closure's second call.
Rayfall call sites materialize lazy arguments, but a caller of the C API
reaches OP_MKCLOSURE with one; lang/closure_lazy_capture_vm passes a real
lazy handle through call_lambda and calls the closure three times. The
Rayfall shape that claimed to cover this never captured a lazy and is
gone. A zero-parameter literal ([] parses as an empty I64 vector) now gets
a template too instead of a private compile per closure.

* fix(query): a text cell reduced from a column is a cell of the rows for concat

A grouped (concat (first s) 'zz) gave null for an empty symbol in a
grouped update and failed to produce one value per group in a grouped
select, while the ungrouped projection read the empty cell as empty text.
The planner's concat kernel takes a cell mask: an operand that reduces a
text column (first, last, min, max) is a cell, so a null there is empty
text and a STR cell keeps STR output. The grouped select's
arithmetic-of-aggregates wrappers run with the group result as the active
table and the hidden aggregate columns flagged as row-derived.

* fix(eval): an unbound capture, eval in closures, deep literals, lexical capture, parameters in the purity walk

A capture whose let had not run read as 0: capture slots are no longer
fixed, so an empty one falls back to the global or a name error like any
let slot. eval and resolve in a compiled body run inside a scope window
over its locals, so a closure's eval sees its captures. Template
compilation stops nesting at 32 levels and leaves deeper literals to the
dynamic path, which compiles one level per call. A tree-walked lambda
call marks its frames as the lexical boundary for capture, so a caller's
locals are not captured whether the maker compiles or not. The by: key
purity walk binds a callee's parameters to the call's argument text, so an
effect reached through a parameter counts and a pure helper passed as one
keeps the whole column.

* perf(eval): share one compiled template per literal for closures made by interpreted code

A closure made by the tree walker (the top level, a lambda that could not
compile, a literal inside a special form the compiler hands to the tree
walker) compiled its own copy of the body on its first call. ray_fn now
keeps a per-thread table of templates keyed by the literal's parameter
and body nodes (retained, so an address stays the entry's own) and its
capture names; a hit shares the bytecode, a miss compiles once. The table
is dropped with the runtime. Tests pin the sharing, two capture sets from
one literal, the block-boundary case of the key null scan and the
interpreted lazy capture.

* fix(eval): match a closure template on every body node

Two literals built for eval can share their parameter, first and last
nodes and differ in between; the template table compared only those, so
the second literal ran the first one's bytecode. An entry keeps every
body node (literals over eight forms are not cached) and a hit requires
all of them. A thread whose table outlived its runtime forgets its
entries instead of releasing objects of a destroyed heap, and ray_fn uses
the compiler's own closure shape gate.

* perf(eval): cheaper closure creation on the interpreted path

A template entry remembers the symbols its literal mentions, so a hit
costs a pass over the dict keys instead of a walk of the body and
allocates nothing; the capture walk gathers row flags in a local buffer
and builds the flag vector only when one is set. Closures made and never
called by an interpreted maker: 249 ms per 200k before, 202 after (dev
121); the fn literal inside or: 136 to 98 ms per 200k.

* fix(eval): closure bodies resolve free names when they run; eval's window wraps only the call

The eval/resolve window opened before the arguments were compiled, so a
return, a let or a call inside an argument ran in the window and a
return leaked the frame; it now opens right before the call and closes
right after it. A closure body resolves the builtins it names when it
runs instead of baking the one the template saw, and a top-level
literal's baked template is recompiled once a function-valued global was
rebound (ray_env_fn_generation). The VM's general call applies the same
elementwise map, concat rule and provenance join as the dedicated
opcodes. The symbol walk and the compiler stop at 1024 and 2048 levels; a
literal holding more than 65536 vector elements is not kept in the
template table; a body that did not compile is marked and never retried.
The by: key purity walk binds a passed lambda's parameters as unknown,
lets a nested literal see the enclosing body's parameters, and resolves
an argument's name where the call was written. A grouped update expands
its helper expression once instead of per group.

* fix(query): the grouped update's planner skip is a flag of the call, not a remembered expression

A remembered expression pointer could match a fresh expression at a
reused address and skip the planner for it, turning a schema error into
a name error. The flag now belongs to one grouped update. The OP_RET
window unwind was unreachable once eval's window wraps only the call and
is gone; the deep-literal test runs at the depth the sanitizer build's
recursive release survives.

* test(lang): pin the compiler's depth guard by result, within every platform's stack

The regression test for the depth guard built a 12000-level literal and
relied on the sanitizer build surviving its recursive release; the macOS
one does not. The body is now a chain of nested ifs: compiled it runs as
jumps and returns the parameter, on the tree walker it recurses and stops
at the eval depth limit. 600 and 1500 levels return 1, 2500 (past the 2048
guard, within the bytecode jump range) is a limit error; with the guard
removed it compiles and returns 1. The syntax page states the limit; the
control-flow page's anchor to the lambdas section now resolves.

* fix(query): concat cell flags cover every operand, and the planner takes 255

The text kernel read its per-operand cell flags from a 64-bit mask that was
written for the first 64 operands only, so operand 64 shifted by 64. The
flags are one byte per operand for all 255 positions, in the planned node
and the two-operand path alike. The planner folded a query concat into one
node up to 16 operands and left longer forms to the binary builtin, which
rejected them; it now takes the executor's 255, so one limit governs the
form. Tests run 65-operand concats: a cell at position 0 with a null
literal at 64 nulls the row, a cell at 64 is a cell (empty text, STR
output), a column joins every row.

---------

Co-authored-by: Serhii Savchuk <ser.vasilich@hotmail.com>

* fix(query): sort and take a no-output grouping on a computed key (#772)

* fix(query): sort and take a no-output grouping on a computed key

A grouped select with a computed by: key and no outputs falls back to
eval-level grouping and returned its first-of-group rows directly, so
asc:, desc: and take: were dropped and groups came back in first-seen
order. It now goes through apply_sort_take, as the plain-key branch
does.

* fix(query): gather a no-output computed-key grouping's columns by type

The no-output computed-key branch built its result columns cell by cell
through store_typed_elem, which has no STR or LIST case: STR cells were
left unset (empty, or strings left from an earlier result, and sorting
them aborted on an unset string pool) and a LIST column failed. The key
and source columns are now gathered at each group's first row with
ray_group_gather, which builds every column type, and a failed gather
returns its error.

* fix(query): keep a LIST-valued computed key and reject a misaligned one

A computed `by:` key with no outputs gathered its key column only when
the key was a typed vector, so a LIST-valued key lost both the key and
its source column, in the grouped and in the empty-result path.

A key whose length is neither the row count nor 1 was grouped as is,
and the gathered columns were then read at group rows past their end.
It now fails with `length`.

* fix(group): release the partial list when a LIST gather append fails

ray_group_gather's LIST arm assigned the result of ray_list_append back
to its output, so a failed append dropped the list it had built so far.

* fix(query): group a no-output select by a global's value, not as a column

A bare `by:` name that is not a column of the table, such as a global
vector, was still taken for a column on the no-output path, and the
missing column was dereferenced.  Such a name now takes the computed-key
path, as the grouping before it already evaluated it.

* fix(query): release the where selection on the no-output grouping path

With `where:` and `by:`, the filter keeps an extra reference to its row
selection that is released only at the end of the select.  The
no-output grouping returns earlier, so each call leaked the selection.

* fix(query): group a no-output select by a local that shadows a column

A `by:` name bound both as a local and as a column was grouped by the
local's value, but the no-output path then read the column's key slot,
which the grouping never filled, and returned garbage or crashed on a
LIST column.  Such a key now takes the computed-key path, and a missing
key column is an error instead of a read of unset memory.

* fix(query): release a by-dict's symbol vector on the no-output paths

The vector of by-dict or quoted key names was released only at the end
of the select, so the no-output grouping and the eval-level grouping,
which return earlier, leaked it on every call.

* fix(query): release the where selection on every exit of a select

The filter's retained row selection was released only on the success
path at the end of the select, so every error return with `where:` and
`by:` leaked it.  The select body now hands the selection to a thin
wrapper that releases it whichever way the body returns.

* fix(eval): free the error a `try` catches

Neither `try` implementation freed the caught error, and a rethrow left
its trace frames behind.  Inside a function, the compiled path also
handed a runtime error object to the handler; it now passes 0, as the
top level does and the error guide documents.

* fix(query): group a no-output select by an atom as one group

An atom `by:` key, such as a global scalar, failed on the no-output
path with a type error.  It is now broadcast to one group, as a
length-1 key is.  A function used as a key now fails with a domain
error that says so, instead of a vector-type error.

* fix(query): keep every column of a no-output by-dict select with where

With no outputs, the where prefilter narrowed the table to the columns
the clauses read, so the result lost the other columns and their order.
The prefilter now projects only when there are outputs.

* fix(query): run a no-output grouped select over a parted table

Every no-output grouped select without `where:` failed on a parted
table with a vector-type error.  It now flattens the table before the
paths split.

* fix(query): release the by-dict symbol vector on every exit of a select

The by-dict and quoted key name vector joins the where selection in the
select wrapper, so the error returns that skipped its release no longer
leak it, and the per-return releases go away.

* fix(query): free compile errors and failed fast-path results

A key the compiler rejected left its error on the graph, and freeing
the graph released it with ray_release, which skips errors, so it
leaked.  Failed top-k, fused take, vector allocation and asof operand
results were dropped the same way; all now go to ray_error_free.

* perf(query): gather only the kept rows of a parted no-output select

Flattening the whole parted table to return one row per group cost the
full table in memory.  The select now runs on a narrow table that
flattens only the columns its clauses read and stands in a row number
for every other column, then gathers just the kept rows from the
partitions.  Tables wider than the gather's column limit still flatten.

* fix(query): count quoted key and sort names as columns on parted tables

The parted no-output select gathered only the columns its clauses
referenced, and skipped quoted symbols, so `by: 's` or `desc: 'h` was
grouped or sorted by a row-number stand-in.

* perf(query): carry one row number through a parted no-output select

Each unreferenced column was a row-number stand-in of its own, so a key
with many groups gathered one row vector per column and could use more
memory than flattening the table.  The narrow table now holds the
referenced columns and one hidden row number, and the result is rebuilt
in the flattened layout from the kept rows.

* fix(query): drop the filter's reference when where: keeps every row

When every row passed, the filter returned the input table itself with
an extra reference, which the no-output and the eval-level grouping
never released, so the whole input leaked on every call.

* test(query): cover the try trace clear and two error and parted cases

A caught error raised through a nested call, a sorted parted select
whose every row is its own group, and a failing asof operand on a
parted table now each have a check.

* fix(group): tell a null key from a function key

The function-key check also caught the null and lazy types.  A null key
now has its own message, a lazy key is materialised first, and only
the function types report a function.

* fix(query): materialise a lazy computed key and keep where on a parted key

A lazy computed key, such as a moving max, failed the no-output path's
grouping.  On a parted table, a where: on the key column was deferred
past the grouping, which a no-output select never reaches, so its rows
went unfiltered into the row path.

* fix(query): sort a parted table without by: by gathering the kept rows

A sort without `by:` that neither streaming path took fell through to
the flat sort, which read parted columns as plain ones and failed with a
type error, and its take form leaked.  The sort now runs over the sort
keys and a row number, then gathers the kept rows from the partitions
with the row gatherer the no-output grouping uses.  Shapes it declines
flatten the table first.  The tests that pinned the type error compare
with the flattened table instead.

* refactor(query): drop the redundant key-name entry of the parted grouping

The derived key name already covers `key`, so the separate entry did
nothing.  A test now pins the key-name guard itself.

* fix(query): decline the parted row gather when __row is a user name

The parted no-output grouping and sort carry a hidden `__row` column and
declined only when the table had one.  A by-dict alias or a bound local
or global of that name shadowed it, giving an error or silently grouping
by row number.  Any such use now falls back to the flattened table.

* refactor(group): drop the unreachable lazy-key materialise

Every expression key reaches this check already materialised; the
computed-key paths in query.c do the work and stay.

* fix(query): keep parted sorts bounded and decline output alias chains

A parted sort without by: flattened the table whenever a sort key was
not output under its own name, though the narrow sort reads keys as
columns either way.  It now declines only when an output names another
output's alias, which the flat path reads as the alias and the gather
read as the source column, a silent wrong result.

* fix(query): stream a parted top-k only when the sort key is its own column

The parted top-k accepted a sort key that is an output alias of another
column: each partition sorted by the column, the merge by the output,
so the take kept the wrong rows.

* fix(query): flatten a parted no-output select whose clauses run code

The parted row gather builds a narrow table from the columns its clauses
name, so a column read inside a helper, or a name built at run time,
was missing from it or resolved to the hidden row column.  Clauses that
call a lambda, call a computed head, read a container that may hold
lambdas, or evaluate text now take the flattened table.

* refactor(query): drop the bound-name check of the parted row column

With code-running clauses declined, a bound `__row` can only be shadowed
through a name the select writes, which the clause check covers.  A
test pins a helper that reads a bound `__row`.

* fix(exec): keep no rows for a sort with take: 0

The sort-and-head fusion passed the take to the sort, which reads a
limit of 0 as no limit, so a sorted select with `take: 0` returned every
row.  The fusion now applies only to a positive take.

* fix(query): check each by-dict value on its own for code in a parted select

The run-code check passed a by-dict's value list through the call-head
rule, so a dict whose first value was a builtin call looked like a
computed call and flattened the whole parted table.

* fix(query): follow a symbol binding to a lambda when checking for code

A dict literal stores its values unevaluated, so `{f: hy}` holds the
symbol `hy`, and calling `(D.f 0)` reaches the lambda through one more
lookup.  The parted run-code check stopped at the symbol and took the
bounded path, which then missed the helper's columns.  A binding that is
a symbol naming a lambda now counts as code, for plain and dotted names.

* fix(query): evaluate a collection concat by: key over the whole column

The planner keeps concat for text, so a bare by: key that concatenates
collections no longer lowers.  Only helper keys fell back to the
whole-column evaluation; a bare concat key failed with "failed to compile
group key expression", even when aligned with the rows.  Route keys that
call concat to the same fallback: an aligned one groups, a misaligned one
is refused with length.

* test(mem): measure top-level try per iteration, not per statement

A handler written as a lambda literal adds an entry to the closure
template cache once per statement (bounded at 256 entries).  Run each
top-level try statement 1000 and 4000 times and require the same cost,
so the probe still catches a per-iteration leak of the caught error.

* fix(query): evaluate any by: key the planner cannot lower over the whole column

A bare computed by: key with no helper, such as (reverse x), (asc x) or
(til (count x)), failed with "failed to compile group key expression".
Only helper and concat keys took the whole-column fallback.  Try to lower
every computed key and send any key that does not lower to the same
fallback: it groups as its lambda form does, honours where:, desc: and
take:, and a key of another length is refused with length.  A text
concat still lowers and stays on the planned path.

* fix(query): evaluate an unlowered by: key over parted columns flattened

The whole-column key fallback evaluated the key over a parted table's
partition columns and failed with "key expression must yield one value
per row".  With outputs, flatten the columns the select reads (the whole
table when a clause runs code whose reads are unseen) before evaluating
the key.  With no output, leave the select to the first-rows path, and
when that path declines, run the select again over the flattened table
so the key reaches the fallback there.

* fix(query): report an unknown column in an unlowered by: key as schema

A key that lowers to the planner reports an unknown column as schema
("column 'c' not found"); one evaluated over the whole column reported
the evaluator's name error.  Before evaluating, look for a free name in
the key that is no column and no bound name, outside any lambda written
in it, and report it as schema.  An unknown function stays a name error.

* perf(group): check for a function or null key inside the scalar-key branch

The check that refuses a by: key bound to a function or to null sat as
its own early return in exec_group_run, with a release loop of its own.
It slowed computed keys on that path by 5-12% at 1M rows (bare bool,
mod, like and if keys), through the code built for the function rather
than the check itself.  Make it part of the existing scalar-key branch,
which already returns its errors, and build the error in a cold
out-of-line helper.  The keys time as on dev again; the errors are
unchanged.

* fix(query): let and set in a do bind their name for an unlowered key

The unknown-column check before the whole-column key evaluation reported
a name the key binds itself, `(do (let q x) (reverse q))` or
`(do (set q x) (reverse q))`, as an unknown column.  A two-argument let
or set now binds its name for the forms after it, and only its value is
checked.  The four-form let the check assumed is no let form.

* fix(query): evaluate an unlowered by-dict value over parted columns flattened

A by-dict value the planner cannot lower, a helper `{k: (fk)}` or a
builtin with no kernel `{k: (reverse i)}`, failed over a parted table:
the parted by-dict deferral compiled it ("failed to compile group key
expression"), and the eager path evaluated it over the partition
columns.  The deferral now takes only values that lower; the eager path
first flattens the columns the select reads (the whole table with no
output, or when a clause runs code whose reads are unseen).

The where: prefilter before a computed key no longer narrows the table
to the columns the clauses name when a clause calls a helper: the
helper's own column reads failed with a name error, flat or parted.

* fix(query): keep projecting a where: prefilter that reads a container

The where: prefilter before a computed key stopped narrowing the table
whenever a clause named a variable bound to a list, dict or table, since
it used the parted gather paths' test, where such a container may hold
lambdas.  A where: reading a config dict then filtered every column of a
wide table.  The prefilter now declines only for code that reads columns
the clauses do not name: a lambda, a name bound to one or to its name,
a computed callee, eval, parse, resolve, load or value.  The parted
gather paths keep the container rule.

* fix(query): know code and binders by the builtin, not by the call's spelling

Two checks in the by: key paths matched names as written.

The unknown-column check before the whole-column key evaluation followed
let and set only as siblings in one form, so a name bound in a nested
do or an if, `(f (do (do (set q x)) q))`, was refused as an unknown
column.  The check exists only to pick the error code: it now runs only
on keys that bind no name and run no code, and leaves every other key to
the evaluator.

The test for code whose column reads a clause does not show matched
fn/eval/parse/resolve/load/value by the spelling of the call head, so a
builtin called by another name, `(set getv resolve)` then
`{k: (getv rname)}`, took the parted narrow-copy path and read its
private row-number column.  Names are now resolved through locals,
globals, dotted paths and one name hop, and a builtin is known by the
name it was registered under.  A name of a column of the table is the
column.

* fix(query): resolve clause names as the evaluator does, objects included

The checks that classify a clause's names and nodes read a name as a
column before looking for a lexical local, so a parameter or let that
hides a column was taken for the column:
- a local lambda named after a column, `(run P (fn [] y))` with
  `{k: (x)}`, was not seen as code, and the parted narrow copy left out
  the column it reads;
- a binder passed as a parameter, `(run set)` with `(x q y)`, was not
  seen as one, and the unknown-column check refused the key;
- the parted sort path gathered the column for an output `x: x` that
  reads a parameter x.
A lexical local now comes first, then the query column, then a global.

A builtin placed in a built query as the object itself, `(list resolve
rname)`, was not recognised as code (and `set`, `let` or `quote` as a
binder); the checks now look at the object as well as at names.

* fix(query): sort a parted table by gathering only stored output columns

The parted sort path took any output name that reads a column, a dotted
field of one included (`month: date.mm`), and then looked for a stored
column of that exact name to gather, failing with "lost a column of a
parted table".  An output it gathers must be a stored column, and one no
lexical local hides; any other leaves the select to the flat sort.

* perf(import): bound parquet symbol import and splayed hash-index I/O (#773)

* perf(parquet): import dictionary counters under RAY_CSV_TRACE

Each symbol pass reports the strings it interned, batch-local repeats,
global probes, hits, inserts, slots read, false tag matches, table
growth and the dictionary sizes, so the import's access pattern can be
measured on real data.

* perf(store): allocate SYM stream lookup tables on first remap

A SYM column writer allocated and cleared its runtime-to-file lookup
tables at open, though a writer fed only chunks already in its target
domain never reads them. They are now made by the first chunk that needs
a remap; the parquet imp…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Performance: merge nested selects before planning, 6x to 570x faster on select over select

2 participants