Skip to content

v2.8.0 - #547

Merged
singaraiona merged 88 commits into
masterfrom
dev
Sep 17, 2026
Merged

v2.8.0#547
singaraiona merged 88 commits into
masterfrom
dev

Conversation

@singaraiona

Copy link
Copy Markdown
Collaborator

What & why

Checklist

  • PR targets dev (not master)
  • Commits follow Conventional Commits (feat: / fix: / perf: / docs: / …)
  • make builds cleanly (no new warnings)
  • make test passes; tests added/updated for behaviour changes

belowzeroff and others added 30 commits September 15, 2026 08:11
collect_part_dirs sorted every partition scheme with strcmp. That is the
key order for dates (fixed-width YYYY.MM.DD) and for opaque names, but an
all-integer set came out in string order — 1, 10, 2 — and every consumer
takes that order as the key order: ray_read_parted emits the I64 MAPCOMMON
keys in it, so the partition-key column of an integer-partitioned table
was unordered and rows interleaved across keys; ray_parted_tables and
ray_parted_fill read the "most recent" partition as the last one, so for
partitions 1..12 that was "9" and a table first added in 12 was invisible
to .db.parted.tables and never backfilled.

Sort an all-integer set by value. The check mirrors infer_mc_type, so the
set sorts numerically exactly when it is classified RAY_MC_I64; date and
symbol sets sort as before.

parse_int_dir also accumulated the digits with no bound: a 20-digit name
overflowed int64 — UB that wrapped the key to an unrelated number, and an
abort under UBSan (part.c:109). Parse with an overflow check; a name whose
value does not fit int64 fails integer classification, so the set drops to
symbol partitioning with the name kept literal — the treatment #492 gave
an impossible calendar date.

test_eval_insert_parted_key_types pinned the string order ("10","2" load
as [10,2], so any insert was 'corrupt'); it now checks the keys load as
[2,10] and that the immutable-tail contract holds on them: a later key
appends, the last key grows, an earlier key is rejected as historical.
Address review on #530.

- A digit-only directory name that does not fit int64 in an otherwise
  integer set is now a `corrupt` error naming the directory, from every
  reader of the set (.db.parted.get / .tables / .fill), instead of a
  silent retype of the whole set to SYM keys — where (== part 2) matched
  nothing and (>= part 2) returned partition 1 too.  A mixed set (digit-only
  names beside dotted non-date names) is still SYM with the long name kept
  literal: there every name is an opaque key.
- Equal-valued spellings (01 / 1) sort by name after the value, so the
  order is filesystem-independent again.
- collect_part_dirs classifies the set once through infer_mc_type and hands
  the key type to ray_read_parted; integer names parse through
  ray_parse_i64 (overflow-checked, unit-tested, fuzzed); the O(n^2)
  exchange sort is a qsort with a comparator; the unreachable minus-sign
  path is gone.  collect_part_dirs returns the error object itself so the
  corrupt case can name the directory; io/oom messages are unchanged.
- Tests: restore coverage of the insert validator's "keys are not strictly
  increasing" branch with a 2 / 02 fixture; cover the corrupt error naming
  the directory, the mixed-set fallback, and INT64_MAX / INT64_MAX + 1.
- Docs: db.md and functions.md no longer tell users to zero-pad integer
  directory names; .db.parted.get documents how names pick the key type.
…r gather dispatch (#531)

Every query graph node, ext node, const vector and optimizer scratch
buffer was allocated with ray_sys_alloc, which is a dedicated mmap per
call and a munmap (plus TLB shootdown) when the graph is freed at the end
of the query.  A point lookup on a 4-row table cost ~10 mmap/munmap pairs
and ~49 us.  ray_sys_alloc is meant for infrastructure that cannot use the
buddy heap (cross-thread lifetime, bootstrap, global state); graph.c
predates ray_alloc_raw and was never migrated.

Move graph.c, opt.c, idiom.c, query.c, collection.c, rerank.c,
embedding.c, datalog.c and builtins.c per-call buffers onto
ray_alloc_raw / ray_calloc_raw / ray_realloc_raw / ray_free_raw.  Buffers
that are fully written before being read (the node array, DFS stacks,
index scratch) take the uninitialised form; flag arrays keep their memset
or use the zeroing form, and memsets that duplicated it are dropped.

sel_compact and exec_filter dispatched the multi-column gather to the pool
for any pass_count, waking every worker to copy one row (~6 futex per
query).  Gate both on ray_pool_par_dispatch_ok(pool, pass_count,
RAY_PARALLEL_THRESHOLD) like the other pool users.

Reporter's workload, 4-row table, 50000 point selects, -c 0:
  49.5 us/query -> 3.3 us/query; 120k mmap + 120k munmap + 120k futex
  -> 49 + 28 + 20 for the whole run.
fix(store): order integer partitions by value; reject names past int64
perf(engine): per-query scratch on the buddy heap, gated filter gather dispatch (#531)
Add aggregation admission diagnostics and execution-route coverage for the P0 type-support plan. Fix grouped BOOL/F32 median and quantile, BOOL output strides, and unaligned radix row-position access.

Record the implementation plan and baseline census. Validation: 3794 full ASan/UBSan tests passed, final contract tests passed with one- and two-core harness settings, and release build and profiler smoke checks passed.
#533)

expr_compile called ray_vec_has_nulls on every SYM column it compiled.
Text metadata is deliberately untrusted, so that walked every row through
an out-of-line ray_vec_is_null call, on every query, before the predicate
ran.  A point lookup on a 100k-row SYM column paid ~11x the I64 cost, all
of it in the null proof.

Two changes:

* expr.c: a pre-pass over the compiled subtree marks SYM scans whose every
  consumer is EQ..GE against a non-null constant or another SYM scan.  A
  null SYM is id 0, below every real intern id, and the fallback ranks a
  null SYM below everything in all six comparisons, so raw id compares in
  the fused lane already produce the null-aware answers.  Those columns
  skip the proof and compile as non-nullable.  Consumers that read the
  lane as a value (ISNULL, CAST, arithmetic) keep the scan and still bail
  on payload nulls.

* vec.c: the remaining callers of ray_vec_has_nulls on SYM/STR get a
  width-specific chunked zero scan instead of the per-row call, ~8x
  cheaper with identical results.

Point lookup, release build, single thread, 100k rows: SYM 475us -> 43us,
now equal to the I64 filter.

Tests: fused-vs-fallback diff over all six comparisons, column-vs-column,
AND/OR trees and a parted SYM column with nulls at three widths; value
consumers assert EXPR_BAIL_NULLS; ray_vec_has_nulls pinned at every SYM
width, on STR and through slices.  text_null/sym_expr_admission updated to
the new contract.
The null-aware kernels rank a null below every value: null != c,
null < c and null <= c are TRUE.  Chunk-zone extrema exclude nulls, so
deciding a morsel all-fail from extrema alone dropped those rows whenever
the chunk also held a null.  Guard the NE/LT/LE all-fail arms on the
chunk's null bit, in expr.c's zone_cmp_decision and in the matching arms
of fused_pred.c's fp_eval_cmp.

The fused_pred arms are currently unreachable with nulls because
fp_col_supported_op rejects nullable numeric columns; a tripwire test pins
that rejection so lifting it also brings zone coverage.

Test: 4096-row nullable I64 column with a 1024-row chunk-zone index so
each morsel is one chunk, nulls inside two all-equal chunks, all six
comparisons checked per row through ray_execute.
…llable numerics

dev's typed predicate path admits nullable numeric columns, so the
tripwire asserting rejection failed on the CI merge build.  Replace it
with the per-row check through fp_eval_pred, one chunk per call.
perf(expr): drop the per-query SYM null scan for null-safe comparisons (#533)
call_lambda binds `self` for a tree-walked call under a sym ID it cached in
a function-local static on first use and never cleared.  Runtime destroy
tears the sym table down, and the next runtime can intern "self" at a
different ID.  From then on every tree-walked call — every call to a
closure, which is never compiled — bound the lambda under the old ID:
`self` in the body no longer named the call's lambda, and whatever
ordinary name had taken the old ID was shadowed by the lambda.

Seen as an upsert inside a closure called through map receiving the
closure itself as its key argument ("key must be a symbol or integer,
got ?"), and only after an earlier runtime in the same process had cached
the ID — in the test runner, after compile/let_reserved_name.

Move the ID to file scope and clear it in ray_lang_destroy next to
ray_compile_reset, which already does this for the compiler's special-form
IDs (env.c resets its own `self` cache in ray_env_destroy).

Test: runtime/lambda_self_sym_per_runtime creates two runtimes in turn,
padding the sym table by different amounts so "self" gets a different ID
in each, and runs a closure that recurses through `self` and returns a
captured value.  Without the fix the second runtime fails with a
stack-limit error; one cached ID cannot match both runtimes, so the test
fails however the cache was filled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
#535)

A multi-row `upsert` boxed every payload cell into an atom, type-checked and
hashed through those atoms, and wrote each matched cell by appending it to the
column and copying the new tail into place — one append, one copy and one
index drop per cell.  For a batch of 935 rows over 6 columns that is 5610
allocations and 5610 tail copies per call.

When the payload is a table (or a row of vectors) whose columns are already
the target's own types, none of that is needed.  upsert_apply_vectors resolves
the batch in payload order through the same key map, appends the new rows with
one ray_vec_append_raw per column, and scatters the matched cells in payload
order — dropping the index and clearing SORTED once per column instead of once
per cell.  Admission (upsert_vec_payload_ok) requires every target column to
have a payload vector of its exact type: BOOL, U8, I16, I32, I64, F64, DATE,
TIME, TIMESTAMP, GUID, or SYM already in the runtime domain.  STR and LIST
columns, partial payloads, width coercions and slices keep the per-cell arm.

Observable behaviour is unchanged: a new key enters the map at the row it will
take, so a key repeated inside the batch still ends at its last value, and the
appends run before the scatter because a repeat can target a row the same
batch just appended.  Nulls travel as their sentinels and raise HAS_NULLS on
the target column.

On a table past the per-core cache nearly all of a probe is two misses — the
slot, then the key cells of the row it names — and one row at a time they
never overlap.  A second instance of the resolve loop hashes the batch first
and loads both a few rows ahead; upsert_apply_vectors picks it when the slots
plus the key columns exceed UPSERT_PF_MIN_BYTES, and the scatter picks the
same look-ahead per column by the column's own size.  Both loops exist twice
with the flag a compile-time constant, and the per-row probe helpers are
force-inlined: a runtime flag inside the loops, or the calls GCC left behind
once the loop existed twice, each cost more on cached-sized tables than the
prefetching gained.

Test: test/rfl/table/upsert_vector.rfl applies every batch twice — as a vector
payload and row by row as atoms — and requires identical tables, over the
order-book shape, every admitted value and key type, keys already duplicated
in the target, null and NaN keys, the `(list …)` row form, a shared target, a
value target, hash indexes on key and non-key columns, the sorted attribute,
and tables past the look-ahead threshold (compared as serialized bytes).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the slots

The table-resident key map stored `row + 1` in a slot and nothing else, so it
could only ever be replaced: a batch that no longer fit the load factor dropped
the map and rebuilt it by reading every key column again and re-hashing every
row.  That rebuild is the longest pause a growing table pays — measured here at
90 ms for 1M rows with a three-column key, because the writes land at random in
a slot array well past the last level of cache.

sym.c has solved this for the intern table since the beginning: its buckets
hold `(hash32 << 32) | (id + 1)`, so ht_grow_to fills the bigger table from the
old buckets alone.  Do the same here.  A slot now packs the low 32 bits of the
key's hash in its high half and `row + 1` in the low half, and ukey_grow
re-places every live entry straight from the old slots — no key column is read,
no key is hashed again, and the tombstones a delete left behind are dropped on
the way.  ukey_fits splits into ukey_describes (this map still names this
table's rows and key) and ukey_has_room; only the second failure becomes a
growth, the first stays a drop, since a map that stopped describing the table
cannot be re-placed into a correct one.

The stored hash pays a second time on every probe: a colliding slot is rejected
by comparing 32 bits instead of reading the candidate row's key cells, which is
what makes the plain update path faster too.

The packing reserves both markers (an occupied slot is never 0 and never
all-ones) and bounds the map to 2^32 slots, so a table at or past 2^32 rows
simply gets no map and keeps the previous per-call behaviour.

Measured against the same branch without this commit, batch of 935 rows,
medians of five interleaved runs, ms per batch:

    insert  360k rows   0.373 -> 0.161
    insert  1M   rows   0.853 -> 0.332
    insert  1.2M rows   0.060 -> 0.047
    update  360k rows   0.081 -> 0.073
    update  1M   rows   0.107 -> 0.085

The batch that actually crosses the capacity, timed on its own: 22.98 -> 16.84
ms at 600k rows, 193.5 -> 86.4 ms at 1M.  The rebuild a foreign write still
forces (a map that no longer describes the table) keeps its old shape and
measured 90 -> 70 ms at 1M.

Test: upsert_vector.rfl grows the map twice — once past its capacity and once
after a delete has left tombstones — and compares against the per-cell path as
every other case does, including a key the delete removed being appended again
through the grown map.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
perf(query): write a keyed upsert of a vector payload column by column (#535)
fix(eval): reset call_lambda's cached `self` sym ID with the runtime
The map enters rows in ascending row order, so two rows carrying the same key
sit in their probe chain lowest-row first and a keyed upsert updates the lowest
one — the row the linear scan it replaced would have picked.  ukey_grow
(#537) re-placed the live entries by walking the old table from slot 0, which
keeps that order only while a chain lies in one piece: a chain that wraps past
the end of the array is met tail first, so the two entries swap and the next
upsert updates the later row.

Walk the old table from an empty slot instead, cyclically.  A chain never
contains an empty slot, so every chain is then traversed from its beginning,
and the load factor of at most a half guarantees such a slot exists.

Seen with a table of 480 rows whose key 470 is duplicated by an insert: after
the map grows, the upsert of key 470 lands on the appended row instead of row
470.

Test: upsert_vector.rfl pins the duplicate resolution across a growth for the
sizes that expose it, against the per-cell path as every other case is.  The
RAY_IDX_UKEY comment gets the slot layout #537 changed, and the ordering
guarantee this restores.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix(query): re-place the upsert key map from a chain start when it grows
ser-vasilich and others added 29 commits September 17, 2026 14:27
…e swap

The American-flag pass moved records inside a bucket with an in-place swap
cycle, which is what shuffled equal strings in the first place; the previous
commit repaired the damage afterwards by re-sorting every bucket of equal
strings on (len, row), and found descending runs with a full string compare
per neighbour.

Scatter each byte pass through a scratch slice in scan order instead, so
equal strings never leave their source order: the bucket of bitwise-equal
strings needs no re-sort again, and the descending run walk can reject
neighbours on length before it touches bytes. The scratch is one buffer of
n_live records: sequential sorts allocate it, parallel sorts reuse the
top-level scatter's drained source, so peak memory does not change for the
parallel path and grows by one key array for the sequential one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Issue #551.  #549 made compression a per-link decision, but the multicast
fan-out frame is shared by every subscriber of a topic (#487), so publish
still framed at the compiled-in RAY_IPC_COMPRESS_THRESHOLD: a tickerplant
with only loopback subscribers compressed on every publication and every
subscriber decompressed it.

ray_ipc_frame_async_at frames at an explicit threshold;
ray_ipc_frame_async keeps its signature and passes the default.
ray_mcast_pub groups a topic's subscribers by the threshold their link
reports (ray_ipc_handle_threshold, from #549) and builds one framing per
distinct value, lazily on first use.  An all-local topic therefore builds
exactly one framing and never compresses it, preserving #487's single
serialization per publication; a mixed topic pays one extra serialization
rather than one per subscriber.  MC_MAX_FRAMINGS caps a publication at 4
distinct policies; past that everyone shares the first framing, which is
correct for any peer because the receiver honours the per-frame
COMPRESSED flag — verified in #549 by running a never-compress build
against a compressing one in both directions.

mc->framed counts framings built rather than publications, which is what
makes the bucketing observable in .mc.stats.

Tests: ipc/mcast_local_not_compressed subscribes a raw socket, publishes a
5000-element i64 run (well past the threshold and highly compressible),
and asserts on the delivered frame's header that RAY_IPC_FLAG_COMPRESSED
is clear.  Verified to fail against dev's publish path with `got 1,
expected 0` and to pass with this change, so it pins the behaviour rather
than the implementation.  It also asserts .mc.pub reached exactly one
subscriber, so the header assertions cannot pass vacuously.
ipc/frame_async_at_threshold covers the framing primitive directly: the
same payload frames compressed at the default and raw at NEVER, asserted
on both the flags and the resulting frame size.

make test 3878/3878, no ASan/UBSan reports.
Closes #555.

pearson_final_result computed `num / sqrt(dx*dy)` with no guard, relying
on the comment's claim that "a constant side → sqrt(<=0)" produces NaN
which the single-null float model canonicalises to NULL_F64.  That holds
only when exactly ONE of dx/dy is negative.  dx and dy are >= 0
mathematically (Cauchy-Schwarz), but a constant column cancels to a small
NEGATIVE residue in doubles, so with BOTH sides constant the product is
positive, the root is finite, and a finite "correlation" outside [-1, 1]
is emitted:

    pearson_corr(x, x)      x constant   ->  -1.0
    pearson_corr(c1, c2)    both const   ->   2.13

Test dx and dy separately and return NULL_F64 when either is
non-positive, which is what the legacy keyed path already does
(src/ops/group.c) — so this restores parity rather than inventing a
rule.  n < 2 leaves dx = dy = 0, so the same guard covers it and the
existing single-row behaviour is unchanged.

cov / scov / wavg were checked for the same shape and do not have it:
covariance is unbounded so its residue is imprecision rather than an
out-of-range value, and scov and wavg already guard n <= 1 and sx == 0.

test/rfl/agg/pearson_degenerate.rfl pins both directions: the degenerate
shapes come back null, and perfectly correlated and anti-correlated data
still return exactly 1.0 and -1.0.

make test 3877/3877, no ASan/UBSan reports.
…ying on it

Closes #556.

agg_ord_compact_fn compacts `pairs` in place.  Its safety comment proves
that an EARLIER chunk's writes cannot clobber a LATER chunk's unread
input, but the reverse is the case that actually arises: a later chunk
writes at its prefix offset, which sits far below its own input range
whenever groups are sparse relative to rows — any ordinary group-by with
duplicates.  At 10M rows / 100k groups, prefix[76] is about 98,000 while
chunk 0 is still scanning [0, 131072).

That is safe today only because ray_pool_dispatch's grain is 8192
ELEMENTS and the dispatch extent here is n_chunks, so the compaction has
always been a single task running its chunks in ascending order — the
forward-safe order the comment proves.  Above 2^30 rows n_chunks would
exceed the grain, the compaction would split into concurrent tasks, and a
late task could overwrite an early task's unread region: one group
silently duplicated and another dropped, with the row count still correct
so the `ordered == ng` check cannot see it.

Gate the parallel path on n_chunks fitting in one task instead of
depending on it, so the code relies on the invariant its comment actually
establishes.  Above that it runs serially — which is what it already did
in every reachable case, so nothing gets slower.

Not reproducible: the racing path needs an input larger than
1,073,741,824 rows, so this is a fix without a failing test.  The bound
comes from reading ray_pool_dispatch's grain, not from measurement.
Closes #554.

The grouped accumulator computed variance in one pass as
sumsq/cnt - mean*mean.  Once values reach ~1e9 those two terms agree to
within double precision and the subtraction cancels; the `vp < 0 ? 0`
clamp then turned the negative residue into a clean 0.0, so the wrong
answer looked like a plausible low-variance result rather than a failure.
Variance is translation-invariant, so the bug is visible without any
reference implementation: the same spread shifted by a constant gave
2.0 at base 0 and 0.0 at base 2e9, while the scalar builtin — which uses
a stable two-pass algorithm — returned the right answer throughout.

The integer accumulators additionally kept the sum of squares in a
wrapping int64, so TIMESTAMP (~8e17 ns) overflowed on the first row and
I32 near 2^31 after two.  Note that fixing only the wrapping would not
have helped: F64 already carried a double sum of squares and still
returned 0.0 at 2e9, because the cancellation is the actual defect.

Both families now accumulate shifted data: sums are of (v - k), where k
is the first value a state sees.  Centring keeps both terms small
whatever the magnitude, so the final subtraction cannot cancel, and the
result is translation-invariant as the definition requires.  Merging two
states re-centres one on the other's origin:

    sum'   = sum   + n*dk
    sumsq' = sumsq + 2*dk*sum + n*dk^2        (dk = k_src - k_dst)

Integer inputs take the difference in INTEGER arithmetic before
converting.  A TIMESTAMP near 8e17 has a double ULP of 128, so converting
first quantises every value to 128 ns and leaves a relative error around
1e-7 — enough to fail the translation-invariance test on its own.  The
subtraction is done on unsigned so it is wrap-defined; for any span under
2^63 it is exact.

State grows from 24 to 32 bytes per group for the ten variance vtables
(k is added); agg_vo_init's layout needs every state_size to be a
multiple of 8, which 32 is.

test/rfl/agg/variance_large_magnitude.rfl uses the invariance itself as
the oracle, so it needs no reference implementation: the same spread at
base 0, 1e6 and 2e9 must agree, for F64, I32, I64 and TIMESTAMP, and a
zero-variance group must still be exactly 0.0.  It fails on dev at the
1e6 case, so the imprecision starts well below the 2e9 that made it
obvious.

Performance is unchanged: 5M rows / 1000 groups, best of 5 at -c 8, F64
var 5.31 -> 5.38 ms and I64 var 5.42 -> 5.43 ms, with an avg control
moving -1.6% in the same runs.

make test 3877/3877, no ASan/UBSan reports.
…cess

Audit finding on #557.

The framing-failure branch I added was unreachable: ferr is set only when
mc_frame_for returns NULL, and every NULL return went through the drop
path which increments dead_n, so the guard's `dead_n == 0` conjunct could
never hold.  The live behaviour was therefore worse than the base: an
allocation failure inside ray_ipc_frame_async_at dropped every affected
subscriber, CLOSED its connection, and still returned ray_i64(seq) — a
normal success value — where ray_mcast_pub previously returned an error
and left all subscriptions intact.

A framing failure is a local resource failure and says nothing about any
peer, so treating it like a failed send was wrong in the first place.

Build every framing the topic needs BEFORE sending any of them.  Nothing
has been delivered when the failure is detected, so the error path leaves
every subscription exactly as it was and the publication is all-or-
nothing rather than half fanned out.  The extra pass over the subscriber
array is cheap and the framings themselves are still built lazily, one
per distinct threshold.

Also from the same audit:
  - the `framed` field comment still said "one per publication, not per
    subscriber"; it is now one per distinct compression policy per
    publication.
  - the new test claimed .mc.pub returns the number of subscribers it
    reached.  It returns the topic's SEQUENCE number, so the assertion
    passed because next_seq starts at 1, not because anything fanned out.
    What actually rules out a vacuous pass is the read that follows: with
    no publication the socket never becomes readable and the test fails
    there.  Comment corrected and the misleading equality dropped.

make test 3878/3878, no ASan/UBSan reports.
fix(query): don't deref a declined upsert key map; correct agg-v2 doc
fix(agg): keep the first-seen order compaction serial rather than relying on it
fix(agg): pearson_corr must be null when a side has no variance
fix(pool): run dispatch_n past the task ring in rounds instead of dropping tasks
feat(mcast): one framing per distinct subscriber compression policy
fix(sort,pivot): stable sort on every path; pivot output in first-seen order
fix(agg): numerically stable grouped variance and stddev
Closes #561, reported by @vbmithr as the encode-side counterpart of #542.

ray_sym_str takes the global sym spinlock around a single array read, so
walking a SYM column cost one atomic exchange per cell — and both
ray_serde_size and ray_ser_raw walk it, so a frame paid 2 * rows * cols
lock round-trips for a handful of distinct strings.  On a single-threaded
tickerplant that spinning is entirely uncontended.  ray_str_len and
ray_str_ptr are out-of-line calls made per cell on top of that, and
profiled at 30% of encode on their own.

A direct-mapped id -> (bytes, len) cache in thread-local storage avoids
all three on a hit.  Sym ids are stable positions for the lifetime of the
table, so the cache persists across calls and needs no per-column setup;
the new ray_sym_epoch changes when the table is torn down and re-inited,
which is the only event that can invalidate it.  Encode allocates
nothing, as it did before this change.

A column with far more distinct symbols than slots would otherwise pay
the probe and the store on top of a resolve it still has to do: measured
15% SLOWER than not caching.  So the first SYM_ENC_SAMPLE cells are
sampled and, if fewer than a quarter hit, the remainder of the column
falls through to the original loop verbatim — routing the cold path
through a shared helper instead cost another 7% in ray_ser_raw, which the
profile showed plainly.  Both numbers are a heuristic, not a limit:
either way every cell resolves correctly.

Non-runtime domains already resolve lock-free and the RAY_SYM_AUDIT hook
is a per-cell contract, so both skip the cache entirely.

Measured on this machine, release builds:

  914-row depth batch, 4 SYM columns   90.6 -> 35.2 us   (-61%)
  same shape, SYM as I64 (control)      6.6 ->  6.6 us
  20k rows, 3 distinct symbols        410.2 -> 128.1 us  (-69%)
  20k rows, 20k distinct (thrashing)  462.8 -> 473.9 us  (+2.4%)
  IPC publisher, 20k frames           147.0 ->  46.0 us/frame (-69%)
  IPC subscriber                       73.0 ->  69.5 us/frame
  bytes on the wire                   65875 -> 65875 (identical)

Every benchmark run applied all 18,280,000 rows.  The residual +2.4% on a
column where every row is a distinct symbol is the bounded sample prefix;
market-data SYM columns are the opposite shape.

The wire format does not change, which the existing SYM round-trip tests
cover: the cache is only for resolution, so a column of 12 cells with 2
distinct values still encodes 12 strings.

make test 3884/3884, no ASan/UBSan reports.
perf(serde): resolve a SYM column's distinct symbols once per encode
try_count_distinct_v2_rewrite recognised the group key only as a bare
symbol (one key) or a {alias: col ...} dict (composite with aliases). A
composite key written the ordinary way, `by: [MobilePhone MobilePhoneModel]`,
matched neither, so `count (distinct x)` over such a key skipped the fused
kernel and the two-pass rewrite and ran through the general per-group path:
one exec_count_distinct per group with per-atom hashing, half its time in
the allocator. The same query in dict form took a seventh of the time.

Accept a symbol vector as the list of key columns; the output columns keep
the source names, which is what that form means everywhere else.

300k rows, 50 x 300 SYM key pairs, 17% distinct values, warm ms:
  by: [a b]      85.0 -> 11.4
  by: {a: a b: b} 12.0 -> 12.1

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t symbol

A `by:` expression that reads exactly one flat SYM column (substr /
str-find / if over it) was compiled into the DAG and run over every row,
and every string op over SYM interns its output per row — eight passes of
interning for a host-extraction key.  Such a key is a function of the
symbol alone: collect the distinct ids actually present (first-seen slot
table), evaluate the expression through the same DAG compiler over that
distinct vector (a one-column table adopting the column's domain), and
spread the result over the rows by slot.  The spread key feeds the group
node as a constant; since a const node's ext slot holds the literal, not
a name, the key column is named after execution the way the eval-level
path names a computed key (last bare symbol of the form, else `key`).

Gates: >= 4096 rows, distinct <= rows/2, domain <= 16x rows and <= 64M;
any id outside the domain (nulls) or a second column reference falls back
to the row-wise evaluation.  The eval-level computed-key path uses the
same helper.

Test group/derived_key_sym_domain: result vs an oracle that materialises
the key as a column and groups on it — incl. where, a key that keeps the
column's own symbols, an all-distinct column (fallback), nulls, and a
two-column expression (must not take the shortcut).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…a near-unique column

The first-seen slot pass used to give up only once the distinct count
passed rows/2 — half a pass of random slot writes over a domain-sized
table on an all-distinct column.  Now, after the first 65536 rows, a
distinct share above 3/4 aborts the pass; the row-wise key path follows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… path; name a computed key consistently

The per-symbol evaluation is valid only for a key that is a pure,
row-local function of the symbol.  The gate was syntactic (one column
referenced), so positional operators — differ, fills, deltas, moving
windows — evaluated over the distinct symbols instead of the rows and
grouped silently wrong (`by: (differ ref)` collapsed to one group), and
a free name bound to a table-length global vector broke the same way.
The key expression now has to pass an allowlist walk: heads from a
fixed set of elementwise operators (let/if/cond, logic, comparison,
arithmetic, substr/str-find/strlen/within/nil?/upper/lower/like/as/
xbar), let-scoped names, symbol literals and the column itself; anything
else falls back to the row-wise key.

A single computed key on the DAG path took its column name from its
op's ext sym, which is not a name (an expression's ext resolves to
whatever shares its id, a const node's slot holds the literal), so the
same query named the key `null` on the row-wise path and by the by-form
on the per-symbol path.  The key column is now named by the eval-level
rule (last bare symbol of the form, else `key`) whichever path ran.

The null symbol is id 0, inside the domain: it gets a slot like any
other symbol; the comment said otherwise.

Tests: differ / fills / a global-vector predicate against the row-wise
oracle; the key column name at 20k rows and below the 4096-row gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…p the key name off an output alias

- The near-unique probe over the first 65536 rows now runs through a
  128k-slot scratch set before any domain-sized allocation; a query that
  falls back no longer pays a dn-sized slot table and its memset.
- A quoted symbol that names a column of the table is resolved to that
  column by the DAG compiler, so such a key is not a function of the one
  symbol; the allowlist walk rejects it.
- A computed key's derived name yields to an output alias spelling the
  same (`by: (substr ref 0 2) ref: (min ref)` → `[key ref]`), and the
  rename waits for a lazy result to materialise.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ps its name

The rename flag was set for the whole single-key branch, which a bare
`by: k` also takes (compiled as a scan), so every plain one-key group
came back with its key column called `key`.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ter, right-sized probe

- A head name is admitted only while it still means the builtin: the DAG
  compiler inlines a global lambda of that name ahead of the builtin, and
  a let-bound name shadows it, so both reject the per-symbol evaluation.
- agg_route_stats().key_domain_evals counts keys evaluated once per
  distinct symbol, so a test can tell the path fired instead of relying
  on an oracle that both paths satisfy.  test_agg_contract: substr and a
  let/str-find/if key fire once; differ, a two-column key, a table below
  the row gate and a shadowed substr do not.
- The near-unique probe set is sized to the probed rows (power of two,
  load <= 1/2, at least 1024 slots) instead of a fixed 1 MiB.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The near-unique probe hashed into a fixed 17-bit range but the set is
now sized to the probed rows, so the first access could land past the
end of a smaller set (4k-64k-row tables).  The initial index is masked
like the collision step.  The first-seen slot counter is int32; the
row/2 gate is capped to match.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
perf(select): evaluate a single-SYM-column group key once per distinct symbol
fix(query): admit a symbol-vector by: to the count-distinct rewrite
@singaraiona
singaraiona merged commit d746dc6 into master Sep 17, 2026
13 checks passed
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.

3 participants