Varun Bhandary created SPARK-60085:
--------------------------------------

             Summary: Reduce hash clustering for Float/Double keys in constant 
maps
                 Key: SPARK-60085
                 URL: https://issues.apache.org/jira/browse/SPARK-60085
             Project: Spark
          Issue Type: Improvement
          Components: SQL
    Affects Versions: 4.2.0, 5.0.0
            Reporter: Varun Bhandary


h3. Problem and relevance

A generated lookup into a constant map containing 1,000 Float/Double category 
IDs can check hundreds of buckets per row. This affects SQL map expressions and 
TargetEncoder's category mappings under default lookup and code-generation 
settings. Results remain correct; the problem is avoidable work repeated across 
input rows.

Spark precomputes a hash table for constant (foldable) maps with at least 1,000 
entries. These maps stay fixed while lookup keys vary per row. [TargetEncoder 
uses this 
pattern|https://github.com/apache/spark/blob/0c14861e323174452d42ffb7947d1c1158c39286/mllib/src/main/scala/org/apache/spark/ml/feature/TargetEncoder.scala#L381-L385]
 to map category IDs to learned values.

h3. Why it happens

For 1,000 entries, the table contains 2,048 buckets, selected using the hash's 
lowest 11 bits. The current Float hash uses its bit representation; Double 
XOR-folds its 64 bits into 32. For both types, keys {{0.0}} through {{999.0}} 
all initially select bucket zero. Collision handling stores them in successive 
slots, creating one long cluster. [Pinned 
implementation|https://github.com/apache/spark/blob/0c14861e323174452d42ffb7947d1c1158c39286/sql/catalyst/src/main/scala/org/apache/spark/sql/catalyst/expressions/complexTypeExtractors.scala#L558-L610].

Spark's actual tables require *500.5 bucket checks on average* for uniformly 
distributed successful lookups. This structural count is independent of timing.

h3. Evidence and tradeoff

A local reference candidate using Spark's existing Murmur3 utility reduces that 
average to *1.442 for Float and 1.418 for Double*. Against master 
{{0c14861e323174452d42ffb7947d1c1158c39286}}, repeated execution shows a 
benefit and a cost:

||Workload||Rows||Baseline (ms)||Candidate (ms)||Change across paired runs||
| TargetEncoder, 1,000 categories, successful lookups | 100,000 | 127.32 | 
12.01 | 9.69–13.29× faster |
| Random fractional Double map, 10,000 keys; all misses using keys −1 through 
−10,000 | 1,000,000 | 29.62 | 38.75 | 25.2–30.8% slower |

Each variant ran in three fresh JVMs; times are medians of their per-case 
medians. Each case used five samples after at least two seconds of warmup for 
TargetEncoder and five for the map, reversing variant and case order in round 
two. Preparation and fitting were excluded; aggregates were independently 
checked. Plans and generated code confirmed the path. These are constructed 
workloads on one M3 Pro/JDK 21 machine, with one worker and a 4 GiB heap.

The slower miss case does not perform more average probing (1.5332 → 1.5294 
checks). Extra hash arithmetic and a wider set of visited buckets accompany it; 
their individual timing contributions are unprofiled. Dense-map unseen keys 
improve structurally too; this random-map result does not establish a slowdown 
for unseen TargetEncoder categories. Existing hashing already distributes 
random map keys well.

h3. Proposed direction

Improve Float/Double hash distribution consistently in table construction and 
generated lookup. Use Murmur3 as a reference and evaluate a cheaper mixer using 
all 64 Double bits before selecting the implementation. Preserve lookup 
semantics, signed-zero equality, NaN handling, other key types and strategy 
selection. No public APIs, settings or dependencies change.

This addresses a distribution problem in [SPARK-55959's hash 
lookup|https://issues.apache.org/jira/browse/SPARK-55959]. The adjacent 
[null-key work in PR #58875|https://github.com/apache/spark/pull/58875] and 
TargetEncoder's repeated lookup are separate concerns.

h3. Reproduction

With default lookup/codegen settings:

{code:sql}
SET spark.sql.adaptive.enabled=false;
SET spark.sql.shuffle.partitions=1;

SELECT sum(element_at(
  map_from_arrays(
    CAST(sequence(0, 999) AS ARRAY<DOUBLE>),
    sequence(0, 999)),
  CAST(id % 1000 AS DOUBLE))) AS checksum
FROM range(100000);
{code}

Expected checksum: {{49950000}}. Replace both {{DOUBLE}} casts with {{FLOAT}} 
for that case. Prepend {{EXPLAIN CODEGEN}} to the {{SELECT}} to inspect the 
bucket loop. This demonstrates the affected path; the timings above require the 
separate benchmark harness.




--
This message was sent by Atlassian Jira
(v8.20.10#820010)

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

Reply via email to