Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 43 additions & 20 deletions spark/src/main/java/org/apache/comet/CometShuffleBlockIterator.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@
import java.io.InputStream;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.nio.channels.Channels;
import java.nio.channels.ReadableByteChannel;
import java.util.function.LongConsumer;

/**
Expand All @@ -44,7 +42,12 @@ public class CometShuffleBlockIterator implements Closeable {

private static final int INITIAL_BUFFER_SIZE = 128 * 1024;

private final ReadableByteChannel channel;
/** Default of {@code spark.comet.shuffle.readBufferSize}. */
public static final int DEFAULT_READ_BUFFER_SIZE = 64 * 1024;

private static final ThreadLocal<byte[]> READ_BUFFER = new ThreadLocal<>();

private final int readBufferSize;
private final InputStream inputStream;
private final LongConsumer recordsReadUpdater;
private final ByteBuffer headerBuf = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN);
Expand All @@ -53,13 +56,14 @@ public class CometShuffleBlockIterator implements Closeable {
private int currentBlockLength = 0;

public CometShuffleBlockIterator(InputStream in) {
this(in, records -> {});
this(in, records -> {}, DEFAULT_READ_BUFFER_SIZE);
}

public CometShuffleBlockIterator(InputStream in, LongConsumer recordsReadUpdater) {
public CometShuffleBlockIterator(
InputStream in, LongConsumer recordsReadUpdater, int readBufferSize) {
this.inputStream = in;
this.channel = Channels.newChannel(in);
this.recordsReadUpdater = recordsReadUpdater;
this.readBufferSize = readBufferSize;
}

/**
Expand All @@ -77,17 +81,15 @@ public int hasNext() throws IOException {
}

// Read 16-byte header: clear() resets position=0, limit=capacity,
// preparing the buffer for channel.read() to fill it
// preparing the buffer for readFully() to fill it
headerBuf.clear();
while (headerBuf.hasRemaining()) {
int bytesRead = channel.read(headerBuf);
if (bytesRead < 0) {
if (headerBuf.position() == 0) {
close();
return -1;
}
throw new EOFException("Data corrupt: unexpected EOF while reading batch header");
readFully(inputStream, headerBuf, readBufferSize);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping Channels.newChannel also drops the only thing that stopped a killed task on the direct read path. The JDK channel checks the thread's interrupt status on every read and throws ClosedByInterruptException, and native calls hasNext() on the task thread. readAsRawStream() in CometBlockStoreShuffleReader returns a bare SequenceInputStream, so this path has no InterruptibleIterator. With this change a killed task keeps reading until the shuffle block it is on runs out.

I measured it locally with a 20M row shuffle into a single block, read by a native project, and killed the reduce task 1 s into its 5.6 s read. With an interrupting kill, which is what speculation and Spark Connect and Thrift server cancellation use, the task stopped 11 ms after the kill on main and kept going for 4.6 s on this branch. The skewed partition this PR is aimed at is also the task most likely to get a speculative copy, and the losing copy now holds its core and memory until it finishes reading its current block.

Could readAsRawStream() wrap the stream so that each read calls context.killTaskIfInterrupted(), the way the Celeborn reader's stream already does? I tried that on top of this branch. Kills with and without an interrupt both stopped within about 10 ms, and a task that isn't killed took the same time. A test that marks the task context interrupted and checks that the next read throws TaskKilledException would keep this from regressing again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5c025a8acb as you suggested. I measured it locally with one 1.4 GiB shuffle block read by a native project and cancelled 1 s into the read. The direct-read task now stops 1 ms after the cancel, with or without an interrupt. Before the fix it kept reading for about 3.4 s either way, and on main it did so for kills without an interrupt. The table against main is in the description.

if (headerBuf.hasRemaining()) {
if (headerBuf.position() == 0) {
close();
return -1;
}
throw new EOFException("Data corrupt: unexpected EOF while reading batch header");
}
headerBuf.flip();
long compressedLength = headerBuf.getLong();
Expand Down Expand Up @@ -121,18 +123,39 @@ public int hasNext() throws IOException {

dataBuf.clear();
dataBuf.limit(currentBlockLength);
while (dataBuf.hasRemaining()) {
int bytesRead = channel.read(dataBuf);
if (bytesRead < 0) {
throw new EOFException("Data corrupt: unexpected EOF while reading compressed batch");
}
readFully(inputStream, dataBuf, readBufferSize);
if (dataBuf.hasRemaining()) {
throw new EOFException("Data corrupt: unexpected EOF while reading compressed batch");
}
// Note: native side uses get_direct_buffer_address (base pointer) + currentBlockLength,
// not the buffer's position/limit. No flip needed.

return currentBlockLength;
}

/**
* Fills {@code dst} from {@code in}, stopping short only at the end of the stream. Both shuffle
* readers use it. The stream is read directly, in pieces of up to {@code readBufferSize}, through
* a buffer reused per thread. {@code Channels.newChannel} would read 8 KiB at a time and call
* {@code available()} before every piece but the first, which costs an fstat and an lseek on a
* local shuffle file.
*/
public static void readFully(InputStream in, ByteBuffer dst, int readBufferSize)
throws IOException {
byte[] chunk = READ_BUFFER.get();
if (chunk == null || chunk.length != readBufferSize) {
chunk = new byte[readBufferSize];
READ_BUFFER.set(chunk);
}
while (dst.hasRemaining()) {
int read = in.read(chunk, 0, Math.min(dst.remaining(), chunk.length));
if (read < 0) {
return;
}
dst.put(chunk, 0, read);
}
}

/**
* Reports a native IPC/codec failure to the remote stream that supplied the current block. Called
* by ShuffleScan via JNI; local streams preserve the original native exception.
Expand Down
12 changes: 12 additions & 0 deletions spark/src/main/scala/org/apache/comet/CometConf.scala
Original file line number Diff line number Diff line change
Expand Up @@ -890,6 +890,18 @@ object CometConf extends ShimCometConf {
s"Write buffer size must be between 1 and ${Int.MaxValue} bytes")
.createWithDefault(1024 * 1024)

val COMET_SHUFFLE_READ_BUFFER_SIZE: ConfigEntry[Long] =
conf("spark.comet.shuffle.readBufferSize")
.category(CATEGORY_SHUFFLE)
.doc(
"Size in bytes of each read from a shuffle block stream. Comet reads a shuffle block " +
"through a reusable buffer of this size per task thread before decoding it.")
.bytesConf(ByteUnit.BYTE)
.checkValue(
v => v > 0 && v <= Int.MaxValue,
s"Read buffer size must be between 1 and ${Int.MaxValue} bytes")
.createWithDefault(CometShuffleBlockIterator.DEFAULT_READ_BUFFER_SIZE.toLong)

val COMET_SHUFFLE_JVM_PREFER_DICTIONARY_RATIO: ConfigEntry[Double] = conf(
"spark.comet.shuffle.jvm.preferDictionary.ratio")
.withAlternative("spark.comet.shuffle.preferDictionary.ratio")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

package org.apache.spark.sql.comet.execution.shuffle

import java.io.InputStream
import java.io.{FilterInputStream, InputStream}

import org.apache.spark.{InterruptibleIterator, MapOutputTracker, SparkEnv, TaskContext}
import org.apache.spark.internal.{config, Logging}
Expand Down Expand Up @@ -91,6 +91,7 @@ class CometBlockStoreShuffleReader[K, C](
val nativeLib = new Native()
val nativeUtil = new NativeUtil()
val tracingEnabled = CometConf.COMET_TRACING_ENABLED.get()
val readBufferSize = CometConf.COMET_SHUFFLE_READ_BUFFER_SIZE.get().toInt

// Closes last read iterator and shared resources after the task is finished.
// We need to close read iterator during iterating input streams,
Expand All @@ -113,7 +114,8 @@ class CometBlockStoreShuffleReader[K, C](
dep.decodeTime,
nativeLib,
nativeUtil,
tracingEnabled)
tracingEnabled,
readBufferSize = readBufferSize)
currentReadIterator
})
.map(b => (0, b))
Expand Down Expand Up @@ -158,10 +160,23 @@ class CometBlockStoreShuffleReader[K, C](
*/
override def readAsRawStream(): InputStream = {
val streams = fetchIterator.map(_._2)
new java.io.SequenceInputStream(new java.util.Enumeration[InputStream] {
val blocks = new java.io.SequenceInputStream(new java.util.Enumeration[InputStream] {
override def hasMoreElements: Boolean = streams.hasNext
override def nextElement(): InputStream = streams.next()
})
// Native code reads this stream on the task thread, outside any InterruptibleIterator, so
// every read checks whether the task was killed.
new FilterInputStream(blocks) {
override def read(): Int = {
context.killTaskIfInterrupted()
in.read()
}

override def read(buffer: Array[Byte], offset: Int, length: Int): Int = {
context.killTaskIfInterrupted()
in.read(buffer, offset, length)
}
}
}

private def fetchContinuousBlocksInBatch: Boolean = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,8 @@ private[shuffle] final class CometCelebornShuffleReader[K, C](
new Native(),
nativeUtil,
CometConf.COMET_TRACING_ENABLED.get(),
Some(serializedSchema))
Some(serializedSchema),
CometConf.COMET_SHUFFLE_READ_BUFFER_SIZE.get().toInt)
} catch {
case NonFatal(failure) =>
Utils.tryWithSafeFinally(throw failure) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import org.apache.spark.sql.execution.metric.{SQLMetric, SQLShuffleReadMetricsRe
import org.apache.spark.sql.internal.SQLConf
import org.apache.spark.sql.vectorized.ColumnarBatch

import org.apache.comet.CometShuffleBlockIterator
import org.apache.comet.{CometConf, CometShuffleBlockIterator}

/**
* Different from [[org.apache.spark.sql.execution.ShuffledRowRDD]], this RDD is specialized for
Expand Down Expand Up @@ -176,7 +176,8 @@ class CometShuffledBatchRDD(
}
new CometShuffleBlockIterator(
readerAndMetrics._1.readAsRawStream(),
readerAndMetrics._2.incRecordsRead)
readerAndMetrics._2.incRecordsRead,
CometConf.COMET_SHUFFLE_READ_BUFFER_SIZE.get().toInt)
}

override def compute(split: Partition, context: TaskContext): Iterator[ColumnarBatch] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,13 @@ package org.apache.spark.sql.comet.execution.shuffle

import java.io.{EOFException, InputStream}
import java.nio.{ByteBuffer, ByteOrder}
import java.nio.channels.{Channels, ReadableByteChannel}

import scala.util.control.NonFatal

import org.apache.spark.sql.execution.metric.SQLMetric
import org.apache.spark.sql.vectorized.ColumnarBatch

import org.apache.comet.{CometShuffleReadFailureHandler, Native}
import org.apache.comet.{CometShuffleBlockIterator, CometShuffleReadFailureHandler, Native}
import org.apache.comet.vector.NativeUtil

/**
Expand All @@ -42,7 +41,8 @@ case class NativeBatchDecoderIterator(
nativeLib: Native,
nativeUtil: NativeUtil,
tracingEnabled: Boolean,
expectedSchema: Option[Array[Byte]] = None)
expectedSchema: Option[Array[Byte]] = None,
readBufferSize: Int = CometShuffleBlockIterator.DEFAULT_READ_BUFFER_SIZE)
extends Iterator[ColumnarBatch] {

// One consumer reads this iterator, while task completion may close it from another thread.
Expand All @@ -60,15 +60,9 @@ case class NativeBatchDecoderIterator(

import NativeBatchDecoderIterator._

private val channel: ReadableByteChannel = if (in != null) {
Channels.newChannel(in)
} else {
null
}

override def hasNext: Boolean = {
synchronized {
if (channel == null || isClosed) {
if (in == null || isClosed) {
return false
}
if (batch.isDefined) {
Expand Down Expand Up @@ -172,7 +166,7 @@ case class NativeBatchDecoderIterator(
private def readNextBlock(): Option[(Int, ByteBuffer, Int)] = {
// read compressed batch size from header
longBuf.clear()
while (longBuf.hasRemaining && channel.read(longBuf) >= 0) {}
CometShuffleBlockIterator.readFully(in, longBuf, readBufferSize)

// If we reach the end of the stream, we are done, or if we read partial length
// then the stream is corrupted.
Expand All @@ -189,7 +183,7 @@ case class NativeBatchDecoderIterator(

// read field count from header
longBuf.clear()
while (longBuf.hasRemaining && channel.read(longBuf) >= 0) {}
CometShuffleBlockIterator.readFully(in, longBuf, readBufferSize)
if (longBuf.hasRemaining) {
throw new EOFException("Data corrupt: unexpected EOF while reading field count")
}
Expand All @@ -214,7 +208,7 @@ case class NativeBatchDecoderIterator(
}
dataBuf.clear()
dataBuf.limit(bytesToRead.toInt)
while (dataBuf.hasRemaining && channel.read(dataBuf) >= 0) {}
CometShuffleBlockIterator.readFully(in, dataBuf, readBufferSize)
if (dataBuf.hasRemaining) {
throw new EOFException("Data corrupt: unexpected EOF while reading compressed batch")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,35 @@ class CometCelebornShuffleReaderSuite extends CometTestBase {
context.markTaskCompleted(None)
}

test("a killed task stops at the next read of a local shuffle's direct-read stream") {
withSQLConf(
CometConf.COMET_EXEC_ENABLED.key -> "true",
CometConf.COMET_SHUFFLE_MODE.key -> "native",
CometConf.COMET_SHUFFLE_ENABLED.key -> "true",
"spark.sql.adaptive.enabled" -> "false") {
withParquetTable((0 until 12).map(i => (i, s"row-$i")), "local_direct_read_rows") {
val shuffled = sql("SELECT * FROM local_direct_read_rows").repartition(1, $"_1")
// Running the query writes the shuffle that the stream below reads again.
assert(shuffled.collect().length == 12)
val rdd = shuffled.queryExecution.executedPlan
.collectFirst { case exchange: CometShuffleExchangeExec =>
exchange.executeColumnar().asInstanceOf[CometShuffledBatchRDD]
}
.getOrElse(fail("Expected a native Comet shuffle exchange"))
val context = TaskContext.empty()
val blocks = rdd.computeAsShuffleBlockIterator(rdd.partitions.head, context)
try {
assert(blocks.hasNext() > 0)
context.markInterrupted("killed after the first batch")
intercept[TaskKilledException](blocks.hasNext())
} finally {
blocks.close()
context.markTaskCompleted(None)
}
}
}
}

test("malformed native frames invalidate once before allocating or opening another reducer") {
def header(length: Long, fields: Long): Array[Byte] = ByteBuffer
.allocate(16)
Expand Down Expand Up @@ -1631,6 +1660,10 @@ class CometCelebornShuffleReaderSuite extends CometTestBase {
NativeBatchDecoderIteratorLifecycleChecks.closesPrefetchedBatch()
}

test("shuffle readers read in pieces of the read buffer size without asking for available") {
NativeBatchDecoderIteratorLifecycleChecks.readersReadInPiecesOfTheReadBufferSize()
}

test("decoder cleanup releases its delivered batch exactly once") {
NativeBatchDecoderIteratorLifecycleChecks.closesDeliveredBatch()
}
Expand Down
Loading
Loading