Skip to content

Commit ea20235

Browse files
author
Foster Guo
committed
refactor: optimize WordState and SimpleMatchState structures for memory efficiency
1 parent 39ceb01 commit ea20235

3 files changed

Lines changed: 43 additions & 31 deletions

File tree

DESIGN.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -302,11 +302,11 @@ For a matcher with PTs {None, VariantNorm, Romanize, Delete} on ASCII text, this
302302

303303
Each rule is stored as a single `Rule` struct containing `segment_counts: Vec<i32>`, `word_id: u32`, and `word: String`. `segment_counts` is only read on the `#[cold]` matrix-init path (first-touch of matrix-mode rules); `word_id` and `word` are only read when producing output results. The hot path avoids loading `Rule` entirely — `and_count` and `RuleShape` are pre-computed into `PatternEntry`, and per-call mutable state lives in `WordState`.
304304

305-
- **`WordState`** (per-rule mutable state): three generation stamps (`matrix_generation`, `positive_generation`, `not_generation`), a `satisfied_mask: u64`, and `remaining_and: u16`.
305+
- **`WordState`** (per-rule mutable state, 8 bytes): three `u16` generation stamps (`matrix_generation`, `positive_generation`, `not_generation`) and `remaining_and: u16`. The `satisfied_mask: u64` for bitmask-path rules lives in a parallel `satisfied_masks: Vec<u64>`, split out to keep the hot struct small (10K rules × 8B = 80KB, fits L1d).
306306

307307
#### Generation-Based Reuse
308308

309-
Instead of zeroing `WordState` arrays between calls, a monotonic `generation: u32` counter is bumped. A field is "live" only when its stamp matches the current generation. Cost: O(1) amortized reset. Wraps at `u32::MAX` (once per ~4 billion calls).
309+
Instead of zeroing `WordState` arrays between calls, a monotonic `generation: u16` counter is bumped. A field is "live" only when its stamp matches the current generation. Cost: O(1) amortized reset. Wraps at `u16::MAX` (once per ~65K calls; bulk-reset cost ~20µs, amortized to <1ns per scan).
310310

311311
#### ScanState Split-Borrow
312312

@@ -330,7 +330,7 @@ For single-entry simple patterns, the automaton value encodes `rule_idx | (1 <<
330330

331331
#### Bitmask vs Matrix
332332

333-
- **Bitmask** (≤64 segments, no repeated counts): each AND hit sets bit `offset` in `satisfied_mask` and decrements `remaining_and`. Reaching 0 → satisfied. NOT hits set `not_generation` immediately.
333+
- **Bitmask** (≤64 segments, no repeated counts): each AND hit sets bit `offset` in the parallel `satisfied_masks[rule_idx]` and decrements `remaining_and`. Reaching 0 → satisfied. NOT hits set `not_generation` immediately.
334334
- **Matrix** (>64 segments or repeated counts): a `Vec<i32>` counter grid sized `[segments × variants]`. AND cells decrement; NOT cells increment. Threshold crossings tracked per-segment via `matrix_status`.
335335

336336
```

matcher_rs/src/simple_matcher/rule.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -383,7 +383,9 @@ impl RuleSet {
383383
word_state.matrix_generation = generation;
384384
word_state.positive_generation = if and_count == 0 { generation } else { 0 };
385385
word_state.remaining_and = and_count as u16;
386-
word_state.satisfied_mask = 0;
386+
// SAFETY: `rule_idx` is in bounds — satisfied_masks is
387+
// sized to match word_states.
388+
unsafe { *ss.satisfied_masks.get_unchecked_mut(rule_idx) = 0 };
387389
ss.touched_indices.push(rule_idx);
388390
if shape.use_matrix() {
389391
// SAFETY: `rule_idx` is in bounds — guaranteed by assert_unchecked above.
@@ -423,8 +425,11 @@ impl RuleSet {
423425
true
424426
} else {
425427
let bit = 1u64 << offset;
426-
if word_state.satisfied_mask & bit == 0 {
427-
word_state.satisfied_mask |= bit;
428+
// SAFETY: `rule_idx` is in bounds — satisfied_masks is
429+
// sized to match word_states.
430+
let mask = unsafe { ss.satisfied_masks.get_unchecked_mut(rule_idx) };
431+
if *mask & bit == 0 {
432+
*mask |= bit;
428433
word_state.remaining_and -= 1;
429434
if word_state.remaining_and == 0 {
430435
word_state.positive_generation = generation;
@@ -457,7 +462,9 @@ impl RuleSet {
457462
word_state.matrix_generation = generation;
458463
word_state.positive_generation = if and_count == 0 { generation } else { 0 };
459464
word_state.remaining_and = and_count as u16;
460-
word_state.satisfied_mask = 0;
465+
// SAFETY: `rule_idx` is in bounds — satisfied_masks is
466+
// sized to match word_states.
467+
unsafe { *ss.satisfied_masks.get_unchecked_mut(rule_idx) = 0 };
461468
ss.touched_indices.push(rule_idx);
462469
if shape.use_matrix() {
463470
// SAFETY: `rule_idx` is in bounds — guaranteed by assert_unchecked above.

matcher_rs/src/simple_matcher/state.rs

Lines changed: 29 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@
1414
//! generation. Stale entries are effectively invisible, giving O(1) amortized
1515
//! reset cost.
1616
//!
17-
//! When `generation` wraps to `u32::MAX`, all stamps are reset to 0 and the
18-
//! counter restarts at 1. This happens at most once every ~4 billion calls per
19-
//! thread.
17+
//! When `generation` wraps to `u16::MAX`, all stamps are reset to 0 and the
18+
//! counter restarts at 1. Using `u16` keeps `WordState` at 8 bytes (fits 10K
19+
//! rules in 80KB — within L1d cache). The bulk-reset fires every ~65K scans
20+
//! (~20µs amortized to <1ns per scan).
2021
//!
2122
//! ```text
2223
//! Call 1 (gen=1): touch rules [0, 3, 7] → only word_states[0,3,7] stamped gen=1
@@ -42,30 +43,23 @@ use std::cell::UnsafeCell;
4243
/// the rule has been initialized for this scan).
4344
#[derive(Default, Clone, Copy)]
4445
pub(super) struct WordState {
45-
/// Generation in which the rule's matrix/bitmask state was initialized.
46+
/// Generation in which the rule's bitmask/matrix state was initialized.
4647
///
4748
/// Set to the current generation on first touch. If it does not match the
4849
/// current generation, the rest of this struct's fields are stale.
49-
pub(super) matrix_generation: u32,
50+
pub(super) matrix_generation: u16,
5051
/// Generation in which all positive (AND) requirements became satisfied.
5152
///
5253
/// Set to the current generation when `remaining_and` reaches zero or on a
5354
/// [`PatternKind::Simple`](super::pattern::PatternKind::Simple) hit. A rule
5455
/// is considered "satisfied" when `positive_generation ==
5556
/// current_generation` and `not_generation != current_generation`.
56-
pub(super) positive_generation: u32,
57+
pub(super) positive_generation: u16,
5758
/// Generation in which a NOT segment vetoed the rule.
5859
///
5960
/// Once set, the rule cannot fire regardless of how many AND segments
6061
/// match.
61-
pub(super) not_generation: u32,
62-
/// Bitset fast path for tracking which AND segments have been satisfied.
63-
///
64-
/// Bit `i` is set when segment `i` has been observed at least once. Only
65-
/// used when the rule does not use the matrix path (i.e.,
66-
/// `RuleShape::use_matrix()` is `false`) and the rule has more than one
67-
/// AND segment.
68-
pub(super) satisfied_mask: u64,
62+
pub(super) not_generation: u16,
6963
/// Remaining AND segments still needed before the rule can fire.
7064
///
7165
/// Initialized to
@@ -94,6 +88,12 @@ pub(super) struct WordState {
9488
pub(super) struct SimpleMatchState {
9589
/// Per-rule state slots indexed by rule id.
9690
pub(super) word_states: Vec<WordState>,
91+
/// Bitmask tracking which AND segments are satisfied, parallel to
92+
/// `word_states`.
93+
///
94+
/// Split out of `WordState` to keep the hot struct at 8 bytes (fits 10K
95+
/// rules in L1d). Only accessed for bitmask-path AND rules.
96+
pub(super) satisfied_masks: Vec<u64>,
9797
/// Per-variant counter matrix for complex rules (one `Vec` per rule
9898
/// index).
9999
///
@@ -123,7 +123,7 @@ pub(super) struct SimpleMatchState {
123123
/// resolved.
124124
pub(super) resolved_count: usize,
125125
/// Monotonic generation id used to avoid clearing full state between calls.
126-
generation: u32,
126+
generation: u16,
127127
}
128128

129129
/// Thread-local reusable scan state shared by all matchers on the current
@@ -159,11 +159,12 @@ pub(super) static SIMPLE_MATCH_STATE: UnsafeCell<SimpleMatchState> =
159159
/// target different struct fields.
160160
pub(super) struct ScanState<'a> {
161161
pub(super) word_states: &'a mut [WordState],
162+
pub(super) satisfied_masks: &'a mut [u64],
162163
pub(super) touched_indices: &'a mut Vec<usize>,
163164
pub(super) resolved_count: usize,
164165
pub(super) matrix: &'a mut [Vec<i32>],
165166
pub(super) matrix_status: &'a mut [Vec<u8>],
166-
pub(super) generation: u32,
167+
pub(super) generation: u16,
167168
}
168169

169170
/// Scan metadata passed through the hot match-processing path.
@@ -256,7 +257,7 @@ impl ScanState<'_> {
256257
/// borrows.
257258
#[cfg(test)]
258259
impl ScanState<'_> {
259-
pub(super) fn generation(&self) -> u32 {
260+
pub(super) fn generation(&self) -> u16 {
260261
self.generation
261262
}
262263

@@ -273,7 +274,7 @@ impl ScanState<'_> {
273274
word_state.matrix_generation = generation;
274275
word_state.positive_generation = if and_count == 0 { generation } else { 0 };
275276
word_state.remaining_and = and_count as u16;
276-
word_state.satisfied_mask = 0;
277+
self.satisfied_masks[rule_idx] = 0;
277278
self.touched_indices.push(rule_idx);
278279

279280
// Derive use_matrix from segment_counts (same logic as build.rs).
@@ -303,6 +304,7 @@ impl SimpleMatchState {
303304
pub(super) const fn new() -> Self {
304305
Self {
305306
word_states: Vec::new(),
307+
satisfied_masks: Vec::new(),
306308
matrix: Vec::new(),
307309
matrix_status: Vec::new(),
308310
touched_indices: Vec::new(),
@@ -315,10 +317,11 @@ impl SimpleMatchState {
315317
/// rules.
316318
///
317319
/// Must be called exactly once at the start of every scan before any state
318-
/// is read. On `u32::MAX` overflow, all generation stamps are
319-
/// bulk-reset to 0 and the counter restarts at 1.
320+
/// is read. On `u16::MAX` overflow, all generation stamps are
321+
/// bulk-reset to 0 and the counter restarts at 1. This fires every ~65K
322+
/// scans — the cost (~20µs for 10K rules) amortizes to <1ns per scan.
320323
pub(super) fn prepare(&mut self, size: usize) {
321-
if self.generation == u32::MAX {
324+
if self.generation == u16::MAX {
322325
for state in self.word_states.iter_mut() {
323326
state.matrix_generation = 0;
324327
state.positive_generation = 0;
@@ -331,6 +334,7 @@ impl SimpleMatchState {
331334

332335
if self.word_states.len() < size {
333336
self.word_states.resize(size, WordState::default());
337+
self.satisfied_masks.resize(size, 0);
334338
self.matrix.resize(size, Vec::new());
335339
self.matrix_status.resize(size, Vec::new());
336340
}
@@ -349,6 +353,7 @@ impl SimpleMatchState {
349353
pub(super) fn as_scan_state(&mut self) -> ScanState<'_> {
350354
ScanState {
351355
word_states: &mut self.word_states,
356+
satisfied_masks: &mut self.satisfied_masks,
352357
touched_indices: &mut self.touched_indices,
353358
resolved_count: self.resolved_count,
354359
matrix: &mut self.matrix,
@@ -432,9 +437,9 @@ mod tests {
432437
state.word_states[1].matrix_generation = current;
433438
state.word_states[2].not_generation = current;
434439

435-
state.generation = u32::MAX - 1;
440+
state.generation = u16::MAX - 1;
436441
state.prepare(3);
437-
assert_eq!(state.generation, u32::MAX);
442+
assert_eq!(state.generation, u16::MAX);
438443

439444
state.prepare(3);
440445
assert_eq!(state.generation, 1);
@@ -495,7 +500,7 @@ mod tests {
495500

496501
assert_eq!(ss.word_states[0].matrix_generation, ss.generation());
497502
assert_eq!(ss.word_states[0].remaining_and, 2);
498-
assert_eq!(ss.word_states[0].satisfied_mask, 0);
503+
assert_eq!(ss.satisfied_masks[0], 0);
499504
assert_eq!(ss.touched_indices(), &[0]);
500505

501506
assert_eq!(ss.matrix[0].len(), 6);

0 commit comments

Comments
 (0)