This is an automated email from the ASF dual-hosted git repository.

alamb pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git


The following commit(s) were added to refs/heads/main by this push:
     new 286476857b perf(arrow-data): Format decimal values without 
intermediate allocs (#11002)
286476857b is described below

commit 286476857bc5a021977dd6eb3e97de9df611c850
Author: Neil Conway <[email protected]>
AuthorDate: Thu Sep 17 12:58:54 2026 -0400

    perf(arrow-data): Format decimal values without intermediate allocs (#11002)
    
    # Which issue does this PR close?
    
    - N/A
    
    # Rationale for this change
    
    Formatting a decimal value allocated two heap strings: the unscaled
    value was converted with `to_string`, and `format_decimal_str` then
    built a second String with the decimal point inserted.
    
    arrow-data gains three functions that share one implementation of the
    formatting rules:
    
    - `write_decimal_str` (private) takes the digits of an unscaled value,
    with an optional sign, and writes them to a `fmt::Write` with the
    decimal point inserted `scale` digits from the right, adding leading
    zeros as needed.
    - `write_decimal` is for callers that have an output to write to, such
    as `ArrayFormatter`: it formats the native value into a stack buffer of
    digits and passes them to `write_decimal_str`.
    - `format_decimal` is for callers that need an owned String, such as
    `DecimalType::format_decimal`: it formats the digits the same way, then
    allocates a String of the required capacity and writes into it through
    `write_decimal_str`.
    
    `write_decimal` and `format_decimal` accept the native value of a
    decimal type, i32, i64, i128 or i256, named by the new sealed
    `DecimalNativeType` trait. `DecimalType` already names these types, but
    it lives in arrow-array, which depends on arrow-data, so it cannot be
    used here.
    
    The existing entry points are now implemented on top of write_decimal
    and format_decimal. This reduces the number of heap allocations in
    `ArrayFormatter` from 2 -> 0 (which improves performance writing CSV and
    JSON output), and from 2 -> 1 for `PrimitiveArray::value_as_string`.
    
    `format_decimal` benchmarks, M4 Max:
    
        case                            before    after   change
        decimal32 (9, 2) 9 digits       593.45   220.13   -62.9%
        decimal64 (18, 6) 18 digits     726.44   243.84   -66.4%
        decimal128 (10, 2) 1 digit      486.70   190.16   -60.9%
        decimal128 (10, 2) 5 digits     480.75   209.84   -56.4%
        decimal128 (38, 10) 38 digits   776.75   303.65   -60.9%
        decimal256 (76, 10) 38 digits  1751.70  1070.30   -38.9%
        decimal256 (76, 10) 76 digits  2097.40  1507.80   -28.1%
    
    The relative improvement for decimal256 is smaller because that case had
    additional overhead; that has been addressed in a concurrent PR
    (#11000).
    
    # What changes are included in this PR?
    
    See above.
    
    # Are these changes tested?
    
    Yes; existing tests pass, new test added.
    
    # Are there any user-facing changes?
    
    No. Decimal output format is unchanged.
    
    # AI usage
    
    Developed with Claude Code Fable 5.1; reviewed with Codex GPT-6 Astra. I
    reviewed, revised, and understand the resulting code.
    
    ---------
    
    Co-authored-by: Jeffrey Vo <[email protected]>
---
 arrow-array/src/types.rs  |  28 ++----
 arrow-cast/src/display.rs |  10 +--
 arrow-data/src/decimal.rs | 219 ++++++++++++++++++++++++++++++++--------------
 3 files changed, 168 insertions(+), 89 deletions(-)

diff --git a/arrow-array/src/types.rs b/arrow-array/src/types.rs
index e0cc57bae5..ba0d88ae64 100644
--- a/arrow-array/src/types.rs
+++ b/arrow-array/src/types.rs
@@ -25,7 +25,7 @@ use crate::timezone::Tz;
 use crate::{ArrowNativeTypeOp, OffsetSizeTrait};
 use arrow_buffer::{Buffer, OffsetBuffer, i256};
 use arrow_data::decimal::{
-    format_decimal_str, is_validate_decimal_precision, 
is_validate_decimal32_precision,
+    DecimalNativeType, is_validate_decimal_precision, 
is_validate_decimal32_precision,
     is_validate_decimal64_precision, is_validate_decimal256_precision, 
validate_decimal_precision,
     validate_decimal32_precision, validate_decimal64_precision, 
validate_decimal256_precision,
 };
@@ -1400,7 +1400,7 @@ mod decimal {
 /// [`Decimal128Array`]: crate::array::Decimal128Array
 /// [`Decimal256Array`]: crate::array::Decimal256Array
 pub trait DecimalType:
-    'static + Send + Sync + ArrowPrimitiveType + decimal::DecimalTypeSealed
+    'static + Send + Sync + ArrowPrimitiveType<Native: DecimalNativeType> + 
decimal::DecimalTypeSealed
 {
     /// Width of the type
     const BYTE_LENGTH: usize;
@@ -1418,8 +1418,12 @@ pub trait DecimalType:
     /// "Decimal32", "Decimal64", "Decimal128" or "Decimal256", for use in 
error messages
     const PREFIX: &'static str;
 
-    /// Formats the decimal value with the provided precision and scale
-    fn format_decimal(value: Self::Native, precision: u8, scale: i8) -> String;
+    /// Formats `value` with `scale` fractional digits. The value is always
+    /// formatted in full: `precision` is ignored and retained for API
+    /// compatibility.
+    fn format_decimal(value: Self::Native, _precision: u8, scale: i8) -> 
String {
+        arrow_data::decimal::format_decimal(value, scale)
+    }
 
     /// Validates that `value` contains no more than `precision` decimal digits
     fn validate_decimal_precision(
@@ -1487,10 +1491,6 @@ impl DecimalType for Decimal32Type {
         DataType::Decimal32(DECIMAL32_MAX_PRECISION, DECIMAL32_DEFAULT_SCALE);
     const PREFIX: &'static str = "Decimal32";
 
-    fn format_decimal(value: Self::Native, precision: u8, scale: i8) -> String 
{
-        format_decimal_str(&value.to_string(), precision as usize, scale)
-    }
-
     fn validate_decimal_precision(num: i32, precision: u8, scale: i8) -> 
Result<(), ArrowError> {
         validate_decimal32_precision(num, precision, scale)
     }
@@ -1523,10 +1523,6 @@ impl DecimalType for Decimal64Type {
         DataType::Decimal64(DECIMAL64_MAX_PRECISION, DECIMAL64_DEFAULT_SCALE);
     const PREFIX: &'static str = "Decimal64";
 
-    fn format_decimal(value: Self::Native, precision: u8, scale: i8) -> String 
{
-        format_decimal_str(&value.to_string(), precision as usize, scale)
-    }
-
     fn validate_decimal_precision(num: i64, precision: u8, scale: i8) -> 
Result<(), ArrowError> {
         validate_decimal64_precision(num, precision, scale)
     }
@@ -1559,10 +1555,6 @@ impl DecimalType for Decimal128Type {
         DataType::Decimal128(DECIMAL128_MAX_PRECISION, DECIMAL_DEFAULT_SCALE);
     const PREFIX: &'static str = "Decimal128";
 
-    fn format_decimal(value: Self::Native, precision: u8, scale: i8) -> String 
{
-        format_decimal_str(&value.to_string(), precision as usize, scale)
-    }
-
     fn validate_decimal_precision(num: i128, precision: u8, scale: i8) -> 
Result<(), ArrowError> {
         validate_decimal_precision(num, precision, scale)
     }
@@ -1595,10 +1587,6 @@ impl DecimalType for Decimal256Type {
         DataType::Decimal256(DECIMAL256_MAX_PRECISION, DECIMAL_DEFAULT_SCALE);
     const PREFIX: &'static str = "Decimal256";
 
-    fn format_decimal(value: Self::Native, precision: u8, scale: i8) -> String 
{
-        format_decimal_str(&value.to_string(), precision as usize, scale)
-    }
-
     fn validate_decimal_precision(num: i256, precision: u8, scale: i8) -> 
Result<(), ArrowError> {
         validate_decimal256_precision(num, precision, scale)
     }
diff --git a/arrow-cast/src/display.rs b/arrow-cast/src/display.rs
index 28a12a0ef3..e27421f8c4 100644
--- a/arrow-cast/src/display.rs
+++ b/arrow-cast/src/display.rs
@@ -33,6 +33,7 @@ use arrow_array::timezone::Tz;
 use arrow_array::types::*;
 use arrow_array::*;
 use arrow_buffer::ArrowNativeType;
+use arrow_data::decimal::write_decimal;
 use arrow_schema::*;
 use chrono::format::{Item, StrftimeItems};
 use chrono::{NaiveDate, NaiveDateTime, SecondsFormat, TimeZone, Utc};
@@ -736,14 +737,14 @@ impl DisplayIndex for &PrimitiveArray<Float16Type> {
 macro_rules! decimal_display {
     ($($t:ty),+) => {
         $(impl<'a> DisplayIndexState<'a> for &'a PrimitiveArray<$t> {
-            type State = (u8, i8);
+            type State = i8;
 
             fn prepare(&self, _options: &FormatOptions<'a>) -> 
Result<Self::State, ArrowError> {
-                Ok((self.precision(), self.scale()))
+                Ok(self.scale())
             }
 
-            fn write(&self, s: &Self::State, idx: usize, f: &mut dyn Write) -> 
FormatResult {
-                write!(f, "{}", <$t>::format_decimal(self.values()[idx], s.0, 
s.1))?;
+            fn write(&self, scale: &Self::State, idx: usize, f: &mut dyn 
Write) -> FormatResult {
+                write_decimal(f, self.values()[idx], *scale)?;
                 Ok(())
             }
         })+
@@ -1432,7 +1433,6 @@ pub fn lexical_to_string<N: lexical_core::ToLexical>(n: 
N) -> String {
 mod tests {
     use super::*;
     use arrow_array::builder::StringRunBuilder;
-
     /// Test to verify options can be constant. See #4580
     const TEST_CONST_OPTIONS: FormatOptions<'static> = FormatOptions::new()
         .with_date_format(Some("foo"))
diff --git a/arrow-data/src/decimal.rs b/arrow-data/src/decimal.rs
index c03dfe2f07..8686dfab84 100644
--- a/arrow-data/src/decimal.rs
+++ b/arrow-data/src/decimal.rs
@@ -26,6 +26,7 @@
 //! [`Decimal256`]: arrow_schema::DataType::Decimal256
 use arrow_buffer::i256;
 use arrow_schema::ArrowError;
+use std::fmt::{Display, Write};
 
 pub use arrow_schema::{
     DECIMAL_DEFAULT_SCALE, DECIMAL32_DEFAULT_SCALE, DECIMAL32_MAX_PRECISION, 
DECIMAL32_MAX_SCALE,
@@ -932,22 +933,16 @@ pub fn validate_decimal32_precision(
         )));
     }
     if value > MAX_DECIMAL32_FOR_EACH_PRECISION[precision as usize] {
-        let unscaled_value = format_decimal_str_internal(&value.to_string(), 
scale);
-        let unscale_max_value = format_decimal_str(
-            &MAX_DECIMAL32_FOR_EACH_PRECISION[precision as usize].to_string(),
-            precision.into(),
-            scale,
-        );
+        let unscaled_value = format_decimal(value, scale);
+        let unscale_max_value =
+            format_decimal(MAX_DECIMAL32_FOR_EACH_PRECISION[precision as 
usize], scale);
         Err(ArrowError::InvalidArgumentError(format!(
             "{unscaled_value} is too large to store in a Decimal32 of 
precision {precision}. Max is {unscale_max_value}"
         )))
     } else if value < MIN_DECIMAL32_FOR_EACH_PRECISION[precision as usize] {
-        let unscaled_value = format_decimal_str_internal(&value.to_string(), 
scale);
-        let unscale_min_value = format_decimal_str(
-            &MIN_DECIMAL32_FOR_EACH_PRECISION[precision as usize].to_string(),
-            precision.into(),
-            scale,
-        );
+        let unscaled_value = format_decimal(value, scale);
+        let unscale_min_value =
+            format_decimal(MIN_DECIMAL32_FOR_EACH_PRECISION[precision as 
usize], scale);
         Err(ArrowError::InvalidArgumentError(format!(
             "{unscaled_value} is too small to store in a Decimal32 of 
precision {precision}. Min is {unscale_min_value}"
         )))
@@ -983,22 +978,16 @@ pub fn validate_decimal64_precision(
         )));
     }
     if value > MAX_DECIMAL64_FOR_EACH_PRECISION[precision as usize] {
-        let unscaled_value = format_decimal_str_internal(&value.to_string(), 
scale);
-        let unscaled_max_value = format_decimal_str(
-            &MAX_DECIMAL64_FOR_EACH_PRECISION[precision as usize].to_string(),
-            precision.into(),
-            scale,
-        );
+        let unscaled_value = format_decimal(value, scale);
+        let unscaled_max_value =
+            format_decimal(MAX_DECIMAL64_FOR_EACH_PRECISION[precision as 
usize], scale);
         Err(ArrowError::InvalidArgumentError(format!(
             "{unscaled_value} is too large to store in a Decimal64 of 
precision {precision}. Max is {unscaled_max_value}"
         )))
     } else if value < MIN_DECIMAL64_FOR_EACH_PRECISION[precision as usize] {
-        let unscaled_value = format_decimal_str_internal(&value.to_string(), 
scale);
-        let unscaled_min_value = format_decimal_str(
-            &MIN_DECIMAL64_FOR_EACH_PRECISION[precision as usize].to_string(),
-            precision.into(),
-            scale,
-        );
+        let unscaled_value = format_decimal(value, scale);
+        let unscaled_min_value =
+            format_decimal(MIN_DECIMAL64_FOR_EACH_PRECISION[precision as 
usize], scale);
         Err(ArrowError::InvalidArgumentError(format!(
             "{unscaled_value} is too small to store in a Decimal64 of 
precision {precision}. Min is {unscaled_min_value}"
         )))
@@ -1030,22 +1019,16 @@ pub fn validate_decimal_precision(value: i128, 
precision: u8, scale: i8) -> Resu
         )));
     }
     if value > MAX_DECIMAL128_FOR_EACH_PRECISION[precision as usize] {
-        let unscaled_value = format_decimal_str_internal(&value.to_string(), 
scale);
-        let unscaled_max_value = format_decimal_str(
-            &MAX_DECIMAL128_FOR_EACH_PRECISION[precision as usize].to_string(),
-            precision.into(),
-            scale,
-        );
+        let unscaled_value = format_decimal(value, scale);
+        let unscaled_max_value =
+            format_decimal(MAX_DECIMAL128_FOR_EACH_PRECISION[precision as 
usize], scale);
         Err(ArrowError::InvalidArgumentError(format!(
             "{unscaled_value} is too large to store in a Decimal128 of 
precision {precision}. Max is {unscaled_max_value}"
         )))
     } else if value < MIN_DECIMAL128_FOR_EACH_PRECISION[precision as usize] {
-        let unscaled_value = format_decimal_str_internal(&value.to_string(), 
scale);
-        let unscaled_min_value = format_decimal_str(
-            &MIN_DECIMAL128_FOR_EACH_PRECISION[precision as usize].to_string(),
-            precision.into(),
-            scale,
-        );
+        let unscaled_value = format_decimal(value, scale);
+        let unscaled_min_value =
+            format_decimal(MIN_DECIMAL128_FOR_EACH_PRECISION[precision as 
usize], scale);
         Err(ArrowError::InvalidArgumentError(format!(
             "{unscaled_value} is too small to store in a Decimal128 of 
precision {precision}. Min is {unscaled_min_value}"
         )))
@@ -1082,22 +1065,16 @@ pub fn validate_decimal256_precision(
     }
 
     if value > MAX_DECIMAL256_FOR_EACH_PRECISION[precision as usize] {
-        let unscaled_value = format_decimal_str_internal(&value.to_string(), 
scale);
-        let unscaled_max_value = format_decimal_str(
-            &MAX_DECIMAL256_FOR_EACH_PRECISION[precision as usize].to_string(),
-            precision.into(),
-            scale,
-        );
+        let unscaled_value = format_decimal(value, scale);
+        let unscaled_max_value =
+            format_decimal(MAX_DECIMAL256_FOR_EACH_PRECISION[precision as 
usize], scale);
         Err(ArrowError::InvalidArgumentError(format!(
             "{unscaled_value} is too large to store in a Decimal256 of 
precision {precision}. Max is {unscaled_max_value}"
         )))
     } else if value < MIN_DECIMAL256_FOR_EACH_PRECISION[precision as usize] {
-        let unscaled_value = format_decimal_str_internal(&value.to_string(), 
scale);
-        let unscaled_min_value = format_decimal_str(
-            &MIN_DECIMAL256_FOR_EACH_PRECISION[precision as usize].to_string(),
-            precision.into(),
-            scale,
-        );
+        let unscaled_value = format_decimal(value, scale);
+        let unscaled_min_value =
+            format_decimal(MIN_DECIMAL256_FOR_EACH_PRECISION[precision as 
usize], scale);
         Err(ArrowError::InvalidArgumentError(format!(
             "{unscaled_value} is too small to store in a Decimal256 of 
precision {precision}. Min is {unscaled_min_value}"
         )))
@@ -1126,30 +1103,123 @@ pub fn format_decimal_str(value_str: &str, _precision: 
usize, scale: i8) -> Stri
     format_decimal_str_internal(value_str, scale)
 }
 
-// Format a decimal string given the scale.
+/// The native value of a decimal type: `i32`, `i64`, `i128` or `i256`
+pub trait DecimalNativeType: Display + sealed::DecimalNativeTypeSealed {}
+
+mod sealed {
+    pub trait DecimalNativeTypeSealed {}
+}
+
+macro_rules! decimal_native {
+    ($($t:ty),+) => {
+        $(
+            impl sealed::DecimalNativeTypeSealed for $t {}
+            impl DecimalNativeType for $t {}
+        )+
+    };
+}
+
+decimal_native!(i32, i64, i128, i256);
+
+/// Formats the unscaled decimal `value` with `scale` fractional digits: the
+/// decimal point is inserted `scale` digits from the right, with leading
+/// zeros as needed, and a negative scale appends zeros instead. The value is
+/// always formatted in full, whatever its precision.
+pub fn format_decimal<V: DecimalNativeType>(value: V, scale: i8) -> String {
+    format_decimal_str_internal(DigitBuffer::from_value(&value).as_str(), 
scale)
+}
+
+/// Like [`format_decimal`], but writes the result to `f` instead of returning
+/// a new `String`.
+pub fn write_decimal<V: DecimalNativeType>(
+    f: &mut dyn Write,
+    value: V,
+    scale: i8,
+) -> std::fmt::Result {
+    write_decimal_str(f, DigitBuffer::from_value(&value).as_str(), scale)
+}
+
+/// Formats `value_str` as [`write_decimal_str`] does, into a `String` with
+/// enough capacity to avoid reallocation
 fn format_decimal_str_internal(value_str: &str, scale: i8) -> String {
-    let (sign, rest) = match value_str.strip_prefix('-') {
-        Some(stripped) => ("-", stripped),
+    let mut out = String::with_capacity(value_str.len() + scale.unsigned_abs() 
as usize + 2);
+    write_decimal_str(&mut out, value_str, scale).expect("writing to a String 
cannot fail");
+    out
+}
+
+/// The length of the longest decimal native value when formatted: `i256::MIN`
+/// has 77 digits and a sign
+const MAX_DECIMAL_VALUE_LEN: usize = 78;
+
+/// The formatted digits of a decimal native value, with its sign
+struct DigitBuffer {
+    bytes: [u8; MAX_DECIMAL_VALUE_LEN],
+    len: usize,
+}
+
+impl DigitBuffer {
+    /// Formats `value`
+    fn from_value(value: &dyn DecimalNativeType) -> Self {
+        let mut buf = Self {
+            bytes: [0; MAX_DECIMAL_VALUE_LEN],
+            len: 0,
+        };
+        write!(buf, "{value}").expect("a decimal native value fits the digit 
buffer");
+        buf
+    }
+
+    fn as_str(&self) -> &str {
+        // `write_str` copies complete strings, so the bytes are valid UTF-8
+        std::str::from_utf8(&self.bytes[..self.len]).expect("DigitBuffer 
contains valid UTF-8")
+    }
+}
+
+impl Write for DigitBuffer {
+    fn write_str(&mut self, s: &str) -> std::fmt::Result {
+        let end = self.len + s.len();
+        let target = self.bytes.get_mut(self.len..end).ok_or(std::fmt::Error)?;
+        target.copy_from_slice(s.as_bytes());
+        self.len = end;
+        Ok(())
+    }
+}
+
+/// Writes `value_str`, the digits of an unscaled decimal value with an 
optional
+/// leading `-`, to `f` as a decimal with `scale` fractional digits: the 
decimal
+/// point is inserted `scale` digits from the right, with leading zeros as
+/// needed, and a negative scale appends zeros instead. The value is always
+/// written in full, whatever its precision.
+fn write_decimal_str(f: &mut dyn Write, value_str: &str, scale: i8) -> 
std::fmt::Result {
+    let (sign, digits) = match value_str.strip_prefix('-') {
+        Some(digits) => ("-", digits),
         None => ("", value_str),
     };
 
     if scale == 0 {
-        value_str.to_string()
+        f.write_str(value_str)
     } else if scale < 0 {
-        if rest == "0" {
-            // Zero must not be zero-padded ("000" is not a valid number)
-            value_str.to_string()
-        } else {
-            let padding = value_str.len() + scale.unsigned_abs() as usize;
-            format!("{value_str:0<padding$}")
+        f.write_str(value_str)?;
+        // Zero must not be zero-padded ("000" is not a valid number)
+        if digits != "0" {
+            for _ in 0..scale.unsigned_abs() {
+                f.write_char('0')?;
+            }
         }
-    } else if rest.len() > scale as usize {
-        // Decimal separator is in the middle of the string
-        let (whole, decimal) = value_str.split_at(value_str.len() - scale as 
usize);
-        format!("{whole}.{decimal}")
+        Ok(())
+    } else if digits.len() > scale as usize {
+        // The decimal point is in the middle of the digits
+        let (whole, fraction) = value_str.split_at(value_str.len() - scale as 
usize);
+        f.write_str(whole)?;
+        f.write_char('.')?;
+        f.write_str(fraction)
     } else {
-        // String has to be padded
-        format!("{}0.{:0>width$}", sign, rest, width = scale as usize)
+        // The digits are all fractional and may need leading zeros
+        f.write_str(sign)?;
+        f.write_str("0.")?;
+        for _ in digits.len()..scale as usize {
+            f.write_char('0')?;
+        }
+        f.write_str(digits)
     }
 }
 
@@ -1157,6 +1227,27 @@ fn format_decimal_str_internal(value_str: &str, scale: 
i8) -> String {
 mod tests {
     use super::*;
 
+    #[test]
+    fn test_format_decimal() {
+        assert_eq!(format_decimal(12345_i32, 2), "123.45");
+        assert_eq!(format_decimal(-5_i64, 3), "-0.005");
+        assert_eq!(format_decimal(0_i128, -2), "0");
+        assert_eq!(format_decimal(i32::MIN, 9), "-2.147483648");
+        assert_eq!(format_decimal(i64::MIN, 18), "-9.223372036854775808");
+        assert_eq!(
+            format_decimal(i128::MIN, 38),
+            "-1.70141183460469231731687303715884105728"
+        );
+        assert_eq!(
+            format_decimal(i256::MIN, 0),
+            
"-57896044618658097711785492504343953926634992332820282019728792003956564819968"
+        );
+        assert_eq!(
+            format_decimal(i256::MAX, 76),
+            
"5.7896044618658097711785492504343953926634992332820282019728792003956564819967"
+        );
+    }
+
     #[test]
     fn test_format_decimal_str() {
         assert_eq!(format_decimal_str("12345", 7, 0), "12345");

Reply via email to