Skip to content

Commit e3ff692

Browse files
committed
OPENNLP-1883: Add SnowballStemmer JMH benchmark: thread-safe vs pre-patch baseline
Measures the thread-safe SnowballStemmer shared across threads and one per thread against a replica of the pre-patch plain-field implementation, at 1/8/32 threads. Results in BENCHMARKS.md: the OwnerOrPerThreadState overhead is within error bars up to 8 threads and only shows (~1.5x) at full 32-thread saturation.
1 parent c7c0234 commit e3ff692

2 files changed

Lines changed: 177 additions & 0 deletions

File tree

opennlp-core/opennlp-runtime/BENCHMARKS.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ variance reporting.
1313
| `TokenizerMEBenchmark` | TokenizerME | 3 approaches |
1414
| `SentenceDetectorMEBenchmark` | SentenceDetectorME | 3 approaches |
1515
| `POSTaggerMEBenchmark` | POSTaggerME | 3 approaches x 2 cache configs |
16+
| `SnowballStemmerBenchmark` | SnowballStemmer | 3 approaches (incl. pre-patch baseline) |
1617

1718
### Approaches measured
1819

@@ -56,6 +57,29 @@ builds. The throughput numbers should be within JMH's error margin.
5657
# ... build and run as above, compare
5758
```
5859

60+
### SnowballStemmer results (Linux, JDK 25, 32 cores, 2 forks x 10 iterations)
61+
62+
`SnowballStemmerBenchmark` compares the thread-safe `SnowballStemmer`
63+
(engine behind `OwnerOrPerThreadState`) against a replica of the
64+
pre-patch implementation (engine in a plain field, not shareable).
65+
One op = stemming 16 English words.
66+
67+
| Strategy | 1 thread | 8 threads | 32 threads |
68+
|----------|---------:|----------:|-----------:|
69+
| `sharedInstance` (patched, one shared stemmer) | 560k ± 3k ops/s | 1.55M ± 0.17M | 3.16M ± 0.34M |
70+
| `instancePerThread` (patched, stemmer per thread) | 509k ± 26k ops/s | 1.60M ± 0.17M | 2.94M ± 0.11M |
71+
| `legacyInstancePerThread` (pre-patch, stemmer per thread) | 544k ± 19k ops/s | 1.46M ± 0.16M | 4.77M ± 0.39M |
72+
73+
At 1 and 8 threads the three strategies are within (or nearly within)
74+
each other's error bars: the `OwnerOrPerThreadState` lookup is not
75+
measurable against the cost of stemming itself. Only at full
76+
saturation (32 threads, hyperthreaded) does the legacy plain-field
77+
baseline pull ahead (~1.5x): with every hardware thread busy, the
78+
per-call owner check plus `ThreadLocal` lookup is no longer hidden by
79+
memory-level parallelism. Real pipelines stem as one stage among many,
80+
so the saturated-microbenchmark gap is an upper bound, and the legacy
81+
strategy was not shareable across threads in the first place.
82+
5983
### POSTagger cache impact
6084

6185
The `POSTaggerMEBenchmark` uses `@Param({"0", "3"})` for cache
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package opennlp.tools.stemmer.snowball;
19+
20+
import java.util.concurrent.TimeUnit;
21+
22+
import org.openjdk.jmh.annotations.Benchmark;
23+
import org.openjdk.jmh.annotations.BenchmarkMode;
24+
import org.openjdk.jmh.annotations.Fork;
25+
import org.openjdk.jmh.annotations.Level;
26+
import org.openjdk.jmh.annotations.Measurement;
27+
import org.openjdk.jmh.annotations.Mode;
28+
import org.openjdk.jmh.annotations.OutputTimeUnit;
29+
import org.openjdk.jmh.annotations.Scope;
30+
import org.openjdk.jmh.annotations.Setup;
31+
import org.openjdk.jmh.annotations.State;
32+
import org.openjdk.jmh.annotations.Threads;
33+
import org.openjdk.jmh.annotations.Warmup;
34+
import org.openjdk.jmh.infra.Blackhole;
35+
import org.openjdk.jmh.runner.Runner;
36+
import org.openjdk.jmh.runner.options.Options;
37+
import org.openjdk.jmh.runner.options.OptionsBuilder;
38+
39+
import opennlp.tools.stemmer.Stemmer;
40+
41+
/**
42+
* JMH benchmark for the thread-safe {@link SnowballStemmer} versus the pre-patch implementation
43+
* that held its generated engine in a plain field.
44+
*
45+
* <p>Three strategies are measured, all at {@link Threads#MAX}:</p>
46+
* <ul>
47+
* <li>{@code sharedInstance} — one thread-safe {@link SnowballStemmer} shared by every thread
48+
* (one thread runs on the owner fast path, the rest on {@link ThreadLocal} state)</li>
49+
* <li>{@code instancePerThread} — one thread-safe {@link SnowballStemmer} per thread (every
50+
* thread is the owner of its own instance, so this isolates the owner-fast-path cost)</li>
51+
* <li>{@code legacyInstancePerThread} — the old non-thread-safe implementation, one per thread
52+
* (the pre-patch baseline; sharing it across threads would be a correctness bug)</li>
53+
* </ul>
54+
*/
55+
@BenchmarkMode(Mode.Throughput)
56+
@OutputTimeUnit(TimeUnit.SECONDS)
57+
@Warmup(iterations = 5, time = 2)
58+
@Measurement(iterations = 10, time = 2)
59+
@Fork(2)
60+
public class SnowballStemmerBenchmark {
61+
62+
private static final String[] INPUT = {
63+
"running", "accompanying", "malediction", "softeners", "declining",
64+
"conspiracies", "monotonically", "annotations", "internationalization",
65+
"denormalization", "photographers", "responsibilities", "acknowledgement",
66+
"this", "cat", "querying"
67+
};
68+
69+
/**
70+
* Replica of the pre-patch {@code SnowballStemmer}: the generated engine lives in a plain
71+
* field, so an instance must not be shared across threads.
72+
*/
73+
static final class LegacySnowballStemmer implements Stemmer {
74+
75+
private final AbstractSnowballStemmer stemmer = new englishStemmer();
76+
77+
@Override
78+
public CharSequence stem(CharSequence word) {
79+
stemmer.setCurrent(word.toString());
80+
stemmer.stem();
81+
return stemmer.getCurrent();
82+
}
83+
}
84+
85+
@State(Scope.Benchmark)
86+
public static class SharedState {
87+
Stemmer stemmer;
88+
89+
@Setup(Level.Trial)
90+
public void create() {
91+
stemmer = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH);
92+
}
93+
}
94+
95+
@State(Scope.Thread)
96+
public static class PerThreadState {
97+
Stemmer stemmer;
98+
99+
@Setup(Level.Trial)
100+
public void create() {
101+
stemmer = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH);
102+
}
103+
}
104+
105+
@State(Scope.Thread)
106+
public static class LegacyPerThreadState {
107+
Stemmer stemmer;
108+
109+
@Setup(Level.Trial)
110+
public void create() {
111+
stemmer = new LegacySnowballStemmer();
112+
}
113+
}
114+
115+
@Benchmark
116+
@Threads(Threads.MAX)
117+
public void sharedInstance(SharedState st, Blackhole bh) {
118+
for (String s : INPUT) {
119+
bh.consume(st.stemmer.stem(s));
120+
}
121+
}
122+
123+
@Benchmark
124+
@Threads(Threads.MAX)
125+
public void instancePerThread(PerThreadState pt, Blackhole bh) {
126+
for (String s : INPUT) {
127+
bh.consume(pt.stemmer.stem(s));
128+
}
129+
}
130+
131+
@Benchmark
132+
@Threads(Threads.MAX)
133+
public void legacyInstancePerThread(LegacyPerThreadState pt, Blackhole bh) {
134+
for (String s : INPUT) {
135+
bh.consume(pt.stemmer.stem(s));
136+
}
137+
}
138+
139+
/**
140+
* Quick local iteration only: {@code forks(0)} disables JVM fork isolation
141+
* (unlike {@code mvn} with the {@code jmh} profile).
142+
* Use the Maven-invoked configuration for publishable numbers.
143+
*/
144+
public static void main(String[] args) throws Exception {
145+
Options opt = new OptionsBuilder()
146+
.include(SnowballStemmerBenchmark.class.getSimpleName())
147+
.forks(0)
148+
.warmupIterations(3)
149+
.measurementIterations(5)
150+
.build();
151+
new Runner(opt).run();
152+
}
153+
}

0 commit comments

Comments
 (0)