diff --git a/opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java b/opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java index 750890eb65..fb6bdcffb6 100644 --- a/opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java +++ b/opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java @@ -17,10 +17,33 @@ package opennlp.tools.stemmer; +import java.util.List; + /** - * The stemmer is reducing a word to its stem. + * Reduces a word to its root form. + * + *

Thread safety is implementation specific.

*/ public interface Stemmer { + /** + * Stems {@code word}. + * + * @param word The input word. Must not be {@code null}. + * @return The stemmed form. + * @throws IllegalArgumentException Thrown if {@code word} is {@code null}. + */ CharSequence stem(CharSequence word); + + /** + * {@return all stem forms for {@code word}} Defaults to a single-element list from + * {@link #stem(CharSequence)}. Dictionary-based engines may override this to return + * multiple roots; delegating wrappers must forward it to preserve the full list. + * + * @param word The input word. Must not be {@code null}. + * @throws IllegalArgumentException Thrown if {@code word} is {@code null}. + */ + default List stemAll(CharSequence word) { + return List.of(stem(word)); + } } diff --git a/opennlp-api/src/main/java/opennlp/tools/stemmer/StemmerFactory.java b/opennlp-api/src/main/java/opennlp/tools/stemmer/StemmerFactory.java new file mode 100644 index 0000000000..10e5003901 --- /dev/null +++ b/opennlp-api/src/main/java/opennlp/tools/stemmer/StemmerFactory.java @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.stemmer; + +/** + * A factory for {@link Stemmer} instances: it captures a stemmer configuration (algorithm, + * repeat count, dictionary path, ...) once and mints configured stemmers on demand. + * + *

Despite the name, this is not one of the {@code BaseToolFactory}-based tool factories (such + * as {@code LemmatizerFactory}) that are instantiated by name from a model manifest. It is a + * plain supplier of configured {@link Stemmer} instances and takes no part in the model-loading + * mechanism.

+ */ +public interface StemmerFactory { + + /** + * {@return a new {@link Stemmer}} + */ + Stemmer newStemmer(); +} diff --git a/opennlp-core/opennlp-runtime/BENCHMARKS.md b/opennlp-core/opennlp-runtime/BENCHMARKS.md index 68c3ac185e..21cb71a664 100644 --- a/opennlp-core/opennlp-runtime/BENCHMARKS.md +++ b/opennlp-core/opennlp-runtime/BENCHMARKS.md @@ -13,6 +13,8 @@ variance reporting. | `TokenizerMEBenchmark` | TokenizerME | 3 approaches | | `SentenceDetectorMEBenchmark` | SentenceDetectorME | 3 approaches | | `POSTaggerMEBenchmark` | POSTaggerME | 3 approaches x 2 cache configs | +| `SnowballStemmerBenchmark` | SnowballStemmer | 3 approaches (incl. plain-field baseline) | +| `CachingStemmerBenchmark` | CachingStemmer | cached vs uncached x 2 workloads | ### Approaches measured @@ -30,32 +32,88 @@ mvn test-compile -Pjmh \ -pl opennlp-core/opennlp-runtime -am \ -Dforbiddenapis.skip=true -Dcheckstyle.skip=true +# Materialize the test classpath once (JMH's forked JVMs inherit +# java.class.path, which mvn exec:java does not populate, running +# through exec:java fails with ClassNotFoundException: ForkedMain) +mvn dependency:build-classpath -pl opennlp-core/opennlp-runtime \ + -Pjmh -DincludeScope=test -Dmdep.outputFile=/tmp/cp.txt + +CP="opennlp-core/opennlp-runtime/target/classes:opennlp-core/opennlp-runtime/target/test-classes:$(cat /tmp/cp.txt)" + # Run all ME benchmarks -mvn exec:java -pl opennlp-core/opennlp-runtime \ - -Pjmh -Dexec.classpathScope=test \ - -Dexec.mainClass=org.openjdk.jmh.Main \ - -Dexec.args="opennlp.tools.*.ME*" +java -cp "$CP" org.openjdk.jmh.Main 'opennlp.tools.*.ME*' # Run POSTagger only (includes cacheSize param) -mvn exec:java -pl opennlp-core/opennlp-runtime \ - -Pjmh -Dexec.classpathScope=test \ - -Dexec.mainClass=org.openjdk.jmh.Main \ - -Dexec.args="POSTaggerMEBenchmark" +java -cp "$CP" org.openjdk.jmh.Main POSTaggerMEBenchmark ``` -### Regression testing (stock vs patched) +### Baseline comparison -Run the `newInstancePerCall` benchmark on both stock and patched -builds. The throughput numbers should be within JMH's error margin. +Run the `newInstancePerCall` benchmark on both `main` and this +branch. The throughput numbers should be within JMH's error margin. ```bash -# On stock (upstream/main): +# On main: # ... build and run as above, save output -# On patched (feature/thread-safe-me): +# On this branch: # ... build and run as above, compare ``` +### SnowballStemmer results (Linux, JDK 25, 32 cores, 2 forks x 10 iterations) + +`SnowballStemmerBenchmark` compares the thread-safe `SnowballStemmer` +(engine behind `OwnerOrPerThreadState`) against a baseline replica +that keeps the engine in a plain field (not shareable). +One op = stemming 16 English words. + +| Strategy | 1 thread | 8 threads | 32 threads | +|----------|---------:|----------:|-----------:| +| `sharedInstance` (one shared stemmer) | 560k ± 3k ops/s | 1.55M ± 0.17M | 3.16M ± 0.34M | +| `instancePerThread` (stemmer per thread) | 509k ± 26k ops/s | 1.60M ± 0.17M | 2.94M ± 0.11M | +| `legacyInstancePerThread` (plain-field baseline, stemmer per thread) | 544k ± 19k ops/s | 1.46M ± 0.16M | 4.77M ± 0.39M | + +At 1 and 8 threads the three strategies are within (or nearly within) +each other's error bars: the `OwnerOrPerThreadState` lookup is not +measurable against the cost of stemming itself. Only at full +saturation (32 threads, hyperthreaded) does the legacy plain-field +baseline pull ahead (~1.5x): with every hardware thread busy, the +per-call owner check plus `ThreadLocal` lookup is no longer hidden by +memory-level parallelism. Real pipelines stem as one stage among many, +so the saturated-microbenchmark gap is an upper bound, and the legacy +strategy was not shareable across threads in the first place. + +### CachingStemmer results (same environment) + +`CachingStemmerBenchmark` compares a `CachingStemmer` (per-thread LRU, +default 1024 entries, wrapping the English Snowball stemmer) against +the uncached shared stemmer. One op = 16 tokens from a 64k-token +stream. The `zipf` workload samples a 512-word vocabulary with 1/rank +weights (real-text repetition; the cache holds the whole vocabulary); +`diverse` samples an 8192-word vocabulary uniformly (8x cache +capacity: mostly misses plus constant eviction). + +| Workload | Strategy | 8 threads | 32 threads | +|----------|----------|----------:|-----------:| +| `zipf` | `cachedShared` | 48.5M ± 0.7M ops/s | 95.4M ± 0.9M | +| `zipf` | `uncachedShared` | 1.43M ± 0.13M | 2.75M ± 0.34M | +| `diverse` | `cachedShared` | 1.81M ± 0.51M | 3.45M ± 0.18M | +| `diverse` | `uncachedShared` | 1.08M ± 0.09M | 3.13M ± 0.12M | + +On the Zipf workload the cache is a ~34x throughput multiplier (raw +stemming becomes a hash lookup for the dominant vocabulary). On the +cache-hostile workload it still does not lose: the ~12% residual hit +rate pays for the eviction overhead. The cache more than recovers the +`OwnerOrPerThreadState` lookup cost observed in +`SnowballStemmerBenchmark` at full saturation. + +The cache is keyed to the physical thread, and these runs use a fixed +platform-thread pool whose threads live for the whole measurement. On +a virtual-thread-per-task executor every task starts with an empty +cache, so the multiplier only applies to repeats within one task; +workloads that stem a handful of words per task should expect +uncached-level throughput there. + ### POSTagger cache impact The `POSTaggerMEBenchmark` uses `@Param({"0", "3"})` for cache diff --git a/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/stemmer/CachingStemmerBenchmark.java b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/stemmer/CachingStemmerBenchmark.java new file mode 100644 index 0000000000..ae0050b2b0 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/stemmer/CachingStemmerBenchmark.java @@ -0,0 +1,199 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.stemmer; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.options.Options; +import org.openjdk.jmh.runner.options.OptionsBuilder; + +import opennlp.tools.stemmer.snowball.SnowballStemmer; +import opennlp.tools.stemmer.snowball.SnowballStemmerFactory; + +/** + * JMH benchmark for {@link CachingStemmer} against an uncached shared {@link SnowballStemmer}. + * + *

Two workloads drive both strategies:

+ * + * + *

One op stems 16 consecutive tokens from the stream; each benchmark thread walks the stream + * from its own cursor.

+ */ +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@Warmup(iterations = 5, time = 2) +@Measurement(iterations = 10, time = 2) +@Fork(2) +public class CachingStemmerBenchmark { + + private static final int STREAM_LENGTH = 65536; + private static final int WORDS_PER_OP = 16; + + private static final String[] ROOTS = { + "run", "walk", "talk", "develop", "nation", "connect", "form", "create", + "act", "direct", "govern", "manage", "operate", "organize", "present", "relate", + "report", "state", "structure", "test", "train", "transform", "translate", "value", + "view", "wonder", "yield", "zone", "note", "mark", "place", "point" + }; + private static final String[] PREFIXES = { + "", "re", "un", "over", "under", "out", "pre", "post", + "non", "anti", "de", "dis", "mis", "sub", "super", "inter" + }; + private static final String[] SUFFIXES = { + "", "s", "ed", "ing", "er", "ers", "ation", "ations", + "ly", "ness", "ment", "ments", "ize", "ized", "izing", "al" + }; + + @State(Scope.Benchmark) + public static class WorkloadState { + + @Param({"zipf", "diverse"}) + String workload; + + String[] stream; + + @Setup(Level.Trial) + public void build() { + Random random = new Random(42); + List vocabulary = new ArrayList<>(); + if ("zipf".equals(workload)) { + // 32 roots x 16 suffixes = 512 unique words, sampled with 1/rank weights. + for (String root : ROOTS) { + for (String suffix : SUFFIXES) { + vocabulary.add(root + suffix); + } + } + double[] cumulative = new double[vocabulary.size()]; + double sum = 0; + for (int rank = 0; rank < vocabulary.size(); rank++) { + sum += 1.0 / (rank + 1); + cumulative[rank] = sum; + } + stream = new String[STREAM_LENGTH]; + for (int i = 0; i < STREAM_LENGTH; i++) { + double r = random.nextDouble() * sum; + int idx = 0; + while (cumulative[idx] < r) { + idx++; + } + stream[i] = vocabulary.get(idx); + } + } else { + // 16 prefixes x 32 roots x 16 suffixes = 8192 unique words, sampled uniformly. + for (String prefix : PREFIXES) { + for (String root : ROOTS) { + for (String suffix : SUFFIXES) { + vocabulary.add(prefix + root + suffix); + } + } + } + stream = new String[STREAM_LENGTH]; + for (int i = 0; i < STREAM_LENGTH; i++) { + stream[i] = vocabulary.get(random.nextInt(vocabulary.size())); + } + } + } + } + + @State(Scope.Benchmark) + public static class UncachedState { + Stemmer stemmer; + + @Setup(Level.Trial) + public void create() { + stemmer = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH); + } + } + + @State(Scope.Benchmark) + public static class CachedState { + Stemmer stemmer; + + @Setup(Level.Trial) + public void create() { + stemmer = new CachingStemmer( + new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH)); + } + } + + @State(Scope.Thread) + public static class Cursor { + int position; + + @Setup(Level.Trial) + public void randomize() { + position = new Random().nextInt(STREAM_LENGTH); + } + } + + @Benchmark + @Threads(Threads.MAX) + public void uncachedShared(WorkloadState w, UncachedState st, Cursor cursor, Blackhole bh) { + for (int i = 0; i < WORDS_PER_OP; i++) { + bh.consume(st.stemmer.stem(w.stream[cursor.position++ & (STREAM_LENGTH - 1)])); + } + } + + @Benchmark + @Threads(Threads.MAX) + public void cachedShared(WorkloadState w, CachedState st, Cursor cursor, Blackhole bh) { + for (int i = 0; i < WORDS_PER_OP; i++) { + bh.consume(st.stemmer.stem(w.stream[cursor.position++ & (STREAM_LENGTH - 1)])); + } + } + + /** + * Quick local iteration only: {@code forks(0)} disables JVM fork isolation + * (unlike {@code mvn} with the {@code jmh} profile). + * Use the Maven-invoked configuration for publishable numbers. + */ + public static void main(String[] args) throws Exception { + Options opt = new OptionsBuilder() + .include(CachingStemmerBenchmark.class.getSimpleName()) + .forks(0) + .warmupIterations(3) + .measurementIterations(5) + .build(); + new Runner(opt).run(); + } +} diff --git a/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/stemmer/snowball/SnowballStemmerBenchmark.java b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/stemmer/snowball/SnowballStemmerBenchmark.java new file mode 100644 index 0000000000..62eb09031c --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/stemmer/snowball/SnowballStemmerBenchmark.java @@ -0,0 +1,153 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.stemmer.snowball; + +import java.util.concurrent.TimeUnit; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; +import org.openjdk.jmh.infra.Blackhole; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.options.Options; +import org.openjdk.jmh.runner.options.OptionsBuilder; + +import opennlp.tools.stemmer.Stemmer; + +/** + * JMH benchmark for the thread-safe {@link SnowballStemmer} versus a baseline replica of the + * previous implementation that held its generated engine in a plain field. + * + *

Three strategies are measured, all at {@link Threads#MAX}:

+ * + */ +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@Warmup(iterations = 5, time = 2) +@Measurement(iterations = 10, time = 2) +@Fork(2) +public class SnowballStemmerBenchmark { + + private static final String[] INPUT = { + "running", "accompanying", "malediction", "softeners", "declining", + "conspiracies", "monotonically", "annotations", "internationalization", + "denormalization", "photographers", "responsibilities", "acknowledgement", + "this", "cat", "querying" + }; + + /** + * Baseline replica of the previous {@code SnowballStemmer} implementation: the generated engine + * lives in a plain field, so an instance must not be shared across threads. + */ + static final class LegacySnowballStemmer implements Stemmer { + + private final AbstractSnowballStemmer stemmer = new englishStemmer(); + + @Override + public CharSequence stem(CharSequence word) { + stemmer.setCurrent(word.toString()); + stemmer.stem(); + return stemmer.getCurrent(); + } + } + + @State(Scope.Benchmark) + public static class SharedState { + Stemmer stemmer; + + @Setup(Level.Trial) + public void create() { + stemmer = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH); + } + } + + @State(Scope.Thread) + public static class PerThreadState { + Stemmer stemmer; + + @Setup(Level.Trial) + public void create() { + stemmer = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH); + } + } + + @State(Scope.Thread) + public static class LegacyPerThreadState { + Stemmer stemmer; + + @Setup(Level.Trial) + public void create() { + stemmer = new LegacySnowballStemmer(); + } + } + + @Benchmark + @Threads(Threads.MAX) + public void sharedInstance(SharedState st, Blackhole bh) { + for (String s : INPUT) { + bh.consume(st.stemmer.stem(s)); + } + } + + @Benchmark + @Threads(Threads.MAX) + public void instancePerThread(PerThreadState pt, Blackhole bh) { + for (String s : INPUT) { + bh.consume(pt.stemmer.stem(s)); + } + } + + @Benchmark + @Threads(Threads.MAX) + public void legacyInstancePerThread(LegacyPerThreadState pt, Blackhole bh) { + for (String s : INPUT) { + bh.consume(pt.stemmer.stem(s)); + } + } + + /** + * Quick local iteration only: {@code forks(0)} disables JVM fork isolation + * (unlike {@code mvn} with the {@code jmh} profile). + * Use the Maven-invoked configuration for publishable numbers. + */ + public static void main(String[] args) throws Exception { + Options opt = new OptionsBuilder() + .include(SnowballStemmerBenchmark.class.getSimpleName()) + .forks(0) + .warmupIterations(3) + .measurementIterations(5) + .build(); + new Runner(opt).run(); + } +} diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/CachingStemmer.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/CachingStemmer.java new file mode 100644 index 0000000000..769b01c987 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/CachingStemmer.java @@ -0,0 +1,157 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.stemmer; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Supplier; + +import opennlp.tools.commons.ThreadSafe; + +/** + * A {@link Stemmer} that memoizes word-to-stem mappings in a bounded per-thread LRU cache. + * + *

Each thread gets its own delegate stemmer (minted from the supplied {@link StemmerFactory}) + * and its own cache, so instances are safe to share regardless of whether the factory's stemmers + * are, and memory is bounded to {@code capacity} entries per thread that stems. Caching pays off + * on threads reused across many words, such as a fixed platform-thread pool; on a + * virtual-thread-per-task executor every task starts with an empty cache, so repeats are served + * only within one task. {@link #stemAll(CharSequence)} is forwarded to the delegate uncached.

+ * + *

Long-running environments such as application containers should call + * {@link #clearThreadLocalState()} when a pooled thread no longer uses this stemmer.

+ */ +@ThreadSafe +public final class CachingStemmer extends DelegatingStemmer { + + /** + * Covers the high-frequency vocabulary of most corpora while keeping the per-thread footprint + * small. + */ + public static final int DEFAULT_CAPACITY = 1024; + + /** + * Creates a caching stemmer with the {@linkplain #DEFAULT_CAPACITY default capacity}. + * + * @param factory The factory that mints one delegate per thread. Must not be {@code null}. + * @throws IllegalArgumentException if {@code factory} is {@code null}. + */ + public CachingStemmer(StemmerFactory factory) { + this(factory, DEFAULT_CAPACITY); + } + + /** + * Creates a caching stemmer. + * + * @param factory The factory that mints one delegate per thread. Must not be {@code null}. + * @param capacity The maximum number of word-to-stem entries kept per thread; must be positive. + * @throws IllegalArgumentException if {@code factory} is {@code null} or {@code capacity} is + * not positive. + */ + public CachingStemmer(StemmerFactory factory, int capacity) { + super(threadStateSupplier(factory, capacity), threadState -> threadState.cache.clear()); + } + + /** + * Validates the constructor arguments eagerly and returns a supplier that mints one delegate and + * its per-thread cache, so invalid arguments fail at construction rather than on first use. + * + * @param factory The factory that mints one delegate per thread. Must not be {@code null}. + * @param capacity The maximum number of word-to-stem entries kept per thread; must be positive. + * @return a supplier of fresh per-thread state. + * @throws IllegalArgumentException if {@code factory} is {@code null} or {@code capacity} is + * not positive. + */ + private static Supplier threadStateSupplier(StemmerFactory factory, int capacity) { + requireFactory(factory); + if (capacity <= 0) { + throw new IllegalArgumentException("capacity must be positive, got " + capacity); + } + return () -> new ThreadState(factory.newStemmer(), capacity); + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code word} is {@code null}. + */ + @Override + public CharSequence stem(CharSequence word) { + if (word == null) { + throw new IllegalArgumentException("word must not be null"); + } + final ThreadState ts = state.get(); + final String key = word.toString(); + final String cached = ts.cache.get(key); + if (cached != null) { + return cached; + } + // toString() copies the result out of any buffer the delegate reuses. + final String stemmed = ts.delegate.stem(key).toString(); + ts.cache.put(key, stemmed); + return stemmed; + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code word} is {@code null}. + */ + @Override + public List stemAll(CharSequence word) { + if (word == null) { + throw new IllegalArgumentException("word must not be null"); + } + return state.get().delegate.stemAll(word); + } + + /** + * Empties the calling thread's cache while keeping its delegate, forcing every subsequent + * word through a fresh stemming pass. Only the calling thread's cache is affected. A thread + * that has not stemmed yet has its state initialized by this call, so invoke it on the + * thread whose cache should be emptied, not from an unrelated maintenance thread. + */ + public void clearCache() { + state.get().cache.clear(); + } + + static final class ThreadState { + + private final Stemmer delegate; + private final LinkedHashMap cache; + + /** + * Creates the per-thread delegate and its access-ordered LRU cache. + * + * @param delegate The stemmer minted for this thread. + * @param capacity The maximum number of entries retained before eviction. + */ + private ThreadState(Stemmer delegate, int capacity) { + this.delegate = delegate; + // Access-ordered for LRU eviction. + this.cache = new LinkedHashMap<>((int) Math.min(capacity / 0.75d + 1.0d, 4096.0d), + 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > capacity; + } + }; + } + } +} diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/DelegatingStemmer.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/DelegatingStemmer.java new file mode 100644 index 0000000000..5f57fd738d --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/DelegatingStemmer.java @@ -0,0 +1,70 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.stemmer; + +import java.util.function.Consumer; +import java.util.function.Supplier; + +import opennlp.tools.util.OwnerOrPerThreadState; + +/** + * Shared plumbing for {@link Stemmer} wrappers that route each call to a per-thread payload minted + * from a {@link StemmerFactory}, following the {@link OwnerOrPerThreadState} pattern of the + * thread-safe {@code *ME} components. Subclasses differ only in the payload they carry per thread + * and in what {@link #stem(CharSequence)} does with it. + * + * @param

the per-thread payload: a bare delegate stemmer, or a delegate paired with per-thread + * working state such as a cache. + */ +abstract class DelegatingStemmer

implements Stemmer { + + /** The per-thread payload holder. */ + final OwnerOrPerThreadState

state; + + /** + * @param init Mints the per-thread payload on first use by the owner and by each extra thread. + * @param cleanup Releases a per-thread payload when its thread clears state. + */ + DelegatingStemmer(Supplier

init, Consumer

cleanup) { + this.state = new OwnerOrPerThreadState<>(init, cleanup); + } + + /** + * Validates a factory argument before it is captured by a subclass supplier, so a {@code null} + * factory fails at construction rather than on first use. + * + * @param factory The factory to validate. Must not be {@code null}. + * @return {@code factory}. + * @throws IllegalArgumentException if {@code factory} is {@code null}. + */ + static StemmerFactory requireFactory(StemmerFactory factory) { + if (factory == null) { + throw new IllegalArgumentException("factory must not be null"); + } + return factory; + } + + /** + * Removes this thread's payload to prevent classloader leaks in container environments. Call + * when the thread is returned to a pool or the stemmer is no longer needed, mirroring + * {@code clearThreadLocalState()} on the thread-safe {@code *ME} components. + */ + public void clearThreadLocalState() { + state.clearForCurrentThread(); + } +} diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmer.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmer.java index 623a2d3d2c..aac1c49c38 100644 --- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmer.java +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmer.java @@ -595,10 +595,16 @@ public String stem(String s) { } /** - * Stem a word provided as a CharSequence. - * Returns the result as a CharSequence. + * Stem a word provided as a CharSequence. Returns the result as a CharSequence. + * + * @param word The word to stem. Must not be {@code null}. + * @return the stemmed word. + * @throws IllegalArgumentException if {@code word} is {@code null}. */ public CharSequence stem(CharSequence word) { + if (word == null) { + throw new IllegalArgumentException("word must not be null"); + } return stem(word.toString()); } diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmerFactory.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmerFactory.java new file mode 100644 index 0000000000..992772778b --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/PorterStemmerFactory.java @@ -0,0 +1,32 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.stemmer; + +import opennlp.tools.commons.ThreadSafe; + +/** + * A thread-safe factory for {@link PorterStemmer} instances. + */ +@ThreadSafe +public record PorterStemmerFactory() implements StemmerFactory { + + @Override + public Stemmer newStemmer() { + return new PorterStemmer(); + } +} diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/SharingStemmer.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/SharingStemmer.java new file mode 100644 index 0000000000..abcd21a1d9 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/SharingStemmer.java @@ -0,0 +1,71 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.stemmer; + +import java.util.List; + +import opennlp.tools.commons.ThreadSafe; +import opennlp.tools.util.OwnerOrPerThreadState; + +/** + * A {@link Stemmer} that is safe to share across threads by routing each call to a per-thread + * delegate minted from a {@link StemmerFactory}. Use it to share a stemmer whose implementation is + * not itself thread-safe, such as {@link PorterStemmer}; thread-safe implementations like + * {@code SnowballStemmer} do not need this wrapper. + * + *

The first thread uses a single owner delegate without touching {@link ThreadLocal} until a + * second thread appears, matching the {@link OwnerOrPerThreadState} pattern used by the + * thread-safe {@code *ME} components.

+ */ +@ThreadSafe +public final class SharingStemmer extends DelegatingStemmer { + + /** + * @param factory The factory that mints per-thread delegates. Must not be {@code null}. + * @throws IllegalArgumentException if {@code factory} is {@code null}. + */ + public SharingStemmer(StemmerFactory factory) { + super(requireFactory(factory)::newStemmer, stemmer -> { }); + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code word} is {@code null}. + */ + @Override + public CharSequence stem(CharSequence word) { + if (word == null) { + throw new IllegalArgumentException("word must not be null"); + } + return state.get().stem(word); + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code word} is {@code null}. + */ + @Override + public List stemAll(CharSequence word) { + if (word == null) { + throw new IllegalArgumentException("word must not be null"); + } + return state.get().stemAll(word); + } +} diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmer.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmer.java index 7b8b1636ae..21c1613ea8 100644 --- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmer.java +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmer.java @@ -17,76 +17,98 @@ package opennlp.tools.stemmer.snowball; +import java.util.function.Supplier; + +import opennlp.tools.commons.ThreadSafe; +import opennlp.tools.stemmer.SharingStemmer; import opennlp.tools.stemmer.Stemmer; +/** + * A {@link Stemmer} backed by the generated Snowball engines. + * + *

The generated engines hold mutable per-call buffers, so this class routes each call to a + * per-thread engine, the same pattern the thread-safe {@code *ME} components use. A single + * {@code SnowballStemmer} instance is therefore safe to share across threads.

+ * + *

As with the {@code *ME} components, long-running environments such as application + * containers should call {@link #clearThreadLocalState()} when a pooled thread no longer uses + * this stemmer; otherwise the thread retains its engine until the instance is unreachable, + * which can pin the defining classloader on redeploys.

+ */ +@ThreadSafe public class SnowballStemmer implements Stemmer { - private final AbstractSnowballStemmer stemmer; - private final int repeat; + private final SharingStemmer sharing; + /** + * Creates a stemmer for the given algorithm and repeat count. + * + * @param algorithm The Snowball algorithm. Must not be {@code null}. + * @param repeat How many times to apply the stemmer per word; must be positive. + * @throws IllegalArgumentException Thrown if {@code algorithm} is {@code null} or + * {@code repeat} is not positive. + */ public SnowballStemmer(ALGORITHM algorithm, int repeat) { - this.repeat = repeat; - - if (ALGORITHM.ARABIC.equals(algorithm)) { - stemmer = new arabicStemmer(); - } else if (ALGORITHM.DANISH.equals(algorithm)) { - stemmer = new danishStemmer(); - } else if (ALGORITHM.DUTCH.equals(algorithm)) { - stemmer = new dutchStemmer(); - } else if (ALGORITHM.CATALAN.equals(algorithm)) { - stemmer = new catalanStemmer(); - } else if (ALGORITHM.ENGLISH.equals(algorithm)) { - stemmer = new englishStemmer(); - } else if (ALGORITHM.FINNISH.equals(algorithm)) { - stemmer = new finnishStemmer(); - } else if (ALGORITHM.FRENCH.equals(algorithm)) { - stemmer = new frenchStemmer(); - } else if (ALGORITHM.GERMAN.equals(algorithm)) { - stemmer = new germanStemmer(); - } else if (ALGORITHM.GREEK.equals(algorithm)) { - stemmer = new greekStemmer(); - } else if (ALGORITHM.HUNGARIAN.equals(algorithm)) { - stemmer = new hungarianStemmer(); - } else if (ALGORITHM.INDONESIAN.equals(algorithm)) { - stemmer = new indonesianStemmer(); - } else if (ALGORITHM.IRISH.equals(algorithm)) { - stemmer = new irishStemmer(); - } else if (ALGORITHM.ITALIAN.equals(algorithm)) { - stemmer = new italianStemmer(); - } else if (ALGORITHM.NORWEGIAN.equals(algorithm)) { - stemmer = new norwegianStemmer(); - } else if (ALGORITHM.PORTER.equals(algorithm)) { - stemmer = new porterStemmer(); - } else if (ALGORITHM.PORTUGUESE.equals(algorithm)) { - stemmer = new portugueseStemmer(); - } else if (ALGORITHM.ROMANIAN.equals(algorithm)) { - stemmer = new romanianStemmer(); - } else if (ALGORITHM.RUSSIAN.equals(algorithm)) { - stemmer = new russianStemmer(); - } else if (ALGORITHM.SPANISH.equals(algorithm)) { - stemmer = new spanishStemmer(); - } else if (ALGORITHM.SWEDISH.equals(algorithm)) { - stemmer = new swedishStemmer(); - } else if (ALGORITHM.TURKISH.equals(algorithm)) { - stemmer = new turkishStemmer(); - } else { - throw new IllegalStateException("Unexpected stemmer algorithm: " + algorithm); - } + this.sharing = new SharingStemmer(new SnowballStemmerFactory(algorithm, repeat)); } + /** + * Creates a stemmer for the given algorithm with {@code repeat = 1}. + * + * @param algorithm The Snowball algorithm. Must not be {@code null}. + */ public SnowballStemmer(ALGORITHM algorithm) { this(algorithm, 1); } - public CharSequence stem(CharSequence word) { - - stemmer.setCurrent(word.toString()); + /** + * Returns a supplier of a fresh Snowball engine for the given algorithm, shared with + * {@link SnowballStemmerFactory} whose products embed one engine directly. + * + * @param algorithm The Snowball algorithm. + * @return a supplier that mints a new engine on each call. + */ + static Supplier engineFor(ALGORITHM algorithm) { + return switch (algorithm) { + case ARABIC -> arabicStemmer::new; + case DANISH -> danishStemmer::new; + case DUTCH -> dutchStemmer::new; + case CATALAN -> catalanStemmer::new; + case ENGLISH -> englishStemmer::new; + case FINNISH -> finnishStemmer::new; + case FRENCH -> frenchStemmer::new; + case GERMAN -> germanStemmer::new; + case GREEK -> greekStemmer::new; + case HUNGARIAN -> hungarianStemmer::new; + case INDONESIAN -> indonesianStemmer::new; + case IRISH -> irishStemmer::new; + case ITALIAN -> italianStemmer::new; + case NORWEGIAN -> norwegianStemmer::new; + case PORTER -> porterStemmer::new; + case PORTUGUESE -> portugueseStemmer::new; + case ROMANIAN -> romanianStemmer::new; + case RUSSIAN -> russianStemmer::new; + case SPANISH -> spanishStemmer::new; + case SWEDISH -> swedishStemmer::new; + case TURKISH -> turkishStemmer::new; + }; + } - for (int i = 0; i < repeat; i++) { - stemmer.stem(); - } + /** + * {@inheritDoc} + */ + @Override + public CharSequence stem(CharSequence word) { + return sharing.stem(word); + } - return stemmer.getCurrent(); + /** + * Removes this thread's engine to prevent classloader leaks in container environments. Call + * when the thread is returned to a pool or the stemmer is no longer needed, mirroring + * {@code clearThreadLocalState()} on the thread-safe {@code *ME} components. + */ + public void clearThreadLocalState() { + sharing.clearThreadLocalState(); } public enum ALGORITHM { diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmerFactory.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmerFactory.java new file mode 100644 index 0000000000..393b676338 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/stemmer/snowball/SnowballStemmerFactory.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.stemmer.snowball; + +import opennlp.tools.commons.ThreadSafe; +import opennlp.tools.stemmer.Stemmer; +import opennlp.tools.stemmer.StemmerFactory; + +/** + * A thread-safe factory that captures a Snowball stemmer configuration for APIs that accept a + * {@link StemmerFactory}. + * + *

Use this factory for the classic one-stemmer-per-thread pattern: {@link #newStemmer()} + * returns a plain, thread-confined stemmer without the per-call thread routing of the + * shareable {@link SnowballStemmer}.

+ * + * @param algorithm The Snowball algorithm. Must not be {@code null}. + * @param repeat How many times to apply the stemmer per word; must be positive. + */ +@ThreadSafe +public record SnowballStemmerFactory(SnowballStemmer.ALGORITHM algorithm, int repeat) + implements StemmerFactory { + + /** + * Creates a factory with {@code repeat = 1}. + * + * @param algorithm The Snowball algorithm. Must not be {@code null}. + */ + public SnowballStemmerFactory(SnowballStemmer.ALGORITHM algorithm) { + this(algorithm, 1); + } + + /** + * Validates the components. + * + * @throws IllegalArgumentException Thrown if {@code algorithm} is {@code null} or + * {@code repeat} is not positive. + */ + public SnowballStemmerFactory { + if (algorithm == null) { + throw new IllegalArgumentException("algorithm must not be null"); + } + if (repeat <= 0) { + throw new IllegalArgumentException("repeat must be positive, got " + repeat); + } + } + + @Override + public Stemmer newStemmer() { + return new ConfinedStemmer(SnowballStemmer.engineFor(algorithm).get(), repeat); + } + + /** A plain, thread-confined stemmer that applies one Snowball engine directly; not thread-safe. */ + private static final class ConfinedStemmer implements Stemmer { + + private final AbstractSnowballStemmer engine; + private final int repeat; + + /** + * Creates a thread-confined stemmer over the given engine. + * + * @param engine The Snowball engine to apply. + * @param repeat How many times to apply the engine per word. + */ + private ConfinedStemmer(AbstractSnowballStemmer engine, int repeat) { + this.engine = engine; + this.repeat = repeat; + } + + /** + * {@inheritDoc} + * + * @throws IllegalArgumentException if {@code word} is {@code null}. + */ + @Override + public CharSequence stem(CharSequence word) { + if (word == null) { + throw new IllegalArgumentException("word must not be null"); + } + engine.setCurrent(word.toString()); + for (int i = 0; i < repeat; i++) { + engine.stem(); + } + return engine.getCurrent(); + } + } +} diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java index e7a47477ed..57f4ea27dc 100644 --- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/NormalizationProfile.java @@ -4,7 +4,7 @@ * this work for additional information regarding copyright ownership. * The ASF licenses this file to You under the Apache License, Version 2.0 * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at + * the License. You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * @@ -17,7 +17,9 @@ package opennlp.tools.util.normalizer; import opennlp.tools.stemmer.Stemmer; +import opennlp.tools.stemmer.StemmerFactory; import opennlp.tools.stemmer.snowball.SnowballStemmer; +import opennlp.tools.stemmer.snowball.SnowballStemmerFactory; /** * Per-language normalization settings, mirroring how OpenNLP already selects a Snowball stemmer by @@ -61,8 +63,17 @@ public record NormalizationProfile(String language, SnowballStemmer.ALGORITHM st } /** - * {@return a new {@link Stemmer} for this language} A fresh instance is returned on each call - * because the Snowball stemmers are stateful and not thread-safe. + * {@return a thread-safe factory for this language's Snowball stemmer} The factory may be + * shared; each {@linkplain StemmerFactory#newStemmer() minted stemmer} is confined to the + * calling thread. + */ + public StemmerFactory stemmerFactory() { + return new SnowballStemmerFactory(stemmerAlgorithm); + } + + /** + * {@return a new {@link Stemmer} for this language} The returned {@link SnowballStemmer} is + * thread-safe and may be shared across threads. */ public Stemmer newStemmer() { return new SnowballStemmer(stemmerAlgorithm); @@ -70,8 +81,10 @@ public Stemmer newStemmer() { /** * Returns a matching analyzer for this language: NFC, case folding, the language's - * {@linkplain #accentFold() diacritic fold} when it has one, then stemming. Each call builds an - * independent analyzer with its own stemmer, so use one per thread when stemming. + * {@linkplain #accentFold() diacritic fold} when it has one, then stemming. The returned + * analyzer is thread-safe, and repeated words resolve from a bounded per-thread stem cache + * instead of being re-stemmed. The cache is keyed to the thread, so build the analyzer once + * and reuse it from threads that live across many calls, such as a fixed platform-thread pool. * * @return the analyzer. */ @@ -80,6 +93,6 @@ public TermAnalyzer matchingAnalyzer() { if (accentFold != null) { builder.transform(Dimension.ACCENT_FOLD, accentFold); } - return builder.stem(newStemmer()).build(); + return builder.stem(stemmerFactory()).build(); } } diff --git a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java index 39f213aaad..934002d7eb 100644 --- a/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java +++ b/opennlp-core/opennlp-runtime/src/main/java/opennlp/tools/util/normalizer/TermAnalyzer.java @@ -25,7 +25,9 @@ import java.util.Set; import opennlp.tools.lemmatizer.Lemmatizer; +import opennlp.tools.stemmer.CachingStemmer; import opennlp.tools.stemmer.Stemmer; +import opennlp.tools.stemmer.StemmerFactory; import opennlp.tools.tokenize.uax29.WordTokenizer; import opennlp.tools.util.Span; @@ -41,10 +43,12 @@ * {@link Dimension#LEMMA} are enabled by supplying a {@link Stemmer} or {@link Lemmatizer}.

* *

An instance is immutable and is thread-safe when its configured transforms are. The built-in - * character normalizers are stateless, but the Snowball stemmers are not, so an analyzer configured - * with a {@link Stemmer} (for example through {@code NormalizationProfile.matchingAnalyzer()}) should - * not be shared across threads when {@link Dimension#STEM} is used. Build one with - * {@link #builder()}.

+ * character normalizers are stateless and the Snowball stemmers are thread-safe. When + * {@link Dimension#STEM} is enabled through {@link Builder#stem(StemmerFactory)}, stemming is + * routed through a {@link CachingStemmer}: the analyzer is safe to share across threads even when + * the factory mints stateful stemmers, and repeated words resolve from a bounded per-thread cache + * instead of being re-stemmed. A raw {@link Stemmer} passed to {@link Builder#stem(Stemmer)} is + * only safe when that stemmer is itself thread-safe or the analyzer is confined to one thread.

*/ public final class TermAnalyzer { @@ -392,6 +396,37 @@ public Builder stem(Stemmer value) { return this; } + /** + * Enables {@link Dimension#STEM} through a {@link StemmerFactory}. The analyzer receives a + * {@link CachingStemmer} with the {@linkplain CachingStemmer#DEFAULT_CAPACITY default cache + * capacity}: it can be shared across threads, and repeated words resolve from a bounded + * per-thread cache instead of being re-stemmed. The cache is keyed to the thread, so the + * memoization pays off on threads reused across many tokens (a fixed platform-thread pool); + * on a virtual-thread-per-task executor every task starts with an empty cache. + * + * @param factory The stemmer factory. Must not be {@code null}. + * @return this builder + * @throws IllegalArgumentException if {@code factory} is {@code null}. + */ + public Builder stem(StemmerFactory factory) { + return stem(factory, CachingStemmer.DEFAULT_CAPACITY); + } + + /** + * Enables {@link Dimension#STEM} through a {@link StemmerFactory} with an explicit stem cache + * capacity, otherwise identical to {@link #stem(StemmerFactory)}. + * + * @param factory The stemmer factory. Must not be {@code null}. + * @param cacheSize The maximum number of word-to-stem entries kept per thread; must be + * positive. + * @return this builder + * @throws IllegalArgumentException if {@code factory} is {@code null} or {@code cacheSize} is + * not positive. + */ + public Builder stem(StemmerFactory factory, int cacheSize) { + return stem(new CachingStemmer(factory, cacheSize)); + } + /** * Enables {@link Dimension#LEMMA} through the given lemmatizer. * diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/CachingStemmerTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/CachingStemmerTest.java new file mode 100644 index 0000000000..6890dbca80 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/CachingStemmerTest.java @@ -0,0 +1,139 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.stemmer; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import opennlp.tools.stemmer.snowball.SnowballStemmer; +import opennlp.tools.stemmer.snowball.SnowballStemmerFactory; + +class CachingStemmerTest { + + private static final SnowballStemmerFactory ENGLISH = + new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH); + + @Test + void cachedResultsMatchUncachedResults() { + Stemmer reference = ENGLISH.newStemmer(); + CachingStemmer cached = new CachingStemmer(ENGLISH); + + List words = List.of("running", "declining", "conspiracies", "this", + "running", "declining", "ab'yle", "running"); + for (String word : words) { + Assertions.assertEquals(reference.stem(word).toString(), cached.stem(word).toString(), + "mismatch for '" + word + "'"); + } + } + + @Test + void repeatedWordsHitTheCache() { + AtomicInteger delegateCalls = new AtomicInteger(); + StemmerFactory countingFactory = () -> { + Stemmer delegate = ENGLISH.newStemmer(); + return word -> { + delegateCalls.incrementAndGet(); + return delegate.stem(word); + }; + }; + + CachingStemmer cached = new CachingStemmer(countingFactory); + for (int i = 0; i < 100; i++) { + Assertions.assertEquals("run", cached.stem("running").toString()); + } + Assertions.assertEquals(1, delegateCalls.get()); + } + + @Test + void evictionKeepsResultsCorrect() { + AtomicInteger delegateCalls = new AtomicInteger(); + StemmerFactory countingFactory = () -> { + Stemmer delegate = ENGLISH.newStemmer(); + return word -> { + delegateCalls.incrementAndGet(); + return delegate.stem(word); + }; + }; + + // Capacity 2 with a 3-word cycle: every access evicts the word needed two steps later. + CachingStemmer cached = new CachingStemmer(countingFactory, 2); + Stemmer reference = ENGLISH.newStemmer(); + List words = List.of("running", "declining", "conspiracies"); + for (int round = 0; round < 5; round++) { + for (String word : words) { + Assertions.assertEquals(reference.stem(word).toString(), cached.stem(word).toString()); + } + } + Assertions.assertTrue(delegateCalls.get() > 3, + "expected evictions to force repeated delegate calls, got " + delegateCalls.get()); + } + + @Test + void stemAllBypassesTheCache() { + CachingStemmer cached = new CachingStemmer(ENGLISH); + List forms = cached.stemAll("running"); + Assertions.assertEquals(1, forms.size()); + Assertions.assertEquals("run", forms.getFirst().toString()); + } + + @Test + void isSafeUnderConcurrentUse() throws Exception { + Stemmer reference = ENGLISH.newStemmer(); + List words = List.of("running", "declining", "conspiracies", "annotations", + "photographers", "responsibilities", "this", "querying"); + List expected = new ArrayList<>(words.size()); + for (String word : words) { + expected.add(reference.stem(word).toString()); + } + + // Capacity below the vocabulary size, so threads continuously evict within their own cache. + CachingStemmer cached = new CachingStemmer(ENGLISH, 4); + try (ExecutorService pool = Executors.newFixedThreadPool(8)) { + Future[] tasks = new Future[32]; + for (int i = 0; i < tasks.length; i++) { + final int offset = i; + tasks[i] = pool.submit(() -> { + for (int n = 0; n < 200; n++) { + for (int w = 0; w < words.size(); w++) { + int idx = (w + offset) % words.size(); + Assertions.assertEquals(expected.get(idx), cached.stem(words.get(idx)).toString()); + } + } + }); + } + for (Future task : tasks) { + task.get(30, TimeUnit.SECONDS); + } + } + } + + @Test + void rejectsInvalidArguments() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new CachingStemmer(null)); + Assertions.assertThrows(IllegalArgumentException.class, () -> new CachingStemmer(ENGLISH, 0)); + Assertions.assertThrows(IllegalArgumentException.class, () -> new CachingStemmer(ENGLISH, -1)); + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerFactoryTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerFactoryTest.java new file mode 100644 index 0000000000..9a19d01109 --- /dev/null +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/stemmer/StemmerFactoryTest.java @@ -0,0 +1,376 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.stemmer; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import opennlp.tools.stemmer.snowball.SnowballStemmer; +import opennlp.tools.stemmer.snowball.SnowballStemmerFactory; + +class StemmerFactoryTest { + + @Test + void snowballFactoryMatchesDirectStemmer() { + StemmerFactory factory = new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH); + Stemmer direct = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH); + Assertions.assertEquals(direct.stem("running"), factory.newStemmer().stem("running")); + } + + @Test + void porterFactoryMatchesDirectStemmer() { + StemmerFactory factory = new PorterStemmerFactory(); + Stemmer direct = new PorterStemmer(); + Assertions.assertEquals(direct.stem("running"), factory.newStemmer().stem("running")); + } + + @Test + void stemAllDefaultReturnsSingleForm() { + StemmerFactory factory = new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH); + List forms = factory.newStemmer().stemAll("running"); + Assertions.assertEquals(1, forms.size()); + Assertions.assertEquals("run", forms.getFirst()); + } + + @Test + void factoryRejectsInvalidRepeat() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH, 0)); + } + + @Test + void sharingStemmerMatchesSingleThreadReference() throws Exception { + StemmerFactory factory = new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH); + Stemmer reference = factory.newStemmer(); + SharingStemmer shared = new SharingStemmer(factory); + + Assertions.assertEquals(reference.stem("running"), shared.stem("running")); + Assertions.assertEquals(reference.stem("this"), shared.stem("this")); + } + + @Test + void sharingStemmerIsSafeUnderConcurrentUse() throws Exception { + StemmerFactory factory = new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH); + SharingStemmer shared = new SharingStemmer(factory); + CharSequence expectedRunning = factory.newStemmer().stem("running"); + CharSequence expectedDeclining = factory.newStemmer().stem("declining"); + + try (ExecutorService pool = Executors.newFixedThreadPool(8)) { + Future[] tasks = new Future[64]; + for (int i = 0; i < tasks.length; i++) { + tasks[i] = pool.submit(() -> { + for (int n = 0; n < 200; n++) { + Assertions.assertEquals(expectedRunning, shared.stem("running")); + Assertions.assertEquals(expectedDeclining, shared.stem("declining")); + } + }); + } + for (Future task : tasks) { + task.get(30, TimeUnit.SECONDS); + } + } + } + + @Test + void snowballStemmerIsSafeUnderConcurrentUse() throws Exception { + Stemmer shared = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH); + CharSequence expectedRunning = shared.stem("running"); + CharSequence expectedDeclining = shared.stem("declining"); + + try (ExecutorService pool = Executors.newFixedThreadPool(8)) { + Future[] tasks = new Future[64]; + for (int i = 0; i < tasks.length; i++) { + tasks[i] = pool.submit(() -> { + for (int n = 0; n < 200; n++) { + Assertions.assertEquals(expectedRunning, shared.stem("running")); + Assertions.assertEquals(expectedDeclining, shared.stem("declining")); + } + }); + } + for (Future task : tasks) { + task.get(30, TimeUnit.SECONDS); + } + } + } + + @Test + void sharingStemmerRejectsNullFactory() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new SharingStemmer(null)); + } + + @ParameterizedTest + @EnumSource(SnowballStemmer.ALGORITHM.class) + void everyAlgorithmIsSafeUnderConcurrentUse(SnowballStemmer.ALGORITHM algorithm) + throws Exception { + // Words across scripts; stemming any input is deterministic, so a mismatch under + // concurrency signals corrupted shared state, regardless of the input language. + List words = List.of("running", "declining", "ab'yle", "prévoyant", + "επιστροφή", "яблоками", "استفتياكما", "skrøbeligheder"); + Stemmer reference = new SnowballStemmer(algorithm); + List expected = new ArrayList<>(words.size()); + for (String word : words) { + expected.add(reference.stem(word).toString()); + } + + Stemmer shared = new SnowballStemmer(algorithm); + try (ExecutorService pool = Executors.newFixedThreadPool(4)) { + Future[] tasks = new Future[16]; + for (int i = 0; i < tasks.length; i++) { + final int offset = i; + tasks[i] = pool.submit(() -> { + for (int n = 0; n < 50; n++) { + for (int w = 0; w < words.size(); w++) { + int idx = (w + offset) % words.size(); + Assertions.assertEquals(expected.get(idx), shared.stem(words.get(idx)).toString()); + } + } + }); + } + for (Future task : tasks) { + task.get(30, TimeUnit.SECONDS); + } + } + } + + @Test + void ownerThreadCanStemConcurrentlyWithOtherThreads() throws Exception { + // The calling thread claims the owner fast path first; other threads must transition the + // stemmer to per-thread state while the owner keeps stemming. + Stemmer shared = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH); + CharSequence expectedRunning = shared.stem("running"); + CharSequence expectedDeclining = shared.stem("declining"); + + CountDownLatch started = new CountDownLatch(4); + try (ExecutorService pool = Executors.newFixedThreadPool(4)) { + Future[] tasks = new Future[4]; + for (int i = 0; i < tasks.length; i++) { + tasks[i] = pool.submit(() -> { + started.countDown(); + for (int n = 0; n < 500; n++) { + Assertions.assertEquals(expectedRunning, shared.stem("running")); + Assertions.assertEquals(expectedDeclining, shared.stem("declining")); + } + }); + } + Assertions.assertTrue(started.await(10, TimeUnit.SECONDS)); + // Owner thread stems concurrently with the pool threads. + for (int n = 0; n < 500; n++) { + Assertions.assertEquals(expectedRunning, shared.stem("running")); + Assertions.assertEquals(expectedDeclining, shared.stem("declining")); + } + for (Future task : tasks) { + task.get(30, TimeUnit.SECONDS); + } + } + } + + @Test + void snowballStemmerIsSafeOnVirtualThreads() throws Exception { + // Every task runs on a fresh virtual thread, exercising per-thread state creation on each + // access rather than reuse from a fixed pool. + Stemmer shared = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH); + CharSequence expected = shared.stem("running"); + + try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) { + List> tasks = new ArrayList<>(256); + for (int i = 0; i < 256; i++) { + tasks.add(pool.submit(() -> { + for (int n = 0; n < 20; n++) { + Assertions.assertEquals(expected, shared.stem("running")); + } + })); + } + for (Future task : tasks) { + task.get(30, TimeUnit.SECONDS); + } + } + } + + @Test + void sharingPorterStemmerIsSafeUnderConcurrentUse() throws Exception { + // PorterStemmer is the remaining stateful engine; SharingStemmer must isolate it per thread. + Stemmer reference = new PorterStemmer(); + CharSequence expectedRunning = reference.stem("running"); + CharSequence expectedConspiracies = reference.stem("conspiracies"); + Stemmer shared = new SharingStemmer(new PorterStemmerFactory()); + + try (ExecutorService pool = Executors.newFixedThreadPool(8)) { + Future[] tasks = new Future[64]; + for (int i = 0; i < tasks.length; i++) { + tasks[i] = pool.submit(() -> { + for (int n = 0; n < 200; n++) { + Assertions.assertEquals(expectedRunning, shared.stem("running")); + Assertions.assertEquals(expectedConspiracies, shared.stem("conspiracies")); + } + }); + } + for (Future task : tasks) { + task.get(30, TimeUnit.SECONDS); + } + } + } + @Test + void factoryPassesRepeatThrough() { + // "internationalization" stems differently at repeat 1 and 2, witnessing the pass-through. + final Stemmer once = new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH, 1).newStemmer(); + final Stemmer twice = new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH, 2).newStemmer(); + Assertions.assertEquals("internation", once.stem("internationalization").toString()); + Assertions.assertEquals("intern", twice.stem("internationalization").toString()); + Assertions.assertEquals( + new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH, 2).stem("internationalization").toString(), + twice.stem("internationalization").toString()); + } + + @Test + void factoryRejectsNullAlgorithmWithIllegalArgument() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new SnowballStemmerFactory(null, 1)); + } + + @Test + void snowballConstructorValidatesLikeTheFactory() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new SnowballStemmer(null, 1)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH, 0)); + } + + @Test + void sharingStemmerForwardsStemAllToMultiOutputDelegate() { + // A multi-output delegate must keep its full result list through the wrapper. + final StemmerFactory multiOutput = () -> new Stemmer() { + @Override + public CharSequence stem(CharSequence word) { + return word.toString() + "-a"; + } + + @Override + public List stemAll(CharSequence word) { + return List.of(word.toString() + "-a", word.toString() + "-b"); + } + }; + final SharingStemmer sharing = new SharingStemmer(multiOutput); + Assertions.assertEquals(2, sharing.stemAll("run").size()); + Assertions.assertEquals("run-b", sharing.stemAll("run").get(1).toString()); + } + + @Test + void clearThreadLocalStateEmptiesTheOwnerCache() { + // On the owning thread the state object is retained and reset: the cache must be empty + // afterwards, observable as a fresh delegate call for a previously cached word. + final List delegateCalls = new ArrayList<>(); + final StemmerFactory counting = () -> word -> { + delegateCalls.add(word.toString()); + return word.toString() + "-s"; + }; + final CachingStemmer caching = new CachingStemmer(counting); + caching.stem("dog"); + caching.stem("dog"); + Assertions.assertEquals(1, delegateCalls.size(), "second lookup is served from the cache"); + caching.clearThreadLocalState(); + Assertions.assertEquals("dog-s", caching.stem("dog").toString()); + Assertions.assertEquals(2, delegateCalls.size(), "the clear emptied the cache"); + } + + @Test + void clearThreadLocalStateReleasesAWorkerThreadsDelegate() throws Exception { + // On a non-owner thread the per-thread delegate is removed and rebuilt on next use. + final List built = new ArrayList<>(); + final StemmerFactory counting = () -> { + synchronized (built) { + built.add(built.size()); + } + return word -> word.toString() + "-s"; + }; + final SharingStemmer sharing = new SharingStemmer(counting); + sharing.stem("owner"); + final int builtForOwner = built.size(); + final Thread worker = new Thread(() -> { + sharing.stem("dog"); + sharing.stem("cat"); + final int beforeClear = built.size(); + sharing.clearThreadLocalState(); + sharing.stem("fish"); + Assertions.assertEquals(beforeClear + 1, built.size(), + "a fresh delegate is built after the clear"); + }); + worker.start(); + worker.join(30_000); + Assertions.assertTrue(built.size() > builtForOwner, "the worker built its own delegate"); + + final SnowballStemmer snowball = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH); + Assertions.assertEquals("run", snowball.stem("running").toString()); + snowball.clearThreadLocalState(); + Assertions.assertEquals("run", snowball.stem("running").toString()); + } + + @Test + void nullWordThrowsIllegalArgumentAcrossTheStemmerApi() { + final SnowballStemmerFactory factory = + new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH).stem(null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> factory.newStemmer().stem(null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new CachingStemmer(factory).stem(null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new SharingStemmer(factory).stem(null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new PorterStemmer().stem((CharSequence) null)); + } + + @Test + void hugeCapacityConstructsAndStems() { + // Pins the overflow-safe table sizing: capacities near Integer.MAX_VALUE are documented + // as legal ("must be positive") and must not wrap the LinkedHashMap initial capacity. + final CachingStemmer cached = new CachingStemmer( + new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH), Integer.MAX_VALUE); + Assertions.assertEquals("run", cached.stem("running").toString()); + } + + @Test + void clearCacheForcesRestemmingOnTheCallingThread() { + final AtomicInteger delegateCalls = new AtomicInteger(); + final StemmerFactory counting = () -> word -> { + delegateCalls.incrementAndGet(); + return word.toString(); + }; + final CachingStemmer cached = new CachingStemmer(counting); + + cached.stem("running"); + cached.stem("running"); + Assertions.assertEquals(1, delegateCalls.get(), "the repeat is served from the cache"); + + cached.clearCache(); + cached.stem("running"); + Assertions.assertEquals(2, delegateCalls.get(), + "after clearCache() the word goes through the delegate again"); + } +} diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java index ba39a5cf1f..149139847b 100644 --- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/NormalizationProfilesTest.java @@ -19,6 +19,9 @@ import java.util.EnumSet; import java.util.List; import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.stream.Collectors; import org.junit.jupiter.api.Test; @@ -201,4 +204,24 @@ public String[] getSupportedLanguages() { assertThrows(IllegalArgumentException.class, () -> NormalizationProfiles.detect(null, detector)); assertThrows(IllegalArgumentException.class, () -> NormalizationProfiles.detect("text", null)); } + + @Test + void testMatchingAnalyzerIsThreadSafeForStemming() throws Exception { + final TermAnalyzer analyzer = NormalizationProfiles.forLanguage("eng").orElseThrow().matchingAnalyzer(); + final String expected = analyzer.analyze("running").getFirst().at(Dimension.STEM); + + try (ExecutorService pool = Executors.newFixedThreadPool(8)) { + final Future[] tasks = new Future[32]; + for (int i = 0; i < tasks.length; i++) { + tasks[i] = pool.submit(() -> { + for (int n = 0; n < 50; n++) { + assertEquals(expected, analyzer.analyze("running").getFirst().at(Dimension.STEM)); + } + }); + } + for (final Future task : tasks) { + task.get(30, java.util.concurrent.TimeUnit.SECONDS); + } + } + } } diff --git a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java index d7fd52474e..e956a66ff6 100644 --- a/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java +++ b/opennlp-core/opennlp-runtime/src/test/java/opennlp/tools/util/normalizer/TermAnalyzerTest.java @@ -30,6 +30,10 @@ import opennlp.tools.lemmatizer.Lemmatizer; import opennlp.tools.stemmer.PorterStemmer; +import opennlp.tools.stemmer.Stemmer; +import opennlp.tools.stemmer.StemmerFactory; +import opennlp.tools.stemmer.snowball.SnowballStemmer; +import opennlp.tools.stemmer.snowball.SnowballStemmerFactory; import opennlp.tools.util.Span; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -306,11 +310,33 @@ void testBuilderRejectsNullArguments() { .transform(null, CaseFoldCharSequenceNormalizer.getInstance())); assertThrows(IllegalArgumentException.class, () -> TermAnalyzer.builder().transform(Dimension.CASE_FOLD, null)); - assertThrows(IllegalArgumentException.class, () -> TermAnalyzer.builder().stem(null)); + assertThrows(IllegalArgumentException.class, + () -> TermAnalyzer.builder().stem((Stemmer) null)); + assertThrows(IllegalArgumentException.class, + () -> TermAnalyzer.builder().stem((StemmerFactory) null)); + assertThrows(IllegalArgumentException.class, + () -> TermAnalyzer.builder().stem(new SnowballStemmerFactory( + SnowballStemmer.ALGORITHM.ENGLISH), 0)); assertThrows(IllegalArgumentException.class, () -> TermAnalyzer.builder().lemmatize(null)); assertThrows(IllegalArgumentException.class, () -> TermAnalyzer.builder().tokenizer(null)); } + @Test + void testStemFactoryCachedResultsMatchDirectStemmer() { + final TermAnalyzer cached = TermAnalyzer.builder().caseFold() + .stem(new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH)).build(); + final TermAnalyzer direct = TermAnalyzer.builder().caseFold() + .stem(new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH)).build(); + + final String text = "Running runners kept running while the RUNNING continued"; + final List cachedTerms = cached.analyze(text); + final List directTerms = direct.analyze(text); + assertEquals(directTerms.size(), cachedTerms.size()); + for (int i = 0; i < directTerms.size(); i++) { + assertEquals(directTerms.get(i).at(Dimension.STEM), cachedTerms.get(i).at(Dimension.STEM)); + } + } + @Test void testMaxTokenLengthRejectsNonPositiveValues() { assertThrows(IllegalArgumentException.class, () -> TermAnalyzer.builder().maxTokenLength(0)); diff --git a/opennlp-eval-tests/src/test/java/opennlp/tools/eval/MultiThreadedStemmerEval.java b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/MultiThreadedStemmerEval.java new file mode 100644 index 0000000000..aaac3c4c2b --- /dev/null +++ b/opennlp-eval-tests/src/test/java/opennlp/tools/eval/MultiThreadedStemmerEval.java @@ -0,0 +1,175 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package opennlp.tools.eval; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import opennlp.tools.commons.ThreadSafe; +import opennlp.tools.stemmer.PorterStemmerFactory; +import opennlp.tools.stemmer.SharingStemmer; +import opennlp.tools.stemmer.Stemmer; +import opennlp.tools.stemmer.snowball.SnowballStemmer; +import opennlp.tools.stemmer.snowball.SnowballStemmer.ALGORITHM; +import opennlp.tools.util.normalizer.Dimension; +import opennlp.tools.util.normalizer.NormalizationProfiles; +import opennlp.tools.util.normalizer.Term; +import opennlp.tools.util.normalizer.TermAnalyzer; + +/** + * Tests that the thread-safe stemmer implementations really are thread-safe by hammering shared + * instances from many threads and comparing every result against a single-threaded reference. + * + * @see ThreadSafe + * @see MultiThreadedToolsEval + */ +public class MultiThreadedStemmerEval extends AbstractEvalTest { + + private static final int NUM_THREADS = 8; + private static final int RUNS_PER_THREAD = 500; + + /** + * Words across scripts and languages, including apostrophes, diacritics, Greek, Cyrillic and + * Arabic. Stemming is deterministic for any input, so every algorithm is run over the whole + * list; a mismatch signals cross-thread corruption of the stemmer's internal buffers. + */ + private static final List WORDS = List.of( + "running", "accompanying", "malediction", "softeners", "declining", + "importantíssimes", "besar", "accidentalment", + "abbattimento", "aborrecimentos", "importantísimas", + "esiintymispaikasta", "aabenbaringen", "skrøbeligheder", "aftonringningen", + "buchbindergesellen", "mitverursacht", "prévoyant", "examinateurs", + "ab'yle", "kaçmamaktadır", "sarayı'nı", + "abbahagynám", "konstrukciójából", "vliegtuigtransport", + "absurdităţilor", "saracilor", "peledakan", "bhfeidhm", + "επιστροφή", "στρατιωτών", "красивая", "яблоками", + "استفتياكما", "استنتاجاتهما"); + + @Test + public void runSharedSnowballStemmersMultiThreaded() throws Exception { + for (ALGORITHM algorithm : ALGORITHM.values()) { + List expected = referenceStems(new SnowballStemmer(algorithm)); + hammer(new SnowballStemmer(algorithm), expected, "Snowball " + algorithm); + } + } + + @Test + public void runSharedPorterStemmerMultiThreaded() throws Exception { + SharingStemmer shared = new SharingStemmer(new PorterStemmerFactory()); + List expected = referenceStems(new PorterStemmerFactory().newStemmer()); + hammer(shared, expected, "SharingStemmer(Porter)"); + } + + @Test + public void runMatchingAnalyzersMultiThreaded() throws Exception { + for (String language : NormalizationProfiles.supportedLanguages()) { + TermAnalyzer analyzer = + NormalizationProfiles.forLanguage(language).orElseThrow().matchingAnalyzer(); + + List expected = new ArrayList<>(WORDS.size()); + for (String word : WORDS) { + expected.add(stemDimension(analyzer, word)); + } + + runInThreads(threadIndex -> { + List order = shuffledIndexes(threadIndex); + for (int run = 0; run < RUNS_PER_THREAD / 10; run++) { + for (int i : order) { + Assertions.assertEquals(expected.get(i), stemDimension(analyzer, WORDS.get(i)), + () -> "matchingAnalyzer(" + language + ") corrupted under concurrency"); + } + } + }); + } + } + + private static String stemDimension(TermAnalyzer analyzer, String word) { + List terms = analyzer.analyze(word); + if (terms.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (Term term : terms) { + sb.append(term.at(Dimension.STEM)).append(' '); + } + return sb.toString(); + } + + private static List referenceStems(Stemmer reference) { + List expected = new ArrayList<>(WORDS.size()); + for (String word : WORDS) { + expected.add(reference.stem(word).toString()); + } + return expected; + } + + private static void hammer(Stemmer shared, List expected, String label) throws Exception { + runInThreads(threadIndex -> { + List order = shuffledIndexes(threadIndex); + for (int run = 0; run < RUNS_PER_THREAD; run++) { + for (int i : order) { + Assertions.assertEquals(expected.get(i), shared.stem(WORDS.get(i)).toString(), + () -> label + " corrupted under concurrency for input '" + WORDS.get(i) + "'"); + } + } + }); + } + + /** + * Each thread visits the word list in its own order so that different threads are (almost) + * always inside different words at the same time, maximizing the chance of exposing shared + * mutable state. + */ + private static List shuffledIndexes(int seed) { + List order = new ArrayList<>(WORDS.size()); + for (int i = 0; i < WORDS.size(); i++) { + order.add(i); + } + Collections.shuffle(order, new Random(seed)); + return order; + } + + private interface ThreadBody { + void run(int threadIndex) throws Exception; + } + + private static void runInThreads(ThreadBody body) throws Exception { + try (ExecutorService pool = Executors.newFixedThreadPool(NUM_THREADS)) { + List> tasks = new ArrayList<>(NUM_THREADS); + for (int t = 0; t < NUM_THREADS; t++) { + final int threadIndex = t; + tasks.add(pool.submit(() -> { + body.run(threadIndex); + return null; + })); + } + for (Future task : tasks) { + task.get(120, TimeUnit.SECONDS); + } + } + } +}