Performance

A throughput figure on its own says little: it depends on the text, on how many patterns there are and how often they hit, and on whether the implementation being compared did the same work. So these measurements follow the discipline of the Rust crate's rebar suite. The corpora are rebar's own, fetched from a pinned commit and checked against their SHA-256. Every implementation runs the same pattern set over the same bytes in the same search mode. And every row carries the number of matches found, which must agree between all the implementations on that row and, where rebar records a count for that pattern set and haystack, must reproduce it. A benchmark whose counts differ is measuring different work, and the report script fails on it.

What is compared
Column What it is
OCaml This library, through compat/ocaml/driver.exe.
Rust default The aho-corasick crate as a caller gets it: it chooses between its NFAs and a DFA, and for small pattern sets runs a SIMD prefilter (Teddy) that scans for candidate bytes without stepping the automaton at all.
Rust NFA The same crate forced to its non-contiguous NFA with the prefilter off: the automaton alone, and the closest structural match to this library.
ahocorasick_rs The Rust crate behind a Python binding, called from Python.
pyahocorasick An independent C implementation, called from Python.

The search modes are the ones on the compatibility page: overlapping is find_all and find_overlapping_iter; overlapping_iter is find_iter, consumed lazily; standard is Stream.feed_nonoverlapping over the whole input and Rust's default find_iter; leftmost_longest and replace are the leftmost-longest search and replace_all; and the _stream modes feed the input in 4 KiB chunks. The Python columns time the call from Python, Python objects per match included, which is what a Python caller pays. Each cell is the median of at least three runs and at least one second of running, on one core, after building the automaton once.

Results

Generated by bench/report.py from one run of bench/run.sh; the environment is listed first. The machine is a shared cloud VM, so treat the figures as order-of-magnitude comparisons between columns, not as absolute numbers for your hardware. Reading the results below says what to make of them.

Throughput, MB/s, median over runs (bigger is better)
workload mode OCaml Rust default Rust NFA ahocorasick_rs pyahocorasick matches
sherlock/names-2 overlapping 174 9,636 203 7,618 231 558
sherlock/names-2 overlapping_iter 176 558
sherlock/names-2 standard 182 11,071 214 7,075 558
sherlock/names-2 leftmost_longest 153 8,614 210 5,817 252 558
sherlock/names-2 is_match early exit early exit early exit found
sherlock/names-2 replace 135 7,447 593,047
sherlock/names-2 standard_stream 189 280 558
sherlock/names-2 overlapping_stream 171 558
sherlock/names-2 leftmost_longest_stream 153 558
sherlock/names-7 overlapping 173 524 202 514 140 740
sherlock/names-7 overlapping_iter 171 740
sherlock/names-7 standard 176 527 208 513 740
sherlock/names-7 leftmost_longest 149 6,241 202 4,477 144 740
sherlock/names-7 is_match early exit early exit early exit found
sherlock/names-7 replace 131 5,406 592,628
sherlock/names-7 standard_stream 186 282 740
sherlock/names-7 overlapping_stream 176 740
sherlock/names-7 leftmost_longest_stream 143 740
sherlock/prefixes overlapping 178 10,511 205 7,570 235 582
sherlock/prefixes overlapping_iter 179 582
sherlock/prefixes standard 193 11,900 213 7,347 582
sherlock/prefixes leftmost_longest 151 9,238 201 6,370 255 582
sherlock/prefixes is_match early exit early exit early exit found
sherlock/prefixes replace 141 7,053 594,818
sherlock/prefixes standard_stream 193 280 582
sherlock/prefixes overlapping_stream 182 582
sherlock/prefixes leftmost_longest_stream 150 582
sherlock/absent overlapping 178 87,180 203 88,701 168 0
sherlock/absent overlapping_iter 178 0
sherlock/absent standard 198 87,385 209 89,501 0
sherlock/absent leftmost_longest 156 87,346 210 89,099 149 0
sherlock/absent is_match 170 87,449 167 0
sherlock/absent replace 144 24,953 594,915
sherlock/absent standard_stream 199 254 0
sherlock/absent overlapping_stream 180 0
sherlock/absent leftmost_longest_stream 154 0
sherlock/words-5000 overlapping 64.0 93.3 67.1 90.0 35.6 572
sherlock/words-5000 overlapping_iter 63.0 572
sherlock/words-5000 standard 63.2 90.4 69.2 94.5 567
sherlock/words-5000 leftmost_longest 59.3 93.4 67.5 91.5 35.0 567
sherlock/words-5000 is_match early exit early exit early exit found
sherlock/words-5000 replace 53.2 86.6 592,533
sherlock/words-5000 standard_stream 61.9 68.6 567
sherlock/words-5000 overlapping_stream 64.5 572
sherlock/words-5000 leftmost_longest_stream 61.6 567
sherlock/words-15000 overlapping 45.9 86.4 53.0 79.5 28.6 2,241
sherlock/words-15000 overlapping_iter 44.1 2,241
sherlock/words-15000 standard 46.9 85.8 58.9 82.3 2,100
sherlock/words-15000 leftmost_longest 44.1 85.6 55.7 82.4 29.4 2,100
sherlock/words-15000 is_match early exit early exit early exit found
sherlock/words-15000 replace 42.5 79.1 587,323
sherlock/words-15000 standard_stream 49.7 58.7 2,100
sherlock/words-15000 overlapping_stream 49.4 2,241
sherlock/words-15000 leftmost_longest_stream 47.7 2,100
opensubtitles/names-5 overlapping 171 536 202 516 159 714
opensubtitles/names-5 overlapping_iter 169 714
opensubtitles/names-5 standard 180 538 209 514 714
opensubtitles/names-5 leftmost_longest 153 7,379 203 5,175 166 714
opensubtitles/names-5 is_match early exit early exit early exit found
opensubtitles/names-5 replace 139 5,897 890,243
opensubtitles/names-5 standard_stream 199 286 714
opensubtitles/names-5 overlapping_stream 180 714
opensubtitles/names-5 leftmost_longest_stream 153 714
opensubtitles/dictionary-15 overlapping 82.3 95.8 73.4 94.3 37.5 15
opensubtitles/dictionary-15 overlapping_iter 83.6 15
opensubtitles/dictionary-15 standard 86.4 92.9 73.5 98.9 15
opensubtitles/dictionary-15 leftmost_longest 83.9 97.7 71.3 94.6 34.5 15
opensubtitles/dictionary-15 is_match early exit early exit early exit found
opensubtitles/dictionary-15 replace 78.9 97.0 899,085
opensubtitles/dictionary-15 standard_stream 85.3 70.0 15
opensubtitles/dictionary-15 overlapping_stream 82.8 15
opensubtitles/dictionary-15 leftmost_longest_stream 82.7 15
opensubtitles/byte-a overlapping 83.9 789 167 140 146 47,062
opensubtitles/byte-a overlapping_iter 133 47,062
opensubtitles/byte-a standard 85.9 779 168 131 47,062
opensubtitles/byte-a leftmost_longest 75.6 782 48.8 130 142 47,062
opensubtitles/byte-a is_match early exit early exit early exit found
opensubtitles/byte-a replace 42.8 439 993,356
opensubtitles/byte-a standard_stream 144 189 47,062
opensubtitles/byte-a overlapping_stream 143 47,062
opensubtitles/byte-a leftmost_longest_stream 121 47,062
opensubtitles/bytes-8 overlapping 18.2 115 84.5 16.5 32.3 309,829
opensubtitles/bytes-8 overlapping_iter 58.4 309,829
opensubtitles/bytes-8 standard 19.9 81.2 73.6 15.7 309,829
opensubtitles/bytes-8 leftmost_longest 18.8 90.4 10.3 15.4 30.6 309,829
opensubtitles/bytes-8 is_match early exit early exit early exit found
opensubtitles/bytes-8 replace 8.9 55.7 1,518,890
opensubtitles/bytes-8 standard_stream 66.7 68.6 309,829
opensubtitles/bytes-8 overlapping_stream 63.6 309,829
opensubtitles/bytes-8 leftmost_longest_stream 51.4 309,829
dense/abc-1m overlapping 2.5 43.3 23.4 1.8 5.4 3,145,725
dense/abc-1m overlapping_iter 27.1 3,145,725
dense/abc-1m standard 7.5 30.2 40.3 5.3 1,048,576
dense/abc-1m leftmost_longest 16.4 74.7 10.5 15.4 31.0 349,526
dense/abc-1m is_match early exit early exit early exit found
dense/abc-1m replace 8.7 73.9 1,398,103
dense/abc-1m standard_stream 52.2 67.6 1,048,576
dense/abc-1m overlapping_stream 11.5 3,145,725
dense/abc-1m leftmost_longest_stream 37.3 349,526
sparse/bytes-1m overlapping 152 490 127 492 39.8 0
sparse/bytes-1m overlapping_iter 149 0
sparse/bytes-1m standard 156 486 127 501 0
sparse/bytes-1m leftmost_longest 138 496 128 489 38.8 0
sparse/bytes-1m is_match 149 495 40.3 0
sparse/bytes-1m replace 127 465 1,048,576
sparse/bytes-1m standard_stream 156 275 0
sparse/bytes-1m overlapping_stream 148 0
sparse/bytes-1m leftmost_longest_stream 140 0
pathological/suffixes overlapping 0.4 7.2 1.5 0.3 0.9 3,999,810
pathological/suffixes overlapping_iter 4.6 3,999,810
pathological/suffixes standard 9.3 30.4 41.4 5.6 200,000
pathological/suffixes leftmost_longest 11.1 240 53.3 113 150 10,000
pathological/suffixes is_match early exit early exit early exit found
pathological/suffixes replace 10.0 227 40,000
pathological/suffixes standard_stream 51.6 68.1 200,000
pathological/suffixes overlapping_stream 0.7 3,999,810
pathological/suffixes leftmost_longest_stream 10.5 10,000
scaling/abc-64k overlapping 3.3 45.9 27.5 196,605
scaling/abc-64k overlapping_iter 26.3 196,605
scaling/abc-64k standard 12.8 31.5 42.1 65,536
scaling/abc-64k leftmost_longest 23.8 76.5 10.3 21,846
scaling/abc-64k is_match early exit early exit found
scaling/abc-64k replace 8.9 74.4 87,383
scaling/abc-64k standard_stream 50.2 68.1 65,536
scaling/abc-64k overlapping_stream 10.3 196,605
scaling/abc-64k leftmost_longest_stream 38.6 21,846
scaling/abc-256k overlapping 2.6 44.6 25.4 786,429
scaling/abc-256k overlapping_iter 26.3 786,429
scaling/abc-256k standard 8.2 30.5 41.8 262,144
scaling/abc-256k leftmost_longest 13.4 74.9 9.9 87,382
scaling/abc-256k is_match early exit early exit found
scaling/abc-256k replace 9.6 67.1 349,527
scaling/abc-256k standard_stream 53.0 68.7 262,144
scaling/abc-256k overlapping_stream 10.7 786,429
scaling/abc-256k leftmost_longest_stream 36.8 87,382
scaling/abc-1m overlapping 2.4 45.2 27.6 3,145,725
scaling/abc-1m overlapping_iter 26.6 3,145,725
scaling/abc-1m standard 7.9 29.1 40.5 1,048,576
scaling/abc-1m leftmost_longest 16.2 74.7 10.4 349,526
scaling/abc-1m is_match early exit early exit found
scaling/abc-1m replace 8.7 73.5 1,398,103
scaling/abc-1m standard_stream 53.8 69.6 1,048,576
scaling/abc-1m overlapping_stream 11.7 3,145,725
scaling/abc-1m leftmost_longest_stream 37.2 349,526
scaling/abc-4m overlapping 1.5 45.4 27.6 12,582,909
scaling/abc-4m overlapping_iter 26.9 12,582,909
scaling/abc-4m standard 7.3 30.7 41.7 4,194,304
scaling/abc-4m leftmost_longest 15.3 74.4 10.4 1,398,102
scaling/abc-4m is_match early exit early exit found
scaling/abc-4m replace 7.2 65.5 5,592,407
scaling/abc-4m standard_stream 45.5 66.6 4,194,304
scaling/abc-4m overlapping_stream 12.3 12,582,909
scaling/abc-4m leftmost_longest_stream 37.3 1,398,102
Counts rebar records for the Rust crate on the same bytes, against the standard-mode count measured here
workload recorded measured result
sherlock/names-2 558 558 reproduced
sherlock/names-7 740 740 reproduced
sherlock/prefixes 582 582 reproduced
sherlock/absent 0 0 reproduced
opensubtitles/names-5 714 714 reproduced
opensubtitles/dictionary-15 15 15 reproduced
opensubtitles/byte-a 47,062 47,062 reproduced
opensubtitles/bytes-8 309,829 309,829 reproduced
Automaton construction, ms
workload OCaml Rust default Rust NFA ahocorasick_rs pyahocorasick
sherlock/names-2 0.02 0.09 0.04 1.28 0.30
sherlock/names-7 0.15 0.07 0.04 0.85 0.36
sherlock/prefixes 0.02 0.05 0.03 0.94 0.39
sherlock/absent 0.11 0.10 0.04 0.97 0.39
sherlock/words-5000 43.43 6.55 5.34 13.63 7.37
sherlock/words-15000 104.07 18.67 19.43 40.75 24.02
opensubtitles/names-5 0.17 0.08 0.04 0.99 0.30
opensubtitles/dictionary-15 29.10 3.59 3.22 8.44 4.16
opensubtitles/byte-a 0.01 0.04 0.04 0.81 0.32
opensubtitles/bytes-8 0.02 0.04 0.03 0.93 0.39
dense/abc-1m 0.09 0.08 0.09 0.98 0.33
sparse/bytes-1m 2.04 0.89 0.20 2.40 0.47
pathological/suffixes 0.04 0.09 0.04 0.86 0.31
scaling/abc-64k 0.13 0.06 0.06
scaling/abc-256k 0.11 0.07 0.05
scaling/abc-1m 0.09 0.07 0.05
scaling/abc-4m 0.09 0.08 0.05
Scaling: ns per byte at growing input sizes (flat means linear time)
workload bytes OCaml overlapping OCaml standard OCaml leftmost_longest Rust default overlapping Rust default standard Rust default leftmost_longest
scaling/abc-64k 65,536 304.35 77.97 41.97 21.77 31.78 13.07
scaling/abc-256k 262,144 389.65 122.52 74.51 22.42 32.80 13.35
scaling/abc-1m 1,048,576 409.39 125.96 61.57 22.15 34.41 13.39
scaling/abc-4m 4,194,304 679.14 136.17 65.51 22.05 32.59 13.44

Reading the results

Ordinary text, few patterns

On English text where matches are rare — the Sherlock and OpenSubtitles name workloads — this library scans at 170 to 200 MB/s, within fifteen percent of the Rust crate's bare NFA and close to pyahocorasick's C engine. The Rust crate's default configuration is up to sixty times faster on the two- and three-pattern rows because it is not running an automaton at all: its Teddy prefilter looks for candidate bytes with SIMD instructions and only confirms hits with the automaton. That is the gap between a dependency-free OCaml automaton and a vectorised scanner, and it narrows as soon as the prefilter stops paying off: with seven names the default engine is at about 530 MB/s, and with 5 000 or 15 000 words under 100.

Dictionaries

With thousands of patterns the automaton is large and the prefilter is gone, so every engine is bounded by how fast it walks the trie. This library reaches 46 MB/s on 15 000 words and 82 to 86 MB/s on the 2 663 long words of dictionary-15: level with or ahead of the Rust NFA, and between half and nine tenths of the Rust default. The first 4096 nodes in breadth-first order have dense rows; beyond them a sorted-children lookup and failure links take over, which is where the remaining gap sits. Construction is where the library is clearly slower: about 100 ms for 15 000 words against the crate's 19, spent filling the dense rows and building the trie through a hash table. It happens once.

Match-dense inputs

When almost every byte ends a match, reporting dominates scanning. find_all returns a list of records, so on dense/abc-1m, with three matches per byte, it keeps three million records live and the garbage collector's work shows: 2.5 MB/s, against 27 MB/s for find_iter, whose records die young, and 43 MB/s for the Rust crate, which allocates nothing. The API page's advice stands: consume dense matches lazily. The leftmost-longest selection no longer pays that cost at all; at 16 MB/s on the same input it is ahead of the Rust NFA's 10, and its streamed form reaches 37 MB/s, a mode the Rust crate does not offer on streams. The pathological row, twenty nested patterns over a run of the same byte, is the same story at twenty matches per byte.

Streams

Feeding the input in 4 KiB chunks costs this library nothing: Stream.feed_nonoverlapping runs at 190 to 200 MB/s on the name workloads, the same as the whole-input search, and faster on dense inputs, where each chunk's result list is short-lived. The Rust crate's stream_find_iter reads through a buffer and runs at 250 to 290 MB/s however fast its whole-input search is, so on streams the two are within a factor of two everywhere and level on the dictionary rows.

Linear time

The scaling rows run the same 39 patterns over 64 KiB to 4 MiB of the same random text. find_iter holds at 26 to 27 MB/s across the four sizes: the automaton's cost per byte is constant, as the amortised argument says it must be. The list-returning modes slow down as the input grows — find_all from 304 to 679 nanoseconds per byte — because their result grows with it and a larger live heap makes each collection dearer. That is the price of the result, not of the scan.

Before and after

The first run of this suite was against the version of the library that stored transitions in a hash table and selected leftmost-longest matches by materialising every overlapping match and sorting. Those numbers are the reason the transition table and the selection were rewritten (the cost section of the API page describes what the library does now); the semantics did not change, which the differential test confirmed byte for byte before and after.

Throughput before and after, MB/s
workload mode before, MB/s after, MB/s ratio
sherlock/names-2 overlapping 51.9 174 3.4x
sherlock/names-2 overlapping_iter 49.8 176 3.5x
sherlock/names-2 standard 63.3 182 2.9x
sherlock/names-2 leftmost_longest 52.8 153 2.9x
sherlock/names-2 replace 43.4 135 3.1x
sherlock/names-2 standard_stream 65.3 189 2.9x
sherlock/names-2 leftmost_longest_stream 43.4 153 3.5x
sherlock/words-15000 overlapping 9.5 45.9 4.8x
sherlock/words-15000 overlapping_iter 10.4 44.1 4.2x
sherlock/words-15000 standard 10.7 46.9 4.4x
sherlock/words-15000 leftmost_longest 9.1 44.1 4.8x
sherlock/words-15000 replace 9.6 42.5 4.4x
sherlock/words-15000 standard_stream 10.3 49.7 4.8x
sherlock/words-15000 leftmost_longest_stream 9.6 47.7 4.9x
opensubtitles/dictionary-15 overlapping 12.8 82.3 6.4x
opensubtitles/dictionary-15 overlapping_iter 12.2 83.6 6.9x
opensubtitles/dictionary-15 standard 12.6 86.4 6.9x
opensubtitles/dictionary-15 leftmost_longest 12.6 83.9 6.7x
opensubtitles/dictionary-15 replace 11.5 78.9 6.9x
opensubtitles/dictionary-15 standard_stream 12.6 85.3 6.8x
opensubtitles/dictionary-15 leftmost_longest_stream 12.1 82.7 6.8x
opensubtitles/bytes-8 overlapping 11.9 18.2 1.5x
opensubtitles/bytes-8 overlapping_iter 26.2 58.4 2.2x
opensubtitles/bytes-8 standard 15.6 19.9 1.3x
opensubtitles/bytes-8 leftmost_longest 4.9 18.8 3.8x
opensubtitles/bytes-8 replace 3.9 8.9 2.3x
opensubtitles/bytes-8 standard_stream 32.7 66.7 2.0x
opensubtitles/bytes-8 leftmost_longest_stream 0.7 51.4 77.1x
dense/abc-1m overlapping 1.7 2.5 1.4x
dense/abc-1m overlapping_iter 11.6 27.1 2.3x
dense/abc-1m standard 6.4 7.5 1.2x
dense/abc-1m leftmost_longest 0.6 16.4 27.0x
dense/abc-1m replace 0.6 8.7 14.5x
dense/abc-1m standard_stream 16.8 52.2 3.1x
dense/abc-1m leftmost_longest_stream 0.1 37.3 732.0x
sparse/bytes-1m overlapping 18.3 152 8.3x
sparse/bytes-1m overlapping_iter 17.2 149 8.7x
sparse/bytes-1m standard 19.4 156 8.0x
sparse/bytes-1m leftmost_longest 18.4 138 7.5x
sparse/bytes-1m replace 16.7 127 7.6x
sparse/bytes-1m standard_stream 18.9 156 8.3x
sparse/bytes-1m leftmost_longest_stream 17.8 140 7.9x
pathological/suffixes overlapping 0.4 0.4 1.1x
pathological/suffixes overlapping_iter 2.6 4.6 1.7x
pathological/suffixes standard 7.4 9.3 1.2x
pathological/suffixes leftmost_longest 0.1 11.1 138.1x
pathological/suffixes replace 0.1 10.0 129.2x
pathological/suffixes standard_stream 23.4 51.6 2.2x
pathological/suffixes leftmost_longest_stream 0.0 10.5 740.2x

What is not measured

Memory
Automaton size and peak allocation during a scan are not recorded. The dense transition rows cost 2 KiB per node for up to 4096 nodes; a 15 000-word automaton is a few megabytes.
Latency
Streamed modes are timed for throughput over 4 KiB chunks, not for the delay between a byte arriving and a match being reported, which the semantics page bounds rather than measures.
Small inputs
Every haystack is at least 200 KB, so per-call overhead — the cost of find_all on a 40-byte log line — does not show.
Other implementations
Hyperscan, daachorse and the Go and Java libraries are not in the table. The drivers speak the format described in bench/README.md; adding one is a matter of writing a driver.

./bench/run.sh reproduces everything on this page, and bench/README.md documents the workloads and the method. Back to correctness, or on to compatibility.