Skip to content

Commit 08a1dd9

Browse files
Read transition arrays directly on the match path, bypassing SafeIndexable dispatch
The per-character match loop reached every transition, value, and prefix through AhoCorasickStore -> a SafeIndexable field. SafeIndexable is abstract with two implementations (DoubleArrayIntList during build, UnboxedIntList after), so each access was a bimorphic virtual call plus a backing-array field load plus a bounds check. That indirection sat in the hottest loop in the library. After build() the store is always backed by UnboxedIntList, so the match path can read the raw int[] directly: monomorphic, and the JIT can hoist the array reference and eliminate per-access bounds checks. The build-time and keyValue paths still go through the SafeIndexable accessors, so pre-build mutation (add/replace, which run before buildRuntimeStructures) is unchanged. Mechanical changes: - Expose UnboxedIntList.backingArray. - AhoCorasickStore.buildRuntimeStructures captures the five backing arrays (runtimeBaseOffsets/Parents/Values/FailureIndices/PrefixIndices) once the lists have been converted to UnboxedIntList. - calculateNextState reads baseOffsets/parents/failureIndices directly; the parentCount bounds check reproduces safeGetParent (out-of-range child has no parent, and RESERVED_VALUE can never equal a non-negative node index). - AhoCorasickSpliterator captures the value/prefix arrays once and indexes them per result. Behavior unchanged (full library test suite green). Allocation per op unchanged, as expected — this removes dispatch, not allocation. ParseBenchmark, 100k dictionary, JDK 25, f2/wi5/i10 (ops/s, higher is better): config before after delta ASCII SPARSE 38.5±0.7 43.8±0.8 +13.6% ASCII DENSE 12.0±0.2 13.7±0.4 +14.6% UNICODE SPARSE 71.4±1.7 81.2±1.5 +13.8% UNICODE DENSE 12.0±0.1 13.2±0.2 +9.6% Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 09b2b5f commit 08a1dd9

3 files changed

Lines changed: 63 additions & 9 deletions

File tree

library/src/main/kotlin/com/pkware/ahocorasick/AhoCorasickBase.kt

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -413,16 +413,24 @@ public abstract class AhoCorasickBase<T> @JvmOverloads constructor(options: Set<
413413
*/
414414
private fun calculateNextState(startingNode: Int, offset: Int): Int {
415415

416+
// Read the post-build backing arrays directly to keep this loop free of SafeIndexable virtual dispatch. The
417+
// bounds check against parentCount reproduces safeGetParent: an out-of-range child yields no parent, and
418+
// RESERVED_VALUE can never equal a (non-negative) node index, so the check below stays correct.
419+
val baseOffsets = store.runtimeBaseOffsets
420+
val parents = store.runtimeParents
421+
val failureIndices = store.runtimeFailureIndices
422+
val parentCount = parents.size
423+
416424
var currentNode = startingNode
417425

418-
var childNode = store.getBaseOffset(currentNode) + offset
419-
var hasValidChild = store.safeGetParent(childNode) == currentNode
426+
var childNode = baseOffsets[currentNode] + offset
427+
var hasValidChild = childNode < parentCount && parents[childNode] == currentNode
420428

421429
while (!hasValidChild && currentNode != ROOT_NODE) {
422430

423-
currentNode = store.getFailureIndex(currentNode)
424-
childNode = store.getBaseOffset(currentNode) + offset
425-
hasValidChild = store.safeGetParent(childNode) == currentNode
431+
currentNode = failureIndices[currentNode]
432+
childNode = baseOffsets[currentNode] + offset
433+
hasValidChild = childNode < parentCount && parents[childNode] == currentNode
426434
}
427435

428436
// The current node will always be ROOT_NODE if no valid child is found.
@@ -873,23 +881,28 @@ public abstract class AhoCorasickBase<T> @JvmOverloads constructor(options: Set<
873881

874882
var nextNode: Int = RESERVED_VALUE
875883

884+
// Captured once so the per-result value and prefix reads avoid SafeIndexable virtual dispatch. The spliterator
885+
// is only created post-build (parse requires isBuilt), so these arrays are always initialized here.
886+
private val values = store.runtimeValues
887+
private val prefixIndices = store.runtimePrefixIndices
888+
876889
override fun tryAdvance(action: Consumer<in AhoCorasickResult<T>>): Boolean {
877890

878891
while (currentInputIndex < input.length || nextNode != RESERVED_VALUE) {
879892

880893
var nextValue: Int
881894
if (nextNode == RESERVED_VALUE) {
882895
currentNode = calculateNextState(currentNode, input[currentInputIndex++].normalize().code)
883-
nextNode = store.getPrefixIndex(currentNode)
884-
nextValue = store.getValue(currentNode)
896+
nextNode = prefixIndices[currentNode]
897+
nextValue = values[currentNode]
885898

886899
// Determine if the current node is the end of a string.
887900
if (nextValue == RESERVED_VALUE) continue
888901
} else {
889902

890903
// Add any other values whose keys are equal to a suffix of the path from the root to current node.
891-
nextValue = store.getValue(nextNode)
892-
nextNode = store.getPrefixIndex(nextNode)
904+
nextValue = values[nextNode]
905+
nextNode = prefixIndices[nextNode]
893906
}
894907

895908
val result = generateResult(currentInputIndex, nextValue, input)

library/src/main/kotlin/com/pkware/ahocorasick/AhoCorasickStore.kt

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,29 @@ internal class AhoCorasickStore {
4444
*/
4545
private var store2: SafeIndexable = DoubleArrayIntList(RESERVED_VALUE)
4646

47+
/**
48+
* Raw backing arrays for the match hot path, captured by [buildRuntimeStructures].
49+
*
50+
* The matcher reads transitions directly as `int[]` through these, bypassing the [SafeIndexable] virtual dispatch
51+
* that the build-time accessors go through. They alias the post-build [UnboxedIntList] backing arrays, so they are
52+
* only valid after [buildRuntimeStructures] runs; reading them before then throws. Named by their post-build
53+
* meaning: [runtimeFailureIndices] aliases [store1], [runtimePrefixIndices] aliases [store2].
54+
*/
55+
lateinit var runtimeBaseOffsets: IntArray
56+
private set
57+
58+
lateinit var runtimeParents: IntArray
59+
private set
60+
61+
lateinit var runtimeValues: IntArray
62+
private set
63+
64+
lateinit var runtimeFailureIndices: IntArray
65+
private set
66+
67+
lateinit var runtimePrefixIndices: IntArray
68+
private set
69+
4770
/**
4871
* Sets the base offset of a node at the given index.
4972
*
@@ -229,6 +252,14 @@ internal class AhoCorasickStore {
229252
values = values.toIntList()
230253
store1 = store1.toIntList()
231254
store2 = store2.toIntList()
255+
256+
// Capture the backing arrays so the match path can index transitions directly, skipping SafeIndexable
257+
// dispatch. Each list above is now an UnboxedIntList whose backing array length equals its logical size.
258+
runtimeBaseOffsets = (baseOffsets as UnboxedIntList).backingArray
259+
runtimeParents = (parents as UnboxedIntList).backingArray
260+
runtimeValues = (values as UnboxedIntList).backingArray
261+
runtimeFailureIndices = (store1 as UnboxedIntList).backingArray
262+
runtimePrefixIndices = (store2 as UnboxedIntList).backingArray
232263
}
233264

234265
/**

library/src/main/kotlin/com/pkware/ahocorasick/UnboxedIntList.kt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ internal class UnboxedIntList(
3838
*/
3939
private var array = IntArray(max(initialCapacity, 1)) { defaultValue }
4040

41+
/**
42+
* The raw backing array, exposed so the Aho-Corasick match path can read transitions directly as `int[]` instead
43+
* of through the [SafeIndexable] virtual dispatch.
44+
*
45+
* For a list produced by [AhoCorasickStore.buildRuntimeStructures] the backing array's length equals [size], so
46+
* callers may index it directly. A structural change ([add], [safeSet], or any resize) may replace this array, so
47+
* callers must re-fetch it after any mutation.
48+
*/
49+
val backingArray: IntArray get() = array
50+
4151
override operator fun get(index: Int) = array[index]
4252

4353
override operator fun set(index: Int, value: Int): Unit = array.set(index, value)

0 commit comments

Comments
 (0)