sunchao commented on code in PR #7106:
URL: https://github.com/apache/datafusion-comet/pull/7106#discussion_r4367567431


##########
native/core/src/parquet/parquet_support.rs:
##########
@@ -330,6 +330,33 @@ fn parquet_convert_array_impl(
 
             Ok(Arc::new(string_array))
         }
+        // Unannotated BINARY read as a string. Spark's cast replaces 
malformed UTF-8 with
+        // U+FFFD, where Arrow's safe cast below would return NULL. Top-level 
columns don't
+        // reach this arm (#7032). The reader keeps a file's ARROW:schema 
types, so an Arrow
+        // writer's `LargeBinary` or `BinaryView` arrives as is; Spark's cast 
takes only
+        // `Binary`, and converting `LargeBinary` reuses its value buffer.
+        (Binary | LargeBinary | BinaryView, Utf8) => {
+            let array = match from_type {
+                Binary => array,
+                _ => cast_with_options(&array, &Binary, &PARQUET_OPTIONS)?,
+            };
+            // Binary to Utf8 reads only the binary output style, which a scan 
leaves unset.
+            let cast_options = 
SparkCastOptions::new_without_timezone(EvalMode::Legacy);
+            let len = array.len();
+            spark_cast(ColumnarValue::Array(array), to_type, 
&cast_options)?.into_array(len)
+        }
+        // The same schema can dictionary-encode such a value. Decode each 
dictionary value once
+        // through the arm above, then expand the keys, as Arrow's cast would.
+        (Dictionary(_, value_type), Utf8) if matches!(value_type.as_ref(), 
Binary | LargeBinary) => {
+            let dictionary = array.as_any_dictionary();
+            let values = parquet_convert_array_impl(

Review Comment:
   [P2] Avoid decoding the entire row-group dictionary for every output batch. 
A selected batch can reference only a short valid string and still repeatedly 
pay to repair a wide malformed entry used elsewhere in the row group. Reuse the 
conversion while the same dictionary backs the scan, or decode only referenced 
entries.
   
   Evidence: One row group contained `normal` and an 86,016-byte malformed 
entry referenced only in its first batch. Selecting the other 31 batches 
decoded the unused entry 31 times. Median Parquet read-and-adapt CPU was 1.97 
ms on base, 7.90 ms on head and 1.85 ms with dictionary reuse. The Spark query 
`SELECT sum(octet_length(s.k)) FROM t WHERE part=1` returned `1523712` on both 
revisions. Median native scan time rose from 3.67 to 11.01 ms.



##########
native/core/src/parquet/parquet_support.rs:
##########
@@ -330,6 +330,33 @@ fn parquet_convert_array_impl(
 
             Ok(Arc::new(string_array))
         }
+        // Unannotated BINARY read as a string. Spark's cast replaces 
malformed UTF-8 with
+        // U+FFFD, where Arrow's safe cast below would return NULL. Top-level 
columns don't
+        // reach this arm (#7032). The reader keeps a file's ARROW:schema 
types, so an Arrow
+        // writer's `LargeBinary` or `BinaryView` arrives as is; Spark's cast 
takes only
+        // `Binary`, and converting `LargeBinary` reuses its value buffer.
+        (Binary | LargeBinary | BinaryView, Utf8) => {
+            let array = match from_type {
+                Binary => array,
+                _ => cast_with_options(&array, &Binary, &PARQUET_OPTIONS)?,
+            };
+            // Binary to Utf8 reads only the binary output style, which a scan 
leaves unset.
+            let cast_options = 
SparkCastOptions::new_without_timezone(EvalMode::Legacy);
+            let len = array.len();
+            spark_cast(ColumnarValue::Array(array), to_type, 
&cast_options)?.into_array(len)
+        }
+        // The same schema can dictionary-encode such a value. Decode each 
dictionary value once
+        // through the arm above, then expand the keys, as Arrow's cast would.
+        (Dictionary(_, value_type), Utf8) if matches!(value_type.as_ref(), 
Binary | LargeBinary) => {
+            let dictionary = array.as_any_dictionary();
+            let values = parquet_convert_array_impl(
+                Arc::clone(dictionary.values()),
+                to_type,
+                parquet_options,
+                None,
+            )?;
+            Ok(take(values.as_ref(), dictionary.keys(), None)?)

Review Comment:
   [P2] `take` flattens the decoded dictionary into one `Utf8` array with 
signed 32-bit offsets. Repeated references can fit in the input batch and 
exceed that limit after replacement. This aborts even `SUM(size(arr))`, which 
only needs list lengths. Split the enclosing batch at parent-row boundaries 
using decoded widths, or retain the dictionary until it can be materialized in 
bounded batches.
   
   Evidence: A 264,786-byte Parquet file held 8,192 singleton lists referencing 
one 88,000-byte dictionary value of `FF` bytes. Each decoded value was 264,000 
bytes. At the default batch size of 8,192, Spark 4.1.3 and the base returned 
`8192` for `SUM(size(arr))`. The head’s native scan raised `Offset overflow 
error: 2147640000`. An Arrow probe independently confirmed the failure during 
offset calculation.



##########
native/spark-expr/src/conversion_funcs/cast.rs:
##########
@@ -876,7 +881,80 @@ fn cast_binary_to_string<O: OffsetSizeTrait>(
 
     let num_rows = input.len();
     let offsets = input.value_offsets();
-    let value_bytes = offsets[num_rows].as_usize() - offsets[0].as_usize();
+    let start = offsets[0].as_usize();
+    let end = offsets[num_rows].as_usize();
+    // Bytes of the non-null rows. A null row can still span bytes, for 
example after `nullif`.
+    let live_bytes = match input.nulls().filter(|nulls| nulls.null_count() > 
0) {
+        Some(nulls) => nulls
+            .valid_indices()
+            .map(|i| offsets[i + 1].as_usize() - offsets[i].as_usize())
+            .sum(),
+        None => end - start,
+    };
+
+    // Check the bytes the rows span once. If they are valid UTF-8, reuse the 
input's value buffer,
+    // or copy them when they fill less than half of its allocation; otherwise 
copy the rows in the
+    // valid prefix and decode the rest. Skip this when null rows span more 
than half of those
+    // bytes: decoding the live rows one at a time then reads less and returns 
a compact buffer
+    // instead of keeping the null rows' bytes alive.
+    let mut valid_prefix = "";
+    if matches!(
+        spark_cast_options.binary_output_style,
+        None | Some(BinaryOutputStyle::Utf8)
+    ) && 2 * live_bytes >= end - start
+    {
+        let data = input.value_data();
+        match std::str::from_utf8(&data[start..end]) {
+            Ok(valid) => {
+                // An offset splits a code point only if it points at a 
continuation byte.
+                if offsets.iter().all(|offset| {
+                    let offset = offset.as_usize();
+                    offset == end || !matches!(data[offset], 0x80..=0xBF)
+                }) {
+                    // Reuse a buffer only when the rows fill at least half of 
its allocation. A
+                    // slice of a large array shares the whole allocation, and 
memory accounting
+                    // such as a sort's charges all of it to every batch that 
holds it, so the
+                    // offsets, values and validity bitmap are copied then, 
without decoding the
+                    // values again.
+                    let fills = |buffer: &Buffer, used: usize| 2 * used >= 
buffer.capacity();

Review Comment:
   [P2] Account for the owner of an imported buffer before sharing it. Arrow C 
Stream gives each child buffer a reference to the root record batch. Retaining 
an eight-byte cast key can therefore pin a discarded wide sibling for as long 
as a downstream sort holds the projection. Detach values, offsets and validity 
for batch-owned inputs, or restrict sharing to independently owned buffers.
   
   Evidence: Ran `SELECT CAST(k AS STRING) AS s, length(blob) AS n FROM wide 
SORT BY s` on one Spark 4.1.3 RDD partition with 
`spark.comet.convert.rdd.enabled=true`, 128-row batches, 8,192 eight-byte keys 
and 4 KiB blobs. Both revisions returned every sorted row correctly. Peak 
`CometArrowAllocator` usage rose from 852,544 bytes on base to 37,772,352 on 
head. Both released all allocations after collection.



##########
native/core/src/parquet/parquet_support.rs:
##########
@@ -330,6 +330,33 @@ fn parquet_convert_array_impl(
 
             Ok(Arc::new(string_array))
         }
+        // Unannotated BINARY read as a string. Spark's cast replaces 
malformed UTF-8 with
+        // U+FFFD, where Arrow's safe cast below would return NULL. Top-level 
columns don't
+        // reach this arm (#7032). The reader keeps a file's ARROW:schema 
types, so an Arrow
+        // writer's `LargeBinary` or `BinaryView` arrives as is; Spark's cast 
takes only
+        // `Binary`, and converting `LargeBinary` reuses its value buffer.
+        (Binary | LargeBinary | BinaryView, Utf8) => {
+            let array = match from_type {
+                Binary => array,
+                _ => cast_with_options(&array, &Binary, &PARQUET_OPTIONS)?,
+            };
+            // Binary to Utf8 reads only the binary output style, which a scan 
leaves unset.
+            let cast_options = 
SparkCastOptions::new_without_timezone(EvalMode::Legacy);
+            let len = array.len();
+            spark_cast(ColumnarValue::Array(array), to_type, 
&cast_options)?.into_array(len)

Review Comment:
   [P2] Preserve map-key identity when decoding a string inside a key. If two 
struct keys contain `FF` and `EF BF BD`, this cast turns both into `{s: �}`. 
Looking up the second key then returns the first entry’s value. Preserve the 
bytes through key evaluation or route affected map reads through a 
byte-preserving Spark path. Falling back only the lookup happens too late.
   
   Evidence: A one-row `map<struct<s:string>,int>` fixture stored `FF → 1` 
followed by `EF BF BD → 2`. `SELECT m[named_struct('s','�')]` returned `2` on 
Spark 4.1.3 and the base, and `1` on the head, with a JVM `Project` over 
`CometNativeScan`. `size(map_from_entries(map_entries(m)))` returned `2` on 
Spark and base and raised `DUPLICATED_MAP_KEY` on head. Both inferred and 
explicit string schemas exhibited the collision.



##########
native/spark-expr/src/conversion_funcs/cast.rs:
##########
@@ -904,14 +982,17 @@ fn cast_binary_to_string<O: OffsetSizeTrait>(
         // the row. Writing to the builder is infallible.
         let written = match spark_cast_options.binary_output_style {
             // Default CAST(binary AS string) and the UTF8 ToPrettyString 
style (Spark 4.0+) both
-            // render via `new String(bytes, UTF_8)`. Route through the shared 
JVM-compatible lossy
-            // decoder so ill-formed bytes become U+FFFD (matching Spark) 
instead of being
-            // reinterpreted unchecked (UB) or panicking on non-UTF-8 input 
(#4488, #4763). The
-            // valid-UTF-8 path borrows, so it copies once (into the Arrow 
value buffer). Divergence
-            // for byte-level round-trips such as CAST(CAST(x AS string) AS 
binary) and value
-            // identity is documented in the compatibility guide and tracked 
by #4764.
+            // render via `new String(bytes, UTF_8)`. Values in the valid 
prefix are copied as is;
+            // the rest go through the shared JVM-compatible lossy decoder so 
ill-formed bytes
+            // become U+FFFD (matching Spark) instead of being reinterpreted 
unchecked (UB) or
+            // panicking on non-UTF-8 input (#4488, #4763). Divergence for 
byte-level round-trips
+            // such as CAST(CAST(x AS string) AS binary) and value identity is 
documented in the
+            // compatibility guide and tracked by #4764.
             None | Some(BinaryOutputStyle::Utf8) => {
-                builder.write_str(&decode_utf8_spark_lossy(value))
+                match valid_prefix.get(range[0].as_usize() - 
start..range[1].as_usize() - start) {
+                    Some(valid) => builder.write_str(valid),
+                    None => builder.write_str(&decode_utf8_spark_lossy(value)),

Review Comment:
   [P2] Reuse the validated prefix inside the first malformed row. When a wide 
multibyte value ends in a truncated character, `valid_prefix.get` rejects the 
whole row range and `decode_utf8_spark_lossy(value)` starts again at byte zero. 
If the row starts on a verified character boundary, append its known-good 
prefix and decode the remaining suffix.
   
   Evidence: Across 32 batches containing one roughly 256 KiB truncated 
multibyte value and 8,191 short values each, `SELECT octet_length(CAST(k AS 
STRING))` returned identical results. Median `CometProject` compute rose from 
26.33 ms on base to 29.88 ms on head. With the wide row first, the isolated 
cast measured 612 µs on base, 798 µs on head and 335 µs with partial-row prefix 
reuse, with identical validated output.



##########
native/spark-expr/src/conversion_funcs/cast.rs:
##########
@@ -876,7 +881,80 @@ fn cast_binary_to_string<O: OffsetSizeTrait>(
 
     let num_rows = input.len();
     let offsets = input.value_offsets();
-    let value_bytes = offsets[num_rows].as_usize() - offsets[0].as_usize();
+    let start = offsets[0].as_usize();
+    let end = offsets[num_rows].as_usize();
+    // Bytes of the non-null rows. A null row can still span bytes, for 
example after `nullif`.
+    let live_bytes = match input.nulls().filter(|nulls| nulls.null_count() > 
0) {
+        Some(nulls) => nulls
+            .valid_indices()

Review Comment:
   [P2] Count live bytes by contiguous validity runs for sparsely nullable 
input. One null per 128 short Parquet values makes `valid_indices()` visit 
nearly every row to establish that the nulls hide zero bytes. Summing offset 
differences at run boundaries preserves the hidden-payload check and avoids 
that extra pass on ordinary valid strings.
   
   Evidence: For 262,144 nested eight-byte values in 8,192-row batches, 
repeated release probes measured about 8 µs per conversion on base and 21 µs on 
head. Changing only the count to `valid_slices()` measured about 8.5 µs with 
equal, validated output. Plain Parquet read-and-adapt CPU rose from 2.75–2.80 
to 3.11–3.23 ms. The separate Spark scan medians were 5.82 ms on base and 4.97 
ms on head, so the demonstrated regression is in conversion and reader CPU.



##########
native/core/src/parquet/parquet_support.rs:
##########
@@ -330,6 +330,33 @@ fn parquet_convert_array_impl(
 
             Ok(Arc::new(string_array))
         }
+        // Unannotated BINARY read as a string. Spark's cast replaces 
malformed UTF-8 with
+        // U+FFFD, where Arrow's safe cast below would return NULL. Top-level 
columns don't
+        // reach this arm (#7032). The reader keeps a file's ARROW:schema 
types, so an Arrow
+        // writer's `LargeBinary` or `BinaryView` arrives as is; Spark's cast 
takes only
+        // `Binary`, and converting `LargeBinary` reuses its value buffer.
+        (Binary | LargeBinary | BinaryView, Utf8) => {
+            let array = match from_type {
+                Binary => array,
+                _ => cast_with_options(&array, &Binary, &PARQUET_OPTIONS)?,
+            };
+            // Binary to Utf8 reads only the binary output style, which a scan 
leaves unset.
+            let cast_options = 
SparkCastOptions::new_without_timezone(EvalMode::Legacy);
+            let len = array.len();
+            spark_cast(ColumnarValue::Array(array), to_type, 
&cast_options)?.into_array(len)

Review Comment:
   [P2] Compact the newly decoded value buffer when a small replacement pushes 
the builder past its initial capacity. One malformed byte in a 512 KiB batch 
adds two output bytes, yet the new scan path hands a 1 MiB allocation to the 
sorter. Sizing for decoded bytes or shrinking the uniquely owned fallback 
buffer before returning it avoids the extra spill pressure.
   
   Evidence: Sorted 262,144 nested 64-byte keys with one malformed final byte 
per 8,192-row batch and a 52 MiB pool. Each head batch used 524,290 bytes in a 
1,048,576-byte allocation. Head spilled twice and wrote 2,390,680 bytes. Base 
returned 32 nulls and had zero spills. A head control with the same 
replacements already encoded produced identical strings and zero spills. 
Compacting the fallback buffer preserved all values at 524,290 bytes.



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