Skip to content

Commit 63d7e7d

Browse files
committed
OPENNLP-1883: Add CachingStemmer: per-thread LRU memoization of word-to-stem mappings
Natural text is Zipf-distributed, so the same words are stemmed constantly. CachingStemmer wraps a StemmerFactory with a bounded per-thread LRU (default 1024 entries) following the same OwnerOrPerThreadState pattern as the *ME components: no cross-thread sharing, thread-safe regardless of the delegate. On the JMH zipf workload the cache is a ~34x throughput multiplier; on a cache-hostile uniform workload over 8x the cache capacity it still breaks even. Results and a corrected JMH invocation are documented in BENCHMARKS.md.
1 parent e3ff692 commit 63d7e7d

4 files changed

Lines changed: 492 additions & 8 deletions

File tree

opennlp-core/opennlp-runtime/BENCHMARKS.md

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ variance reporting.
1414
| `SentenceDetectorMEBenchmark` | SentenceDetectorME | 3 approaches |
1515
| `POSTaggerMEBenchmark` | POSTaggerME | 3 approaches x 2 cache configs |
1616
| `SnowballStemmerBenchmark` | SnowballStemmer | 3 approaches (incl. pre-patch baseline) |
17+
| `CachingStemmerBenchmark` | CachingStemmer | cached vs uncached x 2 workloads |
1718

1819
### Approaches measured
1920

@@ -31,17 +32,19 @@ mvn test-compile -Pjmh \
3132
-pl opennlp-core/opennlp-runtime -am \
3233
-Dforbiddenapis.skip=true -Dcheckstyle.skip=true
3334

35+
# Materialize the test classpath once (JMH's forked JVMs inherit
36+
# java.class.path, which mvn exec:java does not populate — running
37+
# through exec:java fails with ClassNotFoundException: ForkedMain)
38+
mvn dependency:build-classpath -pl opennlp-core/opennlp-runtime \
39+
-Pjmh -DincludeScope=test -Dmdep.outputFile=/tmp/cp.txt
40+
41+
CP="opennlp-core/opennlp-runtime/target/classes:opennlp-core/opennlp-runtime/target/test-classes:$(cat /tmp/cp.txt)"
42+
3443
# Run all ME benchmarks
35-
mvn exec:java -pl opennlp-core/opennlp-runtime \
36-
-Pjmh -Dexec.classpathScope=test \
37-
-Dexec.mainClass=org.openjdk.jmh.Main \
38-
-Dexec.args="opennlp.tools.*.ME*"
44+
java -cp "$CP" org.openjdk.jmh.Main 'opennlp.tools.*.ME*'
3945

4046
# Run POSTagger only (includes cacheSize param)
41-
mvn exec:java -pl opennlp-core/opennlp-runtime \
42-
-Pjmh -Dexec.classpathScope=test \
43-
-Dexec.mainClass=org.openjdk.jmh.Main \
44-
-Dexec.args="POSTaggerMEBenchmark"
47+
java -cp "$CP" org.openjdk.jmh.Main POSTaggerMEBenchmark
4548
```
4649

4750
### Regression testing (stock vs patched)
@@ -80,6 +83,30 @@ memory-level parallelism. Real pipelines stem as one stage among many,
8083
so the saturated-microbenchmark gap is an upper bound, and the legacy
8184
strategy was not shareable across threads in the first place.
8285

86+
### CachingStemmer results (same environment)
87+
88+
`CachingStemmerBenchmark` compares a `CachingStemmer` (per-thread LRU,
89+
default 1024 entries, wrapping the English Snowball stemmer) against
90+
the uncached shared stemmer. One op = 16 tokens from a 64k-token
91+
stream. The `zipf` workload samples a 512-word vocabulary with 1/rank
92+
weights (real-text repetition; the cache holds the whole vocabulary);
93+
`diverse` samples an 8192-word vocabulary uniformly (8x cache
94+
capacity: mostly misses plus constant eviction).
95+
96+
| Workload | Strategy | 8 threads | 32 threads |
97+
|----------|----------|----------:|-----------:|
98+
| `zipf` | `cachedShared` | 48.5M ± 0.7M ops/s | 95.4M ± 0.9M |
99+
| `zipf` | `uncachedShared` | 1.43M ± 0.13M | 2.75M ± 0.34M |
100+
| `diverse` | `cachedShared` | 1.81M ± 0.51M | 3.45M ± 0.18M |
101+
| `diverse` | `uncachedShared` | 1.08M ± 0.09M | 3.13M ± 0.12M |
102+
103+
On the Zipf workload the cache is a ~34x throughput multiplier (raw
104+
stemming becomes a hash lookup for the dominant vocabulary). On the
105+
cache-hostile workload it still does not lose: the ~12% residual hit
106+
rate pays for the eviction overhead. The cache more than recovers the
107+
`OwnerOrPerThreadState` lookup cost observed in
108+
`SnowballStemmerBenchmark` at full saturation.
109+
83110
### POSTagger cache impact
84111

85112
The `POSTaggerMEBenchmark` uses `@Param({"0", "3"})` for cache
Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
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;
19+
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
import java.util.Random;
23+
import java.util.concurrent.TimeUnit;
24+
25+
import org.openjdk.jmh.annotations.Benchmark;
26+
import org.openjdk.jmh.annotations.BenchmarkMode;
27+
import org.openjdk.jmh.annotations.Fork;
28+
import org.openjdk.jmh.annotations.Level;
29+
import org.openjdk.jmh.annotations.Measurement;
30+
import org.openjdk.jmh.annotations.Mode;
31+
import org.openjdk.jmh.annotations.OutputTimeUnit;
32+
import org.openjdk.jmh.annotations.Param;
33+
import org.openjdk.jmh.annotations.Scope;
34+
import org.openjdk.jmh.annotations.Setup;
35+
import org.openjdk.jmh.annotations.State;
36+
import org.openjdk.jmh.annotations.Threads;
37+
import org.openjdk.jmh.annotations.Warmup;
38+
import org.openjdk.jmh.infra.Blackhole;
39+
import org.openjdk.jmh.runner.Runner;
40+
import org.openjdk.jmh.runner.options.Options;
41+
import org.openjdk.jmh.runner.options.OptionsBuilder;
42+
43+
import opennlp.tools.stemmer.snowball.SnowballStemmer;
44+
import opennlp.tools.stemmer.snowball.SnowballStemmerFactory;
45+
46+
/**
47+
* JMH benchmark for {@link CachingStemmer} against an uncached shared {@link SnowballStemmer}.
48+
*
49+
* <p>Two workloads drive both strategies:</p>
50+
* <ul>
51+
* <li>{@code zipf} — a 64k-token stream sampled with 1/rank weights from a 512-word
52+
* vocabulary. This models real text, where a small vocabulary dominates; the default
53+
* 1024-entry cache holds the whole vocabulary.</li>
54+
* <li>{@code diverse} — a 64k-token stream sampled uniformly from an 8192-word vocabulary,
55+
* 8x the cache capacity. This is the cache-hostile case: mostly misses plus constant
56+
* eviction, so it bounds the overhead the cache can add.</li>
57+
* </ul>
58+
*
59+
* <p>One op stems 16 consecutive tokens from the stream; each benchmark thread walks the stream
60+
* from its own cursor.</p>
61+
*/
62+
@BenchmarkMode(Mode.Throughput)
63+
@OutputTimeUnit(TimeUnit.SECONDS)
64+
@Warmup(iterations = 5, time = 2)
65+
@Measurement(iterations = 10, time = 2)
66+
@Fork(2)
67+
public class CachingStemmerBenchmark {
68+
69+
private static final int STREAM_LENGTH = 65536;
70+
private static final int WORDS_PER_OP = 16;
71+
72+
private static final String[] ROOTS = {
73+
"run", "walk", "talk", "develop", "nation", "connect", "form", "create",
74+
"act", "direct", "govern", "manage", "operate", "organize", "present", "relate",
75+
"report", "state", "structure", "test", "train", "transform", "translate", "value",
76+
"view", "wonder", "yield", "zone", "note", "mark", "place", "point"
77+
};
78+
private static final String[] PREFIXES = {
79+
"", "re", "un", "over", "under", "out", "pre", "post",
80+
"non", "anti", "de", "dis", "mis", "sub", "super", "inter"
81+
};
82+
private static final String[] SUFFIXES = {
83+
"", "s", "ed", "ing", "er", "ers", "ation", "ations",
84+
"ly", "ness", "ment", "ments", "ize", "ized", "izing", "al"
85+
};
86+
87+
@State(Scope.Benchmark)
88+
public static class WorkloadState {
89+
90+
@Param({"zipf", "diverse"})
91+
String workload;
92+
93+
String[] stream;
94+
95+
@Setup(Level.Trial)
96+
public void build() {
97+
Random random = new Random(42);
98+
List<String> vocabulary = new ArrayList<>();
99+
if ("zipf".equals(workload)) {
100+
// 32 roots x 16 suffixes = 512 unique words, sampled with 1/rank weights.
101+
for (String root : ROOTS) {
102+
for (String suffix : SUFFIXES) {
103+
vocabulary.add(root + suffix);
104+
}
105+
}
106+
double[] cumulative = new double[vocabulary.size()];
107+
double sum = 0;
108+
for (int rank = 0; rank < vocabulary.size(); rank++) {
109+
sum += 1.0 / (rank + 1);
110+
cumulative[rank] = sum;
111+
}
112+
stream = new String[STREAM_LENGTH];
113+
for (int i = 0; i < STREAM_LENGTH; i++) {
114+
double r = random.nextDouble() * sum;
115+
int idx = 0;
116+
while (cumulative[idx] < r) {
117+
idx++;
118+
}
119+
stream[i] = vocabulary.get(idx);
120+
}
121+
} else {
122+
// 16 prefixes x 32 roots x 16 suffixes = 8192 unique words, sampled uniformly.
123+
for (String prefix : PREFIXES) {
124+
for (String root : ROOTS) {
125+
for (String suffix : SUFFIXES) {
126+
vocabulary.add(prefix + root + suffix);
127+
}
128+
}
129+
}
130+
stream = new String[STREAM_LENGTH];
131+
for (int i = 0; i < STREAM_LENGTH; i++) {
132+
stream[i] = vocabulary.get(random.nextInt(vocabulary.size()));
133+
}
134+
}
135+
}
136+
}
137+
138+
@State(Scope.Benchmark)
139+
public static class UncachedState {
140+
Stemmer stemmer;
141+
142+
@Setup(Level.Trial)
143+
public void create() {
144+
stemmer = new SnowballStemmer(SnowballStemmer.ALGORITHM.ENGLISH);
145+
}
146+
}
147+
148+
@State(Scope.Benchmark)
149+
public static class CachedState {
150+
Stemmer stemmer;
151+
152+
@Setup(Level.Trial)
153+
public void create() {
154+
stemmer = new CachingStemmer(
155+
new SnowballStemmerFactory(SnowballStemmer.ALGORITHM.ENGLISH));
156+
}
157+
}
158+
159+
@State(Scope.Thread)
160+
public static class Cursor {
161+
int position;
162+
163+
@Setup(Level.Trial)
164+
public void randomize() {
165+
position = new Random().nextInt(STREAM_LENGTH);
166+
}
167+
}
168+
169+
@Benchmark
170+
@Threads(Threads.MAX)
171+
public void uncachedShared(WorkloadState w, UncachedState st, Cursor cursor, Blackhole bh) {
172+
for (int i = 0; i < WORDS_PER_OP; i++) {
173+
bh.consume(st.stemmer.stem(w.stream[cursor.position++ & (STREAM_LENGTH - 1)]));
174+
}
175+
}
176+
177+
@Benchmark
178+
@Threads(Threads.MAX)
179+
public void cachedShared(WorkloadState w, CachedState st, Cursor cursor, Blackhole bh) {
180+
for (int i = 0; i < WORDS_PER_OP; i++) {
181+
bh.consume(st.stemmer.stem(w.stream[cursor.position++ & (STREAM_LENGTH - 1)]));
182+
}
183+
}
184+
185+
/**
186+
* Quick local iteration only: {@code forks(0)} disables JVM fork isolation
187+
* (unlike {@code mvn} with the {@code jmh} profile).
188+
* Use the Maven-invoked configuration for publishable numbers.
189+
*/
190+
public static void main(String[] args) throws Exception {
191+
Options opt = new OptionsBuilder()
192+
.include(CachingStemmerBenchmark.class.getSimpleName())
193+
.forks(0)
194+
.warmupIterations(3)
195+
.measurementIterations(5)
196+
.build();
197+
new Runner(opt).run();
198+
}
199+
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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;
19+
20+
import java.util.LinkedHashMap;
21+
import java.util.List;
22+
import java.util.Map;
23+
24+
import opennlp.tools.commons.ThreadSafe;
25+
import opennlp.tools.util.OwnerOrPerThreadState;
26+
27+
/**
28+
* A {@link Stemmer} that memoizes word-to-stem mappings in a bounded per-thread LRU cache.
29+
*
30+
* <p>Natural language is Zipf-distributed: a small vocabulary accounts for most tokens, so the
31+
* same words are stemmed over and over. Wrapping a stemmer in a {@code CachingStemmer} turns
32+
* those repeats into a hash lookup instead of a full stemming pass.</p>
33+
*
34+
* <p>Each thread gets its own delegate stemmer (minted from the supplied {@link StemmerFactory})
35+
* and its own cache, following the {@link OwnerOrPerThreadState} pattern of the thread-safe
36+
* {@code *ME} components. There is no cross-thread sharing at all, so instances are safe to share
37+
* regardless of whether the factory's stemmers are. Memory is bounded by
38+
* {@code capacity * averageWordLength} characters per thread that stems.</p>
39+
*
40+
* <p>{@link #stemAll(CharSequence)} is forwarded to the delegate uncached, so multi-output
41+
* engines keep their full result list.</p>
42+
*/
43+
@ThreadSafe
44+
public final class CachingStemmer implements Stemmer {
45+
46+
/**
47+
* Covers the high-frequency vocabulary of most corpora while keeping the per-thread footprint
48+
* small.
49+
*/
50+
public static final int DEFAULT_CAPACITY = 1024;
51+
52+
private final OwnerOrPerThreadState<ThreadState> state;
53+
54+
/**
55+
* Creates a caching stemmer with the {@linkplain #DEFAULT_CAPACITY default capacity}.
56+
*
57+
* @param factory The factory that mints one delegate per thread. Must not be {@code null}.
58+
* @throws IllegalArgumentException if {@code factory} is {@code null}.
59+
*/
60+
public CachingStemmer(StemmerFactory factory) {
61+
this(factory, DEFAULT_CAPACITY);
62+
}
63+
64+
/**
65+
* Creates a caching stemmer.
66+
*
67+
* @param factory The factory that mints one delegate per thread. Must not be {@code null}.
68+
* @param capacity The maximum number of word-to-stem entries kept per thread; must be positive.
69+
* @throws IllegalArgumentException if {@code factory} is {@code null} or {@code capacity} is
70+
* not positive.
71+
*/
72+
public CachingStemmer(StemmerFactory factory, int capacity) {
73+
if (factory == null) {
74+
throw new IllegalArgumentException("factory must not be null");
75+
}
76+
if (capacity <= 0) {
77+
throw new IllegalArgumentException("capacity must be positive, got " + capacity);
78+
}
79+
this.state = new OwnerOrPerThreadState<>(
80+
() -> new ThreadState(factory.newStemmer(), capacity),
81+
threadState -> threadState.cache.clear());
82+
}
83+
84+
@Override
85+
public CharSequence stem(CharSequence word) {
86+
final ThreadState ts = state.get();
87+
final String key = word.toString();
88+
final String cached = ts.cache.get(key);
89+
if (cached != null) {
90+
return cached;
91+
}
92+
// toString() detaches the result from any buffer the delegate may reuse internally.
93+
final String stemmed = ts.delegate.stem(key).toString();
94+
ts.cache.put(key, stemmed);
95+
return stemmed;
96+
}
97+
98+
@Override
99+
public List<CharSequence> stemAll(CharSequence word) {
100+
return state.get().delegate.stemAll(word);
101+
}
102+
103+
private static final class ThreadState {
104+
105+
private final Stemmer delegate;
106+
private final LinkedHashMap<String, String> cache;
107+
108+
private ThreadState(Stemmer delegate, int capacity) {
109+
this.delegate = delegate;
110+
// Access-ordered so iteration order is least-recently-used first.
111+
this.cache = new LinkedHashMap<>(Math.min(capacity, 4096), 0.75f, true) {
112+
@Override
113+
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
114+
return size() > capacity;
115+
}
116+
};
117+
}
118+
}
119+
}

0 commit comments

Comments
 (0)