This is an automated email from the ASF dual-hosted git repository.
Jefffrey 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 324e51193d perf: Count digits to skip precision check when parsing
decimals (#10998)
324e51193d is described below
commit 324e51193d610cc851a96e55a913a80a808d4828
Author: Neil Conway <[email protected]>
AuthorDate: Sat Sep 5 22:26:14 2026 -0400
perf: Count digits to skip precision check when parsing decimals (#10998)
# Which issue does this PR close?
- N/A
# Rationale for this change
`parse_decimal` validated the parsed value against the precision with
`is_valid_decimal_precision`, an out-of-line call that loads the bounds
for the precision and compares the value against both.
The parser already knows how many digits it kept. We can modify the
mantissa scan to return the digit count and then use this count to skip
the precision check for the common case that the number of parsed digits
implies the parsed value is well within the allowed precision.
`parse_decimal` microbenchmark, Apple M4 Max, µs per 1024 inputs:
```
case before after change
123.123 7.93 7.85 -1.0%
123.1234 11.39 10.96 -3.7%
123.1 11.22 10.27 -8.5%
123 10.49 9.47 -9.7%
-123.123 8.16 8.07 -1.0%
-123.1234 11.47 11.09 -3.3%
-123.1 11.45 10.53 -8.0%
-123 10.71 9.84 -8.1%
0.0000123 8.37 7.89 -5.7%
12. 10.21 9.54 -6.5%
-12. 10.44 9.76 -6.5%
00.1 10.69 9.90 -7.4%
-00.1 10.93 10.00 -8.5%
12345678912345678.1234 24.49 24.53 +0.1%
-12345678912345678.1234 24.67 24.77 +0.4%
99999999999999999.999 20.31 19.90 -2.0%
-99999999999999999.999 20.25 20.40 +0.7%
.123 6.34 6.18 -2.6%
-.123 6.59 6.41 -2.7%
123. 10.67 10.12 -5.1%
-123. 10.94 10.30 -5.8%
string decimal128 short 8.20 7.85 -4.2%
string decimal128 integer 11.19 10.89 -2.7%
string decimal128 exact scale 11.43 11.05 -3.3%
string decimal128 padded scale 10.50 10.42 -0.8%
string decimal128 rounded scale 15.23 14.63 -3.9%
string decimal128 signed 15.28 14.78 -3.3%
string decimal128 38 digits 24.19 23.65 -2.2%
string decimal128 exponent 20.28 20.18 -0.5%
string decimal128 negative exponent 19.52 18.79 -3.7%
string decimal128 negative scale 18.32 17.97 -1.9%
string decimal128 long fraction 22.39 22.14 -1.1%
string decimal256 76 digits 59.03 57.76 -2.1%
string decimal256 rounded scale 55.72 54.71 -1.8%
string decimal32 short 8.51 7.74 -9.1%
string decimal32 9 digits 10.45 9.76 -6.6%
string decimal64 short 7.29 6.84 -6.2%
string decimal64 18 digits 12.04 11.66 -3.2%
```
# What changes are included in this PR?
See above.
# Are these changes tested?
Yes; new test added, existing tests pass.
# Are there any user-facing changes?
No.
# AI usage
Developed with Claude Code Fable 5.1; reviewed with Codex Astra 6. I
reviewed, revised, and understand the resulting code.
---
arrow-cast/src/parse.rs | 104 +++++++++++++++++++++++++++++++++++++-----------
1 file changed, 81 insertions(+), 23 deletions(-)
diff --git a/arrow-cast/src/parse.rs b/arrow-cast/src/parse.rs
index d077a9c5a4..ffc6738aa9 100644
--- a/arrow-cast/src/parse.rs
+++ b/arrow-cast/src/parse.rs
@@ -861,8 +861,12 @@ pub(crate) fn parse_decimal_checked<T: DecimalType>(
precision: u8,
scale: i8,
) -> Result<T::Native, DecimalParseError> {
- let value = parse_decimal_native::<T>(s, scale)?;
- if T::is_valid_decimal_precision(value, precision) {
+ let (value, digits) = parse_decimal_native::<T>(s, scale)?;
+ // A value of at most `precision` digits is within the precision without
+ // inspecting it. A precision beyond the type's maximum is invalid.
+ let fits = precision <= T::MAX_PRECISION
+ && (digits <= precision as usize ||
T::is_valid_decimal_precision(value, precision));
+ if fits {
Ok(value)
} else {
Err(DecimalParseError::Overflow)
@@ -870,20 +874,22 @@ pub(crate) fn parse_decimal_checked<T: DecimalType>(
}
/// Parses `s` as a decimal with the given `scale` into the native type of `T`,
-/// checking only that the result fits the native type (not the precision).
+/// checking only that the result fits the native type (not the precision),
+/// and returns it with an upper bound on its number of decimal digits.
///
/// See [`parse_decimal`] for the accepted syntax and rounding behaviour.
+#[inline]
fn parse_decimal_native<T: DecimalType>(
s: &str,
scale: i8,
-) -> Result<T::Native, DecimalParseError> {
+) -> Result<(T::Native, usize), DecimalParseError> {
let bytes = s.as_bytes().trim_ascii();
let (negative, mut mantissa) = split_sign(bytes);
let mut scale = scale as i64;
loop {
let exponent_at = match parse_decimal_mantissa::<T>(mantissa,
negative, scale) {
- Ok(value) => return Ok(value),
+ Ok(result) => return Ok(result),
Err(MantissaError::InvalidFormat) => return
Err(DecimalParseError::InvalidFormat),
Err(MantissaError::Exponent(index)) => index,
// The digits before an exponent marker need not fit on their own
@@ -934,13 +940,15 @@ const MAX_CHUNK_DIGITS: usize = 18;
/// Scans `mantissa` (digits with at most one decimal point; the sign has
/// already been removed) and folds the digits that are significant at
/// `scale` into a native value, rounding half away from zero on the first
-/// digit that is not.
+/// digit that is not. Also returns an upper bound on the number of decimal
+/// digits of the value: the digits kept, the zeros appended to reach the
+/// scale, and the digit that rounding up can add.
#[inline]
fn parse_decimal_mantissa<T: DecimalType>(
mantissa: &[u8],
negative: bool,
scale: i64,
-) -> Result<T::Native, MantissaError> {
+) -> Result<(T::Native, usize), MantissaError> {
// The number of integer and fractional digits that contribute to the
// result. For a non-negative scale that is every integer digit and the
// first `scale` fractional digits. For a negative scale the last `-scale`
@@ -1036,7 +1044,10 @@ fn parse_decimal_mantissa<T: DecimalType>(
.map_err(|_| MantissaError::Overflow)?;
}
- Ok(value)
+ let digits = usize::try_from(missing.max(0))
+ .unwrap_or(usize::MAX)
+ .saturating_add(int_kept + frac_kept + round as usize);
+ Ok((value, digits))
}
/// Parses the digits of an exponent (`[+|-] digits`), saturating at the bounds
@@ -1705,6 +1716,11 @@ mod tests {
use arrow_array::temporal_conversions::date32_to_datetime;
use arrow_buffer::i256;
+ /// Parses `s` without a precision check, for probing the native range
+ fn parse_native<T: DecimalType>(s: &str, scale: i8) -> Result<T::Native,
DecimalParseError> {
+ parse_decimal_native::<T>(s, scale).map(|(value, _)| value)
+ }
+
#[test]
fn test_parse_nanos() {
assert_eq!(parse_nanos::<3, 0>(&[1, 2, 3]), 123_000_000);
@@ -3106,65 +3122,107 @@ mod tests {
// ... or past the native type itself
assert_eq!(
- parse_decimal_native::<Decimal32Type>("2147483647.5", 0),
+ parse_native::<Decimal32Type>("2147483647.5", 0),
Err(DecimalParseError::Overflow)
);
assert_eq!(
- parse_decimal_native::<Decimal32Type>("-2147483648.5", 0),
+ parse_native::<Decimal32Type>("-2147483648.5", 0),
Err(DecimalParseError::Overflow)
);
assert_eq!(
- parse_decimal_native::<Decimal128Type>(&format!("{}.5",
i128::MAX), 0),
+ parse_native::<Decimal128Type>(&format!("{}.5", i128::MAX), 0),
Err(DecimalParseError::Overflow)
);
assert_eq!(
- parse_decimal_native::<Decimal128Type>(&format!("{}.5",
i128::MIN), 0),
+ parse_native::<Decimal128Type>(&format!("{}.5", i128::MIN), 0),
Err(DecimalParseError::Overflow)
);
assert_eq!(
- parse_decimal_native::<Decimal256Type>(&format!("{}.5",
i256::MAX), 0),
+ parse_native::<Decimal256Type>(&format!("{}.5", i256::MAX), 0),
Err(DecimalParseError::Overflow)
);
assert_eq!(
- parse_decimal_native::<Decimal256Type>(&format!("{}.5",
i256::MIN), 0),
+ parse_native::<Decimal256Type>(&format!("{}.5", i256::MIN), 0),
Err(DecimalParseError::Overflow)
);
}
+ #[test]
+ fn test_parse_decimal_precision_by_digit_count() {
+ // Rounding up can add a digit
+ assert_eq!(
+ parse_decimal::<Decimal128Type>("99999.4", 5, 0).unwrap(),
+ 99999
+ );
+ assert!(parse_decimal::<Decimal128Type>("99999.5", 5, 0).is_err());
+ assert!(parse_decimal::<Decimal128Type>("-99999.5", 5, 0).is_err());
+ assert_eq!(
+ parse_decimal::<Decimal128Type>("99999.5", 6, 0).unwrap(),
+ 100000
+ );
+ // Leading zeros count as digits only for the shortcut; the value is
+ // then checked by its range
+ assert_eq!(
+ parse_decimal::<Decimal128Type>("000000000000000000000001", 1,
0).unwrap(),
+ 1
+ );
+ assert_eq!(
+ parse_decimal::<Decimal128Type>("0.000000000000000000001", 1,
21).unwrap(),
+ 1
+ );
+ assert!(parse_decimal::<Decimal128Type>("0.0000000000000000000012", 1,
22).is_err());
+ // The zeros appended to reach the scale count as digits
+ assert_eq!(parse_decimal::<Decimal128Type>("1", 3, 2).unwrap(), 100);
+ assert!(parse_decimal::<Decimal128Type>("1", 2, 2).is_err());
+ assert!(parse_decimal::<Decimal128Type>("1e2", 2, 0).is_err());
+ assert_eq!(parse_decimal::<Decimal32Type>("1e2", 3, 0).unwrap(), 100);
+ // Scaling down leaves fewer digits
+ assert_eq!(
+ parse_decimal::<Decimal128Type>("123456", 2, -4).unwrap(),
+ 12
+ );
+ assert!(parse_decimal::<Decimal128Type>("123456", 1, -4).is_err());
+ // A precision beyond the type's maximum is invalid
+ assert!(parse_decimal::<Decimal32Type>("1", 10, 0).is_err());
+ assert!(parse_decimal::<Decimal32Type>("00000000001", 10, 0).is_err());
+ assert!(parse_decimal::<Decimal128Type>("1", 39, 0).is_err());
+ assert!(parse_decimal::<Decimal256Type>("1", 77, 0).is_err());
+ }
+
#[test]
fn test_parse_decimal_native_full_range() {
// The native range exceeds the largest precision; the precision check
// is the caller's responsibility
assert_eq!(
- parse_decimal_native::<Decimal32Type>("-2147483648", 0),
+ parse_native::<Decimal32Type>("-2147483648", 0),
Ok(i32::MIN)
);
assert_eq!(
- parse_decimal_native::<Decimal32Type>("2147483648", 0),
+ parse_native::<Decimal32Type>("2147483648", 0),
Err(DecimalParseError::Overflow)
);
assert_eq!(
- parse_decimal_native::<Decimal64Type>("-9223372036854775808", 0),
+ parse_native::<Decimal64Type>("-9223372036854775808", 0),
Ok(i64::MIN)
);
assert_eq!(
- parse_decimal_native::<Decimal64Type>("9223372036854775808", 0),
+ parse_native::<Decimal64Type>("9223372036854775808", 0),
Err(DecimalParseError::Overflow)
);
assert_eq!(
- parse_decimal_native::<Decimal128Type>(&i128::MAX.to_string(), 0),
+ parse_native::<Decimal128Type>(&i128::MAX.to_string(), 0),
Ok(i128::MAX)
);
assert_eq!(
- parse_decimal_native::<Decimal128Type>(&i128::MIN.to_string(), 0),
+ parse_native::<Decimal128Type>(&i128::MIN.to_string(), 0),
Ok(i128::MIN)
);
assert_eq!(
- parse_decimal_native::<Decimal256Type>(&i256::MAX.to_string(), 0),
+ parse_native::<Decimal256Type>(&i256::MAX.to_string(), 0),
Ok(i256::MAX)
);
assert_eq!(
- parse_decimal_native::<Decimal256Type>(&i256::MIN.to_string(), 0),
+ parse_native::<Decimal256Type>(&i256::MIN.to_string(), 0),
Ok(i256::MIN)
);
// The unscaled value (integer digits scaled by 10^21) far exceeds the
@@ -3172,7 +3230,7 @@ mod tests {
// arbitrary (possibly in-range) value
let input = format!("{}.12345678901234567890123", "7".repeat(71));
assert_eq!(
- parse_decimal_native::<Decimal256Type>(&input, 21),
+ parse_native::<Decimal256Type>(&input, 21),
Err(DecimalParseError::Overflow)
);