comphead commented on code in PR #6107:
URL: https://github.com/apache/datafusion-comet/pull/6107#discussion_r4414766322
##########
native/operators/src/explode.rs:
##########
@@ -346,6 +356,82 @@ impl PendingInput {
.as_ref()
.map(|lens| lens.slice(self.row_offset, rows))
}
+
+ fn next_chunk(
+ &mut self,
+ batch_size: usize,
+ list_columns: &[ListUnnest],
+ ) -> Result<(RecordBatch, Option<Int64Array>)> {
+ if let Some(limit) = self.split_limit {
+ let row_length =
self.output_lens.as_ref().unwrap().value(self.row_offset) as usize;
+ if row_length > limit {
+ let length = limit.min(row_length - self.element_offset);
+ let row = self.batch.slice(self.row_offset, 1);
+ let mut columns = row.columns().to_vec();
+ for unnest in list_columns {
+ let list =
columns[unnest.index_in_input_schema].as_list::<i32>();
+ let DataType::List(field) = list.data_type() else {
+ unreachable!();
+ };
+ let available = if list.is_null(0) {
+ 0
+ } else {
+ list.value_length(0) as usize
+ };
+ let skip = self.element_offset.min(available);
+ let start = list.value_offsets()[0] + skip as i32;
+ let end = start + length.min(available - skip) as i32;
+ // Narrow the entries and parallel positions together,
preserving their
+ // original values and sharing their buffers. Shorter
lists still pad.
+ columns[unnest.index_in_input_schema] =
Arc::new(ListArray::new(
+ Arc::clone(field),
+ OffsetBuffer::new(vec![start, end].into()),
+ Arc::clone(list.values()),
+ list.nulls().cloned(),
+ ));
+ }
+ self.element_offset += length;
+ if self.element_offset == row_length {
+ self.row_offset += 1;
+ self.element_offset = 0;
+ }
+ return Ok((
+ RecordBatch::try_new(row.schema(), columns)?,
+ Some(Int64Array::from(vec![length as i64])),
+ ));
+ }
+ }
+ let rows = self.next_chunk_rows(batch_size);
+ let chunk = self.batch.slice(self.row_offset, rows);
+ let lengths = self.chunk_lengths(rows);
+ self.row_offset += rows;
+ Ok((chunk, lengths))
+ }
+}
+
+/// If every row repeats at most R times, no copied buffer or child offset
span exceeds R
+/// times its entire source span. Count elements too: List<Null> has no child
value buffer.
+fn repetition_limit(batch: &RecordBatch, list_columns: &[ListUnnest]) -> usize
{
+ fn max_span(data: &ArrayData) -> usize {
+ std::iter::once(data.len())
+ .chain(data.buffers().iter().map(|buffer| buffer.len()))
+ .chain(data.child_data().iter().map(max_span))
+ .max()
+ .unwrap_or(0)
+ }
+ let span = batch
+ .columns()
+ .iter()
+ .enumerate()
+ .filter(|(index, _)| {
+ !list_columns
+ .iter()
+ .any(|list| list.index_in_input_schema == *index)
+ })
+ .map(|(_, column)| max_span(&column.to_data()))
+ .max()
+ .unwrap_or(0);
+ (i32::MAX as usize / span.max(1)).max(1)
Review Comment:
`span` is the largest buffer of any carried column across the whole input
batch, but what can overflow is the bytes of the rows in one chunk times their
repetition. Take an 8,192-row batch carrying a 1 KiB string. Its values buffer
is 8 MiB, so the limit is 255, and every row with more than 255 elements is
then built alone in 255-element pieces, with `next_chunk_rows` also ending the
chunk before such a row. On main, 300-element arrays with that payload are
built about 27 rows at a time, roughly 8 MB of copied strings, far from the
offset limit. `split_limit` is set for every depth-1 `List`, so this changes
array `explode` too, and fixed-width carried columns count toward `span`
although they cannot overflow an offset. Could the limit come from the
offset-addressed bytes of the rows in the chunk, or apply only when a row's own
carried bytes times its length can overflow? I derived this from the code and
have not measured it. A bench case with a 1 KiB carried string and a few hundr
ed elements per row would show it.
##########
native/operators/src/explode.rs:
##########
@@ -1276,36 +1374,164 @@ fn create_take_indices(
/// c2: 'a', 'b', 'c', 'c', 'c', null, 'd', 'd'
/// ```
///
-/// The `repeat_mask` determines whether an array's values are repeated or
replaced with nulls.
-/// For example, if the `repeat_mask` is:
-///
-/// ```ignore
-/// [true, false]
-/// ```
-///
-/// The final batch will look like:
-///
-/// ```ignore
-/// c1: 1, null, 2, 3, 4, null, 5, 6 // Repeated using `indices`
-/// c2: null, null, null, null, null, null, null, null // Replaced with nulls
-fn repeat_arrs_from_indices(
+fn repeat_arrs(
batch: &[ArrayRef],
- indices: &PrimitiveArray<Int64Type>,
+ lengths: &PrimitiveArray<Int64Type>,
+ total_length: usize,
repeat_mask: &[bool],
) -> Result<Vec<Arc<dyn Array>>> {
+ let mut indices = None;
batch
.iter()
.zip(repeat_mask.iter())
.map(|(arr, &repeat)| {
if repeat {
+ if let Some(strings) = arr.as_string_opt::<i32>() {
+ if let Some(strings) = repeat_short_strings(strings,
lengths, total_length) {
+ return Ok(Arc::new(strings) as ArrayRef);
+ }
+ }
+ if arr.null_count() == 0 && total_length >=
arr.len().saturating_mul(5) {
+ let repeated = arrow::array::downcast_primitive_array! {
+ arr => { Some(Arc::new(repeat_primitive(arr, lengths,
total_length)) as ArrayRef) }
+ _ => None,
+ };
+ if let Some(repeated) = repeated {
+ return Ok(repeated);
+ }
+ }
+ let indices =
+ indices.get_or_insert_with(|| create_take_indices(lengths,
total_length));
Ok(kernels::take::take(arr, indices, None)?)
} else {
- Ok(new_null_array(arr.data_type(), arr.len()))
+ Ok(Arc::clone(arr))
}
})
.collect()
}
+/// Repeat non-null primitives by input runs without allocating gather indices.
+fn repeat_primitive<T: arrow::datatypes::ArrowPrimitiveType>(
+ array: &PrimitiveArray<T>,
+ lengths: &Int64Array,
+ total_length: usize,
+) -> PrimitiveArray<T> {
+ let mut values = Vec::with_capacity(total_length);
+ for (&value, &repeat) in array.values().iter().zip(lengths.values()) {
+ values.resize(values.len() + repeat as usize, value);
+ }
+ PrimitiveArray::new(values.into(),
None).with_data_type(array.data_type().clone())
+}
+
+/// Repeat short Utf8 values using the input runs rather than gathering every
output value.
+/// Batches dominated by long strings or with less than twofold expansion use
Arrow's take instead.
+fn repeat_short_strings(
Review Comment:
`repeat_short_strings`, with its `StringArray::new_unchecked`, and
`repeat_primitive` change how every `explode` repeats carried columns, arrays
included, and map support does not need them. There are no current-head numbers
for them, and the new `explode_chained_carry` and `fan_out_*` Criterion groups
have no posted results. Could they move to their own PR with before and after
numbers from `benches/explode.rs`? That keeps this PR to map support and lets
the `unsafe` path be reverted on its own, as andygrove asked for the
`ParquetReadSuite` change.
##########
spark/src/main/scala/org/apache/comet/rules/CometExecRule.scala:
##########
@@ -1444,6 +1454,29 @@ case class CometExecRule(session: SparkSession,
queryStagePrep: Boolean = false)
visit(plan, if (producesObjects(plan)) Some("code reading Dataset.rdd")
else None)
}
+ // Spark fuses a generator chain without materializing its carried columns
between generators.
+ // Keep that path until native map expansion can avoid the extra repetition
too. Tags survive
+ // the child copies made by bottom-up conversion; exchanges end the shared
pipeline.
+ private def tagChainedMapGenerators(plan: SparkPlan): Unit = {
+ val enabled = CometConf.COMET_EXEC_EXPLODE_ENABLED.get(conf)
+ def visit(op: SparkPlan, generatorAbove: Boolean): Unit = op match {
+ case generate: GenerateExec =>
+ generate.unsetTagValue(CometExecRule.CHAINED_MAP_GENERATOR)
+ generate.generator match {
+ case explode: ExplodeBase
+ if enabled && generatorAbove &&
explode.child.dataType.isInstanceOf[MapType] =>
Review Comment:
This keeps a map generator on Spark whenever another generator sits above
it, while an array generator in the same position stays native, and `array-only
generator chain stays native` asserts that. The stated reason is carried-column
repetition between generators, which costs the same whether the lower generator
reads a map or an array. The description calls the 97 ms against 90 ms
comparison historical, and `c4525cb85f` has since changed how carried values
repeat. Could you post nested numbers at this head for the map chain and the
equivalent `array<struct>` chain? If the gap is gone, the rule, its tests and
the `operators.md` paragraph can go. If it remains, it is a chained-generator
issue rather than a map one. The benchmark also replaced the native
`array<struct>` depth-8 chain with this map chain, which now times Spark
generators, so keeping both cases would answer this.
##########
spark/src/test/resources/sql-tests/expressions/array/explode.sql:
##########
@@ -339,7 +339,7 @@ INSERT INTO test_explode_map VALUES
(2, map()),
(3, NULL)
-query expect_fallback(Comet only supports explode/explode_outer for arrays,
not maps)
+query
Review Comment:
This and the `posexplode(m)` case in `posexplode.sql` were fallback checks,
and as plain queries they now repeat `explode_maps` in `map/explode.sql`, which
has the same entries, empty and NULL shapes for all four generators. Could they
be dropped? `with batches containing only null and empty maps` in
`CometGenerateExecSuite` also looks like a fit for that fixture with `--
Config: spark.comet.batchSize=4`. I have not run that conversion.
--
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]