Skip to content

Merge equivalent finite-state machine states incrementally - #755

Open
Ubospica wants to merge 4 commits into
mlc-ai:mainfrom
Ubospica:perf/merge-equivalent-states
Open

Ubospica wants to merge 4 commits into
mlc-ai:mainfrom
Ubospica:perf/merge-equivalent-states

Conversation

@Ubospica

@Ubospica Ubospica commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Changes

This PR splits the "merge equivalent states" optimization out of #727 and reworks MergeEquivalentStates.

The old implementation rebuilt and renumbered the whole FSM after every logical merge round. Deep shared prefixes or suffixes can take many rounds, so the same states and edges were copied and sorted over and over.

The new implementation uses a single incremental algorithm for all FSM sizes:

  • Keeps the original state IDs stable and represents merged state classes with a union-find structure that carries an intrusive member list per class.
  • Uses a worklist of pending states, so each round only revisits classes whose incoming or outgoing neighborhood changed in the previous round.
  • Works on a canonical class-level view of the edges from the first round: endpoints are mapped to class roots, duplicate edges are removed, and epsilon self-loops are dropped. Both edge directions share one collection helper.
  • Returns immediately after the initial edge scan if no merge rule can possibly apply.
  • Rebuilds the physical FSM exactly once, after the worklist reaches a fixed point. Merged classes are numbered by each class's smallest original state id, so surviving states keep their original relative order.

The result is guaranteed to accept the same language as the input; it is not guaranteed to be byte-identical to the old implementation's output. The earlier byte-identical goal did not hold anyway (see Correctness below), and dropping it removes the machinery that only existed to emulate the old rebuild-per-round output: the flag that switched edge canonicalization on after the first rebuild, the per-round renumbering used for tie-breaking, the round-local union-find, and the separate leaf-only fast path.

Correctness

This PR also fixes two over-merge bugs that exist on main and can change the accepted language:

  • Successor merges (Case 1) combine states with equal reaching paths; leaf merges and predecessor merges (Case 2) combine states with equal continuations. The two equivalences do not compose, but leaf groups could chain with a Case 1 group through a shared state in the same round, giving a dead leaf the continuations of an unrelated state. Case 2 already excluded states grouped by Case 1 in the same round; leaf merges now do the same.
  • Case 1 could select the start state as a merge candidate. The start state is also reachable "for free" by the empty string, so its reaching paths are not determined by its incoming edges. The start state's class is now never a Case 1 candidate.

Validation:

  • All 71 C++ tests pass, including new regression tests for both bugs and a randomized language-preservation test over FSMs with duplicate edges and epsilon self-loops.
  • A differential check on 50,000 random FSMs (4 to 30 states over a three-letter alphabet, with epsilon edges, duplicate edges, and self-loops) compares this implementation and main against the original FSM on every string up to length 5: this implementation changes the language of none of the 50,000 FSMs, while main changes the language of 1,342. A second seed gives the same picture (0 vs 1,348).
  • On the same 50,000 FSMs, this implementation keeps 742,082 total states versus 740,457 on main (+0.2%): it ends up larger in 1,952 cases where it refuses the unsound merges above, and smaller in 412 cases where the canonical edge view exposes merges main misses.
  • The FSM structure digests in test_fsm_structure_stability.py are regenerated. Note that the three digests that this PR previously failed in CI are exactly the large cases where the old rebuild-per-round emulation was not byte-identical; with the language-equivalence semantics the numbering is deterministic and the digests are stable again.
  • Benchmark checksum (sum of merged state counts) is unchanged: 12493908.

Performance

Comparing main commit c5717178 against this change (850cf68e) on Linux x86-64, same optimized build, pinned to one core, three interleaved runs per build, median reported. Benchmark: examples/benchmark/bench_merge_equivalent_states.cc.

Small FSMs:

  • No merge, 8 states: 0.555 -> 0.321 us, -42%.
  • No merge, 32 states: 1.728 -> 0.894 us, -48%.
  • No merge, 128 states: 7.500 -> 4.510 us, -40%.
  • Single-round merge, 8 states: 1.911 -> 1.885 us, -1%.
  • Single-round merge, 32 states: 6.954 -> 5.764 us, -17%.
  • Single-round merge, 128 states: 30.025 -> 21.271 us, -29%.
  • Deep merge, 17 states: 3.943 -> 4.054 us, +3%.
  • Deep merge, 65 states: 22.049 -> 15.616 us, -29%.

Large FSMs:

  • No merge, 8,000 states: 490.435 -> 248.220 us, -49%.
  • Single-round merge, 8,000 states: 2514.967 -> 2670.399 us, +6%.
  • Deep merge, 8,001 states: 3837.511 -> 2419.354 us, -37%.
  • No merge, 80,000 states: 5128.017 -> 3082.947 us, -40%.
  • Single-round merge, 80,000 states: 27730.964 -> 33254.048 us, +20%.
  • Deep merge, 80,001 states: 42658.368 -> 31304.354 us, -27%.

Grammar construction via Grammar::FromEBNF (long sequence of 10,000 element pairs, single choice with 8,000 alternatives, 8,000 chained rules) is within measurement noise of main (-5% to +4%).

The only regressions are the synthetic single-round star graphs at 8,000 and 80,000 states, where one giant merge group is found immediately; the extra cost is the outgoing-edge index and the canonical edge collection. No grammar-construction input shows a regression.

Validation

  • Built the shared library in an optimized build and ran all 71 C++ tests with internal checks enabled.
  • Differential language check of 50,000 random FSMs against main (two seeds).
  • Regenerated and verified the FSM structure digests (17/17 match locally).
  • Ran the in-repo direct-merge benchmarks and the grammar-construction comparison above.

Avoid rebuilding the complete graph after every merge round while preserving the existing state layout and small-FSM performance.
Copilot AI review requested due to automatic review settings July 28, 2026 14:08
Lock down early-return behavior and exact layouts for duplicate, self-loop, and special-edge inputs.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

This PR refactors FSMWithStartEnd::MergeEquivalentStates to incrementally merge equivalent FSM states using stable IDs, union-find classes, and a dirty-state worklist, rebuilding the physical FSM only once at the end. It also adds targeted correctness tests and a standalone benchmark to reproduce key workload shapes.

Changes:

  • Replaced the previous “rebuild every round” merge approach with an incremental, worklist-driven merge algorithm.
  • Added extensive unit tests covering leaf merges, deep prefixes/suffixes, cycles, special edges, and randomized language preservation.
  • Added a benchmark program to measure MergeEquivalentStates performance across small/large and deep-merge shapes.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

File Description
tests/cpp/test_fsm.cc Adds new tests that exercise many MergeEquivalentStates edge cases and invariants.
examples/benchmark/bench_merge_equivalent_states.cc Adds a reproducible microbenchmark driver for measuring MergeEquivalentStates behavior.
cpp/fsm.cc Implements the incremental equivalent-state merge algorithm with union-find + dirty worklist and single rebuild.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread cpp/fsm.cc Outdated
Comment on lines 1374 to 1392
const auto& edges = result.GetFsm().GetEdges(source);
outgoing_row_sizes[source] = static_cast<int32_t>(edges.size());
for (const auto& edge : edges) {
++incoming_row_sizes[edge.target];
for (int32_t edge_index = 0; edge_index < static_cast<int32_t>(edges.size()); ++edge_index) {
const auto& edge = edges[edge_index];
++original_incoming_row_sizes[edge.target];
if ((edge.IsEpsilon() && edge.target == source) ||
(edge_index > 0 && edge == edges[edge_index - 1])) {
input_edges_are_canonical = false;
}
if (edge_index > 0 && edges[edge_index - 1].min == edge.min &&
edges[edge_index - 1].max == edge.max && edges[edge_index - 1].target != edge.target) {
int previous_target = edges[edge_index - 1].target;
bool merges_only_leaves =
result.GetFsm().GetEdges(previous_target).empty() &&
result.GetFsm().GetEdges(edge.target).empty() &&
result.IsEndState(previous_target) == result.IsEndState(edge.target);
may_have_non_leaf_merge |= !merges_only_leaves;
}
}
}
Comment thread cpp/fsm.cc Outdated
Comment on lines +1881 to +1884
std::vector<int> next_active_states;
next_active_states.reserve(
active_num_states - round_touched_states.size() + equivalent_classes.size()
);
Comment thread cpp/fsm.cc Outdated
Comment on lines +1397 to +1400
for (const auto& edge : result.GetFsm().GetEdges(source)) {
original_incoming_edges.MutableRowAt(edge.target
)[original_incoming_write_positions[edge.target]++] = {source, edge.min, edge.max};
}
@Ubospica Ubospica changed the title perf: merge equivalent FSM states incrementally Merge equivalent finite-state machine states incrementally Jul 30, 2026
…rges

Drop the byte-identical-output constraint of MergeEquivalentStates and
guarantee language equivalence instead. This removes the mechanisms that
only existed to reproduce the old rebuild-per-round output byte by byte
(has_rebuilt, current_ids and per-round renumbering, the round-local
union-find, the leaf-only pre-loop, the 15-array arena), and canonicalizes
class-level edges (dedup + epsilon self-loop removal) from round one.

Also fix two over-merge bugs that exist on main and could change the
accepted language:
- Leaf merges chained with Case-1 merges through a shared state in the
  same round, combining a reaching-path equivalence with a continuation
  equivalence that do not compose.
- Case 1 could pick the start state as a candidate, ignoring that the
  start state is also reachable by the empty string without any edge.

Merged states are now numbered by each class's smallest original state
id, so surviving states keep their original relative order. Exact-layout
test expectations and FSM structure digests are updated accordingly; a
50k-random-FSM differential check against main shows zero language
changes for the new implementation (main breaks 1257 of them).
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.

2 participants