Skip to content

perf(arrow-cast): optimize parsing of decimals from strings - #10668

Open
neilconway wants to merge 2 commits into
apache:mainfrom
neilconway:neilc/perf-decimal-parser
Open

perf(arrow-cast): optimize parsing of decimals from strings#10668
neilconway wants to merge 2 commits into
apache:mainfrom
neilconway:neilc/perf-decimal-parser

Conversation

@neilconway

@neilconway neilconway commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

The previous implementation of parse_string_to_decimal_native used a string-manipulation approach: it trimmed the input string, split it based on ".", parsed both halves with i256::from_string, converted the result back to a string with format!, and parsed that again with i256::from_string.

Instead, we use a single pass over the input bytes. A simple state machine walks over the input digits, accumulating a running sum. This avoids all of the string manipulation and heap allocation of the previous approach.

We further optimize this by accumulating the running sum in a u64, and then periodically folding that partial value into the running decimal value (we do this often enough that there is no risk of overflowing the u64). That trades a bit of redundant computation for doing more work in u64 and less work in decimal; based on benchmarking, this is a clear win.

Finally, we don't need to accumulate digits from the suffix of the string. Values beyond the target type's scale don't contribute to the result value; only the first such digit influences rounding behavior.

This new approach also fixes two correctness bugs (#10664 and #10665) in the previous implementation.

Benchmarks (M4 Max)

Parser microbenchmarks (arrow-cast/benches/parse_decimal.rs):

  • string decimal128 integer: ~111 ns → 15.8 ns, −85.8%
  • string decimal128 exact scale: ~109 ns → 15.2 ns, −86.0%
  • string decimal128 padded scale: ~116 ns → 15.3 ns, −86.8%
  • string decimal128 rounded scale: ~175 ns → 15.7 ns, −91.0%
  • string decimal128 signed: 201.8 ns → 15.7 ns, −92.2%
  • string decimal128 38 digits: 116.8 ns → 27.2 ns, −76.8%
  • string decimal256 76 digits: 174.4 ns → 61.9 ns, −64.5%
  • string decimal256 rounded scale: 440.0 ns → 62.3 ns, −85.8%

End-to-end cast kernel (arrow/benches/cast_kernels.rs, 512-row string array, safe mode):

  • cast string to decimal128(38, 3): 558 µs → 68.4 µs, −87.0%
  • cast string to decimal256(76, 3): 519 µs → 80.1 µs, −84.4%

What changes are included in this PR?

  • Rewrite parse_string_to_decimal_native as described above
  • Add benchmark coverage
  • Add unit tests
  • Clarify / extend comments on accepted syntax and rounding behavior
  • Replace a Vec + unsafe with PrimitiveBuilder, which saves an allocation
  • Improve error message reporting

Are these changes tested?

Yes; existing tests pass, and new tests have been added. I also checked the new implementation against a naive oracle built using the num-bigint crate; the new implementation was consistent with num-bigint for 120M randomly generated inputs.

Are there any user-facing changes?

No, aside from fixed bugs.

AI usage

Iterated with the help of Claude Fable; I reviewed and understand the resulting code.

@github-actions github-actions Bot added arrow Changes to the arrow crate arrow-cast labels Aug 12, 2026
Rewrite parse_string_to_decimal_native to accumulate digits in u64
chunks that are folded into the target native type with checked
arithmetic (one wide multiply per 19 digits), instead of splitting the
string and round-tripping through i256 and intermediate allocations.
String-to-decimal128 casts are ~8x faster and decimal256 casts ~6.5x
faster; the parser microbenchmarks improve 64-92% across all cases.

The checked arithmetic also fixes two bugs in the previous
implementation: Decimal256 values whose unscaled magnitude exceeds the
i256 range could silently wrap to an arbitrary in-range value instead
of reporting overflow, and inputs with more than 76 fractional digits
were rejected even when the scaled value fits the target type.

Also use PrimitiveBuilder instead of an intermediate Vec and unsafe
from_trusted_len_iter in the strict (safe=false) cast path. The safe
path keeps the unsafe trusted-len construction: it measures 15-19%
faster than a PrimitiveBuilder equivalent, and its justification
comment now records that. Also document the accepted syntax and
rounding behavior, and add benchmark coverage for string-to-decimal
parsing.
@neilconway neilconway closed this Aug 12, 2026
@neilconway neilconway reopened this Aug 12, 2026
@Jefffrey

Jefffrey commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

i wonder if this will fix/affect some of the other issues we have open for decimal parsing/casting, see:

edit: seems like none, those other issues are for a different decimal parsing function

@neilconway

neilconway commented Aug 13, 2026

Copy link
Copy Markdown
Contributor Author

i wonder if this will fix/affect some of the other issues we have open for decimal parsing/casting, see:

edit: seems like none, those other issues are for a different decimal parsing function

Yeah, I believe those issues are distinct. Although I think it might make sense to unify some of the code here, both to simplify the implementation and to address some inconsistencies (e.g., rounding vs. truncating when given more decimal digits than required for the target type's scale). I can take a look at some of that once this PR lands.

@neilconway
neilconway force-pushed the neilc/perf-decimal-parser branch from 8012d45 to 7f9a781 Compare August 13, 2026 14:41
@alamb

alamb commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

run benchmark parse_decimal

@adriangbot

This comment was marked as duplicate.

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

Comparing neilc/perf-decimal-parser (7f9a781) to 4f0c3c8 (merge-base) diff

Run configuration
run benchmark parse_decimal
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

group                              main                                   neilc_perf-decimal-parser
-----                              ----                                   -------------------------
-.123                              1.00      9.5±0.01ns        ? ?/sec    1.04      9.8±0.01ns        ? ?/sec
-00.1                              1.02     17.7±0.02ns        ? ?/sec    1.00     17.3±0.01ns        ? ?/sec
-12.                               1.00     19.0±0.02ns        ? ?/sec    1.12     21.2±0.03ns        ? ?/sec
-123                               1.00     21.5±0.02ns        ? ?/sec    1.06     22.7±0.02ns        ? ?/sec
-123.                              1.00     21.6±0.02ns        ? ?/sec    1.08     23.3±0.01ns        ? ?/sec
-123.1                             1.00     22.6±0.07ns        ? ?/sec    1.03     23.3±0.04ns        ? ?/sec
-123.123                           1.00     16.0±0.03ns        ? ?/sec    1.03     16.5±0.02ns        ? ?/sec
-123.1234                          1.00     16.2±0.03ns        ? ?/sec    1.04     16.9±0.02ns        ? ?/sec
-12345678912345678.1234            1.07     55.7±0.13ns        ? ?/sec    1.00     52.2±0.05ns        ? ?/sec
-99999999999999999.999             1.07     55.5±0.08ns        ? ?/sec    1.00     51.8±0.03ns        ? ?/sec
.123                               1.00      8.3±0.02ns        ? ?/sec    1.09      9.1±0.01ns        ? ?/sec
0.0000123                          1.00     10.5±0.02ns        ? ?/sec    1.08     11.3±0.03ns        ? ?/sec
00.1                               1.00     15.0±0.06ns        ? ?/sec    1.02     15.3±0.01ns        ? ?/sec
12.                                1.00     18.0±0.03ns        ? ?/sec    1.10     19.8±0.04ns        ? ?/sec
123                                1.00     20.1±0.01ns        ? ?/sec    1.05     21.2±0.02ns        ? ?/sec
123.                               1.00     20.0±0.02ns        ? ?/sec    1.08     21.6±0.02ns        ? ?/sec
123.1                              1.00     20.7±0.05ns        ? ?/sec    1.05     21.7±0.02ns        ? ?/sec
123.123                            1.00     15.2±0.04ns        ? ?/sec    1.01     15.4±0.01ns        ? ?/sec
123.1234                           1.00     15.4±0.03ns        ? ?/sec    1.03     15.8±0.02ns        ? ?/sec
12345678912345678.1234             1.06     54.5±0.15ns        ? ?/sec    1.00     51.4±0.02ns        ? ?/sec
99999999999999999.999              1.07     54.8±0.13ns        ? ?/sec    1.00     51.0±0.02ns        ? ?/sec
string decimal128 38 digits                                               1.00     44.5±5.96ns        ? ?/sec
string decimal128 exact scale                                             1.00     21.5±0.11ns        ? ?/sec
string decimal128 integer                                                 1.00     21.4±0.01ns        ? ?/sec
string decimal128 padded scale                                            1.00     20.7±0.12ns        ? ?/sec
string decimal128 rounded scale                                           1.00     23.9±2.27ns        ? ?/sec
string decimal128 signed                                                  1.00     22.4±0.12ns        ? ?/sec
string decimal256 76 digits                                               1.00    102.9±0.13ns        ? ?/sec
string decimal256 rounded scale                                           1.00    107.7±7.57ns        ? ?/sec

Resource Usage

base (merge-base)

Metric Value
Wall time 210.0s
Peak memory 5.8 MiB
Avg memory 4.4 MiB
CPU user 206.0s
CPU sys 0.0s
Peak spill 0 B

branch

Metric Value
Wall time 280.1s
Peak memory 9.5 MiB
Avg memory 5.4 MiB
CPU user 275.3s
CPU sys 0.1s
Peak spill 0 B

File an issue against this benchmark runner

@Jefffrey

Copy link
Copy Markdown
Contributor

run benchmark cast_kernels
env:
BENCH_FILTER: string to decimal

@adriangbot

This comment was marked as duplicate.

@adriangbot

Copy link
Copy Markdown

🤖 Arrow criterion benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

Comparing neilc/perf-decimal-parser (7f9a781) to 4f0c3c8 (merge-base) diff

Run configuration
run benchmark cast_kernels
env:
  BENCH_FILTER: "string to decimal"
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

group                                 main                                   neilc_perf-decimal-parser
-----                                 ----                                   -------------------------
"cast string to decimal128(38, 3)"    5.84    646.2±1.99µs        ? ?/sec    1.00    110.7±0.53µs        ? ?/sec
"cast string to decimal256(76, 3)"                                           1.00    149.8±0.45µs        ? ?/sec

Resource Usage

base (merge-base)

Metric Value
Wall time 15.0s
Peak memory 9.4 MiB
Avg memory 7.3 MiB
CPU user 11.8s
CPU sys 0.0s
Peak spill 0 B

branch

Metric Value
Wall time 20.0s
Peak memory 14.3 MiB
Avg memory 10.8 MiB
CPU user 18.0s
CPU sys 0.0s
Peak spill 0 B

File an issue against this benchmark runner

@Jefffrey Jefffrey left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my local run of the parse_decimal benchmark, with the new benches:

string decimal128 integer
                        time:   [15.733 ns 15.758 ns 15.782 ns]
+                       change: [−86.119% −86.045% −85.974%] (p = 0.00 < 0.05)
+                       Performance has improved.
Found 9 outliers among 100 measurements (9.00%)
  2 (2.00%) low severe
  4 (4.00%) low mild
  3 (3.00%) high mild

string decimal128 exact scale
                        time:   [15.383 ns 15.443 ns 15.508 ns]
+                       change: [−86.121% −86.036% −85.955%] (p = 0.00 < 0.05)
+                       Performance has improved.
Found 12 outliers among 100 measurements (12.00%)
  1 (1.00%) low mild
  8 (8.00%) high mild
  3 (3.00%) high severe

string decimal128 padded scale
                        time:   [15.591 ns 15.615 ns 15.638 ns]
+                       change: [−87.092% −87.039% −86.988%] (p = 0.00 < 0.05)
+                       Performance has improved.
Found 3 outliers among 100 measurements (3.00%)
  1 (1.00%) low severe
  2 (2.00%) low mild

string decimal128 rounded scale
                        time:   [16.002 ns 16.066 ns 16.134 ns]
+                       change: [−90.851% −90.820% −90.786%] (p = 0.00 < 0.05)
+                       Performance has improved.

string decimal128 signed
                        time:   [15.932 ns 15.987 ns 16.045 ns]
+                       change: [−92.027% −91.995% −91.961%] (p = 0.00 < 0.05)
+                       Performance has improved.

string decimal128 38 digits
                        time:   [27.153 ns 27.196 ns 27.239 ns]
+                       change: [−77.117% −77.040% −76.963%] (p = 0.00 < 0.05)
+                       Performance has improved.
Found 1 outliers among 100 measurements (1.00%)
  1 (1.00%) high mild

string decimal256 76 digits
                        time:   [61.585 ns 61.706 ns 61.824 ns]
+                       change: [−64.960% −64.841% −64.712%] (p = 0.00 < 0.05)
+                       Performance has improved.
Found 2 outliers among 100 measurements (2.00%)
  2 (2.00%) low mild

string decimal256 rounded scale
                        time:   [61.868 ns 61.990 ns 62.116 ns]
+                       change: [−85.577% −85.534% −85.487%] (p = 0.00 < 0.05)
+                       Performance has improved.
Found 4 outliers among 100 measurements (4.00%)
  1 (1.00%) low severe
  2 (2.00%) low mild
  1 (1.00%) high mild

very impressive results

v.map(|v| {
parse_string_to_decimal_native::<T>(v, scale as usize)
.map_err(|_| {
let mut builder = PrimitiveBuilder::<T>::with_capacity(from.len());

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

note: this change isnt captured in cast_kernels benchmark since we only benchmark for safe = true

if fractionals == scale {
first_discarded_digit.get_or_insert(digit);
index += 1;
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i do wonder if instead of continuing via the loop to verify we have a valid number, we specialize to another loop that checks if the remainder characters is an ascii digit; it might be able to vectorize better?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arrow Changes to the arrow crate arrow-cast performance

Projects

None yet

4 participants