viirya commented on code in PR #6125:
URL: https://github.com/apache/datafusion-comet/pull/6125#discussion_r4085630425


##########
native/core/src/parquet/eager_page_index_reader_factory.rs:
##########
@@ -240,6 +277,91 @@ struct EagerPageIndexReader {
     metadata_cache: Arc<FileMetadataCache>,
     metadata_size_hint: Option<usize>,
     spark_variant_schema: bool,
+    /// Empty handle whose registration every fetched buffer's reservation 
shares.
+    reservation: Option<MemoryReservation>,
+    /// Holds the decode-buffer estimate while this file is being read.
+    _decode_reservation: Option<MemoryReservation>,
+}
+
+/// Data page size limit that both parquet-mr and parquet-rs default to.
+const DEFAULT_PAGE_SIZE: usize = 1024 * 1024;
+
+/// Estimates what a scan holds while decoding beyond the compressed column 
chunks, which
+/// [`ReservedBytes`] charges exactly. arrow-rs allocates the rest internally 
with no hook to
+/// observe it: per projected leaf column, a decompressed data page bounded by 
the writer's page
+/// size, plus the output batch being assembled. Dictionary pages are left 
out: they are often
+/// absent or small, and counting them at the same bound doubled the estimate 
against TPC-H
+/// measurements. Files written with larger pages or large dictionaries are 
underestimated.
+pub(crate) fn decode_buffer_estimate(projected: &Schema, batch_size: usize) -> 
usize {
+    fn leaves(data_type: &DataType) -> usize {
+        match data_type {
+            DataType::Struct(fields) => fields.iter().map(|f| 
leaves(f.data_type())).sum(),
+            DataType::List(child)
+            | DataType::LargeList(child)
+            | DataType::FixedSizeList(child, _)
+            | DataType::ListView(child)
+            | DataType::LargeListView(child)
+            | DataType::Map(child, _) => leaves(child.data_type()),
+            _ => 1,
+        }
+    }
+    // Variable-width and nested values have no fixed width; assume a short 
string.
+    let row_width: usize = projected
+        .fields()
+        .iter()
+        .map(|f| f.data_type().primitive_width().unwrap_or(32))
+        .sum();
+    let columns: usize = projected
+        .fields()
+        .iter()
+        .map(|f| leaves(f.data_type()))
+        .sum();
+    columns * DEFAULT_PAGE_SIZE + batch_size * row_width
+}
+
+/// A fetched data-page buffer that holds its share of the scan's memory 
reservation.
+///
+/// The reader cannot see when the decoder is finished with the pages it 
fetched: arrow-rs keeps
+/// the column chunks of the row group being decoded (and the pages sliced 
from them) alive, and
+/// drops them once it moves on. Making the reservation the `Bytes` owner ties 
the release to that
+/// last drop, so the pool sees the scan's input working set, roughly one row 
group's projected
+/// column chunks per partition, without any change to the decoder.
+struct ReservedBytes {
+    bytes: Bytes,
+    _reservation: MemoryReservation,
+}
+
+impl AsRef<[u8]> for ReservedBytes {
+    fn as_ref(&self) -> &[u8] {
+        self.bytes.as_ref()
+    }
+}
+
+/// Charges `buffers` to a new reservation sharing `reservation`'s 
registration, one pool call
+/// for the whole fetch. A scan cannot spill, so a refused grow does not fail 
the read: the
+/// buffers are returned unaccounted and counted in `unreserved_bytes`. The 
attempt still
+/// lets Spark ask the task's other consumers to spill before it refuses.

Review Comment:
   This holds only in a narrow case. `NativeMemoryConsumer.spill()` always 
returns 0, so native consumers never give anything back. `CometFairMemoryPool` 
refuses on its local `pool_size / num` check without calling Spark at all. So 
only JVM consumers in the same task can be asked to spill.
   
   In that case there is also a cost worth writing down. Say 
`acquireExecutionMemory` spills a JVM consumer, such as the JVM columnar 
shuffle's `CometShuffleMemoryAllocator`, and still can only grant part of the 
request. `CometUnifiedMemoryPool::try_grow` releases the whole grant and 
returns an error, and the scan reads unaccounted anyway. The spill it caused 
bought nothing. Could the comment say exactly when a refusal triggers a spill? 
Could the PR description mention this trade-off, since it currently presents 
the spill as a benefit?



##########
native/core/src/parquet/eager_page_index_reader_factory.rs:
##########
@@ -240,6 +277,91 @@ struct EagerPageIndexReader {
     metadata_cache: Arc<FileMetadataCache>,
     metadata_size_hint: Option<usize>,
     spark_variant_schema: bool,
+    /// Empty handle whose registration every fetched buffer's reservation 
shares.
+    reservation: Option<MemoryReservation>,
+    /// Holds the decode-buffer estimate while this file is being read.
+    _decode_reservation: Option<MemoryReservation>,
+}
+
+/// Data page size limit that both parquet-mr and parquet-rs default to.
+const DEFAULT_PAGE_SIZE: usize = 1024 * 1024;
+
+/// Estimates what a scan holds while decoding beyond the compressed column 
chunks, which
+/// [`ReservedBytes`] charges exactly. arrow-rs allocates the rest internally 
with no hook to
+/// observe it: per projected leaf column, a decompressed data page bounded by 
the writer's page
+/// size, plus the output batch being assembled. Dictionary pages are left 
out: they are often
+/// absent or small, and counting them at the same bound doubled the estimate 
against TPC-H
+/// measurements. Files written with larger pages or large dictionaries are 
underestimated.
+pub(crate) fn decode_buffer_estimate(projected: &Schema, batch_size: usize) -> 
usize {
+    fn leaves(data_type: &DataType) -> usize {
+        match data_type {
+            DataType::Struct(fields) => fields.iter().map(|f| 
leaves(f.data_type())).sum(),
+            DataType::List(child)
+            | DataType::LargeList(child)
+            | DataType::FixedSizeList(child, _)
+            | DataType::ListView(child)
+            | DataType::LargeListView(child)
+            | DataType::Map(child, _) => leaves(child.data_type()),
+            _ => 1,
+        }
+    }
+    // Variable-width and nested values have no fixed width; assume a short 
string.
+    let row_width: usize = projected
+        .fields()
+        .iter()
+        .map(|f| f.data_type().primitive_width().unwrap_or(32))
+        .sum();
+    let columns: usize = projected
+        .fields()
+        .iter()
+        .map(|f| leaves(f.data_type()))
+        .sum();
+    columns * DEFAULT_PAGE_SIZE + batch_size * row_width
+}
+
+/// A fetched data-page buffer that holds its share of the scan's memory 
reservation.
+///
+/// The reader cannot see when the decoder is finished with the pages it 
fetched: arrow-rs keeps
+/// the column chunks of the row group being decoded (and the pages sliced 
from them) alive, and
+/// drops them once it moves on. Making the reservation the `Bytes` owner ties 
the release to that
+/// last drop, so the pool sees the scan's input working set, roughly one row 
group's projected
+/// column chunks per partition, without any change to the decoder.
+struct ReservedBytes {
+    bytes: Bytes,
+    _reservation: MemoryReservation,
+}
+
+impl AsRef<[u8]> for ReservedBytes {
+    fn as_ref(&self) -> &[u8] {
+        self.bytes.as_ref()
+    }
+}
+
+/// Charges `buffers` to a new reservation sharing `reservation`'s 
registration, one pool call
+/// for the whole fetch. A scan cannot spill, so a refused grow does not fail 
the read: the
+/// buffers are returned unaccounted and counted in `unreserved_bytes`. The 
attempt still
+/// lets Spark ask the task's other consumers to spill before it refuses.
+fn reserve_buffers(
+    reservation: &MemoryReservation,
+    buffers: Vec<Bytes>,
+    scan_io_metrics: &ScanIoMetrics,
+) -> Vec<Bytes> {
+    let total = buffers.iter().map(Bytes::len).sum();
+    let fetch = reservation.new_empty();
+    if fetch.try_grow(total).is_err() {

Review Comment:
   Under `greedy_unified`, every refused grow here goes to 
`CometTaskMemoryManager.acquireMemory`. Whenever `acquired < size`, that method 
logs a `warn` and calls `internal.showMemoryUsage()`, which prints every 
consumer in the task. A spillable operator only hits that a bounded number of 
times before it spills. This path doesn't stop. The next row group asks again, 
and so does every file open in `create_reader`. On a long scan under pressure, 
that means a full memory dump per row group per task.
   
   Could the reader stop asking once it has been refused, for example for the 
rest of the file? Or could it back off in some other way? `fair_unified` mostly 
avoids this because its local-limit refusal never reaches Spark, but the greedy 
pool does not.



##########
native/core/src/parquet/eager_page_index_reader_factory.rs:
##########
@@ -992,6 +1118,119 @@ mod tests {
         );
     }
 
+    async fn read_ranges_with_pool(pool: Arc<dyn MemoryPool>) -> (Vec<Bytes>, 
usize) {
+        let store = Arc::new(InMemory::new());
+        let location = Path::from("reserved.parquet");
+        store
+            .put(&location, Bytes::from_static(b"0123456789").into())
+            .await
+            .unwrap();
+        let runtime = 
datafusion::execution::runtime_env::RuntimeEnv::default();
+        let metrics = ExecutionPlanMetricsSet::new();
+        let factory = EagerPageIndexReaderFactory::new(
+            store,
+            runtime.cache_manager.get_file_metadata_cache(),
+            ScanIoSource::Local,
+            &metrics,
+        )
+        .with_memory_pool(pool, 0);
+        let mut reader = factory
+            .create_reader(
+                0,
+                PartitionedFile::new(location.to_string(), 10),
+                None,
+                &metrics,
+            )
+            .unwrap();
+        let result = reader.get_byte_ranges(vec![0..2, 4..7]).await.unwrap();
+        drop(reader);
+        let unreserved = metrics
+            .clone_inner()
+            .sum_by_name("scan_io_unreserved_bytes")
+            .unwrap()
+            .as_usize();
+        (result, unreserved)
+    }
+
+    #[tokio::test]
+    async fn fetched_data_pages_hold_a_reservation_until_dropped() {
+        let pool: Arc<dyn MemoryPool> = Arc::new(
+            datafusion::execution::memory_pool::GreedyMemoryPool::new(1024),
+        );
+        let (mut result, unreserved) = 
read_ranges_with_pool(Arc::clone(&pool)).await;
+        assert_eq!(
+            result,
+            vec![Bytes::from_static(b"01"), Bytes::from_static(b"456")]
+        );
+        assert_eq!(unreserved, 0);
+        // The reservation outlives the reader and follows each buffer, 
including slices of it.
+        assert_eq!(pool.reserved(), 5);
+        let slice = result[1].slice(1..2);
+        result.remove(1);
+        assert_eq!(pool.reserved(), 5);
+        drop(slice);
+        assert_eq!(pool.reserved(), 2);
+        drop(result);
+        assert_eq!(pool.reserved(), 0);
+    }
+
+    #[test]
+    fn decode_estimate_counts_leaf_columns_and_one_batch() {
+        use arrow::datatypes::{Field, Fields};
+        let schema = Schema::new(vec![
+            Field::new("a", DataType::Int64, true),
+            Field::new("s", DataType::Utf8, true),
+            Field::new(
+                "st",
+                DataType::Struct(Fields::from(vec![
+                    Field::new("x", DataType::Int32, true),
+                    Field::new("y", DataType::Int32, true),
+                ])),
+                true,
+            ),
+        ]);
+        // Four leaves; row width 8 + 32 + 32 (struct has no primitive width).
+        assert_eq!(
+            decode_buffer_estimate(&schema, 100),
+            4 * DEFAULT_PAGE_SIZE + 100 * 72
+        );
+    }
+
+    #[tokio::test]
+    async fn decode_estimate_is_held_while_the_reader_is_open() {
+        let pool: Arc<dyn MemoryPool> = Arc::new(
+            datafusion::execution::memory_pool::GreedyMemoryPool::new(1024),
+        );
+        let runtime = 
datafusion::execution::runtime_env::RuntimeEnv::default();
+        let metrics = ExecutionPlanMetricsSet::new();
+        let factory = EagerPageIndexReaderFactory::new(
+            Arc::new(InMemory::new()),
+            runtime.cache_manager.get_file_metadata_cache(),
+            ScanIoSource::Local,
+            &metrics,
+        )
+        .with_memory_pool(Arc::clone(&pool), 100);
+        let reader = factory
+            .create_reader(0, PartitionedFile::new("f.parquet", 10), None, 
&metrics)
+            .unwrap();
+        assert_eq!(pool.reserved(), 100);
+        drop(reader);
+        assert_eq!(pool.reserved(), 0);
+    }
+
+    #[tokio::test]
+    async fn refused_reservation_returns_data_unaccounted() {

Review Comment:
   Once the refusal path tells `ResourcesExhausted` apart from other errors 
(per @comphead's comment), could you add a test that uses a small `MemoryPool` 
whose `try_grow` returns `DataFusionError::External`? It would check that 
`get_byte_ranges` and `create_reader` propagate the error instead of counting 
it as unreserved. All the tests here use `GreedyMemoryPool`, which only ever 
returns `ResourcesExhausted`, so nothing covers the difference between refused 
and failed today.



##########
native/core/src/parquet/eager_page_index_reader_factory.rs:
##########
@@ -240,6 +277,91 @@ struct EagerPageIndexReader {
     metadata_cache: Arc<FileMetadataCache>,
     metadata_size_hint: Option<usize>,
     spark_variant_schema: bool,
+    /// Empty handle whose registration every fetched buffer's reservation 
shares.
+    reservation: Option<MemoryReservation>,
+    /// Holds the decode-buffer estimate while this file is being read.
+    _decode_reservation: Option<MemoryReservation>,
+}
+
+/// Data page size limit that both parquet-mr and parquet-rs default to.
+const DEFAULT_PAGE_SIZE: usize = 1024 * 1024;
+
+/// Estimates what a scan holds while decoding beyond the compressed column 
chunks, which
+/// [`ReservedBytes`] charges exactly. arrow-rs allocates the rest internally 
with no hook to
+/// observe it: per projected leaf column, a decompressed data page bounded by 
the writer's page
+/// size, plus the output batch being assembled. Dictionary pages are left 
out: they are often
+/// absent or small, and counting them at the same bound doubled the estimate 
against TPC-H
+/// measurements. Files written with larger pages or large dictionaries are 
underestimated.
+pub(crate) fn decode_buffer_estimate(projected: &Schema, batch_size: usize) -> 
usize {
+    fn leaves(data_type: &DataType) -> usize {
+        match data_type {
+            DataType::Struct(fields) => fields.iter().map(|f| 
leaves(f.data_type())).sum(),
+            DataType::List(child)
+            | DataType::LargeList(child)
+            | DataType::FixedSizeList(child, _)
+            | DataType::ListView(child)
+            | DataType::LargeListView(child)
+            | DataType::Map(child, _) => leaves(child.data_type()),
+            _ => 1,
+        }
+    }
+    // Variable-width and nested values have no fixed width; assume a short 
string.
+    let row_width: usize = projected
+        .fields()
+        .iter()
+        .map(|f| f.data_type().primitive_width().unwrap_or(32))
+        .sum();
+    let columns: usize = projected
+        .fields()
+        .iter()
+        .map(|f| leaves(f.data_type()))
+        .sum();
+    columns * DEFAULT_PAGE_SIZE + batch_size * row_width
+}
+
+/// A fetched data-page buffer that holds its share of the scan's memory 
reservation.
+///
+/// The reader cannot see when the decoder is finished with the pages it 
fetched: arrow-rs keeps
+/// the column chunks of the row group being decoded (and the pages sliced 
from them) alive, and
+/// drops them once it moves on. Making the reservation the `Bytes` owner ties 
the release to that
+/// last drop, so the pool sees the scan's input working set, roughly one row 
group's projected
+/// column chunks per partition, without any change to the decoder.
+struct ReservedBytes {

Review Comment:
   Adding one thing to @comphead's note about view arrays aliasing these 
buffers. If that invariant ever breaks, the problem is bigger than a delayed 
release. The final `shrink` would then run from the FFI release callback when 
the JVM closes the batch. Both `CometFairMemoryPool::shrink` and 
`CometUnifiedMemoryPool::shrink` `panic!` if the JNI release fails, and that 
panic would happen inside a release callback. That seems worth spelling out in 
the comment that records the invariant, so whoever turns on `Utf8View` later 
knows what depends on it.



##########
native/core/src/parquet/eager_page_index_reader_factory.rs:
##########
@@ -217,6 +237,21 @@ impl ParquetFileReaderFactory for 
EagerPageIndexReaderFactory {
             metrics,
         );
 
+        let reservation = self.memory_pool.as_ref().map(|pool| {
+            
MemoryConsumer::new(format!("ParquetScan[{partition_index}]")).register(pool)
+        });
+        // DataFusion opens one file per partition at a time and drops its 
reader when the file
+        // is done, so holding the estimate on the reader charges it once per 
running scan.
+        let decode_reservation = reservation.as_ref().map(|reservation| {
+            let decode = reservation.new_empty();
+            if decode.try_grow(self.decode_buffer_estimate).is_err() {

Review Comment:
   Following up on @comphead's question about blocking in `create_reader`. I 
think the concern is wider than this call. When a plan has no JVM sources, 
`jni_api.rs:1058` spawns it onto the shared tokio runtime. That means the grow 
in `reserve_buffers` runs on a runtime worker as well.
   
   `ExecutionMemoryPool.acquireMemory` parks in `lock.wait()` while a task 
holds less than its minimum share (`curMem + toGrant < minMemoryPerTask`). That 
is most likely at the very start of a task, and that is exactly when this scan 
now makes its first request. Other operators already grow from spawned streams, 
but they do it later in the task. This change makes every scan-rooted task do 
it first thing.
   
   There is a related effect on @comphead's `InterruptedException` point. On a 
worker thread, the task-kill interrupt is never delivered, so a killed task's 
scan can stay parked until some other task releases memory. Is that acceptable 
here? If not, should the scan avoid a blocking acquire for its first grows?



##########
native/core/src/parquet/parquet_exec.rs:
##########
@@ -194,7 +196,14 @@ pub(crate) fn init_datasource_exec(
             scan_io_source,
             parquet_source.metrics(),
         )
-        .with_spark_variant_schema(projects_variant),
+        .with_spark_variant_schema(projects_variant)
+        .with_memory_pool(

Review Comment:
   This turns on reservation accounting for every native Parquet scan and there 
is no way to turn it off. The benchmarks are encouraging. But your 2g run shows 
q9's Spark disk spill going from 8.0 to 15.0 GiB and q10's from 5.6 to 6.9 GiB. 
The decode estimate is also still an open question in @comphead's review.
   
   Would you consider adding a `CometConf` flag, following 
`config_conventions.md`, that skips `with_memory_pool`? That would let users 
who regress disable it while the estimate is refined. A default of `true` seems 
fine to me.



-- 
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]

Reply via email to