Skip to content

Cache Utils.underscore/camelize to cut allocations (#125) - #129

Closed
tungleduyxyz wants to merge 1 commit into
killbill:masterfrom
tungleduyxyz:perf/cache-underscore-camelize-125
Closed

tungleduyxyz wants to merge 1 commit into
killbill:masterfrom
tungleduyxyz:perf/cache-underscore-camelize-125

Conversation

@tungleduyxyz

@tungleduyxyz tungleduyxyz commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

What

Utils.underscore (3 gsub! + tr!) ran twice for every JSON key of every response, and camelize ran for every attribute in to_hash (hence also in == / hash). The set of names is small and finite, so this caches the conversions.

  • Memoize underscore and camelize in a thread-safe, size-capped cache (Utils::Cache, 4096 entries; past the cap, values are just computed). It is a frozen Hash swapped copy-on-write under a Mutex: reads are lock-free and there is no new runtime dependency. Results are now frozen; no caller in this gem mutates them (the only other Utils usage is demodulize).
  • Fast path: names with no uppercase letter, - or :: skip the regexes (on a cache miss).
  • Drop the second underscore in instantiate_record_from_json, and add Utils.underscore_symbols(name), which caches [:attr_name, :attr_name=] together, so no setter string / to_sym is allocated per key. Resource#initialize(hash) uses it too.
  • Only String/Symbol inputs are cached; anything else is computed as before.
  • Item 5 of the issue (per-class camelized key list for to_hash) is not done: camelize is now a hash lookup, which seemed enough.

No behaviour change

  • Differential test of old vs. new underscore/camelize (String and Symbol, upper/lower, 652 inputs including accountCBA, ::, -, empty, non-ASCII, random names): 0 mismatches.
  • Existing specs pass, plus new specs: utils_spec.rb (edge cases, idempotency which justifies dropping the second call, frozen/cached results, input not aliased, thread safety, cache cap) and a from_json / to_hash round-trip in resource_spec.rb.
  • Unit specs pass on CRuby 3.2.2, 3.3.5, 3.4.4 and JRuby 9.4.12 and 10.0.6.0 (remote specs need a Kill Bill server and were not run).

Benchmark

benchmark/utils_benchmark.rb (benchmark-ips + allocation counts): 87 real attribute names and a ~156 KB account-timeline-like payload (synthetic, built from the real model attribute names). master (5d66ece) vs. this branch, same machine (Apple Silicon, JDK 21):

CRuby 3.3.5 before after JRuby 10.0.6.0 before after
underscore (87 keys) 169 µs 12.1 µs (14x) 45.2 µs 8.2 µs (5.5x)
camelize :lower (87 keys) 114 µs 13.2 µs (8.7x) 41.2 µs 10.9 µs (3.8x)
from_json (timeline) 49.8 ms 27.8 ms (1.8x) 12.4 ms 6.3 ms (2.0x)
to_hash (timeline) 11.5 ms 3.0 ms (3.8x) 5.5 ms 2.4 ms (2.3x)

Allocated objects per iteration (CRuby; JRuby does not expose the counter): underscore 1065 → 0, camelize 1837 → 0, from_json 220,206 → 81,231, to_hash 131,961 → 1,017.

Still open from #125

  • A release of the gem and picking it up in Kaui.
  • A follow-up JFR profile on the perf environment.

Possible overlap with #126 (instantiate_record_from_json simplification): the change there is 3 lines (name, setter = Utils.underscore_symbols name, lookup by symbol, send(setter, value)), easy to rebase either way.

🤖 Generated with Claude Code

)

Utils.underscore (3 gsub! + tr!) ran twice for every JSON key of every
response, and camelize ran for every attribute on to_hash (and therefore
on == and hash). The set of attribute names is small and finite, so:

* Memoize underscore and camelize in a thread-safe, size-capped cache
  (copy-on-write frozen Hash behind a Mutex: lock-free reads, no new
  runtime dependency). Results are now frozen.
* Skip the regexes for names that are already underscored.
* Add Utils.underscore_symbols, which caches the attribute name and
  setter symbols together, and use it in Resource so that
  instantiate_record_from_json no longer underscores each key twice nor
  builds a setter string per key.
* Add specs (edge cases, idempotency, thread safety, from_json round
  trip) and benchmark/utils_benchmark.rb.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tungleduyxyz

Copy link
Copy Markdown
Contributor Author

Closing as a duplicate of #127, which covers the same fix for #125 (plus the ReDoS fix) and already has green CI. The remaining ideas from this PR (caching the attribute/setter symbols together, covering Resource#initialize(hash), and a regex-free fast path for already-underscored names) can be picked up in #126.

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