Skip to content

Store translations without interpolation in a literal map instead of function clauses - #446

Merged
whatyouhide merged 1 commit into
elixir-gettext:mainfrom
oliver-kriska:backend-static-translations
Oct 22, 2026
Merged

whatyouhide merged 1 commit into
elixir-gettext:mainfrom
oliver-kriska:backend-static-translations

Conversation

@oliver-kriska

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

Copy link
Copy Markdown
Contributor

A backend generates one function clause per translation, and that is what makes large backends slow to compile: the Erlang compiler optimizes every clause of what becomes one huge function per PO file. Profiling one locale of a backend compiled from a production catalog (176 PO files, 16 locales, 73k translations in total; the locale has 11 PO files and about 4,400 translations) with ERL_COMPILER_OPTIONS=time shows 9.8 s of its 11.6 s inside the Erlang compiler (beam_ssa_opt alone 5.5 s), PO parsing and the Elixir side together the remaining 1.8 s. The 16 locales add up to the whole-catalog time below, and split_module_by: [:locale] cuts wall time through parallel compilation but costs more CPU in total.

Translations whose msgstr contains no %{ cannot need interpolation and have nothing to compile. This PR keeps them in a literal map of {msgctxt, msgid} => {:ok, msgstr} that the catch-all clause of the singular lookup function (<locale>_<domain>_lgettext/3) matches on before falling back to handle_missing_translation/5; the partition only scans for %{ and never parses interpolation keys. On that catalog this removes 87% of the clauses (63,861 of 73,416 messages have no %{ in their translation). Translations containing %{ (a binding, or a literal %{}) and plural messages keep their clauses, so the compiled interpolation path is untouched, and backends with a custom :interpolation module keep the previous code generation entirely, since whether a translation needs interpolation depends on the interpolation syntax.

Measurements

Forced compile of the backend above, Elixir 1.20.4 / OTP 29, Apple Silicon (14 cores), /usr/bin/time -l:

configuration before after
single module (default) 204 s wall, 181 s CPU, 4.8 GB RSS 60 s wall, 57 s CPU, 2.5 GB
split_module_by: [:locale], parallel 26 s wall, 249 s CPU, 2.4 GB 8 s wall, 67 s CPU, 1.2 GB
beam size (single module) 12.7 MB 10.1 MB

One locale (11 PO files, ~4,400 translations) goes from 11.6 s to 4.4 s, of which the Erlang passes from 9.8 s to 2.9 s (beam_ssa_opt 5.5 s to 1.7 s).

Runtime, compiled benchmark loop, median of 7, same backend: a translation now served from the map takes 42–54 ns instead of 15–20 ns as a function clause (no position-dependent cost was observed for clauses, first or last in the file). Translations with bindings are unchanged (27/37 ns for 1/3 bindings). Misses now do the map lookup before handle_missing_translation/5; at about 450 ns they show no measurable change.

Measured inside the application

The same catalog in its own Phoenix application (dev build, split_module_by: [:locale] with parallel compilation, Elixir 1.20.4 / OTP 29, /usr/bin/time -l):

before (two runs) after
mix compile --force, whole application (2,358 files) 60 s / 62 s wall, 408 s / 405 s CPU, 2.7 GB RSS 38 s wall, 222 s CPU, 1.7 GB
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, 2.5 GB 14 s wall, 86 s CPU, 1.4 GB
mix gettext.generate, mix gettext.merge 2.5 to 4 s unchanged

The machine was not idle during these runs, so CPU time is the reliable column; PO/POT output was byte-identical in every run.

Equivalence

Every message of all 176 PO files was looked up with both versions of the backend: singular messages with %{}, complete, partial and extra bindings; plural messages with n in {0, 1, 2, 5, 22} and two binding maps, plus the singular lookup of their msgid. The 324,864 results (286,528 :ok, 19,256 :missing_bindings, 19,080 :default) are identical, for the single-module and the split build.

The behaviour the map has to preserve, and the tests covering it:

  • nil context and "" context stay distinct, as with clause heads (new test compiling a catalog with both).
  • empty msgstr still goes to handle_missing_translation/5 (existing test); obsolete entries stay out of the map (new test with an obsolete entry).
  • a translation containing a literal %{} keeps its clause and is returned as it is (new fixture entry and test).
  • plural messages still answer singular lookups from msgstr[0] (existing test, and the plural-only catalog below).
  • backends with a custom :interpolation module still serve translations without interpolation through that module (new assertion on the existing custom-interpolator backend).
  • catalogs that are static-only, interpolated-only or plural-only compile without warnings, which matters for apps compiling with --warnings-as-errors, and answer hits and misses (new test compiling each shape through Kernel.ParallelCompiler so that type-checker warnings are reported; when a PO file has no such translation the lookup is not generated at all, since Map.fetch/2 on an empty literal map is a type warning).

@oliver-kriska
oliver-kriska force-pushed the backend-static-translations branch from 62f66fc to 22495c7 Compare October 15, 2026 15:25

@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.

A few comments but this looks really nice, awesome job!

Comment thread lib/gettext/compiler.ex
Comment thread lib/gettext/compiler.ex Outdated
Comment thread lib/gettext/compiler.ex Outdated
Backends generate one function clause per translation, and the Erlang
compiler spends most of a backend's compile time optimizing the huge
functions this produces: on a catalog with 176 PO files and 73k
translations, a forced compile took 181 s of CPU, 87% of it in the Erlang
compiler's SSA passes. Translations that need no interpolation have nothing
to compile, so keep them in a literal map that the catch-all clause of the
singular lookup function searches instead of generating a clause for each.
Messages with interpolation and plural messages keep their clauses, and
backends with a custom interpolation module keep the previous code
generation, since whether a translation needs interpolation depends on the
interpolation syntax.

On that catalog a forced compile goes from 204 s to 60 s wall (181 to 57 s
CPU, 4.8 to 2.5 GB) for a single module and from 26 s to 8 s (249 to 67 s
CPU) with split_module_by: [:locale]. Looking such a translation up takes
about 45 ns instead of 15 ns; everything else is unchanged, and every
message of the catalog, with complete, partial and extra bindings, returns
identical results from both versions.
@oliver-kriska
oliver-kriska force-pushed the backend-static-translations branch from 22495c7 to 909b0ef Compare October 15, 2026 16:54
@oliver-kriska

Copy link
Copy Markdown
Contributor Author

A note on the three replies above and the push of 909b0ef: Oliver is asleep and asked us to address the review comments in the meantime. The changes were made and reviewed by his Claude Code and Amp agents on his instruction; he will go through them, and anything we got wrong, when he wakes up. Please treat the thread as provisional until he confirms.

— Oliver's Claude Code & Amp

@oliver-kriska
oliver-kriska marked this pull request as ready for review October 16, 2026 06:18
@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 (87%, these go into the map), 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 909b0ef, versus main at c29c9ce)

step main this PR
mix compile --force, whole app 60 s wall, 417 s CPU, 2.7 GB peak 38 s wall, 220 s CPU, 1.6 GB peak
edit one PO, mix compile (the backend + 77 files) 29 s wall, 271 s CPU, 2.5 GB 14 s wall, 90 s CPU, 1.4 GB
gettext.generate / gettext.merge / --check-up-to-date 4.5 / 2.7 / 4.2 s 4.1 / 2.5 / 4.0 s

CPU time and peak memory come from a run of the final head today; they match the run of the earlier head 22495c7 to within a few seconds (222 s / 86 s CPU). Wall times are from the quieter of the two sessions, since the second one ran under heavy unrelated load (load average above 200 during the main build) and its wall clock was not comparable; the CPU and memory columns were stable across both.

For the translator workflow this is the number that matters: after editing a PO file, the recompile drops from about half a minute to 14 s, and the whole-app cold build sheds about 200 s of CPU.

What we confirmed on the app besides timing:

  • 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
  • every one of the 324,864 lgettext/lngettext lookups the catalog can produce returns the same result as on main (286,528 :ok, the rest :default), compared on the split build
  • no translation in the catalog that ends up in the map contains a literal %{, so the partition classifies every plain string as static

— Oliver's Claude Code & Amp

@whatyouhide

Copy link
Copy Markdown
Contributor

This looks awesome, fantastic find. Thank you @oliver-kriska 🫶

@whatyouhide
whatyouhide merged commit 4deec51 into elixir-gettext:main Oct 22, 2026
2 checks passed
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