Skip to content

fix(newmm): resolve exponential BFS path explosion in ambiguous tokenization - #1319

Merged
bact merged 3 commits into
devfrom
copilot/investigate-tokenization-issue
Mar 9, 2026
Merged

fix(newmm): resolve exponential BFS path explosion in ambiguous tokenization#1319
bact merged 3 commits into
devfrom
copilot/investigate-tokenization-issue

Conversation

Copilot AI commented Mar 9, 2026

Copy link
Copy Markdown
Contributor

newmm would hang or run extremely slowly on text with many ambiguous breaking points — e.g., repeated "ด้านหน้า" (splittable as "ด้าน"+"หน้า" or "ด้านหน้า", with "หน้าด้าน" also valid). Tokenizing 1,000 repetitions took ~1.4 s before; after this fix it takes ~9 ms.

What do these changes do

  • Add visited set to _bfs_paths_graph (O(2^n) → O(V+E))
  • graph.clear() after each commit point in _onecut (both the ambiguous and no-candidate branches), replacing unbounded accumulation
  • Remove the now-unnecessary graph[begin_pos].append(end_pos) in the no-candidate branch
  • Trie.prefixes(text, start=0) gains an optional start offset — avoids creating an O(n) string copy (text[begin_pos:]) on every dictionary lookup
  • Switch _PAT_NONTHAI.match(text[pos:])_PAT_NONTHAI.match(text, pos) for the same reason
  • Add test_newmm_ambiguous_performance regression test: "ด้านหน้า" * 1000 must complete in < 5 s

What was wrong

Three compounding bugs in newmm._onecut / _bfs_paths_graph:

  1. No visited set in BFS — full paths for every possible route were stored in the queue. With 2 valid split choices per ambiguous word, queue depth grows as 2^n. Measured: 31 pops for n=5, 1 023 for n=10, 57 848 for n=100.
  2. graph never cleared — edges from all prior commit steps accumulated indefinitely, growing the BFS search space over the lifetime of a _onecut call.
  3. text[begin_pos:] slice per Trie lookup — created an O(n) string copy on every call, making the per-character work O(n) and the total O(n²) for large inputs.

How this fixes it

The visited set makes BFS O(V+E); with _MAX_GRAPH_SIZE = 50, that is effectively O(1) per commit. Clearing the graph after each commit keeps V and E small. Passing a start offset to Trie.prefixes() eliminates the string-copy overhead entirely.

Trie.prefixes() change is backward-compatible (default start=0 preserves the existing API):

# Before — allocates entire remaining string on each call
for word in custom_dict.prefixes(text[begin_pos:]):
    ...

# After — no allocation; iterates from offset in the original string
for word in custom_dict.prefixes(text, begin_pos):
    ...

To fix #893

Your checklist for this pull request

  • Passed code styles and structures
  • Passed code linting checks and unit test
Original prompt

Analyses the issue of newmm tokenization engine as reported in #893

Download the text file in the comment to test the tokenizer. See where is the problem.

May need to divide and conquer, split the very long text into smaller parts to find the root cause.
But this may not guaranteed. The issue may not be directly about the content of the string but the length or other combinations of character types that occur in particular sequence.

Try to investigate to find the cause and fix it or avoid it (if can't fix entirely) or at least document about it so the user can aware of the limitations.

Or if it can be confirmed that the issue is no longer there, it's good, so we can close the issue.

This work can takes long time, iterate until can find the solution or can know that there is no reasonable solution.


🔒 GitHub Advanced Security automatically protects Copilot coding agent pull requests. You can protect all pull requests by enabling Advanced Security for your repositories. Learn more about Advanced Security.

Copilot AI assigned Copilot and bact Mar 9, 2026
@bact bact added this to PyThaiNLP Mar 9, 2026
@bact bact moved this to In progress in PyThaiNLP Mar 9, 2026
@bact bact added the bug bugs in the library label Mar 9, 2026
- Add visited set to _bfs_paths_graph to prevent re-exploring already-visited
  nodes, reducing worst-case BFS from O(2^n) to O(V+E)
- Clear ambiguity graph dict after each commit point in _onecut to prevent
  unbounded accumulation
- Remove unnecessary graph edge insertion in the no-candidate (elif) branch
- Use Trie.prefixes(text, begin_pos) with start offset to avoid creating
  large string copies on every dictionary lookup call
- Use re.Pattern.match(text, pos) to avoid string slices in regex matches
- Add optional start parameter to Trie.prefixes() (backward-compatible)
- Add test_newmm_ambiguous_performance regression test for issue #893
- Update segment() docstring and CHANGELOG.md

Co-authored-by: bact <128572+bact@users.noreply.github.com>
Copilot AI changed the title [WIP] Analyze and address newmm tokenization engine issue fix(newmm): resolve exponential BFS path explosion in ambiguous tokenization Mar 9, 2026
Copilot finished work on behalf of bact March 9, 2026 13:50
@bact
bact marked this pull request as ready for review March 9, 2026 13:58
@coveralls

coveralls commented Mar 9, 2026

Copy link
Copy Markdown

Coverage Status

coverage: 65.546% (+0.01%) from 65.535%
when pulling 98511de on copilot/investigate-tokenization-issue
into f0d2f71 on dev.

@sonarqubecloud

sonarqubecloud Bot commented Mar 9, 2026

Copy link
Copy Markdown

@bact
bact merged commit 95fa200 into dev Mar 9, 2026
28 checks passed
@github-project-automation github-project-automation Bot moved this from In progress to Done in PyThaiNLP Mar 9, 2026
@bact
bact deleted the copilot/investigate-tokenization-issue branch March 9, 2026 14:15
@bact

bact commented Mar 9, 2026

Copy link
Copy Markdown
Member

From a simple test, run locally, this PR speed up the tokenization. But newmm in normal mode (safe_mode = False) consumes way too much memory.

[newmm(safe_mode=False), dev branch]
  Input size     : 0.1 MB
  Tokens         : 4,823
  Wall-clock time: 0.552 s
  Peak memory    : 81.9 MB
.
[newmm(safe_mode=True), dev branch]
  Input size     : 0.1 MB
  Tokens         : 4,836
  Wall-clock time: 0.100 s
  Peak memory    : 0.4 MB
.
----------------------------------------------------------------------


[newmm(safe_mode=False), 5.2 branch]
  Input size     : 0.1 MB
  Tokens         : 4,823
  Wall-clock time: 0.759 s
  Peak memory    : 0.5 MB
.
[newmm(safe_mode=True), 5.2 branch]
  Input size     : 0.1 MB
  Tokens         : 4,836
  Wall-clock time: 0.566 s
  Peak memory    : 0.5 MB
.
----------------------------------------------------------------------

Trying to fix this with PR #1323

@bact

bact commented Mar 10, 2026

Copy link
Copy Markdown
Member

Got it. The real "issue" is that the dictionary loading was made lazy by #1186 so at the first run of the tokenizer, it needs to load the dictionary.

Try switching the order of two tokenizers (safe_mode=False, then safe_mode=True) yields similar result. The first run will got the higher peak memory, because of the dictionary loading.

So this is not a performance bug, but by design.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug bugs in the library

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Report: newmm bug

3 participants