Repository navigation
Remove redundant work from extraction, backend compilation and runtime interpolation - #445
oliver-kriska wants to merge 3 commits into
Conversation
`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`.
b17f3d8 to
180931c
Compare
|
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 180931c, versus
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
— Oliver's Claude Code & Amp |
josevalim
left a comment
There was a problem hiding this comment.
looks good to me, i think we can remove tests and ship it!
| BackendWithDefaultDomain | ||
| } | ||
|
|
||
| defmodule CountingPlural do |
There was a problem hiding this comment.
In my opinion we can get rid of this test, it is mostly implementation details.
There was a problem hiding this comment.
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
180931c to
7ff8301
Compare
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_infovalue 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
Avoid reading each existing POT file twice during extraction.
read_contents_and_parse/1didFile.read!/1and thenPO.parse_file!/2, which reads the same file again.PO.parse_string!(contents, file: path)is whatparse_file!does after its own read (samefileoption, same BOM and error handling), so the result is identical. A cleanup: it removes a read per domain without adding code.Compute plural info once per PO file at compile time.
compile_po_file/5calledPlural.plural_info/3twice per PO file, innplurals/3andcompile_plural_forms/4. Besides the duplicated work (about 7 us per call with aPlural-Formsheader), this contradicts the documented contract ofc:Gettext.Plural.init/1: "Gettext calls this callback once at compile time". A custom plural module with an expensive or side-effectinginit/1saw it called twice per PO file. The deprecation warning for aPlural-Formsheader that only setsnpluralsis also printed once per file now instead of twice.Compile the interpolation patterns once per VM.
to_interpolatable/1compiled the"%{"and"}"patterns with:binary.compile_pattern/1on 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 extraputs at startup. Passing the literal strings to:binary.split/2instead 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.A compiled hit (
gettextwith 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/5calls on a backend compiled from a production catalog (176 PO files, 16 locales, 73k translations; onlyenandskloaded for the run), compiled benchmark loop, median of 7 consecutive samples per version, dev build, same machine. In that applicationenis the source language (itsdefault.pohas one translated string), so the measured English messages miss and go throughhandle_missing_translation/5andruntime_interpolate/2. That is the path commit 3 speeds up:lgettext/5in localeen(miss)sk, no bindings, for referenceCommits 1 and 2 remove one file read per POT and one
plural_info/3call 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:mix compile --force, whole application (2,358 files)mix compile(77 files: the backend and what depends on it)mix gettext.generate --no-fuzzy(extraction)mix gettext.merge --no-fuzzy(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
is_binary/1fast clause beforeto_string/1ininterpolate/4: within noise on OTP 27 and 29 (the generated code already tests for binaries before dispatchingString.Chars).Related, not in this PR
%{}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.mergeparses 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.handle_missing_translation/5is a public, overridable callback receiving the rawmsgid.Expo.PluralForms.index/2costs 21 to 107 ns per call (en / sk / pl) against about 7 ns native; not worth a compiler change.