Repository navigation
perf(query): compose safe nested selects before planning - #754
Conversation
|
Reviewed. About 300 differential probes compared merged against hook-disabled against
AST unchanged, no refcount growth over ~18k queries, ASan clean, full suite 4104/4104, The performance case has two gaps the bench does not cover:
Smaller:
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. |
Rayforce targeted audit passedThe required Rayforce audit gate passed on the latest run. Workflow run: https://github.com/RayforceDB/rayforce/actions/runs/37853418614 |
|
Re-reviewed at c12ed82 and re-checked at 6c6a32c. The only Fixed:
Still open:
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. |
|
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:
What declining scalar reductions over an inner
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: Planning cost of the new checks: +0.6 µs per query when the scalar decline fires, +1.3 µs when Tests. I reverted each hunk in turn:
The rfl file still merges on only two lines (33 and 34); every other line is below 8192 rows or declined. Fuzzer:
Unrelated to this PR (also on v2.11.0): an overflowing I64 vector multiply stores INT64_MIN without marking it null. |
|
Found after merge, now on dev 24c14e8: an outer
The sort-visibility check in |
* 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…
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
takeremain 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 undertakeremain barriers. The original AST is unchanged; expansion size and nesting are bounded. Named sources useray_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
doto force materialization. Floating reductions can change addition order; fuzzing permits1e-12relative/absolute error only for F64 aggregate values. Row expressions, schema, order, null metadata and error codes compare exactly.Validation:
dd9bd970: both reported bugs fixed, 125 differential queries checked, no wrong results or crashes.4205f704, including audit, static analysis, Linux/macOS debug and release, NDEBUG, TSan, and fuzzing. CI run.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_mergefuzzing 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 usea % 1000, four columns and five runs. Values are medians in milliseconds. The final matrices ran after local tests/fuzzing completed.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
takeretain 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 releaseandpython3 bench/nested_select.py --binary ./rayforce --rows 5000000 --runs 7 --cores 4. Use--a-period 1000 --widths 4 --rows 20000000for selective inner filters, and--cores 1for 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.