feat(i18n): measure the fastest client settings instead of guessing them

The translator sized its batches and lanes from constants and the CPU count,
which is wrong on any host whose bottleneck is the GPU rather than the cores.
`make i18n-tune` sweeps batch sizes 10 to 80 against lane factors 0.5 to 2.0
over 400 real msgids, keeps the pair with the highest entries per second and
writes it to build/i18n-tuning.json, beside the history
cli/contributing/update/collection_source already keeps there.

`make dotenv` turns that file into INFINITO_I18N_BATCH_SIZE and
INFINITO_I18N_LANES, and a translation run prefers them over its own
derivation. With no sweep on disk the handler emits the shipped defaults, so
a checkout that never tunes behaves as before.

The handler had spelled the batch default out a second time; it now takes
BATCH_SIZE from the client that owns it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5 committed 2026-09-24 19:53:51 +02:00
1 parent f83ed78909
commit ac5e7d54c4
5 files changed
+157 -3

No files matched your search

+6
View File
@@ -351,6 +351,12 @@ i18n-extract:
i18n-translate:
@"$${PYTHON}" -m cli.build.i18n translate --domain "$(domain)" $(if $(languages),--languages "$(languages)")
.PHONY: i18n-tune
# Measure the fastest LibreTranslate client settings on this host and record them for `make dotenv`.
# Param language: ISO 639-1 code the sweep translates into (default: de)
i18n-tune:
@"$${PYTHON}" -m cli.build.i18n tune $(if $(language),--language "$(language)")
.PHONY: install
# Install all runtime dependencies.
# Note: incremental via a stamp file (see scripts/install/all.sh).
+1
View File
@@ -50,6 +50,7 @@ alias i8he='m help'
alias i8inex='m i18n-extract'
alias i8inma='m integration-matrix'
alias i8inta='m i18n-translate'
alias i8intu='m i18n-tune'
alias i8kelo='m kernel-loop-load'
alias i8la='m alias'
alias i8li='m lint'
+18 -2
View File
@@ -5,6 +5,7 @@ Usage:
python -m cli.build.i18n translate --domain core|docs [--languages de,fr]
python -m cli.build.i18n languages [--domain core|docs]
python -m cli.build.i18n prune [--domain core|docs] [--languages de,fr]
python -m cli.build.i18n tune [--language de]
"""
from __future__ import annotations
@@ -26,6 +27,7 @@ from utils.i18n.catalog import (
read_catalog,
write_catalog,
)
from utils.i18n.client import BATCH_SIZE
from utils.i18n.extract import core_messages, docs_template
from utils.i18n.languages import (
DOMAINS,
@@ -166,9 +168,11 @@ def translate(domains: list[str], requested: list[str]) -> int:
cpus = usable_cpus()
codes = sorted({code for _, code in work})
lanes = min(len(work), cpus)
tuned = int(os.environ.get("INFINITO_I18N_LANES") or 0)
lanes = min(len(work), tuned or cpus)
batch = int(os.environ.get("INFINITO_I18N_BATCH_SIZE") or BATCH_SIZE)
with server(PROJECT_ROOT, codes, cpus) as url:
client = LibreTranslate(url, max(cpus // lanes, 1))
client = LibreTranslate(url, max(cpus // lanes, 1), batch_size=batch)
client.wait(codes, READY_TIMEOUT_SECONDS)
with ThreadPoolExecutor(lanes) as pool:
for _ in pool.map(lambda job: one_catalog(client, *job), work):
@@ -203,7 +207,19 @@ def main() -> int:
default="",
help="Comma-separated ISO 639-1 codes; empty for every language.",
)
tune_parser = commands.add_parser(
"tune", help="Measure the fastest client settings on this host."
)
tune_parser.add_argument(
"--language",
default="de",
help="ISO 639-1 code the sweep translates into.",
)
args = parser.parse_args()
if args.command == "tune":
from cli.build.i18n.tune import main as tune_main
return tune_main(args.language, usable_cpus())
if args.command == "extract":
return extract([args.domain] if args.domain else list(DOMAINS))
if args.command == "languages":
+127
View File
@@ -0,0 +1,127 @@
"""Measure the fastest client settings against the deployed LibreTranslate.
The server's own thread count follows the CPUs its container may use, which a
deploy decides; only the client side is free to vary per run. This sweeps it
against real catalog entries and records the winner for the env builder, so a
tuned host keeps its numbers across every `make dotenv`.
"""
from __future__ import annotations
import json
import time
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from utils.cache.files import PROJECT_ROOT
from utils.i18n.catalog import catalog_path, read_catalog
from utils.i18n.languages import load_languages, translatable
from utils.i18n.libretranslate import READY_TIMEOUT_SECONDS, LibreTranslate, server
from utils.i18n.translate import pending
TUNING_FILE = Path("build") / "i18n-tuning.json"
SAMPLE_SIZE = 400
BATCH_SIZES = (10, 20, 40, 80)
LANE_FACTORS = (0.5, 1.0, 2.0)
def sample(domain: str, code: str) -> list[str]:
"""Return real source strings the tuner measures against.
Args:
domain: catalog domain to draw from.
code: ISO 639-1 code of the catalog.
"""
catalog = read_catalog(catalog_path(PROJECT_ROOT, code, domain))
todo = pending(catalog)
if len(todo) < SAMPLE_SIZE:
todo = (todo * (SAMPLE_SIZE // max(len(todo), 1) + 1))[:SAMPLE_SIZE]
return [message.id for message in todo[:SAMPLE_SIZE]]
def rate(url: str, texts: list[str], code: str, batch: int, lanes: int) -> float:
"""Return entries per second for one setting pair.
Args:
url: base URL of the running server.
texts: the sample every setting pair is measured against.
code: target language.
batch: texts per request.
lanes: concurrent requests.
"""
client = LibreTranslate(url, 1, batch_size=batch)
width = max(len(texts) // lanes, 1)
slices = [texts[i : i + width] for i in range(0, len(texts), width)]
started = time.monotonic()
with ThreadPoolExecutor(lanes) as pool:
for _ in pool.map(lambda chunk: client.translate(chunk, code), slices):
pass
return len(texts) / (time.monotonic() - started)
def tune(cpus: int, code: str) -> dict:
"""Sweep the client settings and return the fastest pair.
Args:
cpus: the CPU count lane factors scale from.
code: target language the sweep translates into.
"""
texts = sample("docs", code)
results: list[dict] = []
with server(PROJECT_ROOT, [code], cpus) as url:
client = LibreTranslate(url, 1)
client.wait([code], READY_TIMEOUT_SECONDS)
for batch in BATCH_SIZES:
for factor in LANE_FACTORS:
lanes = max(int(cpus * factor), 1)
measured = rate(url, texts, code, batch, lanes)
results.append(
{
"batch_size": batch,
"lanes": lanes,
"entries_per_second": measured,
}
)
print(
f"batch={batch:3d} lanes={lanes:3d} {measured:7.1f} entries/s",
flush=True,
)
best = max(results, key=lambda r: r["entries_per_second"])
return {"best": best, "measurements": results}
def write(outcome: dict) -> Path:
"""Persist the sweep so the env builder can read it.
Args:
outcome: what ``tune`` returned.
"""
path = PROJECT_ROOT / TUNING_FILE
path.parent.mkdir(exist_ok=True)
path.write_text(json.dumps(outcome, indent=2) + "\n", encoding="utf-8")
return path
def main(code: str, cpus: int) -> int:
"""Run the sweep and record its winner.
Args:
code: target language the sweep translates into.
cpus: the CPU count lane factors scale from.
Returns:
0 on success, 2 when no catalog offers the language.
"""
if code not in translatable(load_languages(PROJECT_ROOT), "docs"):
print(f"LibreTranslate does not support {code}")
return 2
outcome = tune(cpus, code)
best = outcome["best"]
path = write(outcome)
print(
f"best: batch_size={best['batch_size']} lanes={best['lanes']} "
f"({best['entries_per_second']:.1f} entries/s), written to {path}",
flush=True,
)
print("Run `make dotenv-force` to publish the values into .env.", flush=True)
return 0
+5 -1
View File
@@ -7,6 +7,8 @@ import json
from pathlib import Path
from typing import TYPE_CHECKING
from utils.i18n.client import BATCH_SIZE
if TYPE_CHECKING:
from utils.env.builder import BuildContext, EnvBuilder
@@ -30,5 +32,7 @@ def measured(repo_root: Path) -> dict:
def apply(eb: EnvBuilder, ctx: BuildContext) -> None:
best = measured(ctx.repo_root)
eb.setdefault(BATCH_KEY, str(best.get("batch_size", 20)), comment=BATCH_COMMENT)
eb.setdefault(
BATCH_KEY, str(best.get("batch_size", BATCH_SIZE)), comment=BATCH_COMMENT
)
eb.setdefault(LANES_KEY, str(best.get("lanes", 0)), comment=LANES_COMMENT)