Skip to content

perf: chunked digit parsing/formatting, cheaper ToBigInt and rounding halves#45

Merged
quagmt merged 1 commit into
quagmt:masterfrom
AlexandrosKyriakakis:perf/chunked-parse-format-and-rounding
Jun 12, 2026
Merged

perf: chunked digit parsing/formatting, cheaper ToBigInt and rounding halves#45
quagmt merged 1 commit into
quagmt:masterfrom
AlexandrosKyriakakis:perf/chunked-parse-format-and-rounding

Conversation

@AlexandrosKyriakakis

Copy link
Copy Markdown
Contributor

This PR contains four small, behavior-preserving optimizations on hot paths. No API or behavior changes — all existing tests, fuzz targets, and lint checks pass unchanged.

Changes

1. bint.go — chunked digit parsing in digitToU128
Long digit strings (>19 digits) are now parsed in chunks of up to 19 digits. Each chunk is accumulated with plain uint64 arithmetic, so only one u128 Mul64+Add64 is needed per chunk instead of per digit. ~-37% on Parse of long strings that still fit in u128.

2. codec.go — chunked integer-part formatting in appendBuffer
The u128 integer part is first reduced to uint64 chunks of 19 digits via at most two QuoRem64(1e19) calls, then each chunk is formatted using 64-bit arithmetic only, instead of one 128-bit division per 2 digits. This also removes the per-2-digit quoRem64 helper call overhead for values with hi == 0. -16% to -22% on String/MarshalJSON. (The now-unused quoRem64 helper is removed.)

3. u128.goToBigInt via big.Int.SetBits
On 64-bit platforms the words are set directly with SetBits, avoiding the intermediate 16-byte buffer, the big-endian encoding, and SetBytes decoding (-23% time, -40% bytes allocated). The portable byte-buffer path is kept for 32-bit platforms (where big.Word is 32 bits); the branch is on bits.UintSize, a compile-time constant, so it is eliminated by the compiler. This helps every big.Int fallback path (GetBig, div/round/quo fallbacks, etc.).

4. decimal.go — cheaper half computation in RoundHAZ/RoundHTZ
factor is always pow10[d.prec-prec] with 1 <= d.prec-prec <= 19, i.e. a power of 10 with hi == 0, so factor / 2 is a single shift on factor.lo instead of a 128-bit QuoRem64(2). RoundBank already used the cheap form. -24% to -28% on RoundHAZ/RoundHTZ.

Benchmarks

benchstat over upstream's benchmarks/ suite (udec cases), -benchtime=0.2s -count=5, Apple M1 Pro, go1.26.1:

                                                                  │    before    │     after            vs base
Parse/udec/1234567890123456789.1234567890123456879-10              38.32n ± ∞ ¹   38.29n ± ∞ ¹        ~ (p=0.690 n=5)
Parse/udec/123-10                                                 10.020n ± ∞ ¹   9.993n ± ∞ ¹        ~ (p=0.881 n=5)
Parse/udec/123456.123456-10                                        17.10n ± ∞ ¹   17.05n ± ∞ ¹        ~ (p=0.214 n=5)
Parse/udec/1234567890-10                                           14.82n ± ∞ ¹   14.83n ± ∞ ¹        ~ (p=0.786 n=5)
Parse/udec/0.1234567890123456879-10                                28.50n ± ∞ ¹   28.36n ± ∞ ¹        ~ (p=0.246 n=5)
Parse/udec/12345678901234567890123456789.123-10                    59.94n ± ∞ ¹   37.48n ± ∞ ¹  -37.47% (p=0.008 n=5)
ParseFallBack/udec/123456789123456789123456.1234567890123456-10    417.7n ± ∞ ¹   400.0n ± ∞ ¹   -4.24% (p=0.008 n=5)
String/udec/1234567890123456789.1234567890123456879-10             77.69n ± ∞ ¹   65.12n ± ∞ ¹  -16.18% (p=0.008 n=5)
String/udec/123-10                                                 24.01n ± ∞ ¹   21.39n ± ∞ ¹  -10.91% (p=0.008 n=5)
String/udec/123456.123456-10                                       37.28n ± ∞ ¹   35.35n ± ∞ ¹   -5.18% (p=0.008 n=5)
String/udec/1234567890-10                                          32.79n ± ∞ ¹   25.71n ± ∞ ¹  -21.59% (p=0.008 n=5)
String/udec/0.1234567890123456879-10                               45.51n ± ∞ ¹   45.64n ± ∞ ¹        ~ (p=0.206 n=5)
MarshalJSON/udec/1234567890123456789.1234567890123456879-10        76.46n ± ∞ ¹   64.60n ± ∞ ¹  -15.51% (p=0.008 n=5)
MarshalJSON/udec/123-10                                            25.00n ± ∞ ¹   22.20n ± ∞ ¹  -11.20% (p=0.008 n=5)
MarshalJSON/udec/123456.123456-10                                  36.97n ± ∞ ¹   33.94n ± ∞ ¹   -8.20% (p=0.008 n=5)
MarshalJSON/udec/1234567890-10                                     31.74n ± ∞ ¹   24.78n ± ∞ ¹  -21.93% (p=0.008 n=5)

Micro-benchmarks for the paths without upstream benchmarks (same settings):

            │    before    │     after            vs base
RoundHAZ-10   8.067n ± ∞ ¹   6.123n ± ∞ ¹  -24.10% (p=0.008 n=5)   // d.RoundHAZ(4) on 1.12345
RoundHTZ-10   8.049n ± ∞ ¹   5.794n ± ∞ ¹  -28.02% (p=0.008 n=5)   // d.RoundHTZ(4) on 1.12345
ToBigInt-10   38.59n ± ∞ ¹   29.80n ± ∞ ¹  -22.78% (p=0.008 n=5)   // 80 B/op -> 48 B/op

Remaining benchmarks (Add/Sub/Mul/Div/Pow, binary codecs, UnmarshalJSON, StringFallBack) are unchanged within noise.

Validation

  • go test -tags='!fuzz' -race -failfast ./... passes
  • go test -tags=fuzz -fuzz=FuzzParse and -fuzz=FuzzMarshalJSON run clean for 20s each (~2.3M execs each)
  • go vet ./..., gofmt, and golangci-lint run clean on all changed lines (the 3 remaining repo findings pre-date this PR and are on untouched lines)
  • Also validated against a downstream consumer's full test suite (alpacahq/alpacadecimal) with a replace directive — passes

🤖 Generated with Claude Code

… halves

Four small, behavior-preserving optimizations on hot paths:

- bint.go: digitToU128 parses long digit strings in chunks of up to 19
  digits using uint64 arithmetic, requiring only one u128 Mul64+Add64
  per chunk instead of per digit (~-37% on Parse of long strings).

- codec.go: appendBuffer reduces the u128 integer part to uint64 chunks
  via at most two QuoRem64(1e19) calls and formats each chunk with
  64-bit arithmetic only, instead of one 128-bit division per 2 digits
  (-16% to -22% on String/MarshalJSON).

- u128.go: ToBigInt sets big.Int words directly via SetBits on 64-bit
  platforms, avoiding the intermediate byte buffer and its decoding
  (-23% time, -40% bytes allocated). The portable byte-buffer path is
  kept for 32-bit platforms.

- decimal.go: RoundHAZ/RoundHTZ compute half of the rounding factor
  with a single shift instead of a 128-bit division, since the factor
  is always a power of 10 with hi == 0 (-24% to -28%); RoundBank
  already used the cheap form.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@codecov

codecov Bot commented Jun 12, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.67%. Comparing base (dfdef5e) to head (c7717fe).

Additional details and impacted files
@@            Coverage Diff             @@
##           master      #45      +/-   ##
==========================================
- Coverage   96.89%   96.67%   -0.22%     
==========================================
  Files           5        5              
  Lines        1481     1506      +25     
==========================================
+ Hits         1435     1456      +21     
- Misses         23       27       +4     
  Partials       23       23              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pull request overview

This PR introduces a set of low-level, hot-path optimizations in core decimal parsing/formatting and rounding logic, plus a faster u128 -> big.Int conversion, aiming to reduce allocations and expensive 128-bit operations without changing externally observable behavior.

Changes:

  • Parse long digit sequences into u128 using 19-digit uint64 chunks to reduce per-digit u128 mul/add work.
  • Format u128 integer parts by reducing to 19-digit uint64 chunks and emitting digits using only 64-bit arithmetic.
  • Optimize u128.ToBigInt() on 64-bit platforms via big.Int.SetBits, avoiding the intermediate byte buffer and decoding.
  • Compute factor/2 in RoundHAZ/RoundHTZ using a cheap shift based on established constraints for factor.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

File Description
bint.go Implements chunked (≤19 digits) parsing in digitToU128 to reduce u128 arithmetic in long-number parsing paths.
codec.go Replaces per-2-digit u128 division formatting with appendU128 chunking to reduce u128 divisions and overhead during string/JSON formatting.
u128.go Adds a 64-bit fast path for ToBigInt() using big.Int.SetBits to cut allocations and conversion overhead.
decimal.go Optimizes half-threshold computation in RoundHAZ/RoundHTZ by shifting a known-even pow10 factor.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@quagmt quagmt left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

LGTM

@quagmt quagmt merged commit c9f302d into quagmt:master Jun 12, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants