Skip to content

nfa: +refactor Dispatch deterministic automata to scalar fast paths - #910

Merged
Adda0 merged 1 commit into
develfrom
issue-801-dfa-fast-paths-pr
Oct 16, 2026
Merged

Adda0 merged 1 commit into
develfrom
issue-801-dfa-fast-paths-pr

Conversation

@Adda0

@Adda0 Adda0 commented Oct 14, 2026 •

Copy link
Copy Markdown
Collaborator

determinize() on an already deterministic input wrapped every state in a
one-element StateSet, reset a synchronized iterator per state, and hashed
and copied the singleton set per transition. A scalar depth-first
renumbering with the same stack discipline and increasing-symbol visit
order produces the identical output (same state numbering, finals and
edges, singleton subset_map entries, identical macrostate_discover call
order) without any subset machinery.

Nfa::is_in_lang() built a StateSet per letter through post() even when
the run is unique. While the walked symbol posts have at most one
target, scalar loop-carried reading suffices; from the first
nondeterministic post on, the subset walk continues from the state
reached with the remaining letters, keeping the answer exact without
reading the prefix again.

Nfas that only need a deterministic language representation no longer
run the determinization machinery: the new as_dfa() helper returns a
copy (or moves an rvalue) when the input is already deterministic and
determinizes otherwise, and the replace.cc marker builders use it.
complement_classical() already had the same dispatch locally.

Measured on a 5000-state, 8-symbol DFA (GCC, -O2): determinize 5.79 ms
to 3.60 ms (was 3.6x over a deep copy, now 2.3x), is_in_lang of a
4001-symbol word 3.55 ms to 0.22 ms.

union_det_complete() verified its deterministic and complete
preconditions only with MATA_ASSERT, so a Release build silently
miscomputed the union of unsupported inputs; it now throws
std::invalid_argument.

Fixes part of #801.


Rebased from a local stack of fixes onto current devel as a standalone commit. Timing figures above were measured on that stack and were not re-measured on devel; release build and unit tests pass on devel + this commit.

@greptile-apps

greptile-apps Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

RetriggerConfidence Score: 4/5

[Medium impact] Optimizes deterministic automata dispatch with scalar fast paths.

The PR appears safe to merge, with a non-blocking improvement to avoid repeating the membership prefix.

Findings

  1. P2 Long prefixes are read twice ▶

Summary

The PR adds scalar paths for deterministic determinize calls and unique runs in is_in_lang.

  • Deterministic automata skip subset work when they are determinized.
  • Single-run word checks follow one state at a time.
  • Replacement markers reuse deterministic regex automata.
  • Deterministic complete unions reject inputs outside their contract.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A["determinize(aut)"] --> B{"aut.is_deterministic()"}
  B -->|Yes| C["Scalar state renumbering"]
  B -->|No| D["Generic subset construction"]
  E["as_dfa(aut)"] --> F{"aut.is_deterministic()"}
  F -->|Yes| G["Copy or move unchanged"]
  F -->|No| A
  H["is_in_lang(run)"] --> I{"One initial state; no epsilon or prefix mode"}
  I -->|Yes| J["Walk single targets"]
  I -->|No| K["Generic word walk"]
  J -->|Multiple targets| K
  J -->|Word ends| L["Check final state"]
Loading

Reviews (1) · Last reviewed commit: "nfa: +refactor Dispatch deterministic au..." · Reviewed by Greptile

Comment thread src/nfa/operations.cc Outdated
determinize() on an already deterministic input wrapped every state in a
one-element StateSet, reset a synchronized iterator per state, and hashed
and copied the singleton set per transition. A scalar depth-first
renumbering with the same stack discipline and increasing-symbol visit
order produces the identical output (same state numbering, finals and
edges, singleton subset_map entries, identical macrostate_discover call
order) without any subset machinery.

Nfa::is_in_lang() built a StateSet per letter through post() even when
the run is unique. While the walked symbol posts have at most one
target, scalar loop-carried reading suffices; from the first
nondeterministic post on, the subset walk continues from the state
reached with the remaining letters, keeping the answer exact without
reading the prefix again.

Nfas that only need a deterministic language representation no longer
run the determinization machinery: the new as_dfa() helper returns a
copy (or moves an rvalue) when the input is already deterministic and
determinizes otherwise, and the replace.cc marker builders use it.
complement_classical() already had the same dispatch locally.

Measured on a 5000-state, 8-symbol DFA (GCC, -O2): determinize 5.79 ms
to 3.60 ms (was 3.6x over a deep copy, now 2.3x), is_in_lang of a
4001-symbol word 3.55 ms to 0.22 ms.

union_det_complete() verified its deterministic and complete
preconditions only with MATA_ASSERT, so a Release build silently
miscomputed the union of unsupported inputs; it now throws
std::invalid_argument.

Fixes part of #801.
@Adda0
Adda0 force-pushed the issue-801-dfa-fast-paths-pr branch from 9864466 to aab260f Compare October 15, 2026 17:47
@Adda0
Adda0 merged commit 8bfe15a into devel Oct 16, 2026
26 checks passed
@Adda0
Adda0 deleted the issue-801-dfa-fast-paths-pr branch October 16, 2026 05:45
Adda0 added a commit that referenced this pull request Oct 16, 2026
…luctant replace without dead states (#943)

Segmentation kept every ε-transition twice: in an unordered_map of
vectors keyed by depth and in a nested unordered_map keyed by depth and
source. Each state is expanded once and all its ε-moves are handled in
a row, so its ε-transitions are contiguous at a single depth. They now
live once in a vector indexed by depth, with a per-state span into it.
get_epsilon_transitions_by_depth() and get_epsilon_transitions(state)
expose them without copies; split_aut_into_segments(), noodlify() and
noodlify_mult_eps() use them. get_epsilon_depths() keeps its type for
the Python binding and is built on its first call, safely for
concurrent const calls; get_epsilon_depth_trans_map() is built the
same way and deprecated. Segmentation stays copy- and
move-constructible: a copy carries the ε-transition index and builds
its own views on demand.

noodlify_mult_eps() probed segments_one_initial_final with
find({target, final}) for every final state of the next segment after
every ε-transition. The ordered map keeps the pairs of one initial
state contiguous, so one lower_bound scan finds exactly the connected
ones. The hits are visited in the order of the segment's final states,
so the noodle order is unchanged. This also answers the "should we use
unordered_map?" TODO: an unordered map would lose that contiguity.

reluctant_nfa_with_marker() added a marker self-loop to every state,
intersected the result with a two-state guard NFA and reduced it. The
copy of a final state behind a marker was a dead lasso that ended up
in the NFT. In the reduced product, the after-marker copy of a
non-final state merges with its letter copy and the one behind a final
state is dead. The function now builds this result directly: the part
of the NFA reachable over alphabet letters, with final states without
successors and a marker self-loop on every non-final state. Symbols
outside the alphabet are still dropped.

reluctant_leftmost_nft() ended the replacement with three or four
ε-transitions: insert_word_by_levels() created a fresh level-1 target,
then an ε to a level-0 state that was inserted into and cleared from
the final states, and for ReplaceMode::All two more ε-steps back to the
initial state. The last replacement letter (or the single ε for an
empty replacement) now goes straight to the initial state, or to the
final state for ReplaceMode::Single.

For the example from #568 (replace 'a' by 'f' over {a, f}),
reluctant_leftmost_nft() drops from 15 to 9 states and has no isolated
states, and replace_reluctant_regex() drops from 20 to 12 states.

begin_marker_nfa() reverted the marker DFA and then swapped its initial
and final states back, with a TODO asking for a flag on revert(). An
internal revert_delta() helper now names that step; the public revert()
is unchanged, as no other caller needs the flag.

Segmentation::get_segments() trims each raw segment with nfa::trim()
instead of trimming a copy in place (#745, item 1).

The tests of reluctant_leftmost_nft() compare against hard-coded NFTs
with nft::are_equivalent(), which reads ε as a letter. Their expected
NFTs are regenerated.

Fixes #795.
Fixes #568.
Part of #745.

## Measurements

Throwaway harness linked against `build/release/g++/src/libmata.a` of
this commit and of `devel` (b6f7fd1), g++ `-O3 -DNDEBUG`. The machine
was loaded (load average ~23 on 16 cores), so user-space instruction
counts (`perf stat -e instructions:u`, deterministic) are the primary
figure; wall clock is the median of 9 alternating base/new pairs on one
pinned core.

Workloads:
- `replace`: `replace_reluctant_regex()` for 11 regexes (`a`, `ab`,
`a+b`, `(a|b)c*`, `a*`, `(ab|ba)+c?`, `a(b|c)*a`, `(a|b)*c(a|b)`,
`a+b+c`, `cb+a+`, `(a|b|c)*ab`) × replacements `''`, `d`, `ddd` × both
modes.
- `seg`: `Segmentation` + `get_segments()`.
- `noodle`: segment automaton with 4 segments of 40 states, 12 ε-sources
per depth (~23k noodles).
- `wide`: 3 segments of 800 states, 400 ε-sources per depth, letters
only inside blocks of 4 states, so most pair probes miss.

| workload | devel instr. | this PR instr. | change |
|---|---:|---:|---:|
| replace ×20 | 554,189,026 | 435,911,013 | −21.3 % |
| noodle seg ×200 | 458,281,680 | 332,013,904 | −27.6 % |
| noodle `noodlify_mult_eps` ×4 | 508,649,431 | 509,477,078 | +0.2 % |
| noodle `noodlify` ×4 | 331,395,567 | 325,482,021 | −1.8 % |
| wide seg ×20 | 415,027,966 | 298,211,326 | −28.1 % |
| wide `noodlify_mult_eps` ×1 | 336,822,446 | 311,681,463 | −7.5 % |
| wide `noodlify` ×1 | 585,575,482 | 535,628,445 | −8.5 % |

| workload (wall clock) | devel median | this PR median | median ratio |
|---|---:|---:|---:|
| replace ×40 | 144.1 ms | 112.2 ms | 0.80 |
| noodle seg ×400 | 107.1 ms | 71.2 ms | 0.67 |
| wide `noodlify_mult_eps` ×2 | 82.1 ms | 70.0 ms | 0.89 |

The range scan in `noodlify_mult_eps()` gains only when the next segment
has many final states that a given ε-target does not reach (`wide`);
with few final states (`noodle`) it is flat. Most of the time in
noodlification remains in `segs_one_initial_final()`, which this PR does
not touch.

Equivalence checks with the same harness, base vs this PR:
- noodles of `noodlify_mult_eps()` and `noodlify()` and the segments of
`get_segments()` on both generated automata: byte-identical
order-sensitive digests (segment state/transition counts and ε counters
per noodle, in output order; segments printed in full).
- `reluctant_leftmost_nft()` for all regex × replacement × mode
combinations: identical images (ε removed) of every input word up to
length 5 over `{a, b, c, BEGIN_MARKER}`; `replace_reluctant_regex()`:
identical images of every word up to length 6 over `{a, b, c}`;
`reluctant_nfa_with_marker()`: identical word sets up to length 6. 66
NFTs, 6,762 distinct output words.

State counts of `replace_reluctant_regex()` drop on every case, e.g. `a`
→ `d` (Single) 24 → 20, (All) 21 → 13; `ab` → `ddd` (All) 43 → 31.

## Tests

- `reluctant_nfa_with_marker()`: no dead marker state (state counts,
trim-stable, deterministic, marker accepted inside but not after a
match); symbols outside the alphabet are still dropped. Fails on devel
(`3 == 2`, `4 == 3`, `5 == 4`; trim shrinks the result).
- `reluctant_leftmost_nft()`: the #568 example has 9 states and is
trim-stable (devel: 15, trim 13). Expected NFTs of the three existing
sections regenerated (`nft::are_equivalent()` reads ε as a letter);
semantic equality with the old NFTs is checked above.
- `Segmentation::get_epsilon_transitions()`: per-depth vector, per-state
views over several ε symbols, states without ε-transitions and out of
range. Fails when the view is truncated.
- `Segmentation` copies and moves: copy- and move-constructible
(static_assert), and a copy or move made before or after the lazy
getters ran, queried after the source is destroyed, returns the same
`get_epsilon_depths()`, `get_epsilon_transitions()` and segments. Fails
to compile when the lazy views are not copyable.
- The existing index-compared noodle tests catch a changed noodle order
(checked by reversing the sort).

`just tests-cpp::check` on devel ba4a338 (debug and release): `test
cases: 330 | 324 passed | 2 skipped | 4 failed as expected` both times.
`just bindings::python::check`: `107 passed, 3 skipped, 2 xfailed`.
(Measurements above were taken on devel b6f7fd1; the commits since
then do not touch the strings code.)

## Overlap with open PRs

- #878 (draft) edits `end_marker_dfa()` / `marker_nft()` in `replace.cc`
and appends tests at the end of `tests/applications/strings/replace.cc`.
This PR does not touch those functions and adds its tests next to the
`reluctant_*` test cases, so no textual conflict is expected.
- #939 changes the `unused_state` line in
`noodlify()`/`noodlify_mult_eps()` and the `curr_state` line in
`reluctant_leftmost_nft()`; both are adjacent to lines changed here, so
whichever lands second needs a trivial rebase.
- #910 changes `determinize()` calls in `replace.cc` elsewhere; #872
changes `noodlify_for_equation()`; no overlap.
- #880 (draft) touches the Python `strings.pyx`; `get_epsilon_depths()`
keeps its type here, so the binding is unaffected.
Adda0 added a commit that referenced this pull request Oct 16, 2026
… once (#958)

`Delta.state_post()`, `Delta.get_transitions_from()` and
`Nfa.get_transitions_from_state()` copied the whole `StatePost` and
then its `to_vector()` before converting it to Python objects. They now
read the post through a const pointer into the delta; the two
`SymbolPost` getters share one helper.

`Segmentation.get_segments()` and `get_untrimmed_segments()` copied the
segment vector by value, then each segment, then assigned its initial
and final states and its delta into a fresh `Nfa`, which also dropped
the segment's alphabet. The getters are now declared to return the
const reference the C++ API returns, and each segment is copied once
into the wrapping `Nfa`, alphabet included.

Part of #755.

### Measurements

Release bindings, `taskset -c 13`, each value a median of 7 calls, 3
alternating runs, median shown. Posts: one state with 2000 symbol posts
of 8 targets each. Segments: a segment automaton of 4 segments with 5000
states each.

| | devel | this branch |
|---|---|---|
| `Delta.state_post()` | 0.94 ms | 0.68 ms |
| `Nfa.get_transitions_from_state()` | 0.96 ms | 0.59 ms |
| `Delta.get_transitions_from()` | 5.18 ms | 3.17 ms |
| `Segmentation.get_segments()` | 7.60 ms | 1.33 ms |
| `Segmentation.get_untrimmed_segments()` | 33.06 ms | 10.12 ms |

Measured on devel `ba4a338a78`; the commits since (#948, #910, #890,
#883, #892, #944) do not change these code paths.

### Tests

`test_strings.py`: segments (trimmed and untrimmed) keep the alphabet of
the segmented automaton and are independent copies; this fails on
`devel` (`segment.alphabet` is `None`). `test_trans.py`: state posts and
transitions from a state with several targets per symbol, from a state
without transitions, and from a state past the delta.

### Overlap

- #892 (merged) already reads posts in place in
`get_trans_from_state_as_sequence()` and `iter_transitions_from()`; this
PR does the same for the three getters #892 left copying.
- No overlap with #880 or #895. Independent of the other #755 PRs; all
of them merge together without conflicts.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant