andygrove commented on code in PR #5780:
URL: https://github.com/apache/datafusion-comet/pull/5780#discussion_r3970067265
##########
native/core/src/execution/operators/iceberg_write.rs:
##########
@@ -581,13 +654,224 @@ impl ClusteredBatchSplitter {
}
}
-/// Gather the rows selected by `indices` out of `batch`. A zero-copy
`RecordBatch::slice` would
-/// be cheaper, but the parquet writer's NaN-count visitor reads list/map
children via
-/// `list_array.values()`, which ignores a slice's offset window -- sliced
list-of-float columns
-/// would over-count NaNs. `take` gathers the referenced children into fresh
compacted arrays,
-/// keeping those counts correct.
-fn materialize_run(batch: &RecordBatch, indices: &UInt32Array) ->
DFResult<RecordBatch> {
- arrow::compute::take_record_batch(batch,
indices).map_err(DataFusionError::from)
+/// Cuts contiguous row ranges out of a batch: the partition runs the
clustered writer needs, and
+/// the [`ROWS_DIVISOR`]-row pieces the rolling writer needs.
+///
+/// A zero-copy `RecordBatch::slice` is the cheap way to cut a range, but it
is only exact when
+/// the parquet writer's NaN-count visitor sees every float through the slice.
The visitor reaches
+/// list elements and map entries through `list_array.values()` /
`map_array.entries()`, which
+/// ignore the parent's offset window, so a sliced `list<float>` column would
have every NaN in
+/// the batch counted once per range cut from it -- and since the JVM carries
the native writer's
+/// NaN counts into the manifest, a `nan_value_count` that reaches
`record_count` makes Iceberg's
+/// metrics evaluator prune the file from ordinary comparison predicates.
Struct children are
+/// safe, because `StructArray::slice` slices them, so the only schemas that
need the fix are the
+/// ones with a float or double under a list or map; those ranges go through
`take`, which gathers
+/// the referenced children into fresh compacted arrays.
+#[derive(Clone, Copy)]
+struct RowSlicer {
+ gather: bool,
+}
+
+impl RowSlicer {
+ /// `schema` is the field-id-decorated target schema every batch is cast
to, so this decision
+ /// is made once per task rather than per batch.
+ fn for_schema(schema: &ArrowSchema) -> Self {
+ Self {
+ gather: schema
+ .fields()
+ .iter()
+ .any(|field| float_under_list_or_map(field.data_type())),
+ }
+ }
+
+ fn slice(&self, batch: &RecordBatch, offset: usize, len: usize) ->
DFResult<RecordBatch> {
+ if offset == 0 && len == batch.num_rows() {
+ return Ok(batch.clone());
+ }
+ if self.gather {
+ gather_rows(batch, offset, len)
+ } else {
+ Ok(batch.slice(offset, len))
+ }
+ }
+
+ /// Cuts a range that outlives the batch it came from, because it is
waiting for the rows that
+ /// complete its unit. Always gathers: a zero-copy slice would pin every
buffer of its parent
+ /// batch for the wait, and a partition that receives a handful of rows
per batch would pin one
+ /// parent per batch until its unit fills.
+ fn detach(&self, batch: &RecordBatch, offset: usize, len: usize) ->
DFResult<RecordBatch> {
+ if offset == 0 && len == batch.num_rows() {
+ // The range is the whole batch, so it pins nothing beyond the
rows it holds.
+ return Ok(batch.clone());
+ }
+ gather_rows(batch, offset, len)
+ }
+}
+
+fn gather_rows(batch: &RecordBatch, offset: usize, len: usize) ->
DFResult<RecordBatch> {
+ let indices = UInt32Array::from_iter_values(offset as u32..(offset + len)
as u32);
+ arrow::compute::take_record_batch(batch,
&indices).map_err(DataFusionError::from)
+}
+
+/// How this task cuts rows and when the rolling writer's size check can
matter: everything needed
+/// to open a [`RowPacer`] for one more file.
+#[derive(Clone, Copy)]
+struct PacingPolicy {
+ slicer: RowSlicer,
+ target_file_size_bytes: usize,
+}
+
+impl PacingPolicy {
+ fn pacer(&self) -> RowPacer {
+ RowPacer::new(*self)
+ }
+}
+
+/// Hands one iceberg-rust writer exactly [`ROWS_DIVISOR`] rows at a time, so
the rolling writer
+/// underneath only ever gets to roll on the row boundaries iceberg-java's
`RollingFileWriter` rolls
+/// on -- multiples of 1000 rows since the current file opened -- however
Spark happened to batch
+/// the rows.
+///
+/// Pacing, rather than just cutting each batch up, is what makes the roll
point independent of the
+/// batch shape: a task fed 800-row batches would otherwise be offered a
boundary every 800 rows
+/// and roll into 800-row files where the JVM writer produces 1000-row ones.
Rows left over from a
+/// batch wait here for the rows that complete their unit, so at most
`ROWS_DIVISOR - 1` rows per
+/// open file are held back.
+///
+/// Pacing costs one writer call per 1000 rows instead of one per batch, which
is measurable on a
+/// wide schema, so it stays off until it can change an outcome: see `pacing`.
+struct RowPacer {
+ policy: PacingPolicy,
+ /// Whether rows are being paced yet. While off, batches go over whole,
because a batch
+ /// boundary the writer sees can only matter if its size check might fire
there -- and it cannot
+ /// while the file is still smaller than the target.
+ ///
+ /// It never goes back off. A roll starts the file's size over, but the
pacer cannot see rolls,
+ /// so from the first file that reaches the target it paces for the rest
of the task.
+ pacing: bool,
+ /// What has been handed over so far, as an upper bound on what the open
file holds: Arrow's
+ /// in-memory footprint of a batch (allocated capacity, so it over-counts
if anything) bounds
+ /// what parquet writes for the same rows, since the writer encodes and
compresses them. If that
+ /// ever failed to hold for some schema, the only consequence is a roll up
to `ROWS_DIVISOR`
+ /// rows late -- the same order as the divergence the two size estimates
already allow.
+ handed_bytes: usize,
+ /// Rows of the current 1000-row block already handed over. Non-zero only
for the first block
+ /// after pacing turns on, where it is what the whole batches before it
left in the open file --
+ /// which is all of them, since nothing could have rolled yet. Carrying it
over is what keeps
+ /// the blocks aligned to the file's own row count rather than to where
pacing started.
+ block_rows: usize,
+ /// Rows handed in but not yet handed over; `block_rows + pending_rows <
ROWS_DIVISOR`.
+ pending: Vec<RecordBatch>,
+ pending_rows: usize,
+}
+
+impl RowPacer {
+ fn new(policy: PacingPolicy) -> Self {
+ Self {
+ policy,
+ pacing: false,
+ handed_bytes: 0,
+ block_rows: 0,
+ pending: Vec::new(),
+ pending_rows: 0,
+ }
+ }
+
+ /// The complete units `batch` makes available, in row order. Whatever
does not fill a unit is
+ /// held for the next call.
+ fn push(&mut self, batch: RecordBatch) -> DFResult<Vec<RecordBatch>> {
+ debug_assert!(
+ self.block_rows + self.pending_rows < ROWS_DIVISOR,
+ "a complete unit was left pending"
+ );
+ let rows = batch.num_rows();
+ if !self.pacing {
+ self.handed_bytes += batch.get_array_memory_size();
Review Comment:
You're right, and I've reverted the gate in 7a4a17366 — it was the newest
commit, so `edc5597e3` is gone in its entirety and pacing is unconditional
again.
Your reasoning is the part I got wrong. I was treating
`get_array_memory_size()` as an upper bound on what parquet would have written,
on the grounds that it is capacity-based and parquet encodes and compresses.
But those are different quantities measured on different sides — the rolling
decision uses parquet's encoded-size estimate, which carries dictionary and
data-page state that has no Arrow counterpart — and "usually larger" is not an
invariant. I could not find one either.
The consequence is also worse than I had written down. I had told myself the
penalty for violating the bound was a roll up to 1000 rows late, which sounded
like it sat inside the divergence the two size estimates already allow. That is
wrong: as you say, if the writer rolls while `pacing` is still false,
`block_rows` is still counting the *previous* file, so every later block is
aligned from the wrong boundary. That is a silently wrong grid for the rest of
the task, not a late roll — and the grid is the entire point of the change.
On your second option, deciding from actual writer state: I looked and it is
not reachable against the pinned iceberg-rust. `RollingFileWriter::should_roll`
consults `current_written_size()`, but both that and `current_row_num()` are
private, and Comet only reaches the writer through `DataFileWriter`, so there
is no way to ask it what it has actually written. So unconditional pacing is
the available answer and this PR takes the cost: ~4% at 20 columns and ~10-15%
at 100 columns on the A/B in the description, which I have updated along with
the reverted-commit rationale. The gate can come back if iceberg-rust exposes
the writer's own size.
Thanks for chasing this into the pinned source rather than taking the commit
message's word for the bound.
##########
docs/source/user-guide/latest/iceberg-writes.md:
##########
@@ -157,7 +157,7 @@ A write is eligible only when ALL of the following hold:
| `write.parquet.bloom-filter-enabled.column.<col>`
| unset or
`false`
|
| `write.metadata.metrics.*`
| any value
(manifest metrics are re-derived on the JVM with Iceberg's own logic)
|
| `write.spark.fanout.enabled`
| any value (the
native writer implements both clustered and fanout modes)
|
-| `write.target-file-size-bytes`
| any value
(file rolling cadence differs; see accepted divergences)
|
+| `write.target-file-size-bytes`
| any value (the
roll point can differ by less than 1000 rows; see accepted divergences)
|
Review Comment:
Fixed in 7a4a17366. You're right that the grid does not bound the distance
between the two writers' roll points, and the table was promising something
this change does not establish.
The table row now reads:
> `write.target-file-size-bytes` | any value (the two writers can choose
different roll points; see accepted divergences)
and the divergences bullet documents the shared grid without a numeric
cross-writer bound:
> ... so each writer rolls only on a 1000-row boundary of its own file.
> The shared grid is all that is shared. What each writer compares against
the target differs — flushed bytes plus parquet-rs's estimate of the open row
group, versus parquet-mr's file position plus its buffered size — and the two
use different threshold comparisons. These are independent size estimates, so
nothing bounds how far apart the two writers' roll points are: they may cross
the target several grid steps apart, and the resulting files can differ in row
count by an arbitrary number of 1000-row blocks. Do not rely on file-layout
parity between the two writers; rely only on each file rolling on its own
1000-row boundary.
I also took the one-step claim out of the PR rationale in both places it
appeared — the diagnosis paragraph and the docs-changes bullet.
Worth noting these two reviews turned out to be the same mistake in two
places. The "one 1000-row step" framing is exactly what made the Arrow-size
gate in @unikdahal's thread look acceptable to me: I reasoned that violating
its bound cost a roll up to 1000 rows late, which sounded like it fell inside a
divergence I had already documented as bounded. It was not bounded, and the
gate's real failure mode was a misaligned grid rather than a late roll. That
commit is reverted in the same push.
--
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]