Skip to content

Commit 636166a

Browse files
committed
tokenization: stop starting a process pool that makes it slower
Asking where the counting loop sits in a full run turned up something larger next door. Tokenization was gated on a fixed PARALLEL_MIN_CHARS = 2_000_000 -- but the measurement table sitting directly above that constant records the pool losing at EVERY size it covers, up to 163M characters, and at exactly 2M it records serial 0.075s against pool 3.870s. The conclusion was never drawn, so every corpus over a couple of megabytes paid ~3s of spawn startup to tokenize more slowly. Re-measured with the v2 tokenizer on synthetic Zipfian text: 6.6M chars serial 0.42s pool 2.91s 26.2M chars serial 1.80s pool 3.52s 52.5M chars serial 3.72s pool 5.07s The gap narrows but does not close; extrapolation puts break-even beyond 100M characters, which for a package aimed at small domain corpora is never. The cost is not the tokenizing, it is shipping every string to a worker and every token list back. The decision is now measured -- sample, extrapolate, require a margin -- mirroring pair_counts._pool_is_worth_starting, which is already accurate in practice (estimated 2.81s against an actual 3.09s). The probe strides across the input instead of taking its head, because corpora are usually ordered and a head is not a fair sample. The optimism of the serial/workers model is documented where it lives rather than hidden. Measured effect on a 1.5M-token run: corpus construction 4.05s -> 1.73s, end to end 12.41s -> 10.04s (19%). Scheduling never affected the result: map_pool preserves order and the tokenizer is pure, so serial and pooled output are identical and no score moves. The two tests that forced the pool through the character threshold now force the measured verdict instead, and the case that was actually broken -- clearing the character gate must NOT be enough on its own -- is now a test of its own.
1 parent b597904 commit 636166a

4 files changed

Lines changed: 203 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,21 @@ Everything below is user-visible; several items change numeric results.
77

88
### Added
99

10+
- **Tokenization no longer starts a process pool that makes it slower.** The
11+
pool was gated on a fixed `PARALLEL_MIN_CHARS = 2_000_000`, but the
12+
measurements recorded next to that constant show the pool losing at *every*
13+
size tested, up to 163M characters -- so every corpus above a couple of
14+
megabytes paid ~3s of spawn startup to tokenize more slowly. Re-measured with
15+
the v2 tokenizer: 6.6M chars 0.42s serial vs 2.91s pooled; 52.5M chars 3.72s
16+
vs 5.07s. The decision is now *measured* (sample, extrapolate, require a
17+
margin) the same way `count_pairs` already decides, so it self-calibrates
18+
instead of being quietly wrong. On a 1.5M-token corpus this cuts corpus
19+
construction from 4.05s to 1.73s and a full corpus->count->PMI->SVD->evaluate
20+
run from 12.41s to 10.04s (**19% end to end**).
21+
22+
Scheduling never affected the result -- `map_pool` preserves order and the
23+
tokenizer is pure -- so no score or matrix changes.
24+
1025
- **A dead worker now explains itself.** A script without an
1126
`if __name__ == "__main__":` guard makes every spawned worker re-run the
1227
script from the top — including the `hyperhyper` call that started the pool —

hyperhyper/preprocessing.py

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,12 @@
66
import multiprocessing
77
import re
88
import sys
9+
import time
910
import unicodedata
1011

1112
from tqdm import tqdm
1213

13-
from .utils import map_pool
14+
from .utils import _default_workers, map_pool
1415

1516
logger = logging.getLogger(__name__)
1617

@@ -74,6 +75,110 @@ def _preprocess_string(s, filters):
7475
# tokenization.
7576
PARALLEL_MIN_CHARS = 2_000_000
7677

78+
# The threshold above turned out to be far too low, and the table above says why
79+
# without having drawn the conclusion: the pool is a *net loss at every size that
80+
# was measured*, including 163M characters -- yet 2M lets it run, and at exactly
81+
# 2M the same table records serial 0.075s against pool 3.870s. Every corpus
82+
# larger than a couple of megabytes therefore paid ~3s of spawn startup to make
83+
# tokenization slower.
84+
#
85+
# Re-measured with `tokenize_string_v2` on synthetic Zipfian text (M1 Pro, 10
86+
# workers, Python 3.12):
87+
#
88+
# 6.6M chars serial 0.42s pool 2.91s
89+
# 26.2M chars serial 1.80s pool 3.52s
90+
# 52.5M chars serial 3.72s pool 5.07s
91+
#
92+
# The gap narrows but does not close: extrapolating the two slopes puts
93+
# break-even beyond 100M characters, which for a package aimed at *small,
94+
# domain-specific* corpora is never. The cost is not the tokenizing, it is
95+
# shipping every string to a worker and every token list back.
96+
#
97+
# So the decision is no longer a fixed character count. It is measured, the same
98+
# way `pair_counts._pool_is_worth_starting` measures it: tokenize a sample,
99+
# extrapolate, and only start a pool if it beats serial by a margin. That
100+
# self-calibrates on machines and text this table never saw, and it cannot go on
101+
# being quietly wrong.
102+
#
103+
# Scheduling has no effect on the *result*: `map_pool` preserves order and the
104+
# tokenizer is a pure function, so serial and pooled output are identical.
105+
106+
# What a pool costs before it tokenizes anything -- dominated by each spawned
107+
# child importing the package. Same measurement as `pair_counts`.
108+
POOL_STARTUP_SECONDS = 3.0
109+
110+
# How much faster the pool must look before it is worth starting.
111+
POOL_SPEEDUP_MARGIN = 1.3
112+
113+
# Texts tokenized in-process to estimate the per-text cost: enough to be above
114+
# timer noise, few enough to be a rounding error on any corpus where the answer
115+
# is not already obvious.
116+
PROBE_TEXTS = 2000
117+
118+
119+
def _pool_is_worth_starting(texts, tokenizer):
120+
"""
121+
Whether tokenizing `texts` across a process pool beats doing it here.
122+
123+
Measured rather than derived from a character count, because the per-character
124+
cost is not a property of the corpus: it swings with text shape (many short
125+
lines cost more per character than few long ones) and with the tokenizer.
126+
"""
127+
workers = _default_workers()
128+
if len(texts) < 2 or workers < 2:
129+
return False
130+
131+
# Sample with a stride rather than taking the first N: a corpus is very
132+
# often ordered (by document, by date, by source), so its head is not a fair
133+
# sample of it. Probing `texts[:N]` of a corpus whose short headlines come
134+
# first would underestimate the work and refuse a pool that was worth
135+
# starting -- and the reverse for a corpus that opens with long documents.
136+
step = max(1, len(texts) // PROBE_TEXTS)
137+
sample = texts[::step][:PROBE_TEXTS]
138+
start = time.perf_counter()
139+
for t in sample:
140+
tokenizer(t)
141+
elapsed = time.perf_counter() - start
142+
if elapsed == 0:
143+
return False
144+
145+
serial = elapsed / len(sample) * len(texts)
146+
# `serial / workers` is the *optimistic* bound: it counts the tokenizing but
147+
# not the cost of shipping every string to a worker and every token list
148+
# back, which the measurements above show to be the dominant term for this
149+
# workload. The estimate is therefore biased towards the pool, and
150+
# `POOL_SPEEDUP_MARGIN` is what keeps that bias from deciding marginal cases.
151+
parallel = POOL_STARTUP_SECONDS + serial / min(workers, len(texts))
152+
logger.debug(
153+
"tokenizing %d texts: serial ~%.2fs, pool ~%.2fs", len(texts), serial, parallel
154+
)
155+
return parallel * POOL_SPEEDUP_MARGIN < serial
156+
157+
158+
def _should_pool(texts, tokenizer):
159+
"""
160+
The single place that decides serial vs pool for tokenization.
161+
162+
Three gates, cheapest first:
163+
164+
1. **Inside a worker** -- never. `Corpus.from_text_files` already runs the
165+
tokenizer in a pool of `workers` processes, so nesting would ask for
166+
`workers ** 2`.
167+
2. **Below `PARALLEL_MIN_CHARS`** -- never, without measuring. This keeps
168+
the common small case (the evaluation preprocesses one dataset column at
169+
a time) from paying even for the probe.
170+
3. **Otherwise, measured** -- see `_pool_is_worth_starting`.
171+
172+
All three are scheduling only: `map_pool` preserves order and the tokenizer
173+
is pure, so the tokens are identical whichever way this goes.
174+
"""
175+
if multiprocessing.parent_process() is not None:
176+
return False
177+
if sum(len(t) for t in texts) < PARALLEL_MIN_CHARS:
178+
return False
179+
return _pool_is_worth_starting(texts, tokenizer)
180+
181+
77182
# spaCy is imported lazily, not at module import time: it costs ~2.2s, it is
78183
# only ever needed by `texts_to_sents`, and every process-pool child that
79184
# imports this module used to pay for it. `_UNSET` distinguishes "not tried
@@ -154,10 +259,7 @@ def tokenize_texts_parallel(texts):
154259
if not hasattr(texts, "__len__"):
155260
texts = list(texts)
156261

157-
if multiprocessing.parent_process() is not None:
158-
return tokenize_texts(texts)
159-
160-
if sum(len(t) for t in texts) < PARALLEL_MIN_CHARS:
262+
if not _should_pool(texts, tokenize_string):
161263
return tokenize_texts(texts)
162264

163265
return map_pool(texts, tokenize_string)
@@ -263,10 +365,7 @@ def tokenize_texts_parallel_v2(texts):
263365
if not hasattr(texts, "__len__"):
264366
texts = list(texts)
265367

266-
if multiprocessing.parent_process() is not None:
267-
return tokenize_texts_v2(texts)
268-
269-
if sum(len(t) for t in texts) < PARALLEL_MIN_CHARS:
368+
if not _should_pool(texts, tokenize_string_v2):
270369
return tokenize_texts_v2(texts)
271370

272371
return map_pool(texts, tokenize_string_v2)

tests/test_evaluation.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -885,6 +885,7 @@ def test_parallel_preprocessing_does_not_change_any_score(
885885
assert count_pools == []
886886

887887
monkeypatch.setattr(preprocessing, "PARALLEL_MIN_CHARS", 0)
888+
monkeypatch.setattr(preprocessing, "_pool_is_worth_starting", lambda *_: True)
888889
pooled = evaluation.eval_similarity(
889890
toy_embedding, TOKEN2ID, tokenize_texts_parallel, lang="en"
890891
)

tests/test_preprocessing.py

Lines changed: 79 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
import subprocess
1515
import sys
16+
import time
1617
import unicodedata
1718
from concurrent import futures
1819

@@ -167,11 +168,12 @@ def test_small_input_does_not_spawn_a_pool(pool_calls):
167168

168169
def test_large_input_still_uses_the_pool(monkeypatch, pool_calls):
169170
"""
170-
The threshold must not quietly delete the parallel path that large corpora
171-
rely on. Lowering it is equivalent to handing in a huge input, without
172-
building one.
171+
The decision must not quietly delete the parallel path that large corpora
172+
rely on. Forcing the verdict is equivalent to handing in an input big enough
173+
for the pool to pay, without building one.
173174
"""
174175
monkeypatch.setattr(preprocessing, "PARALLEL_MIN_CHARS", 1)
176+
monkeypatch.setattr(preprocessing, "_pool_is_worth_starting", lambda *_: True)
175177

176178
result = tokenize_texts_parallel(TEXTS)
177179

@@ -180,16 +182,62 @@ def test_large_input_still_uses_the_pool(monkeypatch, pool_calls):
180182
assert result == tokenize_texts(TEXTS)
181183

182184

183-
def test_threshold_counts_characters_not_items(monkeypatch, pool_calls):
185+
def test_character_threshold_short_circuits_before_measuring(monkeypatch, pool_calls):
186+
"""
187+
Below `PARALLEL_MIN_CHARS` the decision is made without measuring at all --
188+
the probe would otherwise cost more than tokenizing the whole input, which
189+
is the small-input case this gate exists for. The threshold counts
190+
characters, not items.
191+
"""
192+
measured = []
193+
monkeypatch.setattr(
194+
preprocessing,
195+
"_pool_is_worth_starting",
196+
lambda *_: measured.append(1) or True,
197+
)
184198
monkeypatch.setattr(preprocessing, "PARALLEL_MIN_CHARS", 100)
185199

186200
tokenize_texts_parallel(["a b c"] * 5) # 25 chars, way under
187201
assert pool_calls == []
202+
assert measured == [] # not even probed
188203

189-
tokenize_texts_parallel(["x" * 60] * 2) # 120 chars, over
204+
tokenize_texts_parallel(["x" * 60] * 2) # 120 chars, over -> now measured
205+
assert measured == [1]
190206
assert len(pool_calls) == 1
191207

192208

209+
def test_pool_is_refused_when_it_would_not_pay(monkeypatch, pool_calls):
210+
"""
211+
The bug this replaced: `PARALLEL_MIN_CHARS` alone let every corpus over a
212+
couple of megabytes into a pool that was measured to be a NET LOSS at every
213+
size up to 163M characters -- paying ~3s of spawn startup to make
214+
tokenization slower. Clearing the character gate must not be enough; the
215+
work has to actually be worth distributing.
216+
"""
217+
monkeypatch.setattr(preprocessing, "PARALLEL_MIN_CHARS", 1)
218+
219+
result = tokenize_texts_parallel(TEXTS)
220+
221+
assert pool_calls == [] # a handful of short strings: nowhere near worth it
222+
assert result == tokenize_texts(TEXTS)
223+
224+
225+
def test_pool_verdict_scales_with_the_measured_cost(monkeypatch):
226+
"""
227+
The verdict is measured, not derived from a size: a tokenizer slow enough
228+
that distributing it beats a ~3s pool startup is accepted, the same input
229+
with a fast one is refused.
230+
"""
231+
texts = ["a b c"] * 5000
232+
233+
def slow(text):
234+
time.sleep(0.001)
235+
return text.split()
236+
237+
assert preprocessing._pool_is_worth_starting(texts, slow) is True
238+
assert preprocessing._pool_is_worth_starting(texts, str.split) is False
239+
240+
193241
def test_accepts_an_iterator(pool_calls):
194242
"""
195243
`eval_similarity` hands in the columns of a dataset, which are tuples, and
@@ -238,3 +286,29 @@ def test_a_disabled_spacy_is_not_re_imported(monkeypatch):
238286

239287
assert preprocessing._import_spacy() is None
240288
assert preprocessing.spacy is None
289+
290+
291+
def test_pool_probe_samples_across_the_input(monkeypatch):
292+
"""
293+
A corpus is usually ordered -- by document, by date, by source -- so its head
294+
is not a fair sample of it. Probing only `texts[:N]` of a corpus that opens
295+
with short lines and ends with long ones would underestimate the work and
296+
refuse a pool worth starting.
297+
"""
298+
seen = []
299+
300+
def record(text):
301+
seen.append(text)
302+
return text.split()
303+
304+
monkeypatch.setattr(preprocessing, "PROBE_TEXTS", 10)
305+
texts = [f"t{i}" for i in range(1000)]
306+
preprocessing._pool_is_worth_starting(texts, record)
307+
308+
assert len(seen) == 10
309+
# spread across the input rather than taken from its head: a stride of
310+
# `len // PROBE_TEXTS` reaches the last decile, where `texts[:10]` would
311+
# never leave the first percent
312+
assert seen[0] == "t0"
313+
assert seen[-1] == "t900"
314+
assert seen == [f"t{i}" for i in range(0, 1000, 100)]

0 commit comments

Comments
 (0)