peter-toth commented on code in PR #59639:
URL: https://github.com/apache/spark/pull/59639#discussion_r4350560773
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/LateMaterializationParquetRecordReader.java:
##########
@@ -337,17 +376,21 @@ private void decideAllKeysMissingFile() {
public boolean nextBatch() throws IOException {
closeAll(publishedKeyVectors);
publishedKeyVectors = null;
+ trimSurvivorMemory();
if (!super.nextBatch()) return false;
if (keyColumns != null) {
if (spliceCurrentRowGroup) {
- publishedKeyVectors = survivorBatches.pollFirst();
- if (publishedKeyVectors == null) {
+ SurvivorBatch next = survivorBatches.pollFirst();
+ if (next == null) {
// Unreachable, since the queue holds exactly the survivors the emit
loop counts. Without
// this check the key slots would show stale values.
throw ParquetStorageFilter.internalError(String.format(
"Storage-filter survivor queue of row group %d in %s ran out
with %d rows still "
+ "to emit", nextBlockIndex - 1, fileReader.getFile(),
columnarBatch.numRows()));
}
+ publishedKeyVectors = next.vectors();
+ // Still reserved, until the next batch frees these vectors and trims
the reservation.
Review Comment:
Done in
[09b8b2186e3](https://github.com/apache/spark/pull/59639/commits/09b8b2186e31b49f1d5d864601739f989294f582),
with your wording.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilterSuite.scala:
##########
@@ -2119,6 +2199,270 @@ class ParquetStorageFilterSuite extends QueryTest with
SharedSparkSession
}
}
+ // A task memory manager that calls `onAcquire` before each acquisition but
a test consumer's,
+ // with what is asked for and what the task holds then.
+ private class ObservedTaskMemoryManager(
+ memoryManager: MemoryManager,
+ onAcquire: (Long, Long) => Unit) extends
TaskMemoryManager(memoryManager, 0) {
+ override def acquireExecutionMemory(required: Long, consumer:
MemoryConsumer): Long = {
+ if (!consumer.isInstanceOf[TestMemoryConsumer]) {
+ onAcquire(required, getMemoryConsumptionForThisTask)
+ }
+ super.acquireExecutionMemory(required, consumer)
+ }
+ }
+
+ // A task whose memory manager grants at most `limit` bytes, and calls
`onAcquire` as
+ // `ObservedTaskMemoryManager` does.
+ private def memoryTask(
+ limit: Long = Long.MaxValue,
+ onAcquire: (Long, Long) => Unit = (_, _) => ()): TaskContextImpl = {
+ val memoryManager = new TestMemoryManager(spark.sparkContext.conf)
+ memoryManager.limit(limit)
+ new TaskContextImpl(stageId = 0, stageAttemptNumber = 0, partitionId = 0,
taskAttemptId = 0,
+ attemptNumber = 0, numPartitions = 1,
+ taskMemoryManager = new ObservedTaskMemoryManager(memoryManager,
onAcquire),
+ localProperties = new Properties, metricsSystem = null)
+ }
+
+ // Reads `path` under `filter` in batches of `batchSize` and a cap of `cap`,
as a task of
+ // `context`. Returns the rows, and what the task holds as each batch is
read.
+ private def readInTask(
+ path: String,
+ context: TaskContext,
+ offHeap: Boolean = false,
+ batchSize: Int = 4,
+ cap: String = "64MB",
+ filter: Expression = keyAtLeast(10L),
+ schema: StructType = checkedSchema()): (Seq[(Long, Boolean)], Seq[Long])
= {
+ var held = Seq.empty[Long]
+ val rows = readChecked(path, schema, Seq(filter), conf = Map(
+ SQLConf.PARQUET_VECTORIZED_READER_BATCH_SIZE.key -> batchSize.toString,
+ SQLConf.COLUMN_VECTOR_OFFHEAP_ENABLED.key -> offHeap.toString,
+
SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_MAX_SPLICED_ROW_GROUP_BYTES.key -> cap),
+ keyOf = (b, i) => {
+ if (i == 0) held :+=
context.taskMemoryManager().getMemoryConsumptionForThisTask
+ b.column(0).getLong(i)
+ },
+ context = Some(context))
+ (rows, held)
+ }
+
+ // Reads `path` under `k >= 10` while another consumer holds all of the
task's memory but
+ // `margin`, and checks that the read neither drops rows nor spills that
consumer.
+ private def readBesideOther(
+ path: String,
+ rows: Long,
+ margin: Long,
+ cap: String = "64MB"): Unit = {
+ val held = 1000000L
+ val context = memoryTask(held + margin)
+ val other = new TestMemoryConsumer(context.taskMemoryManager)
+ other.use(held)
+ val (read, _) = readInTask(path, context, batchSize = 4096, cap = cap)
+ val name = s"$rows rows with a margin of $margin under a cap of $cap"
+ assert(read == marked(10L until rows, checked = true), s"$name: got
${read.size} rows")
+ assert(other.getUsed == held, s"$name: the read must not spill another
consumer")
+ }
+
+ // One row group of 400,000 rows of a long key and a string value. Under `k
>= 10` its 399,990
+ // survivors fall in one range and buffer 3,599,950 bytes, several steps of
the reservation.
+ private def oneRowGroupOf400000(dir: File): String = {
+ val path = writeSingleParquetFile(dir,
+ spark.range(400000L).selectExpr("id AS k", "CAST(id AS STRING) AS v"),
+ rowGroupSize = ParquetWriter.DEFAULT_BLOCK_SIZE)
+ assert(footerOf(path).getBlocks.size == 1, "the fixture must be one row
group")
+ path
+ }
+
+ test("the survivor buffer is accounted to the task's execution memory") {
+ // What a row group buffers is held while it is emitted, and released when
the next one loads.
+ // Phase 1 reserves a little ahead of what it buffers, and gives that back
before phase 2. Off
+ // heap, only the ranges are accounted, since the vectors' native memory
is not the memory
+ // manager's.
+ withTempDir { dir =>
+ val path = twoRowGroupsOf20(dir)
+ // Past a cap of 100 bytes, at the seventh survivor of each row group,
the keys go, a full
+ // batch of them already queued, and only the range is held.
+ Seq((false, "64MB", Seq(130L, 130L, 130L, 220L, 220L, 220L, 220L, 220L)),
+ (true, "64MB", Seq.fill(8)(40L)),
+ (false, "100b", Seq.fill(8)(40L))).foreach { case (offHeap, cap,
expected) =>
+ val context = memoryTask()
+ val (rows, held) = readInTask(path, context, offHeap, cap = cap)
+ val name = s"off heap $offHeap under a cap of $cap"
+ assert(rows == marked(10L until 40L, checked = true), s"$name: got
$rows")
+ assert(held == expected,
+ s"$name: each batch must hold its row group's reservation until the
next row group " +
+ s"loads; got $held")
+ assert(context.taskMemoryManager.getMemoryConsumptionForThisTask == 0,
+ s"$name: not released")
+ }
+ // On heap, each row group asks first for its first survivor's 49 bytes,
with nothing held
+ // since the previous one's release, and then for as much again as it
holds, three and four
+ // times for the 130 and 220 bytes it reaches.
+ var asked = Seq.empty[(Long, Long)]
+ readInTask(path, memoryTask(onAcquire = (size, held) => asked :+=
((size, held))))
+ assert(asked == Seq((49L, 0L), (49L, 49L), (98L, 98L), (49L, 0L), (49L,
49L), (98L, 98L),
+ (196L, 196L)), s"got $asked")
+ // A reader closed after its first batch, as under a limit, releases
what it still holds.
+ val context = memoryTask()
+ val file = PartitionedFile(
+ InternalRow.empty, SparkPath.fromPathString(path), 0, new
File(path).length())
+ val heldAtFirst =
withSQLConf(SQLConf.PARQUET_VECTORIZED_READER_BATCH_SIZE.key -> "4") {
+ asTask(context) {
+ drain(formatReader(checkedSchema(), Nil,
Seq(keyAtLeast(10L)))(file)) { it =>
+ it.next()
+ context.taskMemoryManager.getMemoryConsumptionForThisTask
+ }
+ }
+ }
+ assert(heldAtFirst == 130L,
+ s"the first batch must hold the first row group; got $heldAtFirst")
+ assert(context.taskMemoryManager.getMemoryConsumptionForThisTask == 0,
+ "a reader closed early must release what it holds")
+ }
+ }
+
+ test("emitting a row group releases its memory in steps") {
+ // Each batch emitted frees its survivors, but the reservation is cut only
once it is a step
+ // past what is still buffered, so a large row group gives its memory back
a few times rather
+ // than per batch.
+ withTempDir { dir =>
+ val path = oneRowGroupOf400000(dir)
+ var asked = Seq.empty[Long]
+ val context = memoryTask(onAcquire = (size, _) => asked :+= size)
+ val (rows, reserved) = readInTask(path, context, batchSize = 4096)
+ assert(rows.size == 399990, s"got ${rows.size} rows")
+ // What batch `b` still holds, its own survivors and those after it at 9
bytes each, and the
+ // range. A batch frees 36,864 bytes, so the reservation steps down
every 29 batches.
+ def held(b: Int): Long = 40L + 9L * (399990L - 4096L * b)
+ val expected = (0 until 98).map(b => held(b - b % 29))
+ assert(reserved == expected, s"expected the reservation to step down at
batches 29, 58 " +
+ s"and 87; got ${reserved.distinct}")
+ assert(context.taskMemoryManager.getMemoryConsumptionForThisTask == 0,
"not released")
+ // A growing buffer asks a few times. First for the first survivor, then
for as much again as
+ // it holds up to 802,816 bytes, and then 1 MiB at a time.
+ assert(asked == Seq(49L) ++ (0 to 14).map(49L << _) ++ Seq.fill(2)(1L <<
20),
+ s"got ${asked.size} asks: ${asked.take(20)}")
+ // The 3,599,950 bytes it ends up buffering are not asked for twice
over. Asking for as much
+ // again as it holds would spill the other consumer.
+ readBesideOther(path, rows = 400000L, margin = 3599950L + (1L << 20))
+ }
+ }
+
+ test("memory the task's memory manager denies gives splicing up, and then
the filter") {
+ withTempDir { dir =>
+ val path = twoRowGroupsOf20(dir)
+ // Each read must leave the task holding nothing.
+ def released(context: TaskContext, available: Long): Unit =
+ assert(context.taskMemoryManager().getMemoryConsumptionForThisTask ==
0,
+ s"a read with $available bytes free must release what it reserved")
+ def rows(available: Long, offHeap: Boolean = false): Seq[(Long,
Boolean)] = {
+ val context = memoryTask(available)
+ val (read, _) = readInTask(path, context, offHeap)
+ released(context, available)
+ read
+ }
+ // The bytes a read transfers, with the row groups the budget limited,
see `budgetGiveUps`.
+ def bytes(available: Long, cap: String = "64MB"): (Long, Seq[Long]) = {
+ val metrics = metricMap()
+ val context = memoryTask(available)
+ val (_, read) = readUnderCap(path, checkedSchema(),
Seq(keyAtLeast(10L)), cap,
+ batchSize = 4, metrics, Some(context))
+ released(context, available)
+ (read, budgetGiveUps(metrics))
+ }
+ val plain = rows(Long.MaxValue)
+ assert(plain == marked(10L until 40L, checked = true), s"got $plain")
+ // 105 bytes hold the 40 of the first row group's range and 7 of its
keys, but not the 8th.
+ // So splicing is given up there, and in the second row group, as a cap
of 105 bytes gives
+ // it up. The read must cost exactly what the cap does, the key column
read again, while the
+ // filter stays. The metrics tell the two causes apart.
+ val keysDenied = rows(105L)
+ assert(keysDenied == plain, s"denied keys must keep the filter; got
$keysDenied")
+ // 50 bytes take the first survivor's 49, and then grant 1 of the 9 the
second one misses,
+ // which goes back.
+ assert(rows(50L) == plain, "a partly granted reservation must keep the
filter")
+ // 130 bytes grant the seventh survivor 32 of the 98 it asks for, which
covers the 5 it
+ // misses, so the first row group keeps splicing. The second is denied
at its eleventh.
+ val (_, partlyGranted) = bytes(130L)
+ assert(partlyGranted == Seq(0L, 1L, 0L, 0L),
+ s"only the second row group must be denied; got $partlyGranted")
+ val (uncappedBytes, uncapped) = bytes(Long.MaxValue)
+ assert(uncapped == Seq(0L, 0L, 0L, 0L), s"got $uncapped")
+ val (cappedBytes, capped) = bytes(Long.MaxValue, cap = "105b")
+ assert(cappedBytes > uncappedBytes, "a cap of 105 bytes must give
splicing up")
+ assert(capped == Seq(2L, 0L, 0L, 0L), s"both row groups pass the cap;
got $capped")
+ val ((keysDeniedBytes, denied), keysLogged) =
+ withLogged(splicingGivenUpOnMemory)(bytes(105L))
+ assert(keysDeniedBytes == cappedBytes,
+ s"denied keys must read what the cap reads; got $keysDeniedBytes
against $cappedBytes")
+ assert(denied == Seq(0L, 2L, 0L, 0L), s"both row groups are denied; got
$denied")
+ assert(keysLogged == 1, s"which is logged once per split; got
$keysLogged")
+ // Nothing at all does not hold the ranges either, so the filter is
given up. Off heap only
+ // the ranges are asked for, so 40 bytes keep the filter and nothing
gives it up.
+ assert(rows(0L) == marked(0L until 40L, checked = false),
+ "denied ranges must give the filter up")
+ val ((_, rangesDenied), rangesLogged) =
withLogged(filterGivenUpOnMemory)(bytes(0L))
+ assert(rangesDenied == Seq(0L, 0L, 0L, 2L),
+ s"both row groups count as giving the filter up, not splicing; got
$rangesDenied")
+ assert(rangesLogged == 1, s"which is logged once per split; got
$rangesLogged")
+ assert(rows(40L, offHeap = true) == plain, "off heap, ranges that fit
must keep the filter")
+ assert(rows(0L, offHeap = true) == marked(0L until 40L, checked = false),
+ "off heap, denied ranges must give the filter up")
+ }
+ }
+
+ test("an error raised while reserving fails the read in a form
ignoreCorruptFiles keeps") {
+ // Reserving the buffer can have the task's memory manager spill another
consumer, which can
+ // raise anything. A `RuntimeException`, which a kill's interrupt during a
spill also becomes,
+ // goes out checked, and an `InternalError` in a fatal `UnknownError`.
Under
+ // `ignoreCorruptFiles` either would otherwise be taken for a corrupt
file, and the rest of the
+ // file dropped.
+ withTempDir { dir =>
+ val path = twoRowGroupsOf20(dir)
+ Seq(new RuntimeException("spill"), new InternalError("spill")).foreach {
error =>
+ val context = memoryTask(1000L)
+ val failing = new TestMemoryConsumer(context.taskMemoryManager) {
+ override def spill(size: Long, trigger: MemoryConsumer): Long =
throw error
+ }
+ failing.use(1000L)
+ val thrown = intercept[Throwable](readInTask(path, context))
+ val expected = if (error.isInstanceOf[InternalError]) {
+ classOf[UnknownError]
+ } else {
+ classOf[SparkException]
+ }
+ assert(thrown.getClass == expected && (thrown.getCause eq error),
+ s"expected $error inside ${expected.getSimpleName}; got $thrown")
+ assert(!DataSourceUtils.shouldIgnoreCorruptFileException(thrown),
s"got $thrown")
+ }
+ // Evicting a cached block to disk can raise an `IOException` there too,
which the task's
+ // memory manager passes on as it is.
+ val full = new IOException("No space left on device")
+ val evicting = memoryTask(onAcquire = (_, _) => throw full)
+ val thrown = intercept[Throwable](readInTask(path, evicting))
+ assert(thrown.getClass == classOf[SparkException] && (thrown.getCause eq
full),
+ s"expected $full inside a SparkException; got $thrown")
+ assert(!DataSourceUtils.shouldIgnoreCorruptFileException(thrown), s"got
$thrown")
+ }
+ }
+
+ test("the survivor buffer asks for little more memory than it holds") {
+ // Another consumer holds all but what the buffer needs plus a margin. The
task's memory manager
+ // spills that consumer to grant a reservation past what is free, so the
buffer must not ask
+ // for more than its margin: less than as much again as it holds while
small, less than 1 MiB
+ // more once it is past that, see "emitting a row group releases its
memory in steps", and
+ // never past the cap.
+ withTempDir { dir =>
+ // The buffer never holds more than 220 bytes, so it asks for less than
440.
+ val path = twoRowGroupsOf20(dir)
+ readBesideOther(path, rows = 40L, margin = 440L)
+ // Doubling from 196 would ask for 392, which a cap of 300 bytes stops
at 300.
Review Comment:
Done in
[09b8b2186e3](https://github.com/apache/spark/pull/59639/commits/09b8b2186e31b49f1d5d864601739f989294f582),
with your wording.
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/LateMaterializationParquetRecordReader.java:
##########
@@ -202,6 +221,22 @@ public class LateMaterializationParquetRecordReader
extends VectorizedParquetRec
/** The array the emitted batch is built over, whose key slots each batch
rewrites. */
private ColumnVector[] spliceBatchColumns;
+ /** What the survivor ranges of the row group being read or emitted cost to
hold. */
+ private long rangeCharge;
+ /** The survivor buffer's account with the task's memory manager, or null
outside a task. */
Review Comment:
Done in
[09b8b2186e3](https://github.com/apache/spark/pull/59639/commits/09b8b2186e31b49f1d5d864601739f989294f582).
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/LateMaterializationParquetRecordReader.java:
##########
@@ -920,6 +1089,51 @@ private static void closeAll(ColumnVector[] vectors) {
}
}
+ /**
+ * The survivor buffer's account with the task's memory manager, so other
consumers see what it
Review Comment:
Done in
[09b8b2186e3](https://github.com/apache/spark/pull/59639/commits/09b8b2186e31b49f1d5d864601739f989294f582).
The javadoc says that up to a batch per key column is held beyond the charge.
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/LateMaterializationParquetRecordReader.java:
##########
@@ -727,15 +785,30 @@ private RowRanges evaluateStorageFilter(
// where a plain read fails.
reportOnce(e, SPLICING_GIVEN_UP_ON_ERROR);
abandonSplicing();
- splicedBytes = 0L;
}
}
- if (splicedBytes + rangeBytes > cap) {
+ if (bufferedKeyCharge() + rangeCharge > cap) {
// The buffer goes first, which is a no-op once splicing is given up.
+ if (spliceCurrentRowGroup) splicingGiveUp =
BudgetGiveUp.SPLICING_ON_CAP;
Review Comment:
Done in
[09b8b2186e3](https://github.com/apache/spark/pull/59639/commits/09b8b2186e31b49f1d5d864601739f989294f582).
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/LateMaterializationParquetRecordReader.java:
##########
@@ -243,9 +278,13 @@ public void close() throws IOException {
try {
closeSplicingState();
} finally {
- if (checkedVector != null) {
- checkedVector.close();
- checkedVector = null;
+ try {
+ if (checkedVector != null) {
+ checkedVector.close();
+ checkedVector = null;
+ }
+ } finally {
+ releaseSurvivorMemoryTo(0L);
Review Comment:
Done in
[09b8b2186e3](https://github.com/apache/spark/pull/59639/commits/09b8b2186e31b49f1d5d864601739f989294f582).
`close()` zeroes `rangeCharge` first, so `abandonSplicing()` releases
everything in one trip.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilterSuite.scala:
##########
@@ -2048,6 +2099,35 @@ class ParquetStorageFilterSuite extends QueryTest with
SharedSparkSession
}
}
+ test("a row group whose filter phase 2 gives up does not count as giving
splicing up") {
Review Comment:
Done in
[09b8b2186e3](https://github.com/apache/spark/pull/59639/commits/09b8b2186e31b49f1d5d864601739f989294f582).
Both tests use `damagedMiddleRowGroup`, beside `twoRowGroupsOf20`, over
`threeRowGroupsOf100` and `evenKey`.
--
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]