Skip to content

Remove redundant work from extraction, backend compilation and runtime interpolation - #445

Open
oliver-kriska wants to merge 3 commits into
elixir-gettext:mainfrom
oliver-kriska:extraction-merge-perf
Open

oliver-kriska wants to merge 3 commits into
elixir-gettext:mainfrom
oliver-kriska:extraction-merge-perf

Conversation

@oliver-kriska

@oliver-kriska oliver-kriska commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Three small, output-preserving changes.

All three are output-equivalent. The extraction change was verified to produce byte-identical PO/POT output against a production app's catalog (176 PO files, 11 domains x 16 locales, 4,900 msgids); the compile change feeds the same escaped plural_info value to both helpers, so the generated code is unchanged; the interpolation change is covered by the interpolation test suite plus a differential check of edge cases against the previous implementation.

Commits

  1. Avoid reading each existing POT file twice during extraction. read_contents_and_parse/1 did File.read!/1 and then PO.parse_file!/2, which reads the same file again. PO.parse_string!(contents, file: path) is what parse_file! does after its own read (same file option, same BOM and error handling), so the result is identical. A cleanup: it removes a read per domain without adding code.

  2. Compute plural info once per PO file at compile time. compile_po_file/5 called Plural.plural_info/3 twice per PO file, in nplurals/3 and compile_plural_forms/4. Besides the duplicated work (about 7 us per call with a Plural-Forms header), this contradicts the documented contract of c:Gettext.Plural.init/1: "Gettext calls this callback once at compile time". A custom plural module with an expensive or side-effecting init/1 saw it called twice per PO file. The deprecation warning for a Plural-Forms header that only sets nplurals is also printed once per file now instead of twice.

  3. Compile the interpolation patterns once per VM. 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 runs on every runtime interpolation: whenever the current locale has no translation for a message, including every render in the default locale of an app without a PO file for it, and once per message at compile time. A compiled pattern is a reference and cannot be a module attribute, so the two patterns are compiled on first use and kept in :persistent_term. Concurrent first calls on a cold VM may each compile and store the patterns; every stored value is valid, so this only costs a few extra puts at startup. Passing the literal strings to :binary.split/2 instead recompiles them on every split and gets slower as bindings grow, which is why that version of this commit was dropped.

Measurements

runtime_interpolate/2, compiled benchmark loop, median of 7 alternating samples, Elixir 1.20.4 / OTP 29 with one scheduler, MIX_ENV=prod. OTP 27 (Elixir 1.18.4) is within 5% of these.

input before after literal patterns (dropped)
no bindings 0.44 us 0.15 us 0.31 us
1 binary binding 0.72 us 0.39 us 0.82 us
3 binary bindings 1.06 us 0.78 us 1.82 us
mixed binary/integer/atom 1.17 us 0.89 us 1.74 us
32 bindings 7.76 us 7.27 us 15.5 us
long text, 1 binding at the end 0.75 us 0.45 us 0.88 us

A compiled hit (gettext with a translation and a binding) is about 0.12 us for reference, so the miss path is still several times slower than a hit; see below.

Measured on a real application's backend

Direct lgettext/5 calls on a backend compiled from a production catalog (176 PO files, 16 locales, 73k translations; only en and sk loaded for the run), compiled benchmark loop, median of 7 consecutive samples per version, dev build, same machine. In that application en is the source language (its default.po has one translated string), so the measured English messages miss and go through handle_missing_translation/5 and runtime_interpolate/2. That is the path commit 3 speeds up:

lgettext/5 in locale en (miss) before after
no bindings, 22-char msgid 461 ns 175 ns
1 binding 711 ns 349 ns
3 bindings 1277 ns 759 ns
no bindings, 370-char msgid 597 ns 273 ns
translated hit in sk, no bindings, for reference 21 ns 21 ns

Commits 1 and 2 remove one file read per POT and one plural_info/3 call per PO file; on this catalog that adds up to about a millisecond per extraction or compile, so no end-to-end difference is expected. The end-to-end run of the application's own tasks confirms it:

before (main, two runs) this PR
mix compile --force, whole application (2,358 files) 60 s / 62 s wall, 408 s / 405 s CPU 59 s wall, 401 s CPU
edit one PO file, mix compile (77 files: the backend and what depends on it) 29 s / 31 s wall, 265 s / 267 s CPU 30 s wall, 269 s CPU
mix gettext.generate --no-fuzzy (extraction) 4.0 s / 4.0 s 4.1 s
mix gettext.merge --no-fuzzy 2.5 s / 2.8 s 2.6 s

(Dev build of the application, 16 locales with split_module_by: [:locale] compiled in parallel, Elixir 1.20.4 / OTP 29, /usr/bin/time -l; PO/POT output byte-identical in every run.)

Dropped after measurement

  • Literal split patterns instead of compiled ones: faster for strings without bindings, slower with bindings (table above). Replaced by commit 3.
  • An is_binary/1 fast clause before to_string/1 in interpolate/4: within noise on OTP 27 and 29 (the generated code already tests for binaries before dispatching String.Chars).

Related, not in this PR

  • Pre-existing interpolation bug found while checking equivalence: a literal %{} followed by a binding drops the text before the binding ("a %{} b %{c}" interpolates to " b X"), at runtime and at compile time. Fixed with a test in Keep the text before a binding that follows a literal %{} #444 (off upstream main, independent of Add recompile-free extraction via persisted module attributes #437).
  • mix gettext.merge parses each POT once per locale: on the production catalog above, default.pot (700 KB) takes about 130 ms to parse, times 16 locales. Locales merge in parallel, so wall-clock gain is smaller than the 2 s of CPU; worth measuring end to end before changing the task.
  • The miss path parses the msgid at runtime although the call site knows it as a literal. Moving that to compile time needs an API decision, since handle_missing_translation/5 is a public, overridable callback receiving the raw msgid.
  • Expo.PluralForms.index/2 costs 21 to 107 ns per call (en / sk / pl) against about 7 ns native; not worth a compiler change.

`read_contents_and_parse/1` read the file with `File.read!/1` and then called
`PO.parse_file!/2`, which reads the very same file again internally. Pass the
contents we already have to `PO.parse_string!/2` instead.

`PO.parse_file!(path, file: path)` is defined as `File.read` followed by
`parse_string(contents, file: path)`, so the parsed result is identical; the
existing `File.read!/1` still raises on a missing file. This removes one disk
read per existing POT file on every `mix gettext.extract`.
@oliver-kriska
oliver-kriska marked this pull request as ready for review October 16, 2026 06:19
@oliver-kriska

Copy link
Copy Markdown
Contributor Author

We ran this PR inside the application that motivated it, to check it on a large real catalog rather than only on the test fixtures.

App size, as far as gettext is concerned

  • 16 locales, 11 domains, 176 PO files, 15 MB of PO text (~425k lines)
  • 73,416 translated messages in total (~4,600 per locale): 63,861 plain strings, 8,051 with %{} bindings, 1,504 plural
  • one backend with split_module_by: [:locale], inside a 2,358-file Phoenix app
  • Elixir 1.20.4 / OTP 29.1.1, 14 cores, dev environment

Results (this branch, head 180931c, versus main at c29c9ce)

step main this PR
mix compile --force, whole app 60–62 s wall, 408 s CPU, 2.7 GB 59 s wall, 401 s CPU, 2.8 GB
edit one PO, mix compile (77 files) 29–31 s wall, 265 s CPU 30 s wall, 269 s CPU
gettext.generate / gettext.merge / --check-up-to-date 4.0 / 2.5 / 4.0 s 4.1 / 2.6 / 4.1 s

So the wall clock is unchanged within noise, which is what this PR should do: it removes a second POT parse per file in extraction, a second Plural.init/1 per PO file at compile time and a per-call :binary.compile_pattern/1 at runtime, all of which are far below the compile cost of the backend itself (that part is #446). What we did confirm on the app:

  • mix gettext.generate --no-fuzzy and mix gettext.merge --no-fuzzy left the committed priv/gettext byte-identical (176 files)
  • mix gettext.generate --check-up-to-date, the app's CI command, passes
  • the whole app compiles with the branch in place of the pinned release

— Oliver's Claude Code & Amp

@josevalim josevalim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good to me, i think we can remove tests and ship it!

Comment thread test/gettext/backend_test.exs Outdated
BackendWithDefaultDomain
}

defmodule CountingPlural do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In my opinion we can get rid of this test, it is mostly implementation details.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, removed in 7ff8301. The branch is otherwise unchanged: the plural_info hoisting and the CHANGELOG line stay, 190 tests green on 1.16-era and 1.20 (CI will confirm).

— Oliver's Claude Code & Amp

`compile_po_file/5` derived the plural data twice: once in `nplurals/3` and
once in `compile_plural_forms/4`, each calling `Plural.plural_info/3`. That
function runs `Code.ensure_compiled!/1` and parses the `Plural-Forms` header,
so it is needless work repeated for every PO file across all locales and
domains.

Compute `plural_info` once in `compile_po_file/5` and pass it to both helpers.
The generated code is identical (the same value is escaped into the plural
dispatcher and fed to `nplurals/1`); only the duplicate computation is removed.
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
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