Skip to content

Cache compiled interpolation patterns across calls - #450

Merged
whatyouhide merged 1 commit into
elixir-gettext:mainfrom
oliver-kriska:cache-interpolation-patterns
Oct 27, 2026
Merged

whatyouhide merged 1 commit into
elixir-gettext:mainfrom
oliver-kriska:cache-interpolation-patterns

Conversation

@oliver-kriska

Copy link
Copy Markdown
Contributor

Reuse compiled interpolation patterns

Cache the compiled "%{" and "}" split patterns in :persistent_term rather than compiling them on every to_interpolatable/1 call. This speeds up runtime interpolation when a translation is missing, including source-language messages without a translated entry, and avoids the same repeated work during compile-time interpolation.

Split from #445 as requested, based directly on the latest upstream main, independently of the POT-read and plural-info changes. The merged literal-%{} fix from #444 is preserved.

A compiled pattern contains a reference and cannot be stored in a module attribute. Literal split patterns were measured and rejected because they recompile on each split and become slower with more bindings.

The original lazy initialization is unchanged: concurrent first callers may compile and store different valid pattern tuples. Correctness is unaffected, but replacement :persistent_term.put/2 calls can trigger global heap scans. This does not guarantee exactly one compilation or write on a cold VM; subsequent calls reuse the stored patterns.

Prior measurements

These are the corrected historical measurements from #445, not new measurements against today's main: Elixir 1.20.4/OTP 29, one scheduler, MIX_ENV=prod, compiled benchmark loop, median of seven alternating samples of runtime_interpolate/2.

Input Before Cached patterns
No bindings 0.44 µs 0.15 µs
One binary binding 0.72 µs 0.39 µs
Three binary bindings 1.06 µs 0.78 µs
32 bindings 7.76 µs 7.27 µs
Long text, one binding at the end 0.75 µs 0.45 µs

Verification on the new base

  • Compilation with --warnings-as-errors and formatting checks pass.
  • The existing full suite passes on Elixir 1.20.4/OTP 29 and 1.18.4/OTP 27: 193 checks (188 tests and 5 doctests).
  • A fresh cold-cache probe with 64 concurrent callers returned the expected interpolated string for every caller; literal %{} prefixes and missing-binding results are preserved. This checks correctness, not startup write count or latency.
  • All three split changes combine without conflicts and pass the full suite together.

to_interpolatable/1 compiled the "%{" and "}" patterns with
:binary.compile_pattern/1 on every call. Each compilation costs about as
much as splitting a short string, and this function runs on every runtime
interpolation: whenever the current locale has no translation for a
message, including every render in the default locale of an application
without a PO file for it, and once per message at compile time.

A compiled pattern is a reference, so it cannot live in a module attribute;
the two patterns are now compiled on first use and kept in persistent_term.
Passing the literal strings to :binary.split/2 instead would compile them
again on every split and gets slower as the number of bindings grows.

Microbenchmark of runtime_interpolate/2 (compiled loop, median of 7
alternating samples, Elixir 1.20.4 / OTP 29, one scheduler; OTP 27 within
5% of these):

    input                        before    after
    no bindings                  0.44 us   0.15 us
    1 binary binding             0.72 us   0.39 us
    3 binary bindings            1.06 us   0.78 us
    32 bindings                  7.76 us   7.27 us
    long text, 1 binding         0.75 us   0.45 us
@whatyouhide
whatyouhide merged commit a9948d9 into elixir-gettext:main Oct 27, 2026
2 checks passed
@whatyouhide

Copy link
Copy Markdown
Contributor

Thank you @oliver-kriska 🫶

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