Spaces:
Running on Zero
Running on Zero
Commit ·
2cf98f7
1
Parent(s): a8287eb
Translate one segment at a time for the local CPU backend
Browse filesBatching doesn't help on CPU the way it does on GPU: a single sequence's
matmuls already use all available threads, so grouping several segments into
one generate() call just pads shorter ones to the longest in the group
(wasted compute) and delays every result in the group until the slowest one
finishes. Looping one at a time removes that padding waste and lets the UI
show each translation as soon as it's ready, with per-segment progress and
stop-checking instead of per-chunk. OpenRouter's batched path (which has a
real reason to batch: fewer HTTP round-trips and shared "preceding" context)
is unchanged.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- engine/translate.py +36 -27
- tests/test_translate.py +60 -2
engine/translate.py
CHANGED
|
@@ -46,6 +46,24 @@ def translate_segments(
|
|
| 46 |
total = len(translatable)
|
| 47 |
print(f"[INFO] Translating {total} segment(s) via {'OpenRouter' if key else 'local CPU model'}.")
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
recent: list[str] = []
|
| 50 |
for batch_start in range(0, total, _BATCH_SIZE):
|
| 51 |
if stop is not None and stop.is_set():
|
|
@@ -57,34 +75,25 @@ def translate_segments(
|
|
| 57 |
if progress_callback:
|
| 58 |
progress_callback(batch_start, total)
|
| 59 |
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
print(f"[WARN] Segment {idx + 1} failed: {e}")
|
| 72 |
-
errors.append(f"Segment {idx + 1}: {e}")
|
| 73 |
-
t = ""
|
| 74 |
-
translations.append(t)
|
| 75 |
-
recent.append(t)
|
| 76 |
-
for idx, t in zip(indices, translations):
|
| 77 |
-
if t:
|
| 78 |
-
segments[idx]["target"] = t
|
| 79 |
-
continue
|
| 80 |
-
else:
|
| 81 |
-
try:
|
| 82 |
-
translations = local_backend.translate_batch(texts)
|
| 83 |
-
except Exception as e:
|
| 84 |
-
print(f"[WARN] Local batch translation failed: {e}")
|
| 85 |
-
for idx in indices:
|
| 86 |
errors.append(f"Segment {idx + 1}: {e}")
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
for idx, t in zip(indices, translations):
|
| 90 |
segments[idx]["target"] = t
|
|
|
|
| 46 |
total = len(translatable)
|
| 47 |
print(f"[INFO] Translating {total} segment(s) via {'OpenRouter' if key else 'local CPU model'}.")
|
| 48 |
|
| 49 |
+
if not key:
|
| 50 |
+
# No GPU to exploit here, so there's no throughput win from batching on CPU
|
| 51 |
+
# (a single sequence's matmuls already use all available threads) — batching
|
| 52 |
+
# would only add padding waste and delay every result in a chunk until the
|
| 53 |
+
# slowest one finishes. One at a time also means the UI can show each
|
| 54 |
+
# translation the moment it's ready instead of waiting on a whole chunk.
|
| 55 |
+
for i, (idx, text) in enumerate(translatable):
|
| 56 |
+
if stop is not None and stop.is_set():
|
| 57 |
+
break
|
| 58 |
+
if progress_callback:
|
| 59 |
+
progress_callback(i, total)
|
| 60 |
+
try:
|
| 61 |
+
segments[idx]["target"] = local_backend.translate_batch([text])[0]
|
| 62 |
+
except Exception as e:
|
| 63 |
+
print(f"[WARN] Local translation failed for segment {idx + 1}: {e}")
|
| 64 |
+
errors.append(f"Segment {idx + 1}: {e}")
|
| 65 |
+
return segments, errors
|
| 66 |
+
|
| 67 |
recent: list[str] = []
|
| 68 |
for batch_start in range(0, total, _BATCH_SIZE):
|
| 69 |
if stop is not None and stop.is_set():
|
|
|
|
| 75 |
if progress_callback:
|
| 76 |
progress_callback(batch_start, total)
|
| 77 |
|
| 78 |
+
preceding = recent[-3:] if recent else None
|
| 79 |
+
translations = openrouter_backend.translate_batch(texts, key, model, template, preceding=preceding)
|
| 80 |
+
if translations is None:
|
| 81 |
+
translations = []
|
| 82 |
+
for idx, text in zip(indices, texts):
|
| 83 |
+
if stop is not None and stop.is_set():
|
| 84 |
+
break
|
| 85 |
+
try:
|
| 86 |
+
t = openrouter_backend.translate_one(text, key, model, template)
|
| 87 |
+
except Exception as e:
|
| 88 |
+
print(f"[WARN] Segment {idx + 1} failed: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
errors.append(f"Segment {idx + 1}: {e}")
|
| 90 |
+
t = ""
|
| 91 |
+
translations.append(t)
|
| 92 |
+
recent.append(t)
|
| 93 |
+
for idx, t in zip(indices, translations):
|
| 94 |
+
if t:
|
| 95 |
+
segments[idx]["target"] = t
|
| 96 |
+
continue
|
| 97 |
|
| 98 |
for idx, t in zip(indices, translations):
|
| 99 |
segments[idx]["target"] = t
|
tests/test_translate.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
import threading
|
| 2 |
-
from unittest.mock import patch
|
| 3 |
|
| 4 |
import pytest
|
| 5 |
|
|
@@ -87,12 +87,70 @@ def test_translate_segments_skips_empty_source():
|
|
| 87 |
def test_translate_segments_local_backend_path(monkeypatch):
|
| 88 |
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
| 89 |
segments = _segments("a", "b")
|
| 90 |
-
|
|
|
|
|
|
|
| 91 |
result, errors = tr.translate_segments(segments, None, "model")
|
| 92 |
assert [s["target"] for s in result] == ["A", "B"]
|
| 93 |
assert errors == []
|
| 94 |
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
def test_translate_segments_local_backend_error_recorded(monkeypatch):
|
| 97 |
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
| 98 |
segments = _segments("a", "b")
|
|
|
|
| 1 |
import threading
|
| 2 |
+
from unittest.mock import call, patch
|
| 3 |
|
| 4 |
import pytest
|
| 5 |
|
|
|
|
| 87 |
def test_translate_segments_local_backend_path(monkeypatch):
|
| 88 |
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
| 89 |
segments = _segments("a", "b")
|
| 90 |
+
# local backend is called one segment at a time (see test_local_backend_calls_one_at_a_time),
|
| 91 |
+
# so the fake must respond per-call rather than returning one fixed list for all calls.
|
| 92 |
+
with patch("engine.translate.local_backend.translate_batch", side_effect=lambda texts: [texts[0].upper()]):
|
| 93 |
result, errors = tr.translate_segments(segments, None, "model")
|
| 94 |
assert [s["target"] for s in result] == ["A", "B"]
|
| 95 |
assert errors == []
|
| 96 |
|
| 97 |
|
| 98 |
+
def test_translate_segments_local_backend_calls_one_at_a_time(monkeypatch):
|
| 99 |
+
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
| 100 |
+
segments = _segments("a", "b", "c")
|
| 101 |
+
with patch(
|
| 102 |
+
"engine.translate.local_backend.translate_batch", side_effect=lambda texts: [t.upper() for t in texts]
|
| 103 |
+
) as mock_local:
|
| 104 |
+
tr.translate_segments(segments, None, "model")
|
| 105 |
+
assert mock_local.call_args_list == [call(["a"]), call(["b"]), call(["c"])]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def test_translate_segments_local_backend_progress_callback_per_segment(monkeypatch):
|
| 109 |
+
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
| 110 |
+
segments = _segments("a", "b", "c")
|
| 111 |
+
progress_calls = []
|
| 112 |
+
with patch("engine.translate.local_backend.translate_batch", side_effect=lambda texts: [t.upper() for t in texts]):
|
| 113 |
+
tr.translate_segments(
|
| 114 |
+
segments, None, "model",
|
| 115 |
+
progress_callback=lambda i, total: progress_calls.append((i, total)),
|
| 116 |
+
)
|
| 117 |
+
assert progress_calls == [(0, 3), (1, 3), (2, 3)]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def test_translate_segments_local_backend_respects_stop_between_segments(monkeypatch):
|
| 121 |
+
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
| 122 |
+
segments = _segments("a", "b", "c")
|
| 123 |
+
stop = threading.Event()
|
| 124 |
+
|
| 125 |
+
def fake_batch(texts):
|
| 126 |
+
if texts == ["b"]:
|
| 127 |
+
stop.set()
|
| 128 |
+
return [t.upper() for t in texts]
|
| 129 |
+
|
| 130 |
+
with patch("engine.translate.local_backend.translate_batch", side_effect=fake_batch):
|
| 131 |
+
result, errors = tr.translate_segments(segments, None, "model", stop=stop)
|
| 132 |
+
|
| 133 |
+
assert [s["target"] for s in result] == ["A", "B", ""]
|
| 134 |
+
assert errors == []
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def test_translate_segments_local_backend_one_segment_error_does_not_stop_others(monkeypatch):
|
| 138 |
+
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
| 139 |
+
segments = _segments("a", "b", "c")
|
| 140 |
+
|
| 141 |
+
def fake_batch(texts):
|
| 142 |
+
if texts == ["b"]:
|
| 143 |
+
raise RuntimeError("boom")
|
| 144 |
+
return [t.upper() for t in texts]
|
| 145 |
+
|
| 146 |
+
with patch("engine.translate.local_backend.translate_batch", side_effect=fake_batch):
|
| 147 |
+
result, errors = tr.translate_segments(segments, None, "model")
|
| 148 |
+
|
| 149 |
+
assert [s["target"] for s in result] == ["A", "", "C"]
|
| 150 |
+
assert len(errors) == 1
|
| 151 |
+
assert "Segment 2" in errors[0]
|
| 152 |
+
|
| 153 |
+
|
| 154 |
def test_translate_segments_local_backend_error_recorded(monkeypatch):
|
| 155 |
monkeypatch.delenv("OPENROUTER_API_KEY", raising=False)
|
| 156 |
segments = _segments("a", "b")
|