OPENNLP-1885: Add subword API and WordPiece encoder, remove BertTokenizer - #1165
Conversation
|
Single-thread throughput measurement after 3fb8b6b, for the record. Machine: AMD Ryzen 9 9950X3D (16 cores, one thread used), Linux, OpenJDK 25.0.3. Workload: 100,100 short texts (77 distinct lines cycled), t5-small unigram model, 32k vocabulary, 1.17M pieces total, 3 warmup passes over the corpus, then 5 timed passes.
That is 1.42x the reference on the same corpus and model, measured call for call from a host language, so the binding's per-call overhead is included in the reference number; the raw C++ core inside a batch loop is faster than that number. The Java side also does more work per token, since it maps every piece back to a UTF-16 span of the original input, which the reference does not produce against the original string. Output is unchanged: the bundled parity fixtures and the T5-small and ALBERT real-model fixtures assert identical pieces, ids, spans, and normalized forms against the reference before and after the optimization commit. |
|
This is now dependent on the embeddings to land. Marking ready for review |
|
If it depends on #1152 , it should still be "draft" state (since the base PR is also draft) |
|
@rzo1 Correction, my earlier comment had the dependency backwards: this PR is standalone on main, and #1152 is the one that stacks on it (its base is this branch). Nothing in opennlp-subword references the embeddings module, and CI is green on the full matrix with no #1152 code involved. Marking it ready again, sorry for the churn. |
cc74b82 to
6d40bc0
Compare
rzo1
left a comment
There was a problem hiding this comment.
Thanks for the extensive work here. Reviewed file-by-file — no blocking issues; the public API validates at the boundary with IAE, serialVersionUIDs are in order, the module is wired in, and the BertTokenizer removal is fine for a pre-release milestone. A handful of minor items to clean up before merge:
Javadoc — missing @throws
WordpieceEncoder.java:75/:87— the 1-arg and 2-arg public constructors delegate to the throwing map constructor (null/duplicate vocab, missing special token) but lack the@throws IllegalArgumentExceptionclause the 5-arg (:106) and map (:126) constructors already carry. Please add it, matching the 5-arg wording.PieceTrie.java:95—build()propagates IAE on a duplicate piece but omits the@throwsbothBuildermethods carry. Add@throws IllegalArgumentException Thrown if a piece is defined more than once.DoubleArrayTrie.java:57— constructor throws IAE whenlengthis non-positive or not a multiple of four; add@throws IllegalArgumentException Thrown if length is not a positive multiple of four.DoubleArrayTrie.java:79—longestPrefixMatch()maps an out-of-range unit reference (corrupt data) to IAE with no@throws; please document it.
Constants
ModelProtoReader.java:100— the tag field-number shift (>>> 3) and wire-type mask (& 7) are raw literals repeated ~14×, whileWIRE_*/FIELD_*are already named constants. Please declareTAG_FIELD_SHIFT = 3andTAG_WIRE_MASK = 7(orfieldOf(tag)/wireTypeOf(tag)helpers) and use them at every tag-decomposition site.PieceTrie.java:70— the 256-entry dispatch-table width is an unnamed literal at:70and:76; onlyDIRECT_THRESHOLDis named. Please addDIRECT_TABLE_SIZE = 256.
Duplication
IntBuilder.javavsByteBuilder.java— the 1.5× growth (data.length + (data.length >> 1)), theMath.max(capacity, 16)floor, andtruncate()validation are duplicated byte-for-byte. Please name the shared16floor and growth policy so the two copies stay in sync. The int/byte split itself is fine as primitive specialization.
Comments
SentencePieceNormalizer.java:150/:193— "heading whitespace" / "heading spaces" should read "leading" in both comments.
Exception convention (discussion)
SentencePieceTokenizer.java:224/:240—load(Path)/load(InputStream)surface a malformed.modelas the uncheckedIllegalArgumentException, which diverges from the other OpenNLP model loaders that throw the checkedInvalidFormatExceptionfor bad model content. Would you considerInvalidFormatExceptionfor content errors (keeping IAE for null-arg guards)? The class currently uses IAE uniformly by design, so flagging for discussion rather than as a fix.
Process
BertTokenizershipped in theopennlp-3.0.0-M4tag, so describing its removal as "unreleased" is slightly imprecise. Not blocking for a milestone, but please correct the wording and add a one-line migration pointer for formerBertTokenizerusers →WordpieceEncoder.
One note on coverage: I reviewed the parity/fixture tests for form, not by re-running them. The piece-for-piece reference assertions look right structurally, but I have not executed the suite as part of this pass.
|
This will be projected to OpenNLP 3.0.0 (M6) - not M5 |
…ENNLP-1895 recorded Restate the map against apache main a864230, cut as 3.0.0-M5 on 2026-07-24. apache#1177 (OPENNLP-1870) merged upstream and moves into the merged box, apache#1190 and apache#1191 are marked ready for review, and OPENNLP-1895 (quantized embedding tables) joins the diagram in its own colour: filed in JIRA with the pull request deliberately held until apache#1165 and apache#1152 move. Statuses now carry the measured GitHub draft flag and how far each head sits behind main, which surfaces three things the old text did not: apache#1182 is a draft again, apache#1167 is based on main rather than on apache#1155 and carries the seam and isBlank commits as copies, and apache#1152 reports conflicts only because its apache-hosted sentencepiece base has diverged from the refreshed head.
|
This PR removes stuff which was added in a previous milestone release of opennlp. Please add some rational so reviewers get an idea why sth was dropped. In addition, it needs to be carefully checked, which classes belong into API and which can go into a custom submoduel. |
|
@rzo1 the removals are intentional; I'll provide the full rational in a few hours. I should've provided a changelog at the top of the review. It'll go over every detail. Moving to draft until that detailed explanation lands. |
|
Since I pushed it, while enhancing it I didn't think it was as good of a contract. There are two subword engines and they needed one contract. The new contract is Example: WordpieceEncoder encoder = new WordpieceEncoder(vocabulary, lowerCase);
List<SubwordPiece> pieces = encoder.encode(text); // piece, vocab id, and span
String[] tokens = encoder.encodeToPieces(text); // what BertTokenizer.tokenize returnedThe old pipeline is kept as
|
|
Nothing in your argumentation force the removal: those are separable. EncoderTokenizer already is the compat shim, it's just package-private and in opennlp-dl. Three things I'd like to change:
If you'd rather see the class gone in this PR, the minimum I'd want is the adapter promoted to public API in opennlp-api plus a migration note, so Tokenizer t = new BertTokenizer(vocab, lowerCase) has a one-line replacement. But it's ~30 lines of delegation and it buys back both the source API and the binary compatibility, so I'd rather deprecate now and remove in 3.1, after it has been deprecated through one stable release. The encoder itself and the span mapping look good - this is only about how we retire the old entry point. |
|
No problem! On it now. |
|
All three are in.
The compatibility check before deleting the old pipeline surfaced a real divergence: the encoder kept U+2028 and U+2029 inside words while the old One deliberate drift to follow convention, documented in the |
|
Per our DEV conversation, I have slimmed this one down a bit so the rest of the impl can be in an addon The API portion is here. |
|
Here are some load bearing ( ;-) ) comments: Blocking1. So Greek text ending in Σ now tokenizes differently from HuggingFace and from what shipped in M4/M5, while the class Javadoc ( 2. 3. 4. 5. 6. 7. 8. Partial validation alignment. Minor
Process
|
Add a general subword tokenizer contract with original-text UTF-16 offsets, plus a dependency-free WordPiece implementation and BERT compatibility layer. Document the API and cover vocabulary validation, reference sequences, Unicode, and offset behavior. Red evidence: - A supplementary-plane word at 100 code points was rejected because UTF-16 code units were counted. - Negative piece ids and empty vocabulary pieces were accepted.
Red evidence on the prior implementation: Greek final sigma and Unicode control categories did not match the BERT reference sequence. The sibling tokenizers also disagreed on the 100-code-point limit and model callers rebuilt non-contiguous ids.
Remove the pre-release BERT wrapper, preserve source offsets through reference-compatible normalization, and pass explicit vocabulary ids through the ONNX model callers. Align both tokenizers on Unicode code-point limits and document the public subword contract.
96d2781 to
141160e
Compare
|
tl;dr - I double and triple checked that everything was fixed. I thought it was a good review. The summary: Reply to rzo1's 2026-09-04 review (issue comment)Rebased on main and reworked; all eight blocking items and the minor list are in.
Minor items: the vocabulary set and per-candidate strings are replaced by a trie, normalization runs per word with an The old inline threads on the SentencePiece fixtures and trie refer to code that moved to apache/opennlp-addons#178; resolving them here. Resolution note for the ten outdated inline threadsMoved to apache/opennlp-addons#178 with the SentencePiece implementation; this PR carries only the API and WordPiece. =================== The load-bearing rung of greek casing has landed. OpenNLP has earned it's keep. |
|
Getting ready for eval build on this one https://ci-builds.apache.org/job/OpenNLP/job/eval-tests-configurable/72/console Running... |
The Keeping open until eval green |
|
I created this for the eval test fix. I took the guilty test and separated it with better test coverage. I also found some regex in the code that we should deal with (lesson: we always see vast performance improvements whenever we remove regex). That'll be a separate set of tickets and out of scope (and not needed for 3.0, but should have a PR). |
Preserve the reviewed subword API and WordPiece changes while integrating merged CJK and ResourceInstaller updates. The API, runtime, deep-learning, and documentation reactor package passes 3,209 tests with no failures or errors; four optional dictionary cases are skipped.
Record the merged apache#1190, apache#1191, apache#1265, and apache#1211 (now supplied by main), the slimmed subword-API-only apache#1165 with its add-ons PR apache#178, the open apache#1266 hunspell follow-up, refreshed PR heads, and the regenerated uber and helper tips.
# Conflicts: # opennlp-api/src/main/java/opennlp/tools/tokenize/BertTokenizer.java # opennlp-api/src/main/java/opennlp/tools/tokenize/SubwordPiece.java # opennlp-api/src/main/java/opennlp/tools/tokenize/SubwordTokenizer.java # opennlp-api/src/main/java/opennlp/tools/tokenize/WordpieceEncoder.java # opennlp-api/src/main/java/opennlp/tools/tokenize/WordpieceTokenizer.java # opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/AbstractDL.java # opennlp-core/opennlp-ml/opennlp-dl/src/main/java/opennlp/dl/vectors/SentenceVectorsDL.java # opennlp-core/opennlp-ml/opennlp-dl/src/test/java/opennlp/dl/CreateTokenizerTest.java # opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/BertTokenizerTest.java # opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WordpieceEncoderReferenceSequencesTest.java # opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/tokenize/WordpieceEncoderTest.java
…-1885-sentencepiece
|
Is this one ready for a review? I see @rzo1 made some comments but was holding off for a bit while that discussion was going on. |
The manual example prints one line per piece with the id, the piece, and the covered source text. The Alice test now builds those lines and compares them to the expected list.
The class Javadoc now states that lower casing applies the full Unicode case mapping with the Final_Sigma context. The trie constructor has Javadoc.
The document categorizer, name finder, and vector classes each had a static encode helper that only delegated to AbstractDL. They now call the protected encodeTokens method directly. The static overload taking a tokenizer is removed, and the per-class copies of the id test are replaced by the AbstractDL level test in CreateTokenizerTest.
|
Yes, this one is ready for review. All eight blocking items and the minor list from Richard's September 4 review are in the code: the lower casing now applies the full Unicode case mapping including the final sigma context, and I checked every pinned Greek case against Python's str.lower(), which is what the reference tokenizer uses. The parity tests compare against an independent reference pipeline and pinned sequences rather than against the encoder itself. The DL classes build a WordpieceEncoder from the vocabulary id map and take ids straight from encode, and BertTokenizer is removed rather than shimmed, per Richard's preference. Both wordpiece classes share the 100 code point word limit, validate their arguments, and copy their vocabularies. Since then I merged current main (which includes the eval test fix from #1268), folded the three per-class encode helpers in opennlp-dl into the one AbstractDL method, added the missing Javadoc, and pinned the manual's WordPiece example output in a test. The earlier inline threads refer to the SentencePiece code that moved to opennlp-addons#178; this PR is only the SubwordTokenizer contract, SubwordPiece, and the WordPiece encoder. I will resolve those threads. The description is updated to the current head, CI is running on it, and I will re-run the eval build on it before merge. |
Summary
SubwordTokenizercontract andSubwordPiecetoopennlp-api; a piece consists of the spelling, the id, and the UTF-16 span in the original input text, with empty spans for control and fill piecesWordpieceEncoder, a dependency-free encoder for BERT-style word lists with longest-prefix segmentation, original-text offsets, and Unicode case mapping including the final sigma contextBertTokenizer;opennlp-dlbuilds aWordpieceEncoderfrom the model word list and takes the ids fromencodeTokensinAbstractDLWordpieceTokenizerwith the encoder: arguments are validated and copied, the default word limit is 100 code points (it was 50 UTF-16 chars), and the protectedcreateTokenizer(vocab, lowerCase)overload is removedThis PR contains no SentencePiece model reader, model normalizer, or inference engine. The concrete SentencePiece implementation is apache/opennlp-addons#178.
API
SubwordTokenizer.encode(text)returnsList<SubwordPiece>.encodeToIdsandencodeToPiecesprovide the corresponding compact views.WordpieceEncoderaccepts a word list, applies longest-prefix segmentation, and preserves original-text offsets. The maximum word length is a number of Unicode code points.Review of 2026-09-04
All items from the September 4 review are in the code: full Unicode lower casing, checked with Python's
str.lower()on the pinned Greek sequences; parity tests that use an independent reference pipeline and pinned sequences; DL ids taken from the encoder; the shared 100 code point limit; validation at the public boundary; trie lookup; per-run normalization;@since 3.0.0;Map.copyOffields. The earlier inline threads refer to SentencePiece files that moved to the addons PR.Validation
Offline build with checkstyle and
-Dopennlp.forkCount=1:opennlp-api: 385 testsopennlp-runtime: 2680 tests, 4 pre-existing skipsopennlp-dl: 90 testsNo failures. The eval build will be re-run on this head before merge.
OPENNLP-1885