mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-09 04:06:42 +00:00
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:
1 parent
f83ed78909
commit
ac5e7d54c4
5 files changed
+157
-3
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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
@@ -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)
|
||||
Reference in new issue
Block a user