Skip to content

Parse strings without a temporary String: 3 → 2 objects per BigDecimal(str), ~18% faster - #565

Open
czj wants to merge 4 commits into
ruby:masterfrom
czj:string-parse-without-temporary-string
Open

czj wants to merge 4 commits into
ruby:masterfrom
czj:string-parse-without-temporary-string

Conversation

@czj

@czj czj commented Oct 26, 2026 •

Copy link
Copy Markdown

Hi again, a smaller one found while looking at how ruby-pg decodes numeric columns, which calls BigDecimal(string) for every value it reads. On our app's database, 1.19M numeric values, about 45% of that decoding time is spent in bigdecimal's parser.

VpAlloc allocates a temporary String with rb_str_tmp_new on every parse to hold the scanned digits, then shrinks it. This PR uses ALLOCV_N instead, which stays on the stack for short inputs and falls back to a heap buffer from 1 KB, freed with ALLOCV_END on every normal exit. It also only tries the Infinity/+Infinity/-Infinity/NaN comparisons when the first character can start one of them (I, N, +, -), instead of four strncmp calls on every number.

The trade-off: on CRuby that heap buffer is GC-managed, so nothing leaks even when parsing raises. On TruffleRuby it's a plain malloc; the invalid-input exits now free it, but an exponent overflow raised from inside VpCtoV (only with BigDecimal::EXCEPTION_OVERFLOW or EXCEPTION_UNDERFLOW turned on) would still skip ALLOCV_END for inputs over 1 KB. Scanning the string in place, without copying it unless an _ was skipped, would close that too and save a few more ns; I kept it out to keep this diff small, but I'm happy to do it here or as a follow-up if you prefer.

On the repository's own benchmark (benchmark/from_small_integer.yml), int_n38 goes from 191.2 to 174.9 ns; int_n9 and int_n19 don't reach this code and are unchanged. BigDecimal("129.90") goes from 98 ns and 3 objects to 80 ns and 2 objects, and BigDecimal("0.00") from 108 to 88 ns. All of these are medians of 9 interleaved runs, Ruby 4.0.9, macOS arm64, against master. Over 200k values sampled from our database it was about 255 → 220 ns per value. On Linux GitHub Actions runners (run, median of 7 interleaved rounds), BigDecimal("129.90") goes from 258 to 208 ns on x86_64 and from 238 to 191 ns on aarch64.

Results are identical to master on 367k inputs: the distinct values of that corpus, plus 300k fuzzed strings with invalid characters, whitespace, underscores, exponents, the special values and strings over 1 KB, with and without exception: false, and through String#to_d. Two tests are added: one checks that BigDecimal("129.90") and "129.90".to_d allocate only their result (it fails on master), and one parses valid and invalid strings over 1 KB under GC.stress, the path that uses the heap buffer.

It's independent from #564 (Float#to_d), and it would also benefit ged/ruby-pg#762, where I asked whether pg could reuse decoded values. Would you rather have the Infinity/NaN check as its own PR? Thanks for having a look!


🤖 Generated with Claude Code, but prompted, read, and verified by @czj, a caring human :)

czj and others added 4 commits October 26, 2026 20:09
Use ALLOCV for the scanned digits buffer (stack for short inputs) and
check for Infinity/NaN only when the first character can start one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The guard lives next to the table whose first characters it mirrors,
and VpAlloc calls the parser unconditionally again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On TruffleRuby, the ALLOCV_N heap fallback (inputs of 1023 bytes or
more) is a plain malloc freed only by ALLOCV_END, so the invalid_value
exits leaked it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@czj

czj commented Oct 27, 2026

Copy link
Copy Markdown
Author

A final review caught a leak on TruffleRuby, where the ALLOCV_N fallback for inputs of 1 KB or more is a plain malloc: the invalid-input exits (exception: false, the loose-parsing zero, and the raise) skipped ALLOCV_END. cdb9348 frees it there and adds a GC.stress test that goes through that buffer; cf3291c adds the allocation test. I've updated the description with the remaining trade-off and the repository benchmark numbers.

This branch has not been deployed

No deployments
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.

1 participant