Unboxed NFA with Fixed-Width Unsigned Indices for Performance Optimization
#154 opened on 2026/01/01
Repository metrics
- Stars
- (109 個のスター)
- PR merge metrics
- (PR metrics pending)
説明
Summary
Create a new UNFA (Unboxed NFA) type that restricts node indices to a configurable fixed-width unsigned integer (default: UInt32) instead of Nat, enabling performance optimizations by avoiding boxing/unboxing in hot loops. The goal is to implement:
UNFAtype withStateId-indexed nodes- Translator from
NFAtoUNFA - Unboxed VM search function
- Proof of correctness showing it reproduces the same transition paths as the original VM (when size constraints are satisfied)
Motivation
In Lean 4, fixed-width unsigned integers (e.g. UInt32) are represented as unboxed integer types at runtime, while Nat is boxed (by shifting the value to the left). The VM's hot loop frequently accesses NFA nodes using Fin nfa.nodes.size indices, which involves:
- Array indexing operations
- State set membership checks (
SparseSet) - Stack operations with state indices
By using fixed-width unsigned indices directly instead of Nat and Fin n, we can:
- Eliminate boxing/unboxing overhead in the hot loop
- Improve cache locality
- Reduce memory footprint (e.g.
UInt32is 4 bytes,UInt16is 2 bytes vsNat's 8 bytes on 64-bit systems)
while maintaining formal correctness through proofs.
Current Architecture
NFA Structure
inductive NFA.Node where
| done
| fail
| epsilon (next : Nat)
| anchor (anchor : Anchor) (next : Nat)
| char (c : Char) (next : Nat)
| split (next₁ next₂ : Nat)
| save (offset : Nat) (next : Nat)
| sparse (cs : Classes) (next : Nat)
structure NFA where
nodes : Array NFA.Node
start : Nat
VM Usage
The VM uses Fin nfa.nodes.size extensively:
SearchState.states : SparseSet nfa.nodes.size(storesFin nvalues)εStack : List (Update × Fin nfa.nodes.size)Vector σ.Update nfa.nodes.sizefor updates- Array access:
nfa[state]wherestate : Fin nfa.nodes.size
Proposed Implementation
0. Configurable Index Type (naming + easy experimentation)
In the current codebase, NFA node indices are consistently referred to as states in the VM (e.g. variables named state, state'). So we use StateId as the name for the index type.
-- Pick the unboxed index type here (experiment-friendly):
abbrev StateId := UInt32 -- could also try UInt16, UInt64, ...
-- Use the same type with a different name so that we have `UFin (n : StateCount)`
abbrev StateCount := StateId
1. UFin: Bounded StateId Index
First, we introduce UFin as the unboxed equivalent of Fin:
namespace Regex.UNFA
/-- A bounded `StateId` index, equivalent to `Fin n` but unboxed -/
structure UFin (n : StateCount) where
val : StateId
isLt : val < n
end Regex.UNFA
2. UNFA Type Definition
namespace Regex.UNFA
/-- Unboxed NFA node with embedded inBounds properties -/
inductive UNode (size : StateCount) where
| done
| fail
| epsilon (next : UFin size)
| anchor (anchor : Anchor) (next : UFin size)
| char (c : Char) (next : UFin size)
| split (next₁ next₂ : UFin size)
| save (offset : StateId) (next : UFin size)
| sparse (cs : Classes) (next : UFin size)
/-- Unboxed NFA with well-formedness embedded -/
structure UNFA (size : StateCount) where
nodes : Vector (UNode size) size.toNat
start : UFin size
-- and other well-formedness properties from NFA.WellFormed, if necessary
end Regex.UNFA
Key design decisions:
- Use
StateIdas the runtime index representation (swap UInt32↔UInt16 easily) - Also use
StateCount := StateIdso that sizes are carried unboxed, and converted viasize.toNatonly at the boundary (Vectorlength) - All indices use
UFin size, ensuring inBounds-ness at the type level UNFAembeds well-formedness. Since we construct it from a well-formed NFA, it shouldn't be hard to deal with well-formedness proofs.
2. Supporting Data Structures
USparseSet
structure USparseSet (n : StateCount) where
count : StateId
dense : Vector StateId n.toNat
sparse : Vector StateId n.toNat
-- Invariants: count ≤ n, and all dense/sparse values are < n
Array/Vector Access with USize
Lean 4 provides Array.uget and Vector.uget that take USize indices, which can be converted from StateId.
We can also implement ufget and getElem for UFin indices.
3. NFA → UNFA Translator
def NFA.toUNFA (nfa : NFA) (wf : nfa.WellFormed) : Option (UNFA nfa.nodes.size.toStateCount) :=
if h : nfa.nodes.size ≤ UInt32.size then
-- Convert all Nat indices to UFin
-- Convert NFA.Node to UNode with embedded proofs
-- Well-formedness is preserved by construction
some ⟨...⟩
else
.none -- NFA too large for StateId
Conversion challenges:
- All
Natindices in nodes must be< StateId.size saveoffset values must also fit inStateId- Need to prove that
NFA.Node.inBoundsimpliesUFinbounds - Since
UNodeembeds size, conversion requires proving each node's indices are valid
4. Unboxed VM Search Function
namespace Regex.UNFA.VM
structure USearchState {s : String} (σ : Strategy s) {size : StateCount} (unfa : UNFA size) where
states : USparseSet size
updates : Vector σ.Update size.toNat
def captureNext {s : String} (σ : Strategy s) {size : StateCount} (unfa : UNFA size) (p : Pos s) : Option σ.Update :=
-- Use Array.uget and Vector.uget with StateId.toUSize
-- Direct unboxed access: unfa.nodes.uget (state.val.toUSize)
end Regex.UNFA.VM
Performance benefits:
Array.uget/Vector.ugetwithUSizeavoidsFinconversionsStateId.toUSizeshould stay unboxed (forUInt32/UInt16etc.)
5. Correctness Proof
The key correctness property is along the lines of:
theorem UNFA.VM.captureNext_equiv {s : String} (nfa : NFA) (wf : nfa.WellFormed)
(unfa : UNFA nfa.nodes.size.toStateCount) (h : nfa.toUNFA wf = some unfa) (p : Pos s) :
VM.captureNext σ nfa wf p = UNFA.VM.captureNext σ unfa p :=
-- Proof that transition paths are identical
-- when size constraints are satisfied
-- Well-formedness is embedded in UNFA, so no separate wf' parameter needed
6. Regex Integration
Update Regex to support both boxed and unboxed representations:
inductive Regex where
| boxed (nfa : NFA) (useBacktracker : Bool)
| unboxed (size : StateCount) (unfa : UNFA size)
def Regex.withUNFA (self : Regex) : Regex :=
match self with
| .boxed nfa useBacktracker =>
if useBacktracker then
self -- Backtracker not supported, keep boxed
else
match nfa.toUNFA nfa.wf with
| some unfa => .unboxed nfa.nodes.size.toUInt32 unfa
| none => self -- Fallback to boxed if too large
| .unboxed _ _ => self -- Already unboxed
This allows:
- Automatic conversion from boxed to unboxed when possible
- Fallback to boxed NFA when size exceeds
StateId.size - Backtracker continues to use boxed NFA (not optimized)
Implementation Plan
Phase 1: Core Types
- Introduce
StateId := UIntXXknob (e.g. UInt32 by default) - Define
UFin (n : StateCount)structure (storesStateId+< nproof) - Define
UNode (size : StateCount)inductive type with embeddedUFinindices - Define
UNFA (size : StateCount)structure with embedded well-formedness - Implement
USparseSetwithStateIdindices - Prove basic properties (UFin bounds, UNode inBounds, etc.)
Phase 2: Translator
- Implement
NFA.toUNFAconversion - Prove well-formedness preservation
- Handle size overflow cases gracefully
Phase 3: Unboxed VM
- Implement
UNFA.VM.captureNextusingArray.uget/Vector.uget(viaStateId.toUSize) - Implement
UNFA.VM.captureNextBuf - Use
StateId.toUSizefor array indexing in hot paths - Optimize hot paths (εClosure, stepChar) with direct unboxed access
Phase 4: Correctness Proofs
- Prove
captureNext_equivtheorem - Prove transition path equivalence
- Verify capture group correctness
Phase 5: Integration
- Update
Regexto inductive withboxed/unboxedcases - Implement
Regex.withUNFAconversion - Update
Regex.captureNextBufto dispatch to appropriate VM - Benchmark performance improvements
Design Notes
Embedding Well-Formedness
Since we only deal with an NFA of fixed size, embedding well-formedness into UNFA works well:
start : UFinensuresstart < nodes.sizeat the type levelUNode'sUFinparameters ensure all indices are inBounds- No need for separate
WellFormedpredicate or runtime checks
Conversion Proofs
When converting NFA.Node to UNode, we need to prove:
NFA.Node.inBounds nfa.nodes.size→UFinbounds for each index- Well-formedness preservation:
NFA.WellFormed→UNFA(embedded) - These proofs may require careful handling of the size constraint
Open Questions
- Size limit handling: Fallback to boxed NFA when size exceeds
StateId.size(handled bywithUNFA) - Benchmarking: How to measure actual performance gains? (See CodSpeed integration issue)
- UFin vs direct StateId: Should we use
UFineverywhere, or allow directStateIdin some contexts?
References
- Lean 4 FFI and Unboxed Types
- Current NFA implementation:
regex/Regex/NFA/Basic.lean - Current VM implementation:
regex/Regex/VM/Basic.lean