viirya commented on code in PR #59639:
URL: https://github.com/apache/spark/pull/59639#discussion_r4333331832
##########
sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala:
##########
@@ -2027,8 +2030,8 @@ object SQLConf {
"false, the reader can only skip whole row groups, and it reads the
key columns twice in " +
"the others. With a page index, it can be slower when the matching
rows are spread " +
"across most pages. The matching key values of a row group are
buffered while it is " +
- s"read, up to
${PARQUET_STORAGE_FILTER_PUSHDOWN_MAX_SPLICED_ROW_GROUP_BYTES.key}, " +
- "outside Spark's memory manager.")
+ s"read, up to
${PARQUET_STORAGE_FILTER_PUSHDOWN_MAX_SPLICED_ROW_GROUP_BYTES.key}, in " +
+ "the task's execution memory unless column vectors are off heap.")
Review Comment:
"in the task's execution memory unless column vectors are off heap" reads as
if nothing is accounted off heap. The row ranges are accounted off heap too,
and with scattered survivors they are the larger part. Maybe: "The matching key
values and row ranges of a row group are buffered while it is read, up to ...,
and counted against the task's execution memory, except key values held in
off-heap column vectors."
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/LateMaterializationParquetRecordReader.java:
##########
@@ -920,6 +1095,35 @@ private static void closeAll(ColumnVector[] vectors) {
}
}
+ /**
+ * The survivor buffer's account with the task's memory manager, so other
consumers see what it
+ * holds. A spill can free the survivors not yet emitted, see {@link
#spillQueuedSurvivors}.
+ * ON_HEAP whatever the reader's memory mode, since off-heap vectors
allocate native memory
+ * outside the Tungsten off-heap pool. With them only the row ranges, which
are always on heap,
+ * are accounted, and a spill frees nothing. The task's memory manager keeps
every consumer until
+ * the task ends, so this lets go of its reader when the reader closes.
+ */
+ private static final class SurvivorMemory extends MemoryConsumer {
+ private volatile LateMaterializationParquetRecordReader reader;
+
+ SurvivorMemory(
+ TaskMemoryManager taskMemoryManager,
LateMaterializationParquetRecordReader reader) {
+ super(taskMemoryManager, MemoryMode.ON_HEAP);
+ this.reader = reader;
+ }
+
+ @Override
+ public long spill(long size, MemoryConsumer trigger) {
Review Comment:
I suggested a spillable consumer on #58895, but seeing what it takes here,
I'm not sure the spill earns its place in this PR. It frees memory only when
all of these hold:
- the column vectors are on heap;
- phase 2 reads the row group in part;
- the spill comes from the reading thread (so never under a pipelined Python
UDF, whose writer thread drives the scan);
- the rows' keys are still queued, not yet emitted.
It never frees the ranges. In exchange it brings the store pre-build (item
3), `replaceColumnReader`, the `loaded*` / `rowsEmittedInRowGroup` mirrors, the
thread confinement and the key re-read, and the re-read can allocate more than
the spill freed (item 1). Accounting plus denial already gives the memory
manager what it needs: other consumers spill before this one is refused, and a
refused reader backs off.
Would you consider having `spill()` return 0 here, and adding the spill in a
follow-up once several readers per task exist (the SPJ case in the
description), where its benefit can be measured? That would also resolve items
1, 3, 4, 7, 9, 13 and 14 for this PR.
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/LateMaterializationParquetRecordReader.java:
##########
@@ -730,12 +800,18 @@ private RowRanges evaluateStorageFilter(
splicedBytes = 0L;
}
}
- if (splicedBytes + rangeBytes > cap) {
+ if (splicedBytes + rangeCharge > cap) {
// The buffer goes first, which is a no-op once splicing is given up.
abandonSplicing();
splicedBytes = 0L;
// The ranges would be thrown away, so stop.
- if (rangeBytes > cap) return null;
+ if (rangeCharge > cap) return null;
+ }
+ if (!reserveSurvivorMemory((accountKeys ? splicedBytes : 0L) +
rangeCharge)) {
Review Comment:
Agreed. Wrapping the two reservations the way `throwIfKilled` wraps a kill,
in a checked `SparkException` around the cause, looks like a cheap fix.
`FileScanRDD` then can't take another consumer's spill failure for a corrupt
file under `ignoreCorruptFiles`. A kill that lands during that spill is already
safe: `TaskMemoryManager` rethrows it as a `RuntimeException`, which the same
wrap would cover, and the task is still killed afterwards anyway.
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/LateMaterializationParquetRecordReader.java:
##########
@@ -888,30 +972,121 @@ private void reportOnce(Throwable t, String message) {
}
/**
- * Gives up splicing for the row group being evaluated and frees its
buffered survivors. Phase 2
- * then reads every projected column, so the rows are unaffected.
+ * Gives up splicing for the row group being read and frees its buffered
survivors, but not the
+ * published ones. Phase 2, or the key re-read after a spill, then reads the
key columns, so the
+ * rows are unaffected.
*/
private void abandonSplicing() {
- for (WritableColumnVector[] batch : survivorBatches) {
- closeAll(batch);
+ for (SurvivorBatch batch : survivorBatches) {
+ closeAll(batch.vectors());
}
survivorBatches.clear();
closeAll(currentKeyAccumulators);
currentKeyAccumulators = null;
currentKeyAccumulatorRowCount = 0;
+ currentKeyAccumulatorCharge = 0L;
spliceCurrentRowGroup = false;
+ releaseSurvivorMemoryTo(heldCharge());
}
/** Closes every survivor vector this reader still holds, the published ones
included. */
private void closeSplicingState() {
try {
closeAll(publishedKeyVectors);
publishedKeyVectors = null;
+ publishedKeyCharge = 0L;
} finally {
abandonSplicing();
}
}
+ /**
+ * Frees the queued survivors of the row group being emitted, and returns
the accounted bytes this
+ * released. The next batch then reads the rest of the row group's keys
again. It acts only on the
+ * reading thread, since the reader takes no lock. There it can come only
between two batches.
+ */
+ private long spillQueuedSurvivors() {
+ if (Thread.currentThread() != readerThread || !spillableRowGroup ||
!spliceCurrentRowGroup
+ || survivorBatches.isEmpty()) {
+ return 0L;
+ }
+ long before = survivorMemory.getUsed();
+ abandonSplicing();
+ rereadRemainingKeys = true;
+ return before - survivorMemory.getUsed();
+ }
+
+ /**
+ * Reads the keys of the rest of the loaded row group again, after a spill
freed its queued
+ * survivors, and points the key slots' column readers at them. The non-key
readers go on where
+ * they are, so both read the same rows.
+ */
+ private void rereadRemainingKeys() throws IOException {
+ rereadRemainingKeys = false;
+ long remainingRows = loadedRowCount - rowsEmittedInRowGroup;
+ if (remainingRows <= 0) return;
+ PrimitiveIterator.OfLong rows = loadedRanges.iterator();
+ for (long i = 0; i < rowsEmittedInRowGroup; i++) {
+ rows.nextLong();
+ }
+ RowRanges.Builder remaining = RowRanges.builder();
+ while (rows.hasNext()) {
+ remaining.addSelectedRow(rows.nextLong());
+ }
+ RowRanges remainingRanges = remaining.build();
+ fileReader.setRequestedSchema(keyOnlyColumns);
+ rereadKeyPages = fileReader.readFilteredRowGroup(loadedBlockIndex,
remainingRanges);
+ requireRowCount(rereadKeyPages, remainingRows, loadedBlockIndex);
+ long blockRowCount =
fileReader.getRowGroups().get(loadedBlockIndex).getRowCount();
+ RowRanges readerRanges = readerRangesFor(remainingRanges, remainingRows,
blockRowCount);
+ for (KeyColumn key : keyColumns) {
+ replaceColumnReader(key.batchSlot(),
+ newColumnReader(key.descriptor(), key.required(), rereadKeyPages,
readerRanges));
+ }
+ }
+
+ /** The accounted bytes the survivor buffer holds, see {@link
SurvivorMemory}. */
+ private long heldCharge() {
+ long held = rangeCharge;
+ if (accountKeys) {
+ held += publishedKeyCharge + currentKeyAccumulatorCharge;
+ for (SurvivorBatch batch : survivorBatches) {
+ held += batch.charge();
+ }
+ }
+ return held;
+ }
+
+ /**
+ * Grows the reservation to cover {@code held} bytes, asking for at least
what it already holds,
+ * so a growing buffer asks only a few times. False when it is denied.
+ */
+ private boolean reserveSurvivorMemory(long held) {
+ if (survivorMemory == null) return true;
+ long used = survivorMemory.getUsed();
+ long missing = held - used;
+ if (missing <= 0) return true;
+ long granted = survivorMemory.acquireMemory(Math.max(missing, used));
Review Comment:
This reproduces as you predicted. In "the survivor buffer asks for little
more memory than it holds", with `memoryManager.limit(held + 10000L)` the other
consumer keeps `1000000`. With `memoryManager.limit(held + 300L)`, which is
still more than the 220 bytes the buffer ever holds, the rows are still right,
but `other.getUsed` drops to `0`: the whole other consumer is spilled. So the
test should also cover a free amount close to what the buffer holds, and the PR
description's "a buffer that fits in the free memory spills no other consumer"
doesn't hold yet. Since the ask is not clamped to the cap either, near the
default 64 MiB the reservation can briefly reach about twice
`maxSplicedRowGroupBytes`, until L629 trims it.
##########
sql/core/src/main/java/org/apache/spark/sql/execution/datasources/parquet/LateMaterializationParquetRecordReader.java:
##########
@@ -730,12 +800,18 @@ private RowRanges evaluateStorageFilter(
splicedBytes = 0L;
}
}
- if (splicedBytes + rangeBytes > cap) {
+ if (splicedBytes + rangeCharge > cap) {
// The buffer goes first, which is a no-op once splicing is given up.
abandonSplicing();
splicedBytes = 0L;
// The ranges would be thrown away, so stop.
- if (rangeBytes > cap) return null;
+ if (rangeCharge > cap) return null;
+ }
+ if (!reserveSurvivorMemory((accountKeys ? splicedBytes : 0L) +
rangeCharge)) {
+ // A reservation the task's memory manager denies is handled as
passing the cap is.
+ abandonSplicing();
+ splicedBytes = 0L;
+ if (!reserveSurvivorMemory(rangeCharge)) return null;
Review Comment:
One consideration on keeping the filter without reserving: with scattered
survivors, the ranges are the larger part of this buffer. Each costs
`ROW_RANGE_BYTES` (40 B), against 9 B for a long key. Continuing unaccounted
after a range denial would leave most of the buffer outside the memory manager,
which is what this PR sets out to fix. So I'd lean toward keeping the give-up,
but logging it once per split and counting it in a metric, so that a scan
losing its filter under memory pressure is visible. Your point about the
repeated acquire off heap stands either way.
##########
sql/core/src/test/scala/org/apache/spark/sql/execution/datasources/parquet/ParquetStorageFilterSuite.scala:
##########
@@ -2119,6 +2127,201 @@ class ParquetStorageFilterSuite extends QueryTest with
SharedSparkSession
}
}
+ // A task whose memory manager draws on `memoryManager`, which a test can
limit.
+ private def memoryTask(memoryManager: TestMemoryManager): TaskContextImpl =
+ new TaskContextImpl(stageId = 0, stageAttemptNumber = 0, partitionId = 0,
taskAttemptId = 0,
+ attemptNumber = 0, numPartitions = 1,
+ taskMemoryManager = new TaskMemoryManager(memoryManager, 0),
+ localProperties = new Properties, metricsSystem = null)
+
+ // Reads `path` under `filter` in batches of `batchSize`, as a task of
`context`. `onRow` gets
+ // each row's index in its batch before its key is read. The confs are set
outside the task, which
+ // sees a read-only one.
+ private def readInTask(
+ path: String,
+ context: TaskContext,
+ offHeap: Boolean = false,
+ filter: Expression = keyAtLeast(10L),
+ schema: StructType = checkedSchema(),
+ batchSize: Int = 4)(onRow: Int => Unit): Seq[(Long, Boolean)] =
withSQLConf(
+ SQLConf.PARQUET_VECTORIZED_READER_BATCH_SIZE.key -> batchSize.toString,
+ SQLConf.COLUMN_VECTOR_OFFHEAP_ENABLED.key -> offHeap.toString) {
+ asTask(context) {
+ readChecked(path, schema, Seq(filter),
+ keyOf = (b, i) => { onRow(i); b.column(0).getLong(i) })
+ }
+ }
+
+ // Reads `path` as `readInTask` does. At the first batch, another consumer
asks for memory the
+ // manager has no more of, so the reader is spilled. Returns the rows, with
what the task held
+ // before and after the spill.
+ private def readWithSpill(
+ path: String,
+ otherThread: Boolean = false,
+ filter: Expression = keyAtLeast(10L),
+ schema: StructType = checkedSchema(),
+ batchSize: Int = 4): (Seq[(Long, Boolean)], Long, Long) = {
+ val memoryManager = new TestMemoryManager(spark.sparkContext.conf)
+ val context = memoryTask(memoryManager)
+ val memory = context.taskMemoryManager
+ val other = new TestMemoryConsumer(memory)
+ var before, after = -1L
+ val rows = readInTask(path, context, filter = filter, schema = schema,
+ batchSize = batchSize) { i =>
+ if (before < 0 && i == 0) {
+ before = memory.getMemoryConsumptionForThisTask
+ memoryManager.limit(0L)
+ if (otherThread) ThreadUtils.runInNewThread("spill")(other.use(before))
+ else other.use(before)
+ after = memory.getMemoryConsumptionForThisTask - other.getUsed
+ other.free(other.getUsed)
+ // Not Long.MaxValue, which the releases still to come would overflow.
+ memoryManager.limit(1L << 40)
+ }
+ }
+ (rows, before, after)
+ }
+
+ test("the survivor buffer is accounted to the task's execution memory") {
+ // What is buffered is held while a spliced row group is emitted, each
batch's share released as
+ // it goes, and the rest at the end. Off heap, only the ranges are
accounted, since the vectors'
+ // native memory is not the memory manager's.
+ withTempDir { dir =>
+ val path = twoRowGroupsOf20(dir)
+ Seq(false -> Seq(130L, 94L, 58L, 220L, 184L, 148L, 112L, 76L),
+ true -> Seq.fill(8)(40L)).foreach { case (offHeap, expected) =>
+ val context = memoryTask(new
TestMemoryManager(spark.sparkContext.conf))
+ val memory = context.taskMemoryManager
+ var held = Seq.empty[Long]
+ val rows = readInTask(path, context, offHeap) { i =>
+ if (i == 0) held :+= memory.getMemoryConsumptionForThisTask
+ }
+ assert(rows == marked(10L until 40L, checked = true), s"off heap
$offHeap: got $rows")
+ assert(held == expected,
+ s"off heap $offHeap: each batch must hold what is still buffered;
got $held")
+ assert(memory.getMemoryConsumptionForThisTask == 0, s"off heap
$offHeap: not released")
+ }
+ // A reader closed after its first batch, as under a limit, releases
what it still holds.
+ val context = memoryTask(new TestMemoryManager(spark.sparkContext.conf))
+ 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("memory the task's memory manager denies gives splicing up, and then
the filter") {
+ withTempDir { dir =>
+ val path = twoRowGroupsOf20(dir)
+ def limitedTask(available: Long): TaskContext = {
+ val memoryManager = new TestMemoryManager(spark.sparkContext.conf)
+ memoryManager.limit(available)
+ memoryTask(memoryManager)
+ }
+ def rows(available: Long): Seq[(Long, Boolean)] =
+ readInTask(path, limitedTask(available))(_ => ())
+ def bytes(available: Long, cap: String = "64MB"): Long = withSQLConf(
+ SQLConf.PARQUET_VECTORIZED_READER_BATCH_SIZE.key -> "4",
+
SQLConf.PARQUET_STORAGE_FILTER_PUSHDOWN_MAX_SPLICED_ROW_GROUP_BYTES.key -> cap)
{
+ asTask(limitedTask(available)) {
+ readCounting(path, formatReader(
+ checkedSchema(), Nil, Seq(keyAtLeast(10L)),
countingHadoopConf()))._2
+ }
+ }
+ 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, 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.
+ val keysDenied = rows(105L)
+ assert(keysDenied == plain, s"denied keys must keep the filter; got
$keysDenied")
+ val cappedBytes = bytes(Long.MaxValue, cap = "105b")
+ assert(cappedBytes > bytes(Long.MaxValue), "a cap of 105 bytes must give
splicing up")
+ val keysDeniedBytes = bytes(105L)
+ assert(keysDeniedBytes == cappedBytes,
+ s"denied keys must read what the cap reads; got $keysDeniedBytes
against $cappedBytes")
+ // Nothing at all does not hold the ranges either, so the filter is
given up.
+ assert(rows(0L) == marked(0L until 40L, checked = false),
+ "denied ranges must give the filter up")
+ }
+ }
+
+ test("the survivor buffer asks for little more memory than it holds") {
+ // Another consumer holds all but 10,000 bytes, and the buffer never holds
more than 220. A
+ // reservation asking for more than is free would spill that consumer.
+ withTempDir { dir =>
+ val path = twoRowGroupsOf20(dir)
+ val memoryManager = new TestMemoryManager(spark.sparkContext.conf)
+ val held = 1000000L
+ memoryManager.limit(held + 10000L)
+ val context = memoryTask(memoryManager)
+ val other = new TestMemoryConsumer(context.taskMemoryManager)
+ other.use(held)
+ val rows = readInTask(path, context)(_ => ())
+ assert(rows == marked(10L until 40L, checked = true), s"got $rows")
+ assert(other.getUsed == held, "the read must not spill another consumer")
+ }
+ }
+
+ test("a spill while a spliced row group is emitted frees its survivors, and
its keys are read " +
Review Comment:
I ran the spill at other points against this commit, using a variant of
`readWithSpill` that spills at the n-th batch instead of the first:
- over `twoRowGroupsOf20` with `k % 2 == 0` and batch size 4, so both row
groups are read in part: at each of the six batches, which includes the 2nd and
3rd batches of block 0 and every batch of block 1;
- over `writePagedFile` with `k % 3 == 0` and batch size 16, so the
remaining rows start on later pages: at batches 1, 2 and 5.
Every case returned exactly the expected keys, all marked checked. A spill
at a row group's last batch freed nothing, as it should, since the queue is
empty there. So the current bookkeeping looks right, and these cases would pin
the mutations you list. Could they be added? One spill over the two-key fixture
would also cover replacing every key's reader, not only `keyColumns[0]`'s.
--
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]