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");