mbutrovich commented on code in PR #4885:
URL: https://github.com/apache/datafusion-comet/pull/4885#discussion_r3631945206


##########
native/spark-expr/src/string_funcs/base64.rs:
##########
@@ -62,33 +64,43 @@ pub fn spark_base64(args: &[ColumnarValue]) -> 
Result<ColumnarValue, DataFusionE
     }
 }
 
-fn encode_array<O: OffsetSizeTrait>(array: &GenericBinaryArray<O>, chunk: 
bool) -> StringArray {
-    array
-        .iter()
-        .map(|value| value.map(|bytes| encode(bytes, chunk)))
-        .collect()
+const LINE_LEN: usize = 76;
+
+/// Length of the padded base64 encoding of `n` input bytes.
+fn base64_encoded_len(n: usize) -> usize {
+    n.div_ceil(3) * 4
 }
 
-fn encode(bytes: &[u8], chunk: bool) -> String {
-    let encoded = BASE64_STANDARD.encode(bytes);
-    if chunk {
-        chunk_into_lines(encoded)
+/// Length after CRLF wrapping if `encoded_len` bytes are chunked at 
`LINE_LEN` chars per line.
+fn chunked_len(encoded_len: usize) -> usize {
+    if encoded_len == 0 {
+        0
     } else {
-        encoded
+        encoded_len + ((encoded_len - 1) / LINE_LEN) * 2
     }
 }
 
-/// Wrap a base64 string into lines of at most 76 characters joined by CRLF, 
with no trailing
-/// separator. Matches `java.util.Base64.getMimeEncoder()`. base64 output is 
pure ASCII, so byte
-/// offsets and character offsets coincide. Takes the string by value so the 
common short-input
-/// case (no wrapping needed) returns it without a second allocation.
-fn chunk_into_lines(encoded: String) -> String {
-    const LINE_LEN: usize = 76;
-    if encoded.len() <= LINE_LEN {
-        return encoded;
+/// Encodes `bytes` into `out`, wrapping at `LINE_LEN` when `chunk` is true. 
`out` is reused
+/// across rows to avoid per-row heap allocations; the caller clears it before 
each call.
+fn encode_into(bytes: &[u8], chunk: bool, out: &mut String) {
+    if !chunk {
+        BASE64_STANDARD.encode_string(bytes, out);
+        return;
     }
-    let separators = (encoded.len() - 1) / LINE_LEN;
-    let mut out = String::with_capacity(encoded.len() + separators * 2);
+    // Encode into a scratch, then wrap. Two passes are unavoidable because 
the base64 crate
+    // does not emit CRLF for us and computing chunk boundaries mid-encode 
would require a
+    // custom writer that carries per-row state.
+    let unwrapped_len = base64_encoded_len(bytes.len());
+    if unwrapped_len <= LINE_LEN {
+        BASE64_STANDARD.encode_string(bytes, out);
+        return;
+    }
+    // Reuse `out` for the wrapped result: encode into a temporary owned by 
the outer scratch,
+    // then copy CRLF-wrapped chunks in. The temporary is short-lived per row, 
but the caller's
+    // long-lived scratch avoids the per-row allocation the previous 
implementation had.
+    let mut encoded = String::with_capacity(unwrapped_len);
+    BASE64_STANDARD.encode_string(bytes, &mut encoded);

Review Comment:
   This allocates a fresh `String` on every call whose encoding exceeds 76 
chars, which is the entire long/chunked benchmark row. The comment on line 100 
("the caller's long-lived scratch avoids the per-row allocation the previous 
implementation had") overstates the result: the caller's `buf` is reused, but 
this temporary reintroduces the allocation for exactly the case the buffer 
reuse was meant to remove.
   
   MIME wrapping is aligned to base64's block structure: 57 input bytes encode 
to exactly 76 base64 chars with no padding (57 is divisible by 3, `19 * 4 = 
76`). So you can encode the input in 57-byte windows straight into `out`, with 
`\r\n` between windows, and drop the `encoded` temporary and the second pass 
entirely:
   
   ```rust
   let mut first = true;
   for window in bytes.chunks(57) {
       if !first {
           out.push_str("\r\n");
       }
       BASE64_STANDARD.encode_string(window, out);
       first = false;
   }
   ```
   
   One pass, zero per-row allocation, same output as the current 
`chunk_into_lines` semantics.



##########
native/spark-expr/src/string_funcs/base64.rs:
##########
@@ -98,12 +110,43 @@ fn chunk_into_lines(encoded: String) -> String {
         out.push_str(&encoded[offset..end]);
         offset = end;
     }
+}
+
+fn encode_array<O: OffsetSizeTrait>(array: &GenericBinaryArray<O>, chunk: 
bool) -> StringArray {
+    // Right-size the value buffer in O(1) from the input's total byte count. 
When chunking,
+    // add the CRLF slack the encoded output will contain so the long-input 
path does not grow.
+    let total_bytes = array.value_data().len();
+    let encoded_total = base64_encoded_len(total_bytes);
+    let data_capacity = if chunk {
+        chunked_len(encoded_total)

Review Comment:
   `base64_encoded_len` is applied to the summed raw bytes, but each row pads 
independently to a multiple of 4. For N rows of 1 byte the estimate is about 
`4N/3` against an actual `4N`, so it reserves roughly a third of what is 
needed, and the chunked case also misses the per-row CRLFs. This is only a 
capacity hint (the builder grows correctly), so no correctness impact, but the 
"sizes the value buffer up front" claim only holds for the few-large-row shape. 
If you take the 57-byte-window approach above, encode straight into the 
builder's value buffer and this estimate can stay as a rough lower bound 
without the extra care.



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