OPENNLP-1885: Add subword API and WordPiece encoder - #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 |
| * state. Out-of-range unit references, which a well-formed trie never produces, fail loudly | ||
| * rather than reading arbitrary memory.</p> | ||
| * | ||
| * @see <a href="https://github.com/s-yata/darts-clone">Darts-clone</a> |
There was a problem hiding this comment.
This is BSD-2 licensed. What of this impl is a copy? Is this a re-impl from the repo? Plz ellaborate.
There was a problem hiding this comment.
It's a re-implementation of the reader side only, written against the serialized unit layout (32-bit little-endian words: label in the low 8 bits, has-leaf in bit 8, offset in bits 10-30 with a bit-9 scale flag, leaf flag in the sign bit). Nothing from the darts-clone builder exists here; the class is 140 lines of read-only longest-prefix walk, and the API, naming, error handling, and comments are original.
The structure comes from the published literature rather than the BSD source: the trie is Aoe's double-array (IEEE TSE 1989), the unit encoding is the compact static variant of Yata et al. (IPM 2007), and the two-kinds-of-offset scheme behind the bit-9 flag is described as darts-clone's technique in Kanda et al. (SPE 2023). darts-clone is Yata's own implementation of his papers, so any correct reader of this format decodes the same bits the same way.
While re-checking this I found one expression that had converged on upstream's exact branchless form (the offset decode); 9670c34 rewrites it as the plain conditional derived from the bit layout and adds the Aoe/Yata/Kanda citations to the class javadoc alongside the existing darts-clone @see, so nothing textually coincides with the BSD source.
Given that, I don't believe a BSD-2 notice is required, but if you'd rather have one in LICENSE out of caution I'll add it.
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
| package opennlp.subword.sentencepiece; |
There was a problem hiding this comment.
Looks like a more general data structure. might live in api?
There was a problem hiding this comment.
I'd caution as it's more format-specific than it looks: it's a reader for the darts-clone serialized layout, not a general trie you can build from arbitrary keys. That means that the API is shaped around PieceTrie's byte-matching. Since it's package-private, promoting it to api later is a compatible change if a second consumer appears. Feels like a premature optimization that we don't have to standardize on at this moment.
Exposing it now would commit us to this shape. Let's keep it package-private in this PR; if a second consumer shows up, moving it to api is a follow-up.
| import java.util.Arrays; | ||
|
|
||
| /** A growable int buffer supporting append, indexed read, and truncate. */ | ||
| final class IntBuilder { |
There was a problem hiding this comment.
Looks like a more general data structure. might live in api?
There was a problem hiding this comment.
It's a deliberately minimal growable int buffer that exists to avoid boxing on the hot path, without the bounds/safety a public utility would need. Same reasoning as DoubleArrayTrie: package-private today means we can promote it later without breaking anything, so I'd wait for a second consumer.
| * 256-entry direct table and narrow nodes scan a short sorted label slice; both layouts enumerate | ||
| * identical transitions.</p> | ||
| */ | ||
| final class PieceTrie implements Serializable { |
There was a problem hiding this comment.
Looks like a more general data structure. might live in api?
There was a problem hiding this comment.
This one is SentencePiece-specific. It maps UTF-8 byte sequences to piece ids using the model's vocabulary conventions, so I don't think it generalizes. I'll keep it package-private for now - same reasoning.
There was a problem hiding this comment.
What is this? There is it from?
There was a problem hiding this comment.
Not a third-party model. It's trained in-tree from corpus.txt by gen_fixtures.py using the reference sentencepiece Python package. 632d996 added a README next to the fixtures covering what gets generated, the venv steps to regenerate, and the TSV format; d8b0449 expands it into a full validation tutorial and links it from the manual's SentencePiece section.
There was a problem hiding this comment.
Generated alongside the model by gen_fixtures.py: it encodes the fixture inputs with the reference sentencepiece implementation and records the expected pieces, ids, UTF-16 spans, and normalized form. So the oracle is the reference implementation, not the Java code under test. Format, regeneration, and validation steps are in the README (632d996, expanded in d8b0449).
There was a problem hiding this comment.
how was this generated? source?
There was a problem hiding this comment.
There was a problem hiding this comment.
This needs a short readme on how this is used or executed? (venv, etc?) - same for the other python stuff.
There was a problem hiding this comment.
Added in 632d996: README.md next to the fixtures with the venv setup, the regeneration command, what each output file is, and the TSV escape format. It also covers gen_real_fixtures.py, which produces the same TSVs for local eval against published models; those are not bundled. d8b0449 expands it into a validation tutorial (regenerate, run the parity suite, optional real-model eval) and links it from the manual's SentencePiece section.
There was a problem hiding this comment.
My question is about the boundary. byteToChar plus SentencePieceNormalizer.Normalized#normToOrig is effectively a second alignment mechanism alongside opennlp.tools.util.normalizer.Alignment / AlignedText. They can't share code today (char↔char vs. normalized-byte → original-byte → char), so I'm not asking for unification here.
Is it intentional that both stay package-private — i.e. we're not committing to a second public alignment model, and consumers get offsets only via SubwordPiece spans? If so, can the class javadoc say that? It keeps the next reader from reading this as duplication of the normalization work merged under OPENNLP-1868/1875/1876.
There was a problem hiding this comment.
Yes, it's intentional. We're not committing to a second public alignment model: both stay package-private and consumers only ever see offsets through SubwordPiece spans. The class javadoc now states this (632d996) so it doesn't read as duplication of the OPENNLP-1868/1875/1876 alignment work.
|
The PR puts the contract ( Independently of that, a few classes in the module look general rather than SentencePiece-specific, and are the ones most likely to get reinvented elsewhere:
The module only depends on |
|
Follow-up in 632d996. Added a README under the SentencePiece test fixtures covering regeneration of the tiny models and optional real-model fixture scripts. |
|
The rule is compatibility, not taste. BertTokenizer is existing public API, deprecated in place as a shim delegating to WordpieceEncoder, and opennlp-api cannot depend on an extension module, so the delegate lives in api. It is one self-contained class. SentencePiece has no compatibility anchor and brings eleven classes including a binary model-format reader, so it stays in opennlp-subword: api carries the contract plus what existing compatibility forces; implementations with real machinery go to extensions. On Utf8Text / DoubleArrayTrie / PieceTrie / ByteBuilder / IntBuilder: agreed they look general, but 3.0 freezes whatever we expose and each is shaped by one consumer today (the trie reads the darts-clone serialized layout; the buffers skip the bounds checks a public utility needs). Promoting them once a second consumer validates the shape is a compatible change, so they stay package-private in this PR. If you feel strongly about either move, say so and I'll do it here; otherwise this is ready for another look. |
45db421 to
96d2781
Compare
|
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
Summary
SubwordTokenizercontract toopennlp-apiSubwordPiecewith exact UTF-16 spans into the caller's original textWordpieceEncoderfor BERT-style vocabulariesBertTokenizeras a deprecated compatibility classThis 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.Each
SubwordPiececontains its vocabulary spelling, id, and source span.Control and fill pieces use empty source spans.
WordpieceEncoderaccepts a vocabulary, applies longest-prefix segmentation,and preserves original-text offsets. Its maximum word length is counted in
Unicode code points.
Validation
opennlp-api: 367 tests passedopennlp-runtime: 2,183 tests passed