Repository navigation
Store translations without interpolation in a literal map instead of function clauses - #446
Conversation
62f66fc to
22495c7
Compare
josevalim
left a comment
There was a problem hiding this comment.
A few comments but this looks really nice, awesome job!
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.
22495c7 to
909b0ef
Compare
|
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 |
|
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
Results (this branch, head 909b0ef, versus
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:
— Oliver's Claude Code & Amp |
|
This looks awesome, fantastic find. Thank you @oliver-kriska 🫶 |
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=timeshows 9.8 s of its 11.6 s inside the Erlang compiler (beam_ssa_optalone 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, andsplit_module_by: [:locale]cuts wall time through parallel compilation but costs more CPU in total.Translations whose
msgstrcontains 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 tohandle_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:interpolationmodule 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:split_module_by: [:locale], parallelOne 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_opt5.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):mix compile --force, whole application (2,358 files)mix compile(77 files: the backend and what depends on it)mix gettext.generate,mix gettext.mergeThe 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:
nilcontext and""context stay distinct, as with clause heads (new test compiling a catalog with both).msgstrstill goes tohandle_missing_translation/5(existing test); obsolete entries stay out of the map (new test with an obsolete entry).%{}keeps its clause and is returned as it is (new fixture entry and test).msgstr[0](existing test, and the plural-only catalog below).:interpolationmodule still serve translations without interpolation through that module (new assertion on the existing custom-interpolator backend).--warnings-as-errors, and answer hits and misses (new test compiling each shape throughKernel.ParallelCompilerso that type-checker warnings are reported; when a PO file has no such translation the lookup is not generated at all, sinceMap.fetch/2on an empty literal map is a type warning).