Conversation
…on native I/O executePlan's ScanExec path re-polled the plan's stream in a tight loop whenever it returned Pending, relying on pull_input_batches to block on the JVM iterators in between. Once every JVM-fed scan holds a batch or has reached EOF that pull is a no-op, so a stream pending on native I/O (a Parquet or Iceberg scan reading from S3 or HDFS) spun the executor thread at 100% CPU for the whole read. A broadcast hash join over a native scan hits this on every probe-side read. pull_input_batches and the two scan operators now report whether a buffer was refilled. When the stream is Pending and nothing was pulled, the loop parks the block_on task until a waker registered by that poll fires, bounded by a short safety timeout, then re-enters the loop so JVM-fed scans still get refilled. Awaiting the stream directly is not safe: ScanExec returns Pending without a waker when an operator drains and re-polls it within one poll. The metrics interval is checked every iteration now that iterations are no longer spins. Closes apache#6091 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
mbutrovich
left a comment
There was a problem hiding this comment.
Thanks @mixermt. The diagnosis in #6091 closes the gap #3553 left open. #3553 parked the executor thread only for plans without JVM-fed inputs, so plans with a ScanExec or ShuffleScanExec still spin while native I/O is pending. You're right that the loop can't .await the stream, because the JVM refill has to run between polls. Parking for one wake-up keeps that refill reachable.
The threading section of development.md (lines 46-48) describes this loop. Can you update it in this PR to say the loop parks until a waker fires when nothing was pulled?
andygrove
left a comment
There was a problem hiding this comment.
Thanks for the writeup on this one, the diagnosis in #6091 made it easy to follow. I traced through the alternative you rejected and I agree it isn't safe. If the loop awaits the stream, the wake can land inside the await, an operator drains a JVM buffer and re-polls that scan within the same poll, and the no-waker Pending that comes back has no loop left to rescue it. Parking for exactly one wake-up is the right shape. I also checked that build_runtime already calls .enable_all(), so the timer driver is there for the park, and that spark.comet.metrics.updateInterval defaults to 3000 ms, comfortably above the 100 ms park, so metrics stay timely.
Two things on top of Matt's comments.
ScanStream and ShuffleScanStream now register the poll's waker when their buffer is empty and get_next_batch wakes it after refilling, so every Pending from the plan carries a waker. The loop in executePlan moves into next_batch(stream, on_pending): poll, refill and check metrics on Pending, then park until a waker fires. The 100 ms timeout, the bool from get_next_batch and the tokio time feature are gone. EOF stays buffered so a re-poll of an exhausted scan returns Ready(None) again instead of another JNI round trip. The tracing memory sample sits behind the metrics interval, and development.md describes the loop. Tests drive next_batch with a stream pending on a sleep (with the park removed it pulls 135,311 times in 50 ms) and with a ScanExec refilled by the pull closure under a timeout, plus a ShuffleScanStream waker test. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
|
Thanks both. Everything below is in the latest push, and the two threads changed the design rather than patching it.
Verified with the rebuilt library: clippy with This rewrites the native execution loop, so the Spark SQL suites should report here rather than in the merge queue. Could a committer add |
The JVM data source path polls operators on the Spark executor thread inside block_on; only tasks they spawn run on tokio workers. The heading now names ShuffleScanExec as well, since pull_input_batches feeds both streams and both register a waker. The native-wait test gets the same ten second bound as the refill test, so a lost wake fails instead of hanging the suite. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Which issue does this PR close?
Closes #6091.
Rationale for this change
Java_org_apache_comet_Native_executePlanruns a plan that has any JVM-fed input (a broadcast build side,CometSparkRowToColumnar, a shuffle read) through a loop that polls the stream and, onPending, pulls the next batches from the JVM. That pull blocks inside JNI while the JVM produces data, so the loop never spun as long as the JVM was the only thing worth waiting for. With native scans reading from S3 or HDFS the stream is also pending on asynchronous I/O, and once every JVM-fed scan holds a batch or has reached EOF the pull is a no-op: the loop re-polls at full speed for the duration of every read, pinning one core per task.On a production workload (Iceberg on HDFS joined with a broadcast relation) the scan stages used 87 core-hours against 6.6 for Spark alone, ran 3x to 6x longer, and the saturated cores caused HDFS ack timeouts and retries. Details in #6091.
What changes are included in this PR?
ScanStreamandShuffleScanStreamhonor theStreamcontract.poll_nextregisterscx.waker()when the buffer is empty, andget_next_batchwakes it after refilling, through anAtomicWakershared by the exec's clones. EOF stays buffered, so a re-poll returnsReady(None)again andget_next_batchis a no-op once the reader is drained.executePlanmoves its loop intonext_batch(stream, on_pending): poll the stream; onPending, refill the JVM-fed scans and check the metrics interval, then park theblock_ontask until a waker fires (park_until_woken, apoll_fnthat yields once). A refill wakes the task before it parks, so it resumes at once; a stream waiting on native I/O sleeps until that I/O wakes it. There is no timeout, because everyPendingnow carries a waker.update_metrics_on_intervalreplaces the per-100-polls gate. It runs on every pending poll and once per returned batch, and the tracinglog_memory_usagesample sits behind the same interval, so trace density does not depend on how often the loop turns.development.mddescribes the pull-then-park loop.How are these changes tested?
next_batch_parks_while_the_stream_waits_on_native_io: a stream pending ontokio::time::sleepfor 50 ms; the pull closure runs once. With the park removed it ran 135,311 times.next_batch_resumes_on_a_refill_and_stops_pulling_after_eof: a realScanExecin test mode. Each park ends only on the refill's wake, under a timeout that turns a lost wake into a failure, and a re-poll after EOF pulls nothing. With EOF cleared on poll it fails.refill_wakes_the_pending_poll_and_eof_stays_bufferedinshuffle_scan.rs: the empty-buffer poll registers the waker, the refill wakes it, and EOF stays buffered.cargo clippy --all-targets -p datafusion-comet -- -D warningsis clean; all 433 core unit tests pass.CometTaskMetricsSuite,CometJoinSuite,CometNativeShuffleInputRDDSuite,CometNativeShuffleSuiteandCometIcebergNativeSuite: 235 tests pass and none hang; the one canceled test is the pre-existing Spark 4.1assumegate (SPARK-55626).This touches the native execution loop, so the Spark SQL suites (
run-spark-4.1-tests) should run before merge.AI Disclosure
Drafted, implemented and tested with AI assistance (Claude Code); reviewed before submission.
🤖 Generated with Claude Code