Skip to content

Speed up tokenization ~25%, add perf bench, clean up lint and the stale gold file - #64

Merged
vthorsteinsson merged 3 commits into
masterfrom
perf/regex-precompile-and-cleanup
Oct 15, 2026
Merged

vthorsteinsson merged 3 commits into
masterfrom
perf/regex-precompile-and-cleanup

Conversation

@vthorsteinsson

Copy link
Copy Markdown
Member

Three independent commits; review them one at a time.

1. Clean up ruff findings from its 0.16 default rule set

Ruff 0.16 turned on pyupgrade, RUF, SIM and other rule families by default. Because the lint group pins ruff>=0.15, unchanged code suddenly had 213 findings. CI runs only mypy and pytest, so it stayed green.

  • Safe autofixes: list[str], X | None, collections.abc imports (fine with the 3.10 floor).
  • Not applied: the autofix that rewrote Tok.from_txt/from_token to use typing_extensions.Self. Tokenizer has no runtime dependencies, so that import would fail on a clean install. The tests didn't catch it because typing_extensions comes in through dev dependencies. The original bound TypeVar stays, with a noqa and the reason.
  • Manual: ClassVar on the Abbreviations tables, tuple startswith/endswith, yield from, datetime.date for date validation.
  • FURB166 is a false positive: its base=0 suggestion would break &#x...; HTML escapes, so that line gets a noqa.
  • SIM102 is ignored in config rather than collapsing 9 nested ifs in the core matching logic. Some of them sit in elif chains, where collapsing would change behavior. If you'd rather collapse the safe ones, say so.

2. Speed up tokenization ~25% and add a performance bench

Found with the new CPython 3.15 sampling profiler (python -m profiling.sampling):

  • The zero-width and composite-glyph replacement passes ran a regex scan on every token. Now one check per text decides whether they are needed at all. (~10%)
  • parse_digits makes up to ~25 re.match("<pattern>", w) calls per number, each paying ~220 ns of re cache lookup. All string-pattern regexes in tokenizer.py are now precompiled. (~12%)
tokenize, large test set before after
CPython 3.13 1371 ms 1097 ms +25%
CPython 3.14 1472 ms 1150 ms +28%
CPython 3.15 1381 ms 1128 ms +22%

Output is byte-identical: digests of (kind, txt, val, original, origin_spans) for every token match across all five bench workloads.

bench/bench.py records timings plus output digests (--save / --compare). bench/profile.sh writes a pstats summary, self time per function, a flamegraph and a line heatmap. It uses --blocking on purpose: without it, the 3.15 profiler fails to unwind ~70% of stacks in this generator pipeline (its "Error rate" header) and charges inner stages' work to the outer stages' next() lines.

On match statements (looked at while doing this): on 3.15, value patterns like case TOK.WORD: still compile to the same COMPARE_OP + jump sequence as an if/elif chain, with no jump table. In a microbenchmark they were 8–14% slower on 3.13–3.15, so this PR doesn't use them.

3. Regenerate the large test set gold file and enforce it with a test

toktest_large_gold_acceptable.txt was byte-identical to Tokenizer 2.0.0's output (Jan 2020). Running each release tag's own CLI shows the drift came from 2.2.0, 3.0.0 and 3.4.0, plus one line from #63, and that nothing changed from 3.4.0 through 3.6.4.

  • The gold file is regenerated, and test/test_large_gold.py now fails on any CLI output drift.
  • The [input|gold] ranges in test/Overview.txt are recomputed. The rule used reproduces 127/137 of the original ranges on the old file; the remainder were boundary inconsistencies plus four typos.
  • ⚠️ Please check the Icelandic wording of the dated note added to the Overview.txt legend.
  • ⚠️ The drift is not uniformly an improvement. Compared with 2.0.0 and measured against gold_perfect, current output gains 244 correct lines and loses 215. Examples of losses: sentence-final abbreviations keep their period (ehf. instead of ehf .), and some sentence splits after abbreviations are missed (…am.k. Bara að það væru 7 .). This PR records current behavior as the baseline; it doesn't fix those.

Verification

  • pytest: 152 passed (CPython 3.13 env and 3.10); mypy clean; ruff check + format clean
  • Clean non-editable installs on CPython 3.10 and PyPy 3.11 pull in no other packages, and their CLI output on the large test set is identical
  • Each commit was tested on its own before committing

🤖 Generated with Claude Code

vthorsteinsson and others added 3 commits October 13, 2026 22:20
Ruff 0.16 enabled pyupgrade, RUF, SIM and other rule families by default,
which the `ruff>=0.15` lint dependency picks up: 213 findings in unchanged
code (CI runs only mypy and pytest, so it stayed green).

- Apply ruff's safe autofixes: PEP 585/604 annotations (`list[str]`,
  `X | None`) and `collections.abc` imports, valid since the 3.10 floor.
- Keep the bound TypeVar on `Tok.from_txt`/`from_token` instead of the
  autofix's `typing_extensions.Self`: Tokenizer has no runtime
  dependencies and `typing.Self` needs Python 3.11.
- Annotate the `Abbreviations` class tables as `ClassVar`, use tuple
  `startswith`/`endswith`, `yield from`, implicit format fields, and
  `datetime.date` for date validation.
- `noqa` FURB166: its suggested `base=0` would break `&#x...;` escapes.
- Ignore SIM102: nested ifs mirror the token matching logic, and several
  sit in elif chains where collapsing them would change behaviour.
- Update CLAUDE.md: the 3.9 annotation advice was stale.

No behaviour change: token output digests on the large test set are
identical, and clean installs on CPython 3.10 and PyPy 3.11 work.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Profiling with the CPython 3.15 sampling profiler (`profiling.sampling`)
showed two low-hanging hot spots in the raw token stages:

- `zerowidth_replacement` and `composite_replacement` ran a regex scan on
  every raw token, although such characters are rare. Check once per text
  instead, and skip the per-token passes when none are present (a removed
  zero-width character can bring a composite glyph together, so its
  presence also enables the composite pass). ~10%.
- `parse_digits` tries up to ~25 `re.match(<pattern string>, w)` calls per
  numeric token, each paying ~220 ns of `re` cache lookup before matching.
  Precompile all string-pattern regexes in tokenizer.py at module level,
  and use `str.replace` for the literal `re.sub` calls. ~12%.

Token output is byte-identical (digests of kind, txt, val, original and
origin_spans over the large test set). Speedup on the large test set:
+25% on CPython 3.13, +28% on 3.14, +22% on 3.15.

bench/bench.py times five workloads (tokenize with and without annotation,
single-string input, split_into_sentences, original=True) and records an
output digest per workload; --save/--compare report the speed change and
whether output is identical. bench/profile.sh runs the 3.15 sampling
profiler with --blocking, which is required here: without it the profiler
fails to unwind ~70% of stacks in the generator pipeline and attributes
inner stages' work to the next() calls of outer ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
test/toktest_large_gold_acceptable.txt, documented in the README as the
current output of the tokenize command, was last regenerated in January
2020: it is byte-identical to the output of Tokenizer 2.0.0. Releases
2.2.0, 3.0.0 and 3.4.0 changed 592 of its lines (and PR #63 one more),
and nothing checked it, so the drift went unnoticed. Running each release
tag's own CLI shows no output change from 3.4.0 through 3.6.4.

- Regenerate the gold file from the current tokenize output.
- Add test/test_large_gold.py, which fails when the CLI output for
  test/toktest_large.txt differs from the gold file and shows the first
  differing hunks.
- Recompute the [input|gold] line ranges in test/Overview.txt: each gold
  range now covers all output lines containing text from the input lines.
  That rule reproduces 127 of the 137 original ranges against the old gold
  file; the rest were boundary inconsistencies and four typos (e.g.
  167913, 18187, a reversed input range and a malformed reference).
  A dated note in the legend says the #B/#C descriptions date from 2.0.0.
- README and CLAUDE.md: describe the test and how to regenerate the gold
  file after an intended tokenization change.

Note that the drift is not uniformly an improvement: against
toktest_large_gold_perfect.txt, current output gains 244 perfect lines and
loses 215 compared with 2.0.0 (e.g. sentence-final abbreviations now keep
their period, and a few sentence splits after abbreviations are missed).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vthorsteinsson
vthorsteinsson merged commit f451d6a into master Oct 15, 2026
6 checks passed
@vthorsteinsson
vthorsteinsson deleted the perf/regex-precompile-and-cleanup branch October 15, 2026 15:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant