Skip to content

perf(arrow-data): Format decimal values without intermediate allocs - #11002

Open
neilconway wants to merge 2 commits into
apache:mainfrom
neilconway:neilc/decimal-display-no-alloc
Open

perf(arrow-data): Format decimal values without intermediate allocs#11002
neilconway wants to merge 2 commits into
apache:mainfrom
neilconway:neilc/decimal-display-no-alloc

Conversation

@neilconway

@neilconway neilconway commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

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.

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 (apache#11000).
@Jefffrey

Jefffrey commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

run benchmark format_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/decimal-display-no-alloc (acca3ef) to 738e69c (merge-base) diff

Run configuration
run benchmark format_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_decimal-display-no-alloc
-----                                           ----                                   ------------------------------
format_decimal/decimal128/(10, 2) 1 digit       1.97    692.5±0.94µs 11.3 MElem/sec    1.00    351.3±1.38µs 22.2 MElem/sec
format_decimal/decimal128/(10, 2) 5 digits      1.88    665.9±1.04µs 11.7 MElem/sec    1.00    355.1±1.14µs 22.0 MElem/sec
format_decimal/decimal128/(38, 10) 38 digits    1.99    990.0±6.85µs  7.9 MElem/sec    1.00    497.2±0.43µs 15.7 MElem/sec
format_decimal/decimal256/(76, 10) 38 digits    1.49      2.3±0.04ms  3.5 MElem/sec    1.00   1515.1±7.53µs  5.2 MElem/sec
format_decimal/decimal256/(76, 10) 76 digits    1.35      3.1±0.04ms  2.5 MElem/sec    1.00      2.3±0.01ms  3.4 MElem/sec
format_decimal/decimal32/(9, 2) 9 digits        2.20    769.8±1.88µs 10.1 MElem/sec    1.00    350.5±2.15µs 22.3 MElem/sec
format_decimal/decimal64/(18, 6) 18 digits      2.42    883.2±1.97µs  8.8 MElem/sec    1.00    365.2±2.07µs 21.4 MElem/sec

Resource Usage

base (merge-base)

Metric Value
Wall time 85.0s
Peak memory 10.9 MiB
Avg memory 9.2 MiB
CPU user 80.9s
CPU sys 0.0s
Peak spill 0 B

branch

Metric Value
Wall time 80.0s
Peak memory 11.0 MiB
Avg memory 9.1 MiB
CPU user 75.1s
CPU sys 0.0s
Peak spill 0 B

File an issue against this benchmark runner

ryux1

This comment was marked as spam.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants