Skip to content

Cache short text numerics as frozen BigDecimals (2.9–5.1× faster numeric decoding) - #768

Open
czj wants to merge 2 commits into
ged:masterfrom
czj:perf/text-numeric-cache
Open

czj wants to merge 2 commits into
ged:masterfrom
czj:perf/text-numeric-cache

Conversation

@czj

@czj czj commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Following #762. PG::TextDecoder::Numeric now keeps a process-wide cache of the frozen BigDecimals it returns for finite texts of up to 8 characters, keyed by the raw text, so values like 0.00, 1.200 or 1299.00 come back without allocating a String or a BigDecimal. Longer texts, NaN and Infinity (so BigDecimal.mode exceptions still fire), and anything BigDecimal() rejects go through BigDecimal() exactly as before.

The cache is a direct-mapped table of 4096 slots. The key is the text padded into a uint64_t, plus its length, so a lookup is one multiply, two compares and an uncontended rb_nativethread_lock_t pair. The lock is never held across BigDecimal(), which can raise, and only rb_ractor_shareable_p() results are stored, so any Ractor can receive them. A hidden TypedData object marks the cached values for the GC, and a pthread_atfork child handler resets the lock and empties the cache, in case another Ractor held it during the fork.

The open question is the default, and I'd like your call on it. As written, the cache is on for everyone, and PG::Coder::NUMERIC_UNCACHED turns it off per coder. That's what lets Rails and BasicTypeMapForResults users benefit without changing anything, but it also means every pg user suddenly gets shared objects for equal values. equal? and object_id change meaning, while the values themselves stay immutable. If you'd rather keep identity unchanged by default, I'll flip it to an opt-in NUMERIC_CACHED flag.

On the size: I started with the 512 slots from the issue, but collisions in a direct-mapped table cost more than I expected, so this uses 4096 slots (96 KB on 64-bit). On the numeric columns of our e-commerce app, PG::Result#values gets 2.9–5.1× faster, with 0.4–1.6 objects allocated per value instead of 3.2–4, and the decoded values are identical to the uncached ones.

Measurements
table numeric columns uncached ns/value 512 slots 4096 slots
items 5 118 2.4× 5.1×
lines 4 130 2.5× 3.2×
nego_items 1 155 1.2× 3.1×
supply_lines 4 133 2.2× 2.9×
shipments 1 174 3.7× 4.4×

That's PG::Result#values with a TypeMapByColumn of numeric decoders, on up to 200,000 rows per table of a production dump. Each build compares the cached decoder against NUMERIC_UNCACHED, which runs master's exact code, in medians of 15 interleaved runs. The setup is Ruby 4.0.9, bigdecimal 4.1.3, PostgreSQL 18.6 on macOS arm64. The row arrays account for much of the remaining allocations (a full row array per value on the single-column tables). The script is here.

The specs cover identity, the length limit, the flag, invalid input, the exception modes, GC liveness (it crashes when the cached values aren't marked) and 4 parallel Ractors.

Would you rather keep it on by default as here, or make it opt-in?


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

czj and others added 2 commits October 28, 2026 11:35
Money columns repeat a handful of values (0.00, VAT rates, round
prices), and every one of them went through BigDecimal() with a fresh
String. Values up to 8 characters now go through a process-wide cache
of 4096 slots keyed by their raw text, so a hit returns the same frozen
BigDecimal without allocating.

A native lock guards the slots against Ractors running in parallel, and
is never held across BigDecimal(). The flag PG::Coder::NUMERIC_UNCACHED
restores a new object for every value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A cached NaN or Infinity skipped BigDecimal.mode exceptions, so only
texts ending with a digit are cached now. Values are stored only when
rb_ractor_shareable_p() says they are, since the cache hands them to
every Ractor.

The lock and the GC marker are set up once with the TextDecoder module,
and a forked child resets the lock and empties the cache, in case
another Ractor held it during the fork.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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