Benchmarks
Criterion benchmark results for the rsigma detection engine.
Hardware: Apple M4 Pro, macOS
Profile: bench (optimized, release)
Date captured: 2026-07-05
Captured on version: 0.18.0 (main)
All suites below were rerun in full on the date above. To refresh for a specific release, check out the matching tag, run the commands in Running, and update the hardware/date/version block.
Synthetic vs. representative workloads
Every Criterion table below uses synthetic rules and events built by benches/datagen.rs. Those rules are dominated by exact field matches, which the candidate index prunes to a handful of candidates per event, so the per-event cost in tables like Single Event Evaluation stays nearly flat as the rule count grows. That flat scaling still does not transfer to real rule corpora, though the gap has narrowed considerably: witness-based indexing gates substring, keyword, regex, and numeric rules too, so a SigmaHQ-scale corpus now evaluates a few percent of its rules per event rather than the ~79% the earlier exact-only index left always-evaluated.
Use the representative baseline below, not the synthetic tables, to size deployments that load SigmaHQ-scale corpora.
SigmaHQ corpus baseline (representative)
Hardware: Apple M4 Pro (8 performance + 4 efficiency cores), macOS
Date captured: 2026-07-28
Corpus: SigmaHQ rules/ at 994da166 (3,132 detection rules, the SHA pinned in CI)
Fixtures: scripts/perf/fetch-fixtures.sh (pinned corpus + deterministic event lanes), run via scripts/perf/baseline-eval.sh and scripts/perf/daemon-matrix.sh, both against the same two binaries via their RSIGMA override. The offline harness now reports the median of three measured runs; the daemon matrix also covers --include-event and a handcrafted correlation lane. scripts/perf/image-compare.sh races two container images over the same fixtures instead of two native binaries, which is how cost that only the released artifact carries becomes visible; it needs Docker but no Rust toolchain and no local k6.
Method: each lane is concatenated to 100,000 events and the reported rate is net of rule load. Loading the pinned corpus costs ~0.3 s, which is a fixed startup cost; leaving it in a per-event figure over a single 10k-event pass made a faster evaluator look slower than it is, and earlier captures of this section understated throughput by roughly 2x for that reason. Both columns are printed by the harness so the split stays visible. engine eval is single-threaded: RAYON_NUM_THREADS makes no measurable difference to it, so these are per-core numbers.
Offline engine eval, single core, before and after witness-based candidate indexing. Match counts are identical in every cell, which is what makes the comparison a throughput measurement rather than a behavior change:
| Lane | Variant | Before | After | Change |
|---|---|---|---|---|
Raw Windows blobs (2.4 KB message) |
default | 2,601 | 33,884 | 13.0x |
--logsource-routing |
3,556 | 98,836 | 27.8x | |
--cross-rule-ac |
3,402 | 26,414 | 7.8x | |
| both | 5,005 | 57,325 | 11.5x | |
| Structured Windows (Sysmon-shaped) | default | 1,112 | 19,358 | 17.4x |
--logsource-routing |
1,695 | 29,800 | 17.6x | |
--cross-rule-ac |
1,580 | 16,322 | 10.3x | |
| both | 2,301 | 22,644 | 9.8x | |
Cisco AAA syslog (raw message + product/service hints) |
default | 2,917 | 66,434 | 22.8x |
--logsource-routing |
50,031 | 106,241 | 2.1x | |
--cross-rule-ac |
4,121 | 41,914 | 10.2x | |
| both | 36,368 | 57,195 | 1.6x | |
Sysmon file events (EventID 11, product/category hints) |
default | 1,954 | 26,836 | 13.7x |
--logsource-routing |
11,027 | 45,481 | 4.1x | |
--cross-rule-ac |
2,750 | 20,711 | 7.5x | |
| both | 13,392 | 32,629 | 2.4x |
Daemon HTTP end-to-end (--input http --batch-size 512, 4 concurrent NDJSON batch posters, throughput from the daemon’s own rsigma_events_processed_total delta, so an HTTP accept backlog cannot inflate it):
| Lane | Variant | Before | After | Change |
|---|---|---|---|---|
| Raw Windows blobs | default | 20,193 | 195,639 | 9.7x |
--logsource-routing |
25,904 | 342,070 | 13.2x | |
| both | 31,819 | 271,997 | 8.5x | |
| Structured Windows | default | 8,868 | 87,704 | 9.9x |
--logsource-routing |
12,550 | 113,601 | 9.1x | |
| both | 16,645 | 100,124 | 6.0x | |
| Cisco AAA syslog | default | 21,715 | 287,000 | 13.2x |
--logsource-routing |
278,618 | 401,364 | 1.4x | |
| both | 242,610 | 310,490 | 1.3x | |
| Sysmon file events | default | 14,647 | 126,001 | 8.6x |
--logsource-routing |
72,733 | 174,550 | 2.4x | |
| both | 84,475 | 147,196 | 1.7x |
--cross-rule-ac changed sides. Before witness indexing it earned its keep on top of routing (31.8k vs 25.9k on the raw-blob lane end-to-end). After, --logsource-routing alone beats every AC configuration on all four lanes both offline and end-to-end, and adding AC on top of routing costs 20-35%. The candidate index performs the same substring work over a smaller rule population, so the cross-rule automaton is now a duplicated hot-path pass. It stays correct and feature-gated for pure-substring corpora at 5,000+ rules, where it should be benchmarked against routing rather than against the bare default.
Parallel efficiency fell as per-event cost did, which is the expected shape and locates the next bottleneck. On the raw-blob lane the daemon delivered 7.8x its single-core rate before (20,193 over 2,601) and 5.8x after (195,639 over 33,884) on 8 performance cores. On the cheapest configurations the ratio is lower still: the Cisco lane with routing scales 3.8x (401,364 over 106,241). Detection itself does parallelize inside a batch, since Engine::evaluate_batch fans a batch across rayon when the parallel feature is on, so what the remaining gap represents is the fixed per-event pipeline cost around evaluation (HTTP framing, JSON parse, result serialization, sink write) plus the engine lock the batch is held under.
The formatted daemon path now parses lines in parallel before taking the engine mutex, while a separate ordering lock keeps one batch in flight and preserves correlation and output order. On the raw Windows lane with --logsource-routing, --batch-size 512, 16 k6 VUs, and the same pinned corpus, a same-build median-of-three comparison moved 8-thread throughput from 306,351 to 384,906 events/s while one-thread throughput stayed near 90,000 events/s. Scaling improved from 3.39x (42% efficiency) to 4.19x (52% efficiency); parse share at eight threads fell from about 40% to 27%. Repeated runs after sustained compilation and benchmarking measured 50-52% efficiency, so parsing was confirmed as a major bottleneck but not the only serial cost. A Bytes/request-body-span prototype did not improve this workload and was not retained; the daemon does retain single-parse DLQ routing, eliminating its previous validation parse.
Phase timing after that change showed evaluate still ~71% of measured batch time at eight threads. Sampling profiles attributed most of that to candidate-index field probes and JsonEvent::get_field misses over absent Sigma fields. Probing only witnesses under the event’s top-level keys, plus a dotted-path root fast-miss in get_field, raised the same routed daemon median from about 93k to 102k events/s at one thread and from about 386k to 404k events/s at eight threads on the same host.
Those ratios hold only when the allocator scales with the fan-out, which is a property of the build rather than of the engine. Every figure above is a native build against the system allocator, while the released ghcr.io/timescale/rsigma binary is static musl, and musl’s mallocng serializes the concurrent allocation evaluate_batch performs. Until jemalloc became the musl global allocator the released artifact therefore did not scale at all. Measured on an AMD Ryzen 7 PRO 6850U (8 cores / 16 threads, Linux) over the pinned corpus and the raw-blob lane, with the daemon pinned to six physical cores and the load generator to the other two, --batch-size 512 and a /dev/null sink; the daemon column is the median of three 30 s runs from scripts/perf/image-compare.sh and the single-core column comes from scripts/perf/baseline-eval.sh on the same two binaries:
| Variant | Build | Single core | Daemon, 6 cores | Scaling |
|---|---|---|---|---|
| default | musl | 26,557 | 36,875 | 1.39x |
| musl + jemalloc | 30,045 | 95,505 | 3.18x | |
--logsource-routing |
musl | 57,463 | 53,435 | 0.93x |
| musl + jemalloc | 65,826 | 160,238 | 2.44x |
Stock musl scales at 0.93x with routing: six cores deliver less than one, so the fan-out costs more than it returns. Changing allocator is worth ~14% single-threaded and 2.6-3.0x on the daemon, and that disproportion is the signature of allocator contention rather than per-event cost. Match counts are identical in every cell. jemalloc also reaches these figures on fewer cores (6.7-6.8 against 8.0), and halves rule load from 0.86 s to 0.49 s. Because none of this is visible to a native build, it needs scripts/perf/image-compare.sh rather than the harnesses above.
The witness audit (cargo run --release -p rsigma-eval --example witness_audit) shows 99.6% of the corpus carries a sound required-positive witness. The production CandidateIndex, measured directly over 10,000 events in each deterministic lane, returns a p95 of 0.29-3.86% of the 3,132 loaded rules: 0.38% on raw Windows and Cisco syslog, 3.74% on structured Windows, and 3.19% on Sysmon file events. This confirms the below-10% criterion with the actual index rather than the audit’s intentionally over-approximating simulation. Peak RSS for load + eval is ~113 MB. Sampling profiles taken before witness indexing attributed per-event time to event field lookup and hashing (~25-45%), std substring search and searcher setup (~13-30%), the condition and detection tree walk (~15%), case folding, and allocator traffic. Witness indexing removed most of that work rather than making it cheaper, so the remaining profile is due a re-capture before any further micro-optimization.
Match volume depends on flags because logsource routing is also a correctness filter: on the raw-blob lane the default configuration reports ~3 matches per event (mostly cross-product keyword false positives), while --logsource-routing drops that to ~0.02 per event.
The daemon matrix measures --include-event both with and without routing, including the match-heavy lane, because cloning the complete input into each result is material at ~3 matches per event and nearly free at ~0.02. Its checked-in correlation lane exercises event count, value count, value sum, and ordered temporal processing instead of relying on SigmaHQ, because the pinned corpus contains no correlation rules.
Running
# All benchmarks across all crates
cargo bench
# Individual suites
cargo bench -p rsigma-parser --bench parse
cargo bench -p rsigma-eval --bench eval
cargo bench -p rsigma-eval --bench eval --features daachorse-index # includes the cross-rule AC suite
cargo bench -p rsigma-eval --bench logsource
cargo bench -p rsigma-eval --bench schema
cargo bench -p rsigma-eval --bench routing
cargo bench -p rsigma-eval --bench array
cargo bench -p rsigma-eval --bench correlation
cargo bench -p rsigma-eval --bench correlation_memory # peak-heap stress (not Criterion)
cargo bench -p rsigma-eval --bench result_serialize
cargo bench -p rsigma-runtime --bench runtime_throughput
cargo bench -p rsigma-runtime --bench input_formats --features logfmt,cef,evtx
cargo bench -p rsigma-runtime --bench otlp --features otlp
cargo bench -p rsigma-runtime --bench dynamic_pipelines
cargo bench -p rsigma-runtime --bench enrichment
cargo bench -p rsigma-runtime --bench alert_pipeline
cargo bench -p rsigma-runtime --bench risk
# Specific benchmark group
cargo bench -p rsigma-eval --bench eval -- eval_throughput
# Quick mode (fewer samples, useful for CI smoke tests)
cargo bench -- --quick
Parser (rsigma-parser)
Rule Parsing
| Benchmark | Time (median) | Throughput |
|---|---|---|
| Single rule | 10.6 us | - |
| 10 rules | 110.0 us | - |
| 100 rules | 1.18 ms | 2.37 MiB/s |
| 500 rules | 5.81 ms | 5.09 MiB/s |
| 1000 rules | 11.9 ms | 12.4 MiB/s |
| Complex condition (single) | 23.8 us | - |
Wildcard and Regex Rules (pre-compiled pattern cache)
| Benchmark | Time (median) |
|---|---|
| Wildcard rules (100) | 85.8 us |
| Wildcard rules (500) | 88.2 us |
| Wildcard rules (1000) | 85.1 us |
| Regex rules (100) | 51.5 us |
| Regex rules (500) | 50.2 us |
| Regex rules (1000) | 51.6 us |
Wildcard/regex rule parsing is O(1) due to pattern caching (only the first parse compiles the patterns).
Evaluation Engine (rsigma-eval)
Rule Compilation
| Rules | Time (median) |
|---|---|
| 100 | 138.8 us |
| 500 | 620.9 us |
| 1,000 | 1.16 ms |
| 5,000 | 6.12 ms |
Rule Load Paths
Compares the three engine entry points for loading rules at large N. add_collection and add_rules rebuild the inverted and bloom indexes once at the end of the batch; add_rule in a loop folds each rule incrementally with an amortized-doubling bloom rebuild (64-rule floor, 2x ratchet).
| Rules | add_collection |
add_rules |
add_rule loop |
|---|---|---|---|
| 1,000 | 1.21 ms (1.21 us/rule) | 1.18 ms (1.18 us/rule) | 1.63 ms (1.63 us/rule) |
| 10,000 | 11.94 ms (1.19 us/rule) | 12.04 ms (1.20 us/rule) | 17.99 ms (1.80 us/rule) |
| 100,000 | 131.44 ms (1.31 us/rule) | 131.17 ms (1.31 us/rule) | 176.26 ms (1.76 us/rule) |
All three paths scale linearly in the rule count. Per-rule cost is essentially constant from 1K to 100K, confirming the O(N) total complexity:
add_collectionandadd_rulescost roughly 1.2-1.3 us/rule. The fixed per-batch cost is dominated by the final inverted index + bloom build over the aggregate.add_rulein a loop costs roughly 1.6-1.8 us/rule, about 40% more than the batched paths. The overhead is the per-rule incremental insert plus the ~11 doubling-watermark rebuilds the bloom triggers between 1 and 100K rules. There is no quadratic blowup; the constant factor pays for the incremental contract.
The takeaway is that add_rule is not a foot-gun for bulk loads. Batched APIs are still slightly faster and remain the recommended path for cold-load scenarios; the single-rule path exists for cases where the caller wants per-rule error reporting (rsigma rule validate) or per-rule mutation semantics.
Run with cargo bench -p rsigma-eval --bench eval -- rule_load.
Single Event Evaluation
Time to evaluate one event against N compiled rules. The rules are synthetic and exact-match-indexable, so the index prunes almost all of them per event; see Synthetic vs. representative workloads for why real corpora scale linearly instead.
| Rules | Time (median) | Per-rule |
|---|---|---|
| 100 | 2.55 us | 25.5 ns |
| 500 | 14.2 us | 28.4 ns |
| 1,000 | 31.8 us | 31.8 ns |
| 5,000 | 168.6 us | 33.7 ns |
Detection Throughput (100 rules)
| Events | Time (median) | Throughput |
|---|---|---|
| 1,000 | 2.71 ms | 370 Kelem/s |
| 10,000 | 27.4 ms | 364 Kelem/s |
| 100,000 | 276.4 ms | 362 Kelem/s |
Batch Mode (Sequential vs Parallel)
| Configuration | Time (median) | Throughput |
|---|---|---|
| 100 rules, sequential | 2.73 ms | 367 Kelem/s |
| 100 rules, batch | 2.73 ms | 366 Kelem/s |
| 1000 rules, sequential | 33.9 ms | 29.5 Kelem/s |
| 1000 rules, batch | 34.5 ms | 28.9 Kelem/s |
| 5000 rules, sequential | 182.1 ms | 5.49 Kelem/s |
| 5000 rules, batch | 244.8 ms | 4.09 Kelem/s |
Wildcard and Regex Matching
| Benchmark | Time (median) |
|---|---|
| Wildcard (100 rules) | 21.0 us |
| Wildcard (500 rules) | 20.9 us |
| Wildcard (1000 rules) | 20.9 us |
| Regex (100 rules) | 5.50 us |
| Regex (500 rules) | 5.51 us |
| Regex (1000 rules) | 5.55 us |
Wildcard/regex matching scales O(1) with rule count thanks to compiled pattern sets.
Aho-Corasick Threshold Sweep
Single rule with N |contains patterns evaluated against 50 randomly generated events at varying haystack lengths. Drove the choice of AHO_CORASICK_THRESHOLD = 8 in compiler/optimizer.rs. Throughput is per event.
| Patterns | h=100 B | h=1 KB | h=8 KB | h=64 KB |
|---|---|---|---|---|
| 1 | 8.34 Melem/s (5.99 us / batch) | 4.99 Melem/s (10.0 us) | 1.11 Melem/s (45.1 us) | 67.8 Kelem/s (737.9 us) |
| 2 | 5.91 Melem/s (8.46 us) | 1.60 Melem/s (31.2 us) | 195 Kelem/s (256.1 us) | 18.0 Kelem/s (2.77 ms) |
| 4 | 4.10 Melem/s (12.2 us) | 1.09 Melem/s (45.9 us) | 93.9 Kelem/s (532.3 us) | 10.1 Kelem/s (4.96 ms) |
| 8 | 5.44 Melem/s (9.19 us) | 639 Kelem/s (78.2 us) | 82.2 Kelem/s (608.2 us) | 10.2 Kelem/s (4.90 ms) |
| 16 | 5.32 Melem/s (9.39 us) | 641 Kelem/s (78.0 us) | 82.3 Kelem/s (607.9 us) | 10.1 Kelem/s (4.94 ms) |
| 32 | 5.19 Melem/s (9.64 us) | 638 Kelem/s (78.4 us) | 79.3 Kelem/s (630.6 us) | 9.27 Kelem/s (5.39 ms) |
Throughput flattens at p=8: p16 and p32 perform within ~3% of p8 because the AC automaton scans the haystack once regardless of pattern count. Below 8 patterns, the sequential str::contains path with SIMD acceleration (memchr / Two-Way) wins on longer haystacks; at p8 the automaton already beats the p4 sequential path on short haystacks. The crossover stands at 8.
Run with cargo bench -p rsigma-eval --bench eval -- eval_ac_threshold_sweep.
Bloom Pre-Filter (--bloom-prefilter)
200 non-matching events evaluated against N substring-heavy rules with the bloom prefilter off vs on (Engine::set_bloom_prefilter(true)).
| Rules | Off | On | Speedup |
|---|---|---|---|
| 100 | 6.62 ms (151 Kelem/s) | 6.65 ms (150 Kelem/s) | ~1.0x |
| 500 | 43.7 ms (22.9 Kelem/s) | 38.6 ms (25.9 Kelem/s) | ~1.13x |
| 1,000 | 88.6 ms (11.3 Kelem/s) | 74.4 ms (13.4 Kelem/s) | ~1.19x |
| 5,000 | 442.6 ms (2.26 Kelem/s) | 365.7 ms (2.73 Kelem/s) | ~1.21x |
The bloom filter pays off from roughly 500 rules upward on non-matching traffic and is neutral below that. It never slows evaluation down in this workload, but it is opt-in because matching-heavy traffic pays the filter cost without the skip benefit.
Run with cargo bench -p rsigma-eval --bench eval -- eval_bloom_rejection.
Cross-Rule Aho-Corasick Pre-Filter, daachorse-index feature
200 non-matching events evaluated against N pure-substring rules. Best-case workload for the cross-rule index: every rule is AC-prunable (every detection consists exclusively of positive substring matchers, no negation in conditions), and every event has zero pattern hits across all fields.
| Rules | Off (default) | On (set_cross_rule_ac(true)) |
Speedup |
|---|---|---|---|
| 1,000 | 17.50 ms (11.4 Kelem/s) | 288.8 us (693 Kelem/s) | ~61x |
| 5,000 | 87.63 ms (2.28 Kelem/s) | 1.05 ms (191 Kelem/s) | ~84x |
| 10,000 | 186.92 ms (1.07 Kelem/s) | 2.05 ms (97.6 Kelem/s) | ~91x |
The cross-rule index turns O(rules × patterns) per event into O(haystack_length) for the AC scan, so throughput is essentially constant in rule count once the index is enabled.
For typical mixed workloads (substring + exact + regex rules, events that hit multiple fields, smaller rule sets) the index adds build-time and lookup overhead with smaller wins or none, and can even cause a slowdown. Off by default. Enable via Engine::set_cross_rule_ac(true) programmatically, or --cross-rule-ac on rsigma engine daemon / rsigma engine eval (requires the daachorse-index Cargo feature). Always benchmark against representative data before flipping it on.
Run with cargo bench -p rsigma-eval --features daachorse-index --bench eval -- eval_cross_rule_ac.
Logsource Pruning (--logsource-routing)
Single-event evaluation over an always-evaluated (contains-only, never-matching) ruleset split 50/50 across product: windows and product: linux, with a windows-tagged event. With pruning on, the conflicting-product half is never iterated, so the win tracks the pruned fraction.
| Rules | Off | On | Speedup |
|---|---|---|---|
| 1,000 | 112.2 us | 64.1 us | ~1.75x |
| 10,000 | 1.19 ms | 678.1 us | ~1.76x |
Run with cargo bench -p rsigma-eval --bench logsource.
Schema Classification (engine classify, --schema-routing, --observe-schemas)
Per-event cost of SchemaClassifier::classify against the built-in signature set. This is the hot-path overhead schema routing and schema observability add per event.
| Event | Time (median) |
|---|---|
| ECS Windows (early match, highest specificity) | 289 ns |
| Sysmon flat (mid-list match) | 443 ns |
| OCSF | 216 ns |
| Unknown (full signature scan, worst case) | 548 ns |
| ECS Windows with ambiguity check | 296 ns |
Classification stays well under a microsecond per event even in the full-scan worst case, so --observe-schemas and --schema-routing cost a fraction of a percent at typical pipeline throughputs.
Run with cargo bench -p rsigma-eval --bench schema.
Schema-Routed Dispatch (--schema-routing)
End-to-end routed evaluation over a mixed stream (one third ECS, one third flat Sysmon, one third unrecognized; 1,000 events, 100 non-matching rules), with ecs and sysmon bound to their builtin pipelines, against a single pipeline-less engine over the same stream.
| Configuration | Time (median) | Throughput |
|---|---|---|
| Single engine, no pipelines | 5.07 ms | 197 Kelem/s |
Routed, per-event route() |
9.34 ms | 107 Kelem/s |
Routed, process_batch |
9.45 ms | 106 Kelem/s |
The ~4.3 us/event difference overstates the dispatch cost: classification itself is ~0.3 us (see above), and most of the rest is the routed engines doing real matching work, because their pipeline-mapped rules reference fields the events actually carry (process.command_line), while the unrouted baseline’s unmapped rules miss ECS events on absent fields without ever scanning a value. That is the correctness gap routing exists to close: the baseline is faster partly because it silently cannot match two thirds of the stream.
Run with cargo bench -p rsigma-eval --bench routing.
Array Matching (sigma-version: 3)
Per-event cost of the array evaluation paths. Events carry a connections array of objects; the flat baseline is the same engine evaluating a single scalar field (326 ns). Array-scope bodies are evaluated per member rather than through the batched flat-field matchers, so cost is linear in member count.
| Path | 10 members | 100 members | 1,000 members |
|---|---|---|---|
| Implicit any-member, hit at last element | 1.80 us | 12.4 us | 113.2 us |
| Implicit any-member, miss (full scan) | 564 ns | 3.78 us | 34.6 us |
[any] scope, hit at first element |
2.47 us | 22.2 us | 220.2 us |
[any] scope, hit at last element |
2.75 us | 24.9 us | 245.3 us |
[any] scope, miss |
635 ns | 5.80 us | 57.8 us |
[all] scope, every member satisfies |
2.68 us | 24.8 us | 247.1 us |
[all] scope, first member fails |
391 ns | 3.08 us | 31.4 us |
Positional indexing is O(1) in the array length: args[0] costs 693 ns and args[-1] 671 ns against a 1,000-element array, about 2x the flat baseline for the index resolution.
Two properties fall out of the numbers. Non-firing evaluation costs ~35-60 ns per member (a 100-member [any] miss is 5.8 us, ~18x the flat baseline), and [all] exits early on the first failing member. But a firing rule pays ~110-250 ns per member regardless of where the matching element sits: [any] hit-first costs nearly the same as hit-last because the fan-out continues past the first hit to collect every matching member for match details. Budget array rules by array length times rule count on matching traffic, not by early-exit intuition.
Run with cargo bench -p rsigma-eval --bench array.
Result Serialization
Serializing EvaluationResult to the flat NDJSON wire shape, comparing the derive-based baseline against a #[serde(flatten)] variant and a hand-written Serialize impl.
| Payload | v1 baseline | v2 flatten derive | v3 hand-written |
|---|---|---|---|
| Small detection | 136 ns | 135 ns | 135 ns |
| Realistic detection | 481 ns | 477 ns | 479 ns |
| Small correlation | 177 ns | 182 ns | 180 ns |
| Realistic correlation | 1.72 us | 1.72 us | 1.73 us |
All three implementations are within noise of each other; a hand-written serializer buys nothing, so the derive stays. Even a realistic correlation result serializes in under 2 us, an order of magnitude below its evaluation cost.
Run with cargo bench -p rsigma-eval --bench result_serialize.
Correlation Engine (rsigma-eval)
Event Count Correlation
1000 events evaluated against N correlation rules.
| Corr rules | Time (median) | Throughput |
|---|---|---|
| 5 | 1.10 ms | 907 Kelem/s |
| 10 | 1.09 ms | 915 Kelem/s |
| 20 | 1.12 ms | 894 Kelem/s |
Temporal Correlation
1000 events evaluated with temporal ordering constraints.
| Corr rules | Time (median) | Throughput |
|---|---|---|
| 3 | 567.4 us | 1.76 Melem/s |
| 5 | 569.4 us | 1.76 Melem/s |
| 10 | 569.6 us | 1.76 Melem/s |
Correlation Throughput
| Events | Time (median) | Throughput |
|---|---|---|
| 10,000 | 20.1 ms | 497 Kelem/s |
| 100,000 | 199.6 ms | 501 Kelem/s |
Sequential vs Batch (10,000 events)
| Mode | Time (median) | Throughput |
|---|---|---|
| Sequential | 20.0 ms | 499 Kelem/s |
| Batch | 21.7 ms | 462 Kelem/s |
State Pressure (unique group-by keys)
| Unique keys | Time (median) | Throughput |
|---|---|---|
| 1,000 | 795.2 us | 1.26 Melem/s |
| 10,000 | 8.15 ms | 1.23 Melem/s |
| 50,000 | 41.4 ms | 1.21 Melem/s |
Window Modes: sliding vs tumbling vs session
Identical event_count workload for all three modes: 10,000 events across 1,000 group keys, one event per group per 10s tick, 1h window, 10m session gap. The window decision in apply_window_open is O(1) (deque front/back inspection), so the three modes cost the same per event.
| Window mode | Time (median) | Throughput |
|---|---|---|
sliding (default) |
8.12 ms | 1.23 Melem/s |
tumbling |
7.77 ms | 1.29 Melem/s |
session |
7.91 ms | 1.26 Melem/s |
Run with cargo bench -p rsigma-eval --bench correlation -- correlation_window_modes.
Window-Mode Memory Stress
The correlation_memory bench is not a Criterion suite: it installs a counting global allocator and reports peak and settled heap deltas over the engine baseline, isolating window-state maintenance (alert thresholds are unreachable; event construction is excluded from the deltas). It reproduces the two scenarios from the SEP #214 discussion on memory becoming the bottleneck in stateful window correlation.
A. High-cardinality session windows (one event per unique key, event_count, gap 5m, cap 2h), exercising the max_state_entries hard cap and stalest-first eviction:
| Configuration | Throughput | Peak heap | Settled | Live groups |
|---|---|---|---|---|
| 100k keys, default cap (100k) | 756 Kelem/s | 20.5 MiB | 17.7 MiB | 100,000 |
| 1M keys, default cap (100k) | 841 Kelem/s | 39.8 MiB | 22.4 MiB | capped |
| 1M keys, cap raised to 2M | 742 Kelem/s | 327.4 MiB | 243.8 MiB | 1,000,000 |
A live session group costs ~256 B settled, dominated by the GroupKey heap strings rather than the timestamps. Throughput under active eviction is the highest of the three runs because the state map stays small; the eviction sort is amortized over the 10% headroom the cap reclaims.
B. Long-lived chatty sessions (groups emitting continuously inside an open session; gap never exceeded, so the per-group deque grows to timespan/interval entries):
| Workload | Throughput | Peak heap | Bytes/in-window event |
|---|---|---|---|
event_count session, 1k groups @ 30s (240 ev/window) |
1.08 Melem/s | 2.2 MiB | ~10 B |
event_count sliding, 1k groups @ 30s (240 ev/window) |
1.06 Melem/s | 2.2 MiB | ~10 B |
value_count session, 1k groups @ 30s, distinct strings |
306 Kelem/s | 21.1 MiB | ~92 B |
event_count session, 100 groups @ 1 ev/s (7,200 ev/window) |
1.10 Melem/s | 6.3 MiB | ~9 B |
value_count session, 100 groups @ 1 ev/s, distinct (1,800 ev/window) |
57 Kelem/s | 16.0 MiB | ~93 B |
C. Mode comparison (10k groups, 1M events, 1h window): sliding 915 Kelem/s, tumbling 959 Kelem/s, session 997 Kelem/s, all at a 6.6 MiB peak. Memory differences between modes come only from how long entries are retained, not from per-event overhead.
Run with cargo bench -p rsigma-eval --bench correlation_memory (about half a minute total).
Runtime (rsigma-runtime)
LogProcessor Pipeline Throughput
End-to-end processing: format parsing, detection, and result collection (100 rules).
| Format | Events | Time (median) | Throughput |
|---|---|---|---|
| JSON | 1,000 | 1.14 ms | 880 Kelem/s |
| JSON | 10,000 | 8.94 ms | 1.12 Melem/s |
| Syslog | 1,000 | 744.3 us | 1.34 Melem/s |
| Syslog | 10,000 | 6.35 ms | 1.57 Melem/s |
| Plain | 1,000 | 184.4 us | 5.42 Melem/s |
| Plain | 10,000 | 1.05 ms | 9.52 Melem/s |
| Auto-detect | 1,000 | 1.07 ms | 939 Kelem/s |
| Auto-detect | 10,000 | 8.86 ms | 1.13 Melem/s |
Feature-Gated Input Formats (100 rules)
logfmt and CEF run through the same LogProcessor pipeline as the table above; EVTX measures EvtxFileReader binary-record parsing over the checked-in 2 MiB security.evtx fixture (~2,260 records), the dominant cost of engine eval -e @file.evtx.
| Format | Events | Time (median) | Throughput |
|---|---|---|---|
| logfmt | 1,000 | 1.83 ms | 546 Kelem/s |
| logfmt | 10,000 | 15.8 ms | 631 Kelem/s |
| CEF | 1,000 | 2.11 ms | 473 Kelem/s |
| CEF | 10,000 | 19.0 ms | 527 Kelem/s |
| EVTX (parse only) | ~2,260 records | 11.6 ms | 195 Kelem/s |
Run with cargo bench -p rsigma-runtime --bench input_formats --features logfmt,cef,evtx.
OTLP Log Decode (otlp feature)
logs_request_to_raw_events: flattening an OTLP ExportLogsServiceRequest (string body, eight log attributes, four resource attributes, scope metadata, trace context per record) into the JSON events the engine evaluates. This is the ingest-side cost of OTLP/HTTP and OTLP/gRPC downstream of transport and protobuf decoding.
| Records | Time (median) | Throughput |
|---|---|---|
| 100 | 225.4 us | 444 Kelem/s |
| 1,000 | 2.27 ms | 441 Kelem/s |
| 10,000 | 22.8 ms | 439 Kelem/s |
Decode costs a flat ~2.3 us per record independent of batch size, comparable to the JSON-line parse-plus-detect cost, so OTLP ingestion roughly halves single-core throughput relative to NDJSON input.
Run with cargo bench -p rsigma-runtime --bench otlp --features otlp.
Field Observability (--observe-fields)
The same 10,000-event JSON workload as above (seven fields per event, 100 rules) with the field observer off vs on.
| Mode | Time (median) | Throughput |
|---|---|---|
| Off | 8.58 ms | 1.17 Melem/s |
| On | 11.5 ms | 867 Kelem/s |
The observer adds ~0.3 us per event on seven-key events (a mutex-guarded counter update per field key), about 25% of this workload’s total pipeline cost. Cheap in absolute terms, but it is the most expensive of the opt-in per-event observability features and scales with the event’s key count.
Run with cargo bench -p rsigma-runtime --bench runtime_throughput -- runtime_observe_fields.
Raw Engine vs LogProcessor (10,000 events, 100 rules)
| Mode | Time (median) | Throughput |
|---|---|---|
| Raw Engine (pre-parsed) | 11.3 ms | 884 Kelem/s |
| LogProcessor (JSON) | 8.70 ms | 1.15 Melem/s |
| LogProcessor (auto-detect) | 8.73 ms | 1.15 Melem/s |
Rule Scaling (1,000 JSON events)
| Rules | Time (median) | Throughput |
|---|---|---|
| 100 | 1.04 ms | 959 Kelem/s |
| 500 | 1.04 ms | 957 Kelem/s |
| 1,000 | 1.04 ms | 961 Kelem/s |
Rule count has minimal impact on runtime throughput due to the engine’s indexed matching.
Post-Engine Layers (rsigma-runtime)
Sink-path stages that run after evaluation and before delivery. All figures are per 1,000-result batch; cardinality is the number of distinct entity/fingerprint values cycling through the batch.
Enrichment (template primitive)
The CPU-only floor cost of the enrichment pipeline: template interpolation, kind/scope filtering, semaphore acquisition, and the enrichments-map injection. I/O-bound primitives (http, command, lookup) are dominated by their fetch latency and response cache, not the pipeline.
| Enrichers | Time (median) | Per result |
|---|---|---|
| 1 | 857.0 us | ~0.86 us |
| 4 | 2.56 ms | ~0.64 us per enricher |
Run with cargo bench -p rsigma-runtime --bench enrichment.
Alert Pipeline (dedup + incident grouping)
| Entity cardinality | Time (median) | Per result |
|---|---|---|
| 1 | 435.2 us | ~0.44 us |
| 10 | 449.6 us | ~0.45 us |
| 100 | 564.2 us | ~0.56 us |
Dedup folding plus incident grouping cost roughly half a microsecond per result and grow gently with fingerprint cardinality (more open alerts and incidents to track).
Run with cargo bench -p rsigma-runtime --bench alert_pipeline.
Risk Layer (annotation + per-entity accumulation)
| Entity cardinality | Time (median) | Per result |
|---|---|---|
| 1 | 3.87 ms | ~3.9 us |
| 10 | 1.21 ms | ~1.2 us |
| 100 | 1.09 ms | ~1.1 us |
Single-entity is the worst case, inverted from the alert pipeline: every result accrues into one entity whose window deque keeps growing and whose accumulated risk repeatedly crosses the incident threshold. At realistic cardinalities the layer costs about a microsecond per result.
Run with cargo bench -p rsigma-runtime --bench risk.
Dynamic Pipelines (rsigma-runtime)
Source Resolution (File I/O + JSON Parse)
| Items | Time (median) |
|---|---|
| 10 | 17.4 us |
| 100 | 20.5 us |
| 1,000 | 61.6 us |
| 10,000 | 441.8 us |
Data Parsing (No I/O)
| Format | Items | Time (median) |
|---|---|---|
| JSON | 10 | 366 ns |
| JSON | 100 | 2.78 us |
| JSON | 1,000 | 24.3 us |
| JSON | 10,000 | 239.0 us |
| YAML | 10 | 3.46 us |
| Lines | 100 | 2.83 us |
Extract Expressions
Expression evaluation on a 100-item dataset with nested objects.
| Language | Expression type | Time (median) |
|---|---|---|
| JQ | Identity (.items) |
206.5 us |
| JQ | Filter (select(.active)) |
284.2 us |
| JQ | Nested path (.a.b.c) |
177.9 us |
| JSONPath | Simple ($.items[*].name) |
22.5 us |
| JSONPath | Filter ([?@.active==true]) |
23.6 us |
| CEL | Field access (data.metadata.count) |
54.1 us |
| CEL | List filter (.filter(x, x.active)) |
759.3 us |
JQ times rose roughly 3x against the 0.9.0 baseline following the jaq 1.x to 3.0 migration (0.13.0, Radically Open Security audit fixes); JSONPath is unaffected and now ~10x faster than JQ for comparable filters.
Template Expansion
TemplateExpander::expand substituting ${source.*} references in pipeline vars.
| Vars | Values/source | Time (median) |
|---|---|---|
| 1 | 10 | 487 ns |
| 5 | 10 | 2.17 us |
| 10 | 10 | 4.24 us |
| 20 | 10 | 8.54 us |
| 5 | 100 | 10.1 us |
| 5 | 1,000 | 91.3 us |
Resolve with Extract (File + Filter, 500 IOC entries)
| Language | Time (median) |
|---|---|
JQ (.indicators[] | select(.active) | .value) |
943.8 us |
JSONPath ($.indicators[?@.active==true].value) |
253.4 us |
CEL (data.indicators.filter(x, x.active).map(x, x.value)) |
39.7 ms |
Dynamic Detection End-to-End
Full pipeline: resolve source, expand templates, apply value_placeholders, evaluate events.
| Scenario | Time (median) | Throughput |
|---|---|---|
| Detect 1000 events (50 IOCs) | 351.3 us | 2.85 Melem/s |
| Reload with resolve | 175.4 us | - |
Reload now includes the fail-closed dynamic-source re-resolution that load_rules performs since 0.14.0, which is why it costs more than the 0.9.0 baseline measured without it. In production, reload latency is dominated by source fetch time anyway.
Key Observations
- AC threshold is empirically 8: substring-list throughput flattens at 8 patterns once Aho-Corasick takes over. p16/p32 perform within ~3% of p8; below 8, the sequential
str::containsSIMD path (memchr / Two-Way) is faster on longer haystacks. - Cross-rule AC is order-of-magnitude on substring-only rule sets: with the
daachorse-indexfeature enabled, 200 non-matching events against 10K pure-substring rules dropped from 187 ms to 2.05 ms (~91x). Off by default; only worth enabling for substring-heavy rule sets where most events don’t match (e.g., threat-intel feeds against high-volume telemetry). - The bloom prefilter pays off from ~500 rules: ~20% faster on non-matching traffic at 1K-5K rules, neutral at 100. Opt-in because matching-heavy traffic pays the filter cost without the skip benefit.
- Logsource pruning wins track the pruned fraction: a 50/50 two-product split evaluates ~1.75x faster with
--logsource-routingon, at both 1K and 10K rules. - Schema classification is sub-microsecond: 216-548 ns per event against the full built-in signature set, so schema routing and observability are effectively free at pipeline throughputs.
- Array matching is linear in member count, and hits cost more than misses: non-firing paths cost ~35-60 ns per member while a firing rule pays ~110-250 ns per member independent of match position, because the fan-out collects every matching member for match details. Positional indexing (
args[N]) stays O(1). - Detection is fast: ~365K events/sec with 100 rules in pure evaluation mode, scaling linearly with event count; the full JSON runtime pipeline reaches 1.12M events/sec.
- Runtime overhead is negative: LogProcessor with JSON batching is faster than raw Engine evaluation due to batch-level optimizations and format-aware parsing.
- Rule count scales well: runtime throughput is flat from 100 to 1,000 rules thanks to indexed field matching.
- Correlation is efficient: temporal correlations (1.76M elem/s) are ~2x faster than event-count correlations (~900K elem/s), and both scale linearly with events; the mixed-workload pipeline sustains ~500K events/sec.
- Window modes cost the same per event: sliding, tumbling, and session all run at ~1.2-1.3 Melem/s on an identical workload. The window decision is O(1); choosing
sessionoverslidingis free at evaluation time. - Correlation memory is bounded by entry count, not bytes: the
max_state_entriescap (default 100k) held 1M unique session keys to a 39.8 MiB peak via stalest-first eviction. Within the cap, per-group deques grow with timespan x event rate: ~10 B per in-window event forevent_count, ~92 B forvalue_countwith distinct string values. value_countdistinct counting is the correlation bottleneck: the distinct count is recomputed per event over the whole window (O(W) per event), so throughput drops from ~1.1 Melem/s to 57 Kelem/s at 1,800 distinct values per window; CPU collapses before memory does. Prefer shorter windows orevent_countwhere distinctness is not required.- Every wire format lands within one order of magnitude: JSON 1.1M, logfmt 631K, CEF 527K, and OTLP decode 440K events/sec per core, with EVTX binary parsing at 195K records/sec. Format choice moves single-core ingest capacity by at most ~2.5x.
--observe-fieldsis the priciest per-event observability opt-in: ~0.3 us per event on seven-key events (~25% of the JSON pipeline cost on this workload), versus effectively free schema observation. It scales with event key count.- Routed evaluation costs less than it looks: the ~2x gap versus an unrouted engine is mostly the routed engines doing real matching on pipeline-mapped fields the unmapped baseline can never match. Pure dispatch (classify + route) is sub-microsecond.
- Post-engine layers cost about a microsecond per result: template enrichment ~0.9 us, alert-pipeline dedup + grouping ~0.5 us, risk accumulation ~1.1 us at realistic entity cardinalities. At 10K detections/sec the full opt-in sink path consumes under 3% of a core.
- Result serialization is not worth hand-optimizing: the derive-based serializer matches a hand-written impl within noise; even realistic correlation results serialize in under 2 us.
- Template expansion is negligible: even with 20 vars, expansion adds < 10 us. Not a bottleneck.
- JSONPath is the fastest extraction language: ~10x faster than JQ for comparable filter operations since the jaq 3.0 migration, with none of CEL’s interpretation overhead.
- CEL has high overhead: ~150x slower than JSONPath for list filtering. Best suited for simple field access or small datasets.
- Dynamic pipelines add no per-event cost: once the engine is built, detection throughput with dynamic pipelines (2.85M elem/s) is comparable to static pipeline performance.
For operator-facing performance guidance (when to enable --bloom-prefilter and --cross-rule-ac, how to tune --batch-size and --buffer-size), see Performance Tuning. For the metrics that surface throughput and back-pressure at runtime, see Prometheus metrics and Observability.