Skip to content

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

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

czj wants to merge 6 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, so a lookup is one multiply, a compare and an uncontended rb_nativethread_lock_t pair. A text is cached the second time it's seen in a row in its slot: the first time only leaves its key. Caching every miss made a column of mostly distinct values, like 1M random prices, 1.8× slower than without the cache, because rb_ractor_shareable_p() walks each new object and the stored values got promoted to the old generation; with this rule that column decodes at the uncached speed. 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 updates them on compaction, and a pthread_atfork child handler resets the lock and empties the cache, in case another Ractor held it during the fork.

The cache is on for everyone, and PG::Coder::DISABLE_CACHE turns it off per coder. Equal texts now share one frozen object, so equal? and object_id change meaning while the values stay immutable.

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 (64 KB on 64-bit). On the numeric columns of our e-commerce app, PG::Result#values gets 2.5–4.2× faster, with 0.6–1.7 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 cached ns/value speedup
items 5 213 51 4.2×
lines 4 212 73 2.9×
nego_items 1 217 82 2.6×
supply_lines 4 203 81 2.5×
shipments 1 237 67 3.5×
1M random numeric(10,2) 1 245 241 1.0×

That's PG::Result#values with a TypeMapByColumn of numeric decoders, on up to 200,000 rows per table of representative production data from our app, each figure the median of 3 processes of 15 interleaved runs; the last row is the worst case, generated with generate_series. Each run compares the cached decoder against DISABLE_CACHE, which runs master's exact code. 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 scripts are here and here.

The specs cover identity, caching on the second sighting, the length limit, the flag, invalid input, the exception modes, GC liveness and compaction, and 4 parallel Ractors.

czj and others added 3 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>
Remove unnecessary length member, make the cache GC compation and generational friendly
@larskanis
larskanis force-pushed the perf/text-numeric-cache branch 2 times, most recently from 1f7efbf to f96dec0 Compare October 30, 2026 21:12
@larskanis

Copy link
Copy Markdown
Collaborator

@czj Looks pretty good! I added two more commits. Are they OK?

@czj

czj commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for taking this further @larskanis! Yes, both look good to me. Dropping len is safe since a numeric's text never contains a NUL byte and ends with a digit, so the zero padding keeps keys distinct, and an empty slot can't match. I built f96dec0 locally and the type and GC compaction specs pass, including your new one.

To make it usable for possible other encoders/decoders using a cache in the future.
@larskanis
larskanis force-pushed the perf/text-numeric-cache branch from f96dec0 to 100c63c Compare October 31, 2026 21:49
A miss called rb_ractor_shareable_p(), which walks the object, and stored the value into the old cache marker, so it got promoted. A column of mostly distinct values like 1234.56 decoded 1.8x slower than without the cache. Values seen once now leave only their key, and are stored when seen again.

Assisted-by: Claude Opus 5.5 [Claude Code]
@czj czj changed the title Cache short text numerics as frozen BigDecimals (2.9–5.1× faster numeric decoding) Cache short text numerics as frozen BigDecimals (2.5–4.2× faster numeric decoding) Nov 11, 2026
@czj

czj commented Nov 11, 2026

Copy link
Copy Markdown
Contributor Author

@larskanis I pushed 93961cf, which changes the cache a bit, so a heads-up: on a column of mostly distinct values (1M random numeric(10,2)), caching every miss made decoding 1.8× slower than with DISABLE_CACHE, because rb_ractor_shareable_p() walks each new object and the stored values got promoted to the old generation. A text is now cached the second time it shows up in a row in its slot, which brings that column back to the uncached speed. It costs a little on our tables, 2.5–4.2× instead of 2.9–5.1×, so I updated the title and the description with the new numbers.

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.

2 participants