This is an automated email from the ASF dual-hosted git repository.

krickert pushed a commit to branch OPENNLP-1903-NameFinder-Threading
in repository https://gitbox.apache.org/repos/asf/opennlp.git


The following commit(s) were added to 
refs/heads/OPENNLP-1903-NameFinder-Threading by this push:
     new 6004a51df OPENNLP-1903: Add JMH benchmark for BeamSearch on long 
sequences
6004a51df is described below

commit 6004a51dfdf6a637119256c98187e9aedac8e825
Author: Kristian Rickert <[email protected]>
AuthorDate: Mon Aug 3 03:41:49 2026 -0400

    OPENNLP-1903: Add JMH benchmark for BeamSearch on long sequences
    
    BeamSearchBenchmark decodes synthetic sequences (length 8/64/256) with a
    shared BeamSearch (beam 3, deterministic hash-seeded model, contexts
    cache 0/64) at 1 and max threads. It uses only the unchanged public API
    so the same class runs against pre- and post-refactor ml-commons jars;
    the O(n^2) per-candidate copying the refactor removed only materializes
    on long sequences.
    
    Measured (JDK 25, 24 pinned cores, 2 forks x 10 iterations): the win
    grows with sequence length, from 2.3x at len 8 to 9.1x at len 256 under
    concurrency, and 24-thread scaling goes from 2.5x (pre) to 11.5x (post)
    at len 256. Full table and method notes in BENCHMARKS.md.
---
 opennlp-core/opennlp-runtime/BENCHMARKS.md         |  47 ++++
 .../java/opennlp/tools/ml/BeamSearchBenchmark.java | 245 +++++++++++++++++++++
 2 files changed, 292 insertions(+)

diff --git a/opennlp-core/opennlp-runtime/BENCHMARKS.md 
b/opennlp-core/opennlp-runtime/BENCHMARKS.md
index 21cb71a66..6e6f8aa9c 100644
--- a/opennlp-core/opennlp-runtime/BENCHMARKS.md
+++ b/opennlp-core/opennlp-runtime/BENCHMARKS.md
@@ -15,6 +15,7 @@ variance reporting.
 | `POSTaggerMEBenchmark` | POSTaggerME | 3 approaches x 2 cache configs |
 | `SnowballStemmerBenchmark` | SnowballStemmer | 3 approaches (incl. 
plain-field baseline) |
 | `CachingStemmerBenchmark` | CachingStemmer | cached vs uncached x 2 
workloads |
+| `BeamSearchBenchmark` | BeamSearch | 3 sequence lengths x 2 cache configs, 1 
vs max threads |
 
 ### Approaches measured
 
@@ -121,6 +122,52 @@ size, producing a matrix of 3 approaches x 2 cache configs 
= 6
 benchmark runs. This quantifies whether the context generator
 cache provides measurable benefit.
 
+### BeamSearch results
+
+`BeamSearchBenchmark` decodes synthetic token sequences with a shared
+`BeamSearch` (beam size 3, deterministic hash-seeded model, accept-all
+validator). One op = one `bestSequence` call; invocations rotate through a
+pool of 64 seeded inputs. The `sequenceLength` param (8/64/256) is the key
+axis: the O(n^2) per-candidate outcome-list copying that the chain-node
+refactor eliminated only shows up on long sequences (NER chunks run 200+
+tokens). The `cacheSize` param (0/64) toggles the contexts cache. To produce
+the pre-refactor baseline, swap the pre-refactor `opennlp-ml-commons` jar
+onto the classpath ahead of the freshly built classes — the benchmark only
+uses the unchanged public API — and rerun.
+
+Results (Linux, JDK 25, 24 pinned cores of a 32-core box, 2 forks x 10
+iterations; ops/s = decoded sequences per second):
+
+| Method | sequenceLength | cacheSize | 1 thread | 24 threads | scaling |
+|--------|---------------:|----------:|---------:|-----------:|--------:|
+| pre-refactor  | 8   | 0  | 226,337 ± 4,164  | 1,404,404 ± 67,614 | 6.2x |
+| pre-refactor  | 64  | 0  | 17,802 ± 43      | 67,482 ± 817       | 3.8x |
+| pre-refactor  | 256 | 0  | 2,236 ± 38       | 5,491 ± 26         | 2.5x |
+| pre-refactor  | 8   | 64 | 234,592 ± 705    | 1,382,812 ± 72,577 | 5.9x |
+| pre-refactor  | 64  | 64 | 17,968 ± 92      | 61,639 ± 2,726     | 3.4x |
+| pre-refactor  | 256 | 64 | 2,224 ± 20       | 5,260 ± 92         | 2.4x |
+| post-refactor | 8   | 0  | 450,384 ± 3,306  | 3,162,724 ± 495,301 | 7.0x |
+| post-refactor | 64  | 0  | 33,936 ± 482     | 297,001 ± 13,049   | 8.8x |
+| post-refactor | 256 | 0  | 4,357 ± 168      | 50,176 ± 307       | 11.5x |
+| post-refactor | 8   | 64 | 488,155 ± 8,095  | 3,512,519 ± 7,541  | 7.2x |
+| post-refactor | 64  | 64 | 34,818 ± 232     | 347,266 ± 942      | 10.0x |
+| post-refactor | 256 | 64 | 4,308 ± 27       | 50,203 ± 230       | 11.7x |
+
+Two readings. First, the refactor's win grows with sequence length —
+~2x single-thread at every length, and at 24 threads 2.3x (len 8),
+4.4x (len 64), and 9.1x (len 256) — the signature of an O(n^2) copy
+cost being removed. Second, the pre-refactor scaling collapses as
+sequences lengthen (6.2x down to 2.5x on 24 threads: memory-write
+saturation from per-candidate list copying), while post-refactor
+scaling grows with length (7.0x up to 11.5x).
+
+Note the synthetic model's `eval` is a cheap hash, so this isolates
+the sequence-mechanics cost the refactor targets; with a real maxent
+model the eval compute is shared by both versions and the relative
+single-thread win is smaller (~1.3x on ARM, roughly neutral on x86),
+while the concurrent scaling gap is preserved (measured 5.8x -> 13.3x
+at saturation on 24 pinned cores with a real NER model).
+
 ## JUnit Correctness Test
 
 `ThreadSafetyBenchmarkIT` is a Failsafe integration test (`*IT.java`). It
diff --git 
a/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/ml/BeamSearchBenchmark.java
 
b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/ml/BeamSearchBenchmark.java
new file mode 100644
index 000000000..85d77fe8a
--- /dev/null
+++ 
b/opennlp-core/opennlp-runtime/src/jmh/java/opennlp/tools/ml/BeamSearchBenchmark.java
@@ -0,0 +1,245 @@
+/*
+ * 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.ml;
+
+import java.util.Random;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+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 org.openjdk.jmh.runner.options.TimeValue;
+
+import opennlp.tools.ml.model.MaxentModel;
+import opennlp.tools.util.BeamSearchContextGenerator;
+import opennlp.tools.util.SequenceValidator;
+
+/**
+ * JMH benchmark for {@link BeamSearch} on long input sequences.
+ * <p>
+ * One op = one {@code bestSequence} call on a synthetic token sequence of
+ * {@code sequenceLength} tokens. Long sequences are what expose the O(n^2)
+ * per-candidate outcome-list copying that the chain-node refactor eliminated;
+ * the 5-10 token sentences used by the ME benchmarks would show nothing.
+ * Only the pre-refactor public API is used
+ * ({@code BeamSearch(int, MaxentModel, int)} and
+ * {@code bestSequence(T[], Object[], BeamSearchContextGenerator, 
SequenceValidator)}),
+ * so the same compiled class exercises both implementations: to produce the
+ * baseline, swap the pre-refactor {@code opennlp-ml-commons} jar onto the
+ * classpath ahead of the freshly built classes and rerun.
+ */
+@BenchmarkMode(Mode.Throughput)
+@OutputTimeUnit(TimeUnit.SECONDS)
+@Warmup(iterations = 5, time = 2)
+@Measurement(iterations = 10, time = 2)
+@Fork(2)
+public class BeamSearchBenchmark {
+
+  private static final int BEAM_SIZE = 3;
+  private static final int NUM_INPUTS = 64;
+  private static final int VOCAB_SIZE = 17;
+  private static final long INPUT_SEED = 0x5eedL;
+
+  private static final SequenceValidator<String> ACCEPT_ALL =
+      (i, input, outcomes, outcome) -> true;
+
+  @State(Scope.Benchmark)
+  public static class SearchState {
+
+    @Param({"8", "64", "256"})
+    int sequenceLength;
+
+    @Param({"0", "64"})
+    int cacheSize;
+
+    BeamSearch beamSearch;
+    TokenContextGenerator contextGenerator;
+    String[][] inputs;
+    final AtomicInteger cursor = new AtomicInteger();
+
+    @Setup(Level.Trial)
+    public void create() {
+      beamSearch = new BeamSearch(BEAM_SIZE, new SeededModel(), cacheSize);
+      contextGenerator = new TokenContextGenerator();
+      inputs = new String[NUM_INPUTS][];
+      Random rnd = new Random(INPUT_SEED);
+      for (int n = 0; n < NUM_INPUTS; n++) {
+        String[] input = new String[sequenceLength];
+        for (int i = 0; i < sequenceLength; i++) {
+          input[i] = "t" + rnd.nextInt(VOCAB_SIZE);
+        }
+        inputs[n] = input;
+      }
+    }
+
+    String[] nextInput() {
+      // Rotate through the input pool so repeated invocations decode different
+      // sequences instead of hammering one fully cache-warm input.
+      return inputs[Math.floorMod(cursor.getAndIncrement(), NUM_INPUTS)];
+    }
+  }
+
+  /**
+   * A deterministic pseudo-random {@link MaxentModel}: the probability for an
+   * outcome is derived from a hash of the joined context strings with
+   * splitmix64-style mixing, so repeated evals of the same context return
+   * identical values in (0.01, 0.99]. Values are intentionally not normalized.
+   * The buffer contract is honored: {@code eval(context, probs)} writes into
+   * the passed array and returns that same array.
+   */
+  static final class SeededModel implements MaxentModel {
+
+    private final String[] outcomes = {"start", "cont", "other"};
+
+    private double prob(String[] context, int outcomeIndex) {
+      long h = 0x5eedL;
+      for (String c : context) {
+        h = mix(h, c.hashCode());
+      }
+      h = mix(h, outcomeIndex);
+      // splitmix64 finalizer for avalanche
+      h ^= h >>> 30;
+      h *= 0xBF58476D1CE4E5B9L;
+      h ^= h >>> 27;
+      h *= 0x94D049BB133111EBL;
+      h ^= h >>> 31;
+      double u = (h >>> 11) * (1.0 / (1L << 53)); // [0, 1)
+      return 0.01 + 0.98 * u; // (0.01, 0.99]
+    }
+
+    private static long mix(long h, long v) {
+      return (h ^ (v + 0x9E3779B97F4A7C15L)) * 0x100000001B3L;
+    }
+
+    @Override
+    public double[] eval(String[] context) {
+      return eval(context, new double[outcomes.length]);
+    }
+
+    @Override
+    public double[] eval(String[] context, double[] probs) {
+      for (int i = 0; i < outcomes.length; i++) {
+        probs[i] = prob(context, i);
+      }
+      return probs; // buffer contract: write into the passed array AND return 
it
+    }
+
+    @Override
+    public double[] eval(String[] context, float[] values) {
+      return eval(context);
+    }
+
+    @Override
+    public String getOutcome(int i) {
+      return outcomes[i];
+    }
+
+    @Override
+    public int getNumOutcomes() {
+      return outcomes.length;
+    }
+
+    @Override
+    public String getAllOutcomes(double[] outcomes) {
+      return null;
+    }
+
+    @Override
+    public String getBestOutcome(double[] outcomes) {
+      return null;
+    }
+
+    @Override
+    public int getIndex(String outcome) {
+      for (int i = 0; i < outcomes.length; i++) {
+        if (outcomes[i].equals(outcome)) {
+          return i;
+        }
+      }
+      return -1;
+    }
+  }
+
+  /**
+   * Derives contexts from the current token and the previous outcome, interned
+   * so identical context content maps to the same {@code String[]} instance
+   * and the identity-keyed contexts cache in {@link BeamSearch} produces hits.
+   * Thread-safe.
+   */
+  static final class TokenContextGenerator implements 
BeamSearchContextGenerator<String> {
+
+    private final ConcurrentHashMap<String, String[]> intern = new 
ConcurrentHashMap<>();
+
+    @Override
+    public String[] getContext(int index, String[] sequence,
+                               String[] priorDecisions, Object[] 
additionalContext) {
+      String prev = index > 0 ? priorDecisions[index - 1] : "<s>";
+      String[] ctx = {"tok=" + sequence[index], "prev=" + prev};
+      String key = ctx[0] + '|' + ctx[1];
+      String[] existing = intern.putIfAbsent(key, ctx);
+      return existing != null ? existing : ctx;
+    }
+  }
+
+  @Benchmark
+  @Threads(1)
+  public void bestSequenceSingle(SearchState state, Blackhole bh) {
+    bh.consume(state.beamSearch.bestSequence(state.nextInput(), null,
+        state.contextGenerator, ACCEPT_ALL));
+  }
+
+  @Benchmark
+  @Threads(Threads.MAX)
+  public void bestSequenceConcurrent(SearchState state, Blackhole bh) {
+    bh.consume(state.beamSearch.bestSequence(state.nextInput(), null,
+        state.contextGenerator, ACCEPT_ALL));
+  }
+
+  /**
+   * 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(BeamSearchBenchmark.class.getSimpleName())
+        .forks(0)
+        .warmupIterations(1)
+        .warmupTime(TimeValue.seconds(1))
+        .measurementIterations(1)
+        .measurementTime(TimeValue.seconds(1))
+        .build();
+    new Runner(opt).run();
+  }
+}

Reply via email to