Skip to content

perf: every Comet broadcast hash join probe task re-decodes the whole broadcast in the JVM #6806

Open

Description

@comphead

What is the problem the feature request solves?

With a 1,048,576-row build side, a Comet broadcast hash join probe task takes 31.2 ms, against 8.5 ms in Spark (mean over 32 probe tasks of 65,536 rows each, same data and query). About three quarters of the Comet task decodes the broadcast in the JVM, and every probe task decodes all of it again.

Profile of one Comet probe task, run alone about 800 times, from a 1 ms stack sampler on the task thread plus macOS sample for native frames:

Where the Comet probe task's time goes Share About
lz4-java decompression of the broadcast 58% 18 ms
↳ of which xxhash32 block checksums 14% 4.4 ms
Channels.newChannel copy loop in Utils.decodeBatches 8% 2.6 ms
Other JVM decode and hand-off (DataInputStream, Arrow IPC read, export to native, releases) 8% 2.4 ms
Native Parquet scan of the probe file 7% 2.2 ms
Native hash table build (try_create_array_map, min and max of the build keys) 6% 1.9 ms
Join probe and output 5% 1.5 ms
UTF-8 check of the imported build batch 2% 0.7 ms
Other 6% 1.9 ms

The native build matches the Total time for collecting build-side of join metric, 2.84 ms per task. Spark's probe task is about half Parquet scan and 40% output row writes. Spark builds its hash table once, on the driver.

The decode path, at 9a6241f:

  • CometBroadcastExchangeExec broadcasts Arrow IPC batches compressed with Spark's spark.io.compression.codec, lz4 by default (Utils.serializeBatches, Utils.coalesceBroadcastBatches, broadcastInternal).
  • Spark's TorrentBroadcast compresses the broadcast blocks again with the same codec when spark.broadcast.compress is on, which is the default. So the payload is compressed twice, and the inner layer is the one every task undoes.
  • CometBatchRDD.compute decodes the whole broadcast in every task through Utils.decodeBatches: codec.compressedInputStream, then a DataInputStream, then Channels.newChannel into ArrowReaderIterator.
  • Here, lz4-java ran its pure-Java decompressor (LZ4JavaUnsafeFastDecompressor) and verified an xxhash32 checksum on every block.

The cost per task grows with the build side. In an earlier sweep of the same benchmark, without converting Comet's output to rows:

Build rows Comet probe task minus Spark's One decode of the serialized broadcast, timed on the driver
65,536 −0.4 to −0.8 ms (Comet faster) 1.0 ms
262,144 2.9–3.0 ms 4.4–4.5 ms
1,048,576 22.3 ms 18.7–19.5 ms

Converting the output to rows, as a query ending in a row-based operator does, adds about 2 ms to every Comet probe task.

Describe the potential solution

Additional context

Willingness to contribute

I can contribute a fix for this bug independently

Activity

  1. self-assigned this
    on Oct 8, 2026
  2. added this to the 1.2.0 milestone on Oct 10, 2026
  3. andygrove commented on Oct 10, 2026

    @andygrove
    Member

    This seems like a big perf win

  4. andygrove commented on Oct 15, 2026

    @andygrove
    Member

    @comphead I know you offered to work on this, so I wanted to let you know that I opened #7016 for it. It takes the first approach from your list: the tasks of an executor that read the same broadcast at the same time share one decoded copy, so only the first of them decodes it. On your shape (a 1M-row build side and 32 probe tasks under local[8]), the probe tasks' summed CPU went from 879 ms to 331 ms. It also replaces the Channels.newChannel loop in the decode path, though that only saved about 2.5% per decode in my runs.

    I left Comet's codec layer alone. With the decode shared, it is paid once per executor while its tasks overlap rather than once per task, and dropping it would roughly double the broadcast held on-heap and change the dataSize that AQE reads. Each task also still builds its own hash table, which is the remaining part of #6013.

    Would you mind taking a look? If you still have CometHashJoinTaskTimeBenchmark around, it would be good to see how much of the gap in your profile it closes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:joinsJoin operators and dynamic filter pushdownenhancementNew feature or requestperformance

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions