perf: evaluate elementwise scalar functions once per distinct dictionary value - #24588
perf: evaluate elementwise scalar functions once per distinct dictionary value#24588radmirnovii wants to merge 7 commits into
Conversation
Declares that each output row depends only on the corresponding input row. Nothing reads it yet; the physical layer uses it in the following commit to evaluate a function over a dictionary's distinct values instead of over every row. Default false, so no existing function changes behaviour.
|
Per-function numbers behind "a flat ~0.4–0.8 µs per cold batch": the five
An arm is nearly free where it exists — it already holds the dictionary, so One measurement note, since it cost this table a rewrite: criterion calls the |
Which issue does this PR close?
Related to #20935, which asks for exactly this: "Ideally this would be a
reusable pattern (perhaps a helper or wrapper) that individual string functions
can opt into, rather than duplicating the logic in every function."
Related to #19458; its first half — delivering the dictionary to the function —
merged as #22905. This is the other half: using what is delivered, once for
every function.
Note: this branch includes the benchmark commit from #24586 until it merges.
Rationale for this change
A scalar function over a dictionary-encoded column runs once per row, although
the batch holds no more distinct values than the dictionary does. Eleven
functions already work around this with hand-written handling in their kernels:
That arm sits in nine functions across eight files, with a
ScalarValue::Dictionarytwin inbit_lengthandoctet_length. Every newfunction writes it again, and a function without one —
encode, the hashes,the regex functions — cannot have the optimization at all. This does it once,
in
ScalarFunctionExpr, for any function that declares the property.What changes are included in this PR?
ScalarUDFImpl::evaluates_elementwise(defaultfalse) declares that eachoutput row depends only on the corresponding input row. Over a dictionary
argument the physical layer then unwraps the call and re-maps the result
through the keys, choosing the cheapest sound tier per batch:
fis strict;one appended NULL slot (correct even where
f(NULL)is not NULL);coercion produces today. A dictionary return declines instead and the
function sees the column as it arrived.
Results are reused across batches because a batch carries its own keys but the
dictionary of its whole column chunk — the Parquet reader hands every batch of
a chunk the same value buffers. Results are keyed on the memory the values
occupy and hold it alive, bounded to eight dictionaries and 4 MiB per
expression (a Parquet dictionary page is at most 1 MiB by default). Hits take
only a read lock, so the partitions sharing an expression do not serialize on
each other; a first sighting records a hash, and the second, which proves the
dictionary repeats, buys evaluating all of it.
Three functions opt in:
encode(had no dictionary handling and could nothave; gains encoding preservation plus the declaration, kernel untouched),
reverseandinitcap(arms since #23930; gain only the declaration).The arms stay: the physical layer still hands the dictionary over wherever it
declines — extension metadata, a key type too narrow for the NULL slot.
Are these changes tested?
26 unit tests in
scalar_function.rscover each tier and its boundaries —null keys under strict and non-strict functions, garbage under null keys, the
NULL slot overflowing a narrow key type, the profitability bound, two
dictionary arguments, dictionary scalars, extension metadata, errors from
referenced vs unreferenced values, memoization across batches but not across
dictionaries or past its byte budget, and concurrent hits over one shared
expression.
functions.sltadds the narrow-key decline forreverseend toend, and existing dictionary coverage for
reverse/initcapnow runs throughthe generic path unchanged. For
encode,expr.sltpins results and theplan — the cast is to
Dictionary(Int32, BinaryView), not away from theencoding.
Are there any user-facing changes?
A new trait method with a default; no existing implementation changes. All
three functions return exactly what they did — only how often they compute it
changes.
Benchmarks
The benchmark lands separately in #24586, measuring today's paths — the
hand-written arm (whose cold and warm batches cost the same, an arm cannot
reuse anything) and the cast every unpreserved function pays. This PR extends
it with
encode's dictionary-typed groups, which only become expressiblehere. 8192-row batches, medians, pinned to one core.
cold: a dictionary perbatch;
warm: one shared across batches, as a Parquet column chunk deliversthem. no preservation: the dictionary cast away, one call per row — what
encodedid before this change (reverse's arm already costs about the coldcolumn).
Two cells above are ranges because they are bimodal across repeated runs of
the same binary, and the bimodality is a property of the heap, not of either
path:
encode's expand tier at full cardinality measured 0.87–1.09 ms acrossrun contexts, and the cast path itself flipped the same way at 256 distinct
(546 µs alone, 1.10 ms inside the full suite) while doing byte-identical work
to its stable neighbours. Both paths allocate ~450 KB per batch there; the
mechanism's own work in that cell is microseconds (an aborted compaction scan
and a hash), and a column with no repeated values should not be
dictionary-encoded in the first place. Against the hand-written arms the
mechanism costs a flat ~0.6–0.9 µs per cold batch and repays it on the first
repeated dictionary; per-function numbers in the first comment.
What this does not do
lose above the batch size (
asciiup to 4.6x in that band). A per-value costmodel does not exist yet, so the remaining nine arms should be measured
before opting in, not converted in bulk.
separate change.
optimization. Tracked by FFI:
FFI_ScalarUDFsilently drops producer overrides of defaulted trait methods #22330.take the unpeeled path.
🤖 Generated with Claude Code
https://claude.ai/code/session_01NysGeXTG5opiJKBApsAe5H