Skip to content

Commit 382d2e3

Browse files
author
Foster Guo
committed
perf: fold no-op transform scans into parent on ASCII text
When parent text is pure ASCII, transforms like VariantNorm, Romanize, RomanizeChar, and EmojiNorm are guaranteed no-ops. Previously each produced a redundant full DFA traversal of identical text with only a different pt_index_mask. Now fold_noop_children_masks recursively merges their masks into the parent's scan, eliminating redundant scans. For {None, VariantNorm, Romanize, Delete} on ASCII text: 4 scans → 2. Measured ~7-8% throughput improvement on multi-PT ASCII miss workloads. Also adds noop_fold benchmark module to cover this path.
1 parent bedb425 commit 382d2e3

3 files changed

Lines changed: 104 additions & 126 deletions

File tree

DESIGN.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -289,6 +289,19 @@ The threshold (0.67) was calibrated from an 8,932-point characterization sweep a
289289

290290
In `walk_and_scan`, density propagates through the transform tree via `TransformStep::output_density()` (conservative: returns parent density). The materialized path can refine this when the transform produces confirmed-ASCII output. `density == 0.0` replaces the old `is_ascii` boolean for transform no-op detection.
291291

292+
#### No-op Scan Folding
293+
294+
When the parent text is pure ASCII (`density == 0.0`), transforms like VariantNorm, Romanize, RomanizeChar, and EmojiNorm produce identical text (they only operate on non-ASCII codepoints). Scanning the same text again with a different `pt_index_mask` wastes an entire DFA traversal.
295+
296+
`fold_noop_children_masks` recursively merges no-op children's `pt_index_mask` into the parent's scan mask. The parent scans once with the OR'd mask; no-op children are skipped entirely during the walk. This is correct because:
297+
298+
- Each `PatternEntry` has a fixed `pt_index` — hits pass exactly one mask branch.
299+
- `mark_positive` and `satisfied_mask |= bit` are idempotent.
300+
- Matrix path uses the same `text_index` (`parent_vi`) — same column, same counters.
301+
- The AC engine reports each position exactly once per scan.
302+
303+
For a matcher with PTs {None, VariantNorm, Romanize, Delete} on ASCII text, this reduces 4 scans to 2 (root+VN+Romanize merged, Delete separate), yielding ~7-8% throughput improvement on the scan-dominated path.
304+
292305
---
293306

294307
### State Management

matcher_rs/benches/bench.rs

Lines changed: 29 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -482,135 +482,52 @@ mod rule_complexity {
482482
}
483483
}
484484

485-
// ── 7. Overlap Comparison ──────────────────────────────────────────────────────
486-
// Question: How much faster is non-overlapping find_iter vs find_overlapping_iter?
485+
// ── 7. No-op Fold ─────────────────────────────────────────────────────────────
486+
// Question: How much throughput is gained by folding no-op transform scans?
487487
//
488-
// Builds a standalone aho-corasick DFA (same patterns as our matcher) and compares
489-
// the two iteration modes directly. Isolates AC engine cost from callback overhead.
488+
// Uses multiple PTs where VariantNorm and Romanize are no-ops on ASCII text.
489+
// Miss scenario isolates scan cost (no hit processing, no early exit).
490490

491-
mod overlap_comparison {
491+
mod noop_fold {
492492
use super::*;
493-
use aho_corasick::{AhoCorasick, AhoCorasickBuilder, AhoCorasickKind, MatchKind};
494-
use daachorse::{
495-
DoubleArrayAhoCorasick, DoubleArrayAhoCorasickBuilder, MatchKind as DaacMatchKind,
496-
charwise::{CharwiseDoubleArrayAhoCorasick, CharwiseDoubleArrayAhoCorasickBuilder},
497-
};
498-
499-
fn build_ac_dfa(lang: &str, size: usize) -> AhoCorasick {
500-
let patterns = word_list(lang);
501-
let selected: Vec<&str> = (0..size)
502-
.map(|i| patterns[(i * 997) % patterns.len()])
503-
.collect();
504-
AhoCorasickBuilder::new()
505-
.kind(Some(AhoCorasickKind::DFA))
506-
.match_kind(MatchKind::Standard)
507-
.build(&selected)
508-
.unwrap()
509-
}
510-
511-
fn build_daac_bytewise(lang: &str, size: usize) -> DoubleArrayAhoCorasick<u32> {
512-
let patterns = word_list(lang);
513-
let patvals: Vec<(&str, u32)> = (0..size)
514-
.map(|i| (patterns[(i * 997) % patterns.len()], i as u32))
515-
.collect();
516-
DoubleArrayAhoCorasickBuilder::new()
517-
.match_kind(DaacMatchKind::Standard)
518-
.build_with_values(patvals)
519-
.unwrap()
520-
}
521-
522-
fn build_daac_charwise(lang: &str, size: usize) -> CharwiseDoubleArrayAhoCorasick<u32> {
523-
let patterns = word_list(lang);
524-
let patvals: Vec<(&str, u32)> = (0..size)
525-
.map(|i| (patterns[(i * 997) % patterns.len()], i as u32))
526-
.collect();
527-
CharwiseDoubleArrayAhoCorasickBuilder::new()
528-
.match_kind(DaacMatchKind::Standard)
529-
.build_with_values(patvals)
530-
.unwrap()
531-
}
532-
533-
// --- aho-corasick DFA ---
534-
535-
#[divan::bench(args = RULE_COUNTS, max_time = 5)]
536-
fn dfa_overlapping_en(bencher: Bencher, size: usize) {
537-
let ac = build_ac_dfa("en", size);
538-
let haystack = EN_HAYSTACK;
539-
bencher.counter(BytesCount::new(haystack.len())).bench(|| {
540-
let mut count = 0u64;
541-
for line in haystack.lines() {
542-
count += ac.find_overlapping_iter(line).count() as u64;
543-
}
544-
count
545-
});
546-
}
547493

548-
#[divan::bench(args = RULE_COUNTS, max_time = 5)]
549-
fn dfa_non_overlapping_en(bencher: Bencher, size: usize) {
550-
let ac = build_ac_dfa("en", size);
551-
let haystack = EN_HAYSTACK;
552-
bencher.counter(BytesCount::new(haystack.len())).bench(|| {
553-
let mut count = 0u64;
554-
for line in haystack.lines() {
555-
count += ac.find_iter(line).count() as u64;
556-
}
557-
count
558-
});
494+
fn build_noop_heavy_table(size: usize) -> HashMap<ProcessType, HashMap<u32, String>> {
495+
let slice = (size / 4).max(1);
496+
HashMap::from([
497+
(ProcessType::None, build_literal_map("en", slice, false)),
498+
(
499+
ProcessType::VariantNorm,
500+
build_literal_map("en", slice, false),
501+
),
502+
(ProcessType::Romanize, build_literal_map("en", slice, false)),
503+
(
504+
ProcessType::Delete,
505+
build_literal_map("en", size - slice * 3, false),
506+
),
507+
])
559508
}
560509

561-
// --- daachorse bytewise ---
562-
563-
#[divan::bench(args = RULE_COUNTS, max_time = 5)]
564-
fn daac_bw_overlapping_en(bencher: Bencher, size: usize) {
565-
let daac = build_daac_bytewise("en", size);
510+
#[divan::bench(max_time = 5)]
511+
fn is_match_miss(bencher: Bencher) {
512+
let table = build_noop_heavy_table(DEFAULT_RULE_COUNT);
513+
let matcher = SimpleMatcher::new(&table).unwrap();
566514
let haystack = EN_HAYSTACK;
567515
bencher.counter(BytesCount::new(haystack.len())).bench(|| {
568-
let mut count = 0u64;
569516
for line in haystack.lines() {
570-
count += daac.find_overlapping_iter(line).count() as u64;
517+
let _ = black_box(matcher.is_match(line));
571518
}
572-
count
573519
});
574520
}
575521

576-
#[divan::bench(args = RULE_COUNTS, max_time = 5)]
577-
fn daac_bw_non_overlapping_en(bencher: Bencher, size: usize) {
578-
let daac = build_daac_bytewise("en", size);
522+
#[divan::bench(max_time = 5)]
523+
fn process_miss(bencher: Bencher) {
524+
let table = build_noop_heavy_table(DEFAULT_RULE_COUNT);
525+
let matcher = SimpleMatcher::new(&table).unwrap();
579526
let haystack = EN_HAYSTACK;
580527
bencher.counter(BytesCount::new(haystack.len())).bench(|| {
581-
let mut count = 0u64;
582-
for line in haystack.lines() {
583-
count += daac.find_iter(line).count() as u64;
584-
}
585-
count
586-
});
587-
}
588-
589-
// --- daachorse charwise ---
590-
591-
#[divan::bench(args = RULE_COUNTS, max_time = 5)]
592-
fn daac_cw_overlapping_cn(bencher: Bencher, size: usize) {
593-
let daac = build_daac_charwise("cn", size);
594-
let haystack = CN_HAYSTACK;
595-
bencher.counter(BytesCount::new(haystack.len())).bench(|| {
596-
let mut count = 0u64;
597-
for line in haystack.lines() {
598-
count += daac.find_overlapping_iter(line).count() as u64;
599-
}
600-
count
601-
});
602-
}
603-
604-
#[divan::bench(args = RULE_COUNTS, max_time = 5)]
605-
fn daac_cw_non_overlapping_cn(bencher: Bencher, size: usize) {
606-
let daac = build_daac_charwise("cn", size);
607-
let haystack = CN_HAYSTACK;
608-
bencher.counter(BytesCount::new(haystack.len())).bench(|| {
609-
let mut count = 0u64;
610528
for line in haystack.lines() {
611-
count += daac.find_iter(line).count() as u64;
529+
let _ = black_box(matcher.process(line));
612530
}
613-
count
614531
});
615532
}
616533
}

matcher_rs/src/simple_matcher/search.rs

Lines changed: 62 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ use std::borrow::Cow;
3636

3737
use tinyvec::TinyVec;
3838

39+
use crate::process::graph::ProcessTypeBitNode;
3940
use crate::process::step::TransformStep;
4041
use crate::process::string_pool::return_string_to_pool;
4142

@@ -98,6 +99,38 @@ fn check_word_boundary(text: &[u8], start: usize, end: usize, flags: u8) -> bool
9899
true
99100
}
100101

102+
/// Recursively folds no-op children's `pt_index_mask` into the parent's mask.
103+
///
104+
/// When the parent text is pure ASCII, certain transforms (VariantNorm, Romanize,
105+
/// RomanizeChar, EmojiNorm) are guaranteed no-ops — the child's text is identical
106+
/// to the parent's. Scanning that text again with a different mask wastes an entire
107+
/// DFA traversal. By folding the child's mask into the parent's scan, we eliminate
108+
/// redundant scans while preserving correctness:
109+
///
110+
/// - Each `PatternEntry` has a fixed `pt_index` → hits pass exactly one mask branch.
111+
/// - `mark_positive` / `satisfied_mask |= bit` are idempotent (bitmask path).
112+
/// - Matrix path uses the same `text_index` (parent_vi) → same column, same counters.
113+
/// - The AC engine reports each position exactly once per scan → no double-counting.
114+
fn fold_noop_children_masks(
115+
tree: &[ProcessTypeBitNode],
116+
node_idx: usize,
117+
parent_ascii: bool,
118+
) -> u64 {
119+
let mut mask = tree[node_idx].pt_index_mask;
120+
if !parent_ascii {
121+
return mask;
122+
}
123+
for &ci in &tree[node_idx].children {
124+
let child = &tree[ci];
125+
if child.pt_index_mask != 0 && child.step.is_some_and(|s| s.is_noop_on_ascii_input()) {
126+
// Recurse: a no-op non-leaf may itself have no-op children whose
127+
// masks should also fold up to the same scan point.
128+
mask |= fold_noop_children_masks(tree, ci, true);
129+
}
130+
}
131+
mask
132+
}
133+
101134
/// Hot-path search helpers layered on top of the compiled scan engines.
102135
impl SimpleMatcher {
103136
/// Fast path for matchers that contain only direct simple literal rules.
@@ -226,11 +259,17 @@ impl SimpleMatcher {
226259
// density == 0.0 ↔ text is pure ASCII (replaces text.is_ascii()).
227260
let root_density = text_non_ascii_density(text);
228261

229-
// Scan root (ProcessType::None) if it terminates here.
230-
if tree[0].pt_index_mask != 0 {
262+
// Fold no-op children's masks into the root scan to eliminate redundant
263+
// DFA traversals. On ASCII text, transforms like VariantNorm/Romanize
264+
// produce identical text — scanning it again with a different mask is
265+
// pure waste. Folding merges those masks into one scan.
266+
let root_scan_mask = fold_noop_children_masks(tree, 0, root_density == 0.0);
267+
268+
// Scan root text if any PT terminates here (including folded no-ops).
269+
if root_scan_mask != 0 {
231270
let ctx = ScanContext {
232271
text_index: 0,
233-
process_type_mask: tree[0].pt_index_mask,
272+
process_type_mask: root_scan_mask,
234273
num_variants,
235274
exit_early,
236275
non_ascii_density: root_density,
@@ -290,9 +329,15 @@ impl SimpleMatcher {
290329
let parent_density = density_flags[parent_aidx];
291330
let parent_ascii = parent_density == 0.0;
292331

332+
let is_noop = parent_ascii && step.is_noop_on_ascii_input();
333+
293334
if is_leaf {
294335
if child.pt_index_mask != 0 {
295-
let is_noop = parent_ascii && step.is_noop_on_ascii_input();
336+
// No-op leaves were already folded into the parent's scan
337+
// mask by fold_noop_children_masks — skip entirely.
338+
if is_noop {
339+
continue;
340+
}
296341

297342
// Fused transform-scan dispatch:
298343
//
@@ -304,8 +349,9 @@ impl SimpleMatcher {
304349
//
305350
// Fused paths cover Delete/Normalize/VariantNorm/Romanize.
306351
// Parent density is the correct estimate for all fused transforms.
307-
let use_fused = !(is_noop
308-
|| self.scan.has_dfa() && parent_density <= CHARWISE_DENSITY_THRESHOLD);
352+
// Note: is_noop leaves are already skipped above.
353+
let use_fused =
354+
!(self.scan.has_dfa() && parent_density <= CHARWISE_DENSITY_THRESHOLD);
309355
let fused_result = if use_fused {
310356
let parent_text = texts[parent_aidx].as_ref();
311357
let vi = variant_counter;
@@ -355,11 +401,9 @@ impl SimpleMatcher {
355401
result
356402
} else {
357403
// Normal path: materialize then scan.
358-
let changed = if !is_noop {
359-
step.apply(texts[parent_aidx].as_ref(), parent_density)
360-
} else {
361-
None
362-
};
404+
// Note: is_noop leaves are skipped above, so apply()
405+
// always runs here.
406+
let changed = step.apply(texts[parent_aidx].as_ref(), parent_density);
363407

364408
if let Some((s, child_density)) = changed {
365409
let vi = variant_counter;
@@ -413,11 +457,15 @@ impl SimpleMatcher {
413457
node_arena[child_idx] = child_aidx;
414458
node_variant[child_idx] = child_vi;
415459

416-
// Scan if this node terminates.
417-
if child.pt_index_mask != 0 {
460+
// Scan if this node terminates. No-op non-leaves were
461+
// already folded into the parent's scan — skip.
462+
// Also fold this node's own no-op children into its mask.
463+
if child.pt_index_mask != 0 && !is_noop {
464+
let child_ascii = density_flags[child_aidx] == 0.0;
465+
let scan_mask = fold_noop_children_masks(tree, child_idx, child_ascii);
418466
let ctx = ScanContext {
419467
text_index: child_vi,
420-
process_type_mask: child.pt_index_mask,
468+
process_type_mask: scan_mask,
421469
num_variants,
422470
exit_early,
423471
non_ascii_density: density_flags[child_aidx],

0 commit comments

Comments
 (0)