comphead commented on PR #5806:
URL: 
https://github.com/apache/datafusion-comet/pull/5806#issuecomment-5605325013

   Reviewed against the PR head `4f6a1cee80`.
   
   Overall this is a well-built change. The approach is right, the Arrow usage 
is correct, and the two hazards specific to this kernel (sliced `MapArray` 
entry offsets, per-row lookup keys) are both handled and both tested at the 
Rust and SQL levels. No blockers from me.
   
   ## Major
   
   ### 1. The map's validity buffer is never consulted, so a NULL map row is 
answered from its entries
   
   **Where:** `native/spark-expr/src/map_funcs/map_extract.rs:181-192`
   
   The per-row scan derives NULL purely from "no matching entry":
   
   ```rust
   let found = (start..end).find(|&i| matched.value(i));
   if let Some(i) = found { indices[row] = (i + entries_start) as u32; }
   nulls.append(found.is_some());
   ```
   
   `map_array.nulls()` is never read. A row marked NULL in the `MapArray` whose 
offset window is non-empty returns that entry's value instead of NULL.
   
   Spark's `GetMapValue` and `ElementAt` are `BinaryExpression`s evaluated 
through `nullSafeEval`, so a NULL map operand returns NULL unconditionally, 
independent of what the underlying storage holds. Arrow does not require a null 
list/map slot to have a zero-length offset window, only that offsets stay 
monotonic, so the code relies on an invariant it does not own.
   
   This is *not* a regression against the pinned DataFusion 55.0.0, which has 
the same gap (`general_map_extract_inner` iterates `value_offsets().windows(2)` 
with no validity test and passes `None` as the output `ListArray`'s null 
buffer). But upstream closed exactly this gap in apache/datafusion#24999, which 
added `if map_array.is_valid(row_index)` plus `map_array.nulls().cloned()`. 
Since this PR forks the kernel into Comet, Comet will not inherit that fix when 
the DataFusion pin moves.
   
   I traced the producers that could deliver such an input (arrow-rs Parquet 
list reader, arrow-java `ListVector.setValueCount`, 
`MutableArrayData::extend_nulls` behind DataFusion's `CaseExpr` scatter, 
`filter`, `concat`, Comet's own `spark_map_sort`) and all of them emit 
degenerate offsets for null rows. So I could not construct a query that 
triggers it today. Latent rather than live.
   
   **Suggested direction:** one line at the end of the scan, before building 
`indices`:
   
   ```rust
   let nulls = NullBuffer::union(map_array.nulls(), nulls.finish().as_ref());
   ```
   
   `map_array.nulls()` is already sliced to the visible rows, so no offset 
arithmetic is needed. A Rust unit test building a `MapArray` via 
`MapArray::try_new` with a null row over a non-empty offset window is the only 
way to construct the input.
   
   ## Minor
   
   ### 2. A constant map argument is expanded to one copy per row before the 
compare
   
   **Where:** `map_extract.rs:120-123`
   
   `ColumnarValue::Scalar(scalar) => scalar.to_array_of_size(number_rows)?` 
materializes `number_rows x entries` map entries, and the vectorized `eq` then 
runs over all of them. For an 8192-row batch against a folded map literal with 
`E` entries that is `8192 * E` Arrow entries built and compared per batch.
   
   This is reachable: `CometLiteral` rebuilds folded `MapType` literals 
natively (gated on `MapKeySupport`), and `CometMapExpressionSuite` already 
exercises `element_at(<folded map literal>, _1)`. It is not a regression (DF 
55's `make_scalar_function` expands scalars identically), and the very large 
case in the suite goes through `CometCreateMap`'s JVM dispatcher rather than a 
literal, so the exposure is bounded by how large a map literal survives as a 
`Literal`. Still, in a PR whose thesis is "hoist the constant out of the row 
loop", the constant *map* is left un-hoisted while the constant *key* is 
hoisted.
   
   If you take it, the cheap version is a scalar-map branch that compares the 
key array against the single map's `E` entry keys with `E` full-length `eq` 
calls. Memory drops from `O(N * E)` to `O(N)`. If you would rather not add a 
second path, a comment at line 122 recording that the expansion is deliberate 
and bounded would keep the next reader from re-deriving this.
   
   ### 3. `spark_map_extract` is exported publicly with no consumer
   
   **Where:** `native/spark-expr/src/lib.rs:64`, `map_funcs/mod.rs:19`
   
   `SparkMapExtract` is used by `comet_scalar_funcs.rs` and the bench. The free 
function `spark_map_extract` is only called from `invoke_with_args` and the 
module's own tests, both of which see it through `super::*`. Compare 
`spark_map_sort`, which is re-exported because `comet_scalar_funcs.rs` imports 
it by name. Dropping `pub` on the function and the re-export keeps the crate's 
public surface to the UDF.
   
   ### 4. `DataType::Null` in the map position is now an error rather than a 
passthrough
   
   **Where:** `map_extract.rs:104-112` and `124-129`
   
   DF 55's `return_type`, `coerce_types`, and `map_extract_inner` all 
special-case `map_type.is_null()`. The replacement does not, so a `Null`-typed 
first argument produces `map_extract: the first argument must be a map, got 
Null`. I believe this is unreachable from Comet (Spark's `ExtractValue.apply` 
requires a `MapType` child, and `CometElementAt.getSupportLevel` returns 
`Unsupported` for anything that is not an array or a map), so I would not add 
the branch. Worth being deliberate about it, since the doc comment claims the 
kernel "is never less capable than the one it replaces" and this is the one 
place it is.
   
   ### 5. Test coverage does not span the key types the new compare primitive 
accepts
   
   The compare changed from `dyn Array` / `ArrayData` equality to 
`arrow::compute::kernels::cmp::eq`, which is type-strict in ways `ArrayData` 
equality is not (decimal precision and scale, timestamp time zone strings, 
integer width). Everything that survives `MapKeySupport` reaches the new `eq`, 
but the tests only exercise `Utf8`, `Int32`, and `Binary` keys 
(`element_at_map.sql` covers string, int with coercion, and binary; the Rust 
tests cover `Utf8` and `Int32`).
   
   Not exercised anywhere: `decimal`, `date`, `timestamp` / `timestamp_ntz`, 
`boolean`, and the narrower integer widths as map key types. Adding a handful 
of rows to `element_at_map.sql` is cheap relative to the risk that one of these 
takes the `exec_err!` path in `key_match_mask` at runtime instead of matching.
   
   ## Questions
   
   ### 6. The override is registered by name only, so `element_at` still 
resolves to DataFusion's implementation
   
   DataFusion's `SessionState::register_udf` registers a UDF under its aliases 
as well as its name, and upstream `MapExtract` declares `aliases: 
["element_at"]`. `SparkMapExtract` declares none, so after 
`register_all_comet_functions` the registry holds `map_extract -> 
SparkMapExtract` and `element_at -> MapExtract` (list-returning). Nothing in 
Comet emits the name `element_at` (grepped `spark/src/main`, `native/core/src`, 
`native/spark-expr/src`), so this is inert today, and it was equally inert 
before the PR because the deleted `planner.rs` arm also matched only 
`"map_extract"`. Worth either adding the alias or noting in the doc comment 
that the override is name-scoped, so a future `element_at` emission does not 
silently get a one-element list?
   
   ### 7. The PR description overstates one of the behavior changes
   
   > a lookup key whose runtime type is not the map's key type is now rejected 
rather than silently missing every row
   
   DF 55's `map_extract_inner` already does this:
   
   ```rust
   if key_type != key_arg.data_type() {
       return exec_err!("The key type {} does not match the map key type {}", 
...);
   }
   ```
   
   So that part is a no-op change, only the message text differs. Worth 
correcting in the description so it is not weighed as new risk.
   
   There *is* a real null-semantics improvement here that the description does 
not claim. DF 55 compares a NULL lookup key against a NULL stored key with 
`ArrayData` equality, which reports them equal, so a map carrying a NULL key 
(reachable through the known `map_from_arrays` gap, #4680) would return that 
key's value where Spark returns NULL. The new kernel returns NULL for a NULL 
lookup key on both the scalar path (line 150-153) and the array path (the mask 
is intersected with the comparison's null buffer at line 228). Upstream's 
`make_comparator` fix does not close this, since `SortOptions::default()` also 
treats two nulls as equal. That is a genuine correctness win worth mentioning 
in the description.
   
   ## Scope
   
   Appropriately scoped. Kernel, registration, the planner cleanup the kernel 
enables, a bench, and tests. No drive-by refactoring, no unrelated formatting, 
no new configuration, no API change beyond the one noted in finding 3.
   
   The `elementwise_match_mask` backstop (lines 239-253) is the only arguably 
speculative piece, since `MapKeySupport` declines every key type `eq` rejects, 
so it is unreachable from Comet. It faithfully reproduces DF 55's comparison, 
is 15 lines, and is unit-tested, so I would keep it. 
`datafusion-comet-spark-expr` is a published crate with consumers outside 
Comet's serde gate.
   
   No duplicated abstraction. `spark_map_sort` handles the sliced-`MapArray` 
trap by rebasing offsets rather than windowing them, which is right for its 
output shape, so there is no helper to extract.
   
   ## Spark compatibility
   
   Checked against `complexTypeExtractors.scala`. `GetMapValue.nullSafeEval` 
delegates to `GetMapValueUtil.getValueEval`, which scans entries in order and 
stops at the first `ordering.equiv` hit, returning null when not found or when 
the matched value is null. `ElementAt`'s map overload shares that path. Both 
are `nullSafeEval`, so a NULL map or a NULL key short-circuits to NULL. Since 
SPARK-40066 (Spark 3.4) neither throws under ANSI for a missing key, so there 
is no ANSI divergence to reproduce here.
   
   Matches Spark: first-match-wins, missing key, NULL key, NULL map with a 
degenerate window, empty map, NULL stored value. Improves on the replaced 
kernel for a NULL lookup key against a NULL stored key.
   
   Diverges: the NULL map row with a non-empty window (finding 1). 
Pre-existing, not introduced here.
   
   Remaining risk is carried entirely by `MapKeySupport`, unchanged by this PR. 
The gate is correct for the new kernel for the same reasons it was correct for 
the old one, since both compare raw Arrow values rather than Spark's normalized 
keys.
   
   One thing worth adding to the description: removing the `ListExtract` 
wrapper also decouples Comet from apache/datafusion#24999, which changed the 
absent-key result from a one-element NULL list to an empty list. `ListExtract` 
with `fail_on_error=false` would have absorbed that, but not depending on it is 
better. That answers my earlier question on this PR.
   
   ## Tests
   
   Present and load-bearing:
   
   - Sliced `MapArray` entry offsets, at both the Rust level 
(`sliced_map_keeps_original_entry_offsets`, constant and per-row key) and the 
SQL level (`element_at on a sliced map reads the visible entries`, via `ORDER 
BY ... LIMIT ... OFFSET`). Reverting the `entries_start` arithmetic fails both.
   - Per-row lookup key including NULL key and NULL map, Rust and SQL.
   - Duplicate keys resolving to the first match, which pins the 
`find`-stops-at-first-hit contract.
   - Empty-window fast path, empty input, scalar map argument, non-string keys, 
the nested-key backstop, and both rejection paths.
   
   Missing:
   
   - A NULL map row over a non-empty offset window (finding 1). Only 
constructible in Rust.
   - Key types beyond `Utf8` / `Int32` / `Binary` (finding 5).
   - A scalar map combined with an array key. The code path exists at line 120 
crossed with line 157 and is untested, though the risk is low.
   
   The two new Scala tests would pass on the pre-PR implementation too, which 
is correct for what they are. They are regression tests for the new kernel's 
specific hazards, not for a bug the PR fixes, and the sliced-map one does fail 
if the windowing is wrong.
   
   Non-nullable map value types (`valueContainsNull = false`) are covered 
indirectly by the existing `map_entries(element_at(map(1, map(1, 2)), _1))` 
test, which routes a non-nullable-value map out of `element_at`.
   
   ## Performance
   
   Evidence is required for this PR and it is supplied at both levels, which is 
the right shape. The new criterion bench runs both implementations over the 
same inputs so the comparison stays reproducible, and it is parameterized over 
entries-per-map and constant versus per-row key.
   
   The kernel-level claim (11x to 56x) is credible from the code. DF 55 
allocates two `ArrayRef` slices per candidate entry and compares through `dyn 
Array` equality, which the new path replaces with one `eq` over the batch plus 
a bit scan plus one `take`. I confirmed the pinned 55.0.0 source is the slow 
slice-based version, so the baseline is the one Comet actually ships.
   
   By dimension:
   
   - CPU: large reduction on the constant-key path, smaller but real on the 
per-row path.
   - Allocations: large reduction. The remaining per-call allocations are the 
`gather` vector and the gathered per-entry key array on the per-row path, both 
`O(total entries in the batch)`, plus `indices` at `O(rows)`.
   - Memory: the per-row path materializes a full per-entry copy of the lookup 
key at line 174, which for string keys copies the key bytes once per entry 
rather than once per row. Bounded and worth the trade. The scalar-map expansion 
at line 122 is the unbounded one, see finding 2.
   - I/O, shuffle, latency: unaffected.
   
   No regression risk I can see for the shapes that were already fast. The 
`entries_start == entries_end` early return keeps the all-empty and all-NULL 
case at one `new_null_array`.
   
   The end-to-end table reads honestly, including the caveat that the Spark arm 
drifted about 15% between runs and that the Comet columns should be read 
against their own `size(attrs)` control. `+64 ms -> +1.5 ms` isolated lookup 
cost is consistent with the kernel numbers.
   


-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to