Skip to content

OPENNLP-1883: Make stemmers thread-safe, add StemmerFactory and per-thread stem caching#1163

Open
krickert wants to merge 12 commits into
apache:mainfrom
ai-pipestream:stemmer-factory
Open

OPENNLP-1883: Make stemmers thread-safe, add StemmerFactory and per-thread stem caching#1163
krickert wants to merge 12 commits into
apache:mainfrom
ai-pipestream:stemmer-factory

Conversation

@krickert

@krickert krickert commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Thank you for contributing to Apache OpenNLP.

In order to streamline the review of the contribution we ask you
to ensure the following steps have been taken:

For all changes:

  • Is there a JIRA ticket associated with this PR? Is it referenced in the commit message?
  • Does your PR title start with OPENNLP-XXXX where XXXX is the JIRA number you are trying to resolve?
  • Has your PR been rebased against the latest commit within the target branch (typically main)?
  • Is your initial contribution a single, squashed commit?

For code changes:

  • Have you ensured that the full suite of tests is executed via mvn clean install at the root opennlp folder?
  • Have you written or updated unit tests to verify your changes?
  • If adding new dependencies to the code, are these dependencies licensed in a way that is compatible for inclusion under ASF 2.0?
  • If applicable, have you updated the LICENSE file, including the main LICENSE file in opennlp folder?
  • If applicable, have you updated the NOTICE file, including the main NOTICE file found in opennlp folder?

For documentation related changes:

  • Have you ensured that format looks appropriate for the output in which it is rendered?

What is this PR for?

SnowballStemmer holds mutable per-call buffers in the generated engine, so sharing an instance across threads silently corrupts results — no exception, just wrong stems. With 3.0.0 unreleased, this aligns stemmers with the thread-safe *ME components (OPENNLP-1816).

Changes

  • SnowballStemmer routes each call to a per-thread generated engine via OwnerOrPerThreadState (same pattern as the *ME classes); the generated Snowball code is untouched. One instance is now safe to share and is annotated @ThreadSafe.
  • New StemmerFactory (opennlp-api): an immutable, thread-safe capture of stemmer configuration, with SnowballStemmerFactory and PorterStemmerFactory implementations and a SharingStemmer adapter for engines that are not thread-safe themselves (e.g. PorterStemmer).
  • New CachingStemmer: bounded per-thread LRU memoization of word-to-stem mappings. Natural text is Zipf-distributed, so most tokens are repeats; JMH shows ~34x throughput on a Zipf workload and break-even on a cache-hostile one. TermAnalyzer.Builder.stem(StemmerFactory) and NormalizationProfile.matchingAnalyzer() use it, so profile analyzers are shareable and cached by default.
  • Tests: concurrency unit tests for all 21 Snowball algorithms (fixed pools, owner-thread transition, virtual threads), MultiThreadedStemmerEval mirroring MultiThreadedToolsEval, and JMH benchmarks (SnowballStemmerBenchmark, CachingStemmerBenchmark) with results and run instructions in BENCHMARKS.md.

Test plan

  • mvn test on opennlp-runtime: 1,363 tests pass (includes the 38 stemmer and 32 analyzer tests)
  • mvn test -Peval-tests -Dtest=MultiThreadedStemmerEval on opennlp-eval-tests: 3/3
  • Full reactor mvn clean test verify -Pjacoco -Pci: green

@krickert krickert requested review from mawiesne and rzo1 July 8, 2026 14:56
@krickert

krickert commented Jul 8, 2026

Copy link
Copy Markdown
Contributor Author

I'll flip off draft after the parent is merged, but reviews can start.

krickert added 5 commits July 8, 2026 15:05
…d SharingStemmer

SnowballStemmer now routes each call to a per-thread generated engine via
OwnerOrPerThreadState, the same pattern as the thread-safe *ME components,
so one instance can be shared across threads without touching the
generated Snowball code. StemmerFactory (api) captures stemmer
configuration as an immutable, thread-safe seam for stateful engines;
SharingStemmer adapts a factory into a shareable Stemmer for
implementations that are not thread-safe themselves (e.g. PorterStemmer).
NormalizationProfile.matchingAnalyzer() and TermAnalyzer are now safe to
share across threads when stemming.
…unit tests

MultiThreadedStemmerEval hammers shared SnowballStemmer instances for
every algorithm, a SharingStemmer-wrapped PorterStemmer, and every
NormalizationProfile matching analyzer from 8 threads, comparing each
result against a single-threaded reference, mirroring
MultiThreadedToolsEval. The unit tests now also cover all 21 algorithms
under concurrency, the owner thread stemming concurrently with pool
threads, virtual threads (a fresh thread per task), and Porter sharing.
…atch baseline

Measures the thread-safe SnowballStemmer shared across threads and one
per thread against a replica of the pre-patch plain-field
implementation, at 1/8/32 threads. Results in BENCHMARKS.md: the
OwnerOrPerThreadState overhead is within error bars up to 8 threads and
only shows (~1.5x) at full 32-thread saturation.
…to-stem mappings

Natural text is Zipf-distributed, so the same words are stemmed
constantly. CachingStemmer wraps a StemmerFactory with a bounded
per-thread LRU (default 1024 entries) following the same
OwnerOrPerThreadState pattern as the *ME components: no cross-thread
sharing, thread-safe regardless of the delegate. On the JMH zipf
workload the cache is a ~34x throughput multiplier; on a cache-hostile
uniform workload over 8x the cache capacity it still breaks even.
Results and a corrected JMH invocation are documented in BENCHMARKS.md.
…ngStemmer

Builder.stem(StemmerFactory) now wraps the factory in a CachingStemmer
(default capacity), with a stem(StemmerFactory, int) overload for
explicit cache sizing, and NormalizationProfile.matchingAnalyzer()
builds through the factory again so profile analyzers get the per-thread
stem cache for free. Analyzer results are unchanged; repeated words
resolve from the cache instead of being re-stemmed.
@krickert krickert marked this pull request as ready for review July 8, 2026 19:06
@krickert krickert requested review from atarora and jzonthemtn July 8, 2026 19:06
@krickert krickert self-assigned this Jul 9, 2026
@rzo1

rzo1 commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Hi @krickert - as mentioned on Slack I currently dont have the time for a manual review but I just let Fable do a comprehensive review on this PR. Here is the result:

Main points:

  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/SharingStemmer.java: stemAll(CharSequence) is not overridden, so the inherited default returns List.of(stem(word)) and any multi-output delegate (the very case the new stemAll API anticipates) silently loses all but one stem form when wrapped. CachingStemmer forwards stemAll to its delegate correctly; SharingStemmer should do the same (delegates.get().stemAll(word)), and a test should cover this path.
  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/CachingStemmer.java (also SharingStemmer, SnowballStemmer): none of the new stemmer classes exposes a release path to OwnerOrPerThreadState.clearForCurrentThread(), unlike the clearThreadLocalState() methods on TokenizerME, SentenceDetectorME, POSTaggerME and NameFinderME they claim to mirror. The owner-reset lambdas (e.g. threadState.cache.clear()) are therefore unreachable dead code, and pooled threads retain one engine plus an up-to-1024-entry cache per stemmer instance per thread until the instance is GC'd (with classloader-pinning risk on redeploy). This is aggravated by NormalizationProfile.matchingAnalyzer() building a fresh CachingStemmer per call, which leaves stale ThreadLocal entries and gets no cache reuse across calls. Either expose a clear/release method (and test it) or remove the lambdas.
  • opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java: the new default method stemAll(CharSequence) is speculative public API. No multi-output stemmer exists in the codebase (the javadoc cites Hunspell, which OpenNLP does not ship), the sole override in CachingStemmer is a pass-through, and the only callers are this PR's own tests. Multi-output normalization is already the contract of opennlp.tools.lemmatizer.Lemmatizer. Once shipped in opennlp-api at 3.0.0 this is frozen; suggest dropping it here and adding it under its own JIRA when a real multi-output stemmer lands.
  • opennlp-api/src/main/java/opennlp/tools/stemmer/StemmerFactory.java: the name collides conceptually with the sibling BaseToolFactory-based tool factories (notably LemmatizerFactory) while having an unrelated functional-interface contract and no javadoc distinguishing it, and it permanently claims the canonical name should stemmers ever join the BaseToolFactory/model-manifest mechanism. Consider a name outside that pattern (e.g. StemmerProvider/StemmerSupplier) or at least an explicit javadoc disclaimer.
  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmer.java: every stem() call now pays an OwnerOrPerThreadState.get() lookup, and the PR's own benchmarks show the previously supported one-instance-per-thread pattern regresses ~1.6x at saturation (4.77M vs 2.94M ops/s at 32 threads) with no opt-out back to the plain-field engine. Users who already confined a stemmer per thread gain nothing but inherit the cost; consider keeping a zero-indirection path.
  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/CachingStemmer.java: per-thread state is keyed to the physical thread, so under Executors.newVirtualThreadPerTaskExecutor() (a pattern this PR's own tests exercise) every task re-allocates engine and cache and the advertised Zipf speedup never materializes. The 34x javadoc/BENCHMARKS.md claims only hold for reused platform threads; qualify the claims or offer a shareable bounded concurrent cache.

Minor:

  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/CachingStemmer.java: SnowballStemmerFactory mints an already thread-safe SnowballStemmer as the per-thread delegate, so every cache miss pays the threadId/atomic lookup twice and each thread state carries a redundant second ThreadLocal plus an eagerly allocated engine. TermAnalyzer.Builder.stem(StemmerFactory) and matchingAnalyzer() take this nested path by default; a non-wrapped factory product would remove the double indirection.
  • opennlp-api/src/main/java/opennlp/tools/stemmer/StemmerFactory.java: the default convenience method stem(CharSequence) duplicates the Stemmer.stem signature on the factory type, and its own javadoc mainly warns against using it (mints a new stemmer per call). newStemmer().stem(word) is a trivial one-liner; suggest dropping the method.
  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java: the Builder.stem(StemmerFactory) overload shares a name with stem(Stemmer) but silently adds caching/sharing semantics, and the pair is ambiguous for null literals or arguments typed as both interfaces. A distinct name (e.g. stemCached) would make the behavior explicit.
  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/CachingStemmer.java: null-argument validation is inconsistent across the new API (CachingStemmer/SharingStemmer throw IllegalArgumentException, SnowballStemmerFactory uses Objects.requireNonNull and throws NPE). Pick one convention before this surface is frozen in 3.0.0.
  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmerFactory.java: the factory rejects repeat <= 0 but the rewritten SnowballStemmer(ALGORITHM, int) constructor accepts any value unchecked; apply the same guard in the constructor.
  • opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerFactoryTest.java: only the repeat validation is tested; nothing verifies that new SnowballStemmerFactory(ENGLISH, 2).newStemmer() actually passes repeat through (a pass-through typo would go undetected by the whole suite).
  • opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/CachingStemmer.java: the LRU map is built with initialCapacity = capacity at load factor 0.75, so it rehashes once when it passes ~0.75*capacity despite being size-bounded; use (int) (capacity / 0.75f) + 1.
  • opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java: testMatchingAnalyzerIsThreadSafeForStemming uses inline fully-qualified names (java.util.concurrent.Executors etc.) and var, inconsistent with the imports and explicit types used in the rest of the file and the sibling tests added by this PR.

Human review will follow.

…-indirection factory products

SharingStemmer forwards stemAll to its per-thread delegate so a
multi-output engine keeps its full result list through the wrapper, and
all three thread-routing stemmers expose clearThreadLocalState(),
mirroring the thread-safe ME components, so pooled threads can release
their engine and cache instead of retaining them until the instance is
collected. The caching javadoc and BENCHMARKS.md now state that the
memoization is keyed to the physical thread and what that means on a
virtual-thread-per-task executor.

SnowballStemmerFactory products are now thread-confined stemmers driving
their engine through a plain field, so the one-stemmer-per-thread pattern
and the per-thread delegates inside the caching and sharing wrappers pay
none of the shared-instance thread routing. The SnowballStemmer
constructor validates algorithm and repeat like the factory, validation
is IllegalArgumentException across the new surface, the LRU map is sized
for its load factor so a full cache never rehashes, and StemmerFactory
documents that it is a plain supplier, not one of the BaseToolFactory
tool factories; its one-shot stem convenience method is gone.

Tests cover the multi-output forwarding, the repeat pass-through with a
witness word that stems differently at repeat one and two, the owner
cache reset and worker-thread delegate release behind
clearThreadLocalState, and the constructor validation.
@krickert

Copy link
Copy Markdown
Contributor Author

Thanks. Addressed in b76eef4, with two deliberate keeps:

Main points:

  • SharingStemmer.stemAll: fixed, it forwards to the per-thread delegate; a test wraps a multi-output delegate and asserts the full result list survives.
  • State release: all three thread-routing stemmers (CachingStemmer, SharingStemmer, SnowballStemmer) expose clearThreadLocalState(), mirroring the ME components. On the owning thread the retained state object is reset (a test observes the cache emptying by counting delegate calls); on a worker thread the per-thread entry is removed and rebuilt on next use (tested on a spawned thread). The owner-reset lambdas are therefore reachable and tested.
  • Stemmer.stemAll kept: the review is right that no bundled multi-output stemmer exists yet, but the wrapper-forwarding bug this round fixed is exactly the class of defect that appears when the hook is retrofitted later, and a stacked branch already exercises stemAll against the new light stemmer tier. The javadoc now states the forwarding obligation for wrappers.
  • StemmerFactory name kept with the suggested disclaimer: the javadoc now states it is a plain supplier of configured stemmers and takes no part in the BaseToolFactory model-manifest mechanism.
  • Per-call routing cost for confined use: SnowballStemmerFactory.newStemmer() now returns a thread-confined product driving its engine through a plain field, no thread routing at all. That restores a zero-indirection path for the one-instance-per-thread pattern and removes the double indirection inside the caching and sharing wrappers, which build their per-thread delegates from the factory.
  • Virtual threads: the 34x claim is now qualified in the CachingStemmer javadoc and BENCHMARKS.md: the cache is keyed to the physical thread, pays off on reused platform threads, and a virtual-thread-per-task executor starts every task cold.

Minor:

  • Double indirection through factory products: fixed by the confined product above.
  • StemmerFactory.stem(word) convenience: dropped.
  • TermAnalyzer.Builder.stem(StemmerFactory) name kept: the overload pair is documented, a null literal does not compile against either overload without a cast either way, and the factory overload adding caching is the documented reason the overload exists. The javadoc now spells out the thread-keyed cache semantics.
  • Null validation: IllegalArgumentException across the new surface; SnowballStemmerFactory no longer throws NPE.
  • Constructor guard: SnowballStemmer(ALGORITHM, int) validates algorithm and repeat like the factory, tested.
  • Repeat pass-through: tested with a witness word (internationalization stems to internation at repeat 1 and intern at repeat 2).
  • LRU initial capacity: sized for the load factor so a full cache never rehashes.
  • NormalizationProfilesTest style: inline qualified names and var replaced with imports and explicit types.

@rzo1 rzo1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank for the PR @krickert - I've reviewed the PR and left some comments.

My main suggestion: CachingStemmer and SharedStemmer could be generic over the wrapped Stemmer type. That would cut the current code duplication and let them wrap any concrete implementation including each other, e.g. a SharedStemmer inside a CachingStemmer, etc.

Comment thread opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java Outdated
Comment thread opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java Outdated
Comment thread opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java
Comment thread opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java
Comment thread opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java Outdated
…g, cache controls

The Stemmer and StemmerFactory interfaces state their contracts without
implementation or threading claims, and null words throw
IllegalArgumentException across the API (Snowball, factory products,
caching and sharing wrappers, Porter), pinned by tests. SnowballStemmer
now composes SharingStemmer so the per-thread routing pattern lives in
one class. CachingStemmer gains clearCache() for the calling thread,
interns cached stems through StringInterners, and both stateful classes
document the container release path of the *ME components.
…erflow

The cached stems are no longer routed through the JVM-wide interner: its
default implementation holds strong references forever, so open-vocabulary
input would grow without bound and defeat the documented per-thread cache
cap. The LRU alone keeps the bound honest. The LRU table sizing is computed
in double arithmetic because the int expression overflows to a negative
capacity near Integer.MAX_VALUE; a test pins construction at the extreme.
clearCache() documents that it initializes state for a thread that has not
stemmed yet.
if (cached != null) {
return cached;
}
// toString() detaches the result from any buffer the delegate may reuse internally. The

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Think this comment can be dropped.

@krickert

Copy link
Copy Markdown
Contributor Author

Fixed the dedupe. I extracted a package-private DelegatingStemmer base that holds the shared per-thread plumbing (the OwnerOrPerThreadState field, factory validation, and clearThreadLocalState), so SharingStemmer and CachingStemmer now differ only in their per-thread payload and their stem logic. One deliberate difference from the suggestion: the type parameter is the per-thread payload, not the wrapped Stemmer type. Both wrappers mint delegates from a StemmerFactory and only call the Stemmer contract, so parameterizing over the concrete stemmer would push a type parameter onto StemmerFactory as well without changing behavior. Fail-fast construction is unchanged and the existing tests cover both wrappers.

krickert added 3 commits July 12, 2026 20:46
…ate wrapper input

Applies the review conventions from the OPENNLP-1869 review: em dashes and history framing leave the benchmark docs, rationale essays shrink to contract sentences, every override carries inheritDoc, and the stemmer wrappers validate their arguments at the boundary.
…m commentary

Aligns null-argument validation with the project convention and removes remaining rationale commentary.
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.

2 participants