Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion opennlp-api/src/main/java/opennlp/tools/stemmer/Stemmer.java
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>Thread safety is implementation specific.</p>
*/
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<CharSequence> stemAll(CharSequence word) {
return List.of(stem(word));
}
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* A factory for {@link Stemmer} instances: it captures a stemmer configuration (algorithm,
* repeat count, dictionary path, ...) once and mints configured stemmers on demand. Unlike the
* {@code BaseToolFactory}-based tool factories, it is a plain supplier and takes no part in
* model loading.
*/
public interface StemmerFactory {

/**
* {@return a new {@link Stemmer}}
*/
Stemmer newStemmer();
}
74 changes: 66 additions & 8 deletions opennlp-core/opennlp-runtime/BENCHMARKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. pre-patch baseline) |
| `CachingStemmerBenchmark` | CachingStemmer | cached vs uncached x 2 workloads |

### Approaches measured

Expand All @@ -30,17 +32,19 @@ 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)
Expand All @@ -56,6 +60,60 @@ builds. The throughput numbers should be within JMH's error margin.
# ... 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 replica of the
pre-patch implementation (engine in a plain field, not shareable).
One op = stemming 16 English words.

| Strategy | 1 thread | 8 threads | 32 threads |
|----------|---------:|----------:|-----------:|
| `sharedInstance` (patched, one shared stemmer) | 560k ± 3k ops/s | 1.55M ± 0.17M | 3.16M ± 0.34M |
| `instancePerThread` (patched, stemmer per thread) | 509k ± 26k ops/s | 1.60M ± 0.17M | 2.94M ± 0.11M |
| `legacyInstancePerThread` (pre-patch, 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
Expand Down
Original file line number Diff line number Diff line change
@@ -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}.
*
* <p>Two workloads drive both strategies:</p>
* <ul>
* <li>{@code zipf} — a 64k-token stream sampled with 1/rank weights from a 512-word
* vocabulary. This models real text, where a small vocabulary dominates; the default
* 1024-entry cache holds the whole vocabulary.</li>
* <li>{@code diverse} — a 64k-token stream sampled uniformly from an 8192-word vocabulary,
* 8x the cache capacity. This is the cache-hostile case: mostly misses plus constant
* eviction, so it bounds the overhead the cache can add.</li>
* </ul>
*
* <p>One op stems 16 consecutive tokens from the stream; each benchmark thread walks the stream
* from its own cursor.</p>
*/
@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<String> 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();
}
}
Loading
Loading