billingsmoore Claude Sonnet 5 commited on
Commit
2cf98f7
·
1 Parent(s): a8287eb

Translate one segment at a time for the local CPU backend

Browse files

Batching 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>

Files changed (2) hide show
  1. engine/translate.py +36 -27
  2. 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
- if key:
61
- preceding = recent[-3:] if recent else None
62
- translations = openrouter_backend.translate_batch(texts, key, model, template, preceding=preceding)
63
- if translations is None:
64
- translations = []
65
- for idx, text in zip(indices, texts):
66
- if stop is not None and stop.is_set():
67
- break
68
- try:
69
- t = openrouter_backend.translate_one(text, key, model, template)
70
- except Exception as e:
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
- continue
 
 
 
 
 
 
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
- with patch("engine.translate.local_backend.translate_batch", return_value=["A", "B"]):
 
 
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")