Parsing Backends
Parsanol supports two parsing backends: Packrat (memoization-based) and Bytecode VM (stack-based). Understanding when to use each is crucial for optimal performance. This guide explains the characteristics of each backend, how to identify problematic grammar patterns, and how to analyze your grammar.
Backend Comparison
| Aspect | Packrat | Bytecode VM |
|---|---|---|
| Algorithm | Memoization (cache results) | Backtracking (re-try) |
| Time Complexity | Guaranteed O(n) | O(n) to O(2^n) |
| Memory Usage | O(n × r) - n=positions, r=rules | O(d) - d=nesting depth |
| Compilation | None required | Pre-compilation needed |
| Predictability | Always consistent | Depends on grammar |
| Best For | Complex grammars, safety-critical | Simple patterns, memory-limited |
Both backends produce identical parsing results. The difference is only in
performance characteristics. Use Backend::Auto
to let Parsanol analyze your grammar and choose automatically.
How Each Backend Works
Packrat (Memoization)
The Packrat backend stores parse results in a memoization table keyed by (position, rule) pairs. Each rule is evaluated at most once per position.
Memo Table:
┌─────────┬────────┬─────────────────┐
│ Position│ Rule │ Cached Result │
├─────────┼────────┼─────────────────┤
│ 0 │ expr │ Success, len=5 │
│ 0 │ term │ Success, len=2 │
│ 2 │ expr │ Success, len=3 │
│ ... │ ... │ ... │
└─────────┴────────┴─────────────────┘
For input length n with r rules:
Table size = O(n × r)
Why O(n)? Even with backtracking, each (position, rule) combination is computed exactly once. Subsequent accesses are cache hits.
Bytecode VM
The Bytecode VM compiles grammar to stack-based instructions. On failure, it backtracks to the most recent choice point and tries the next alternative.
Bytecode for pattern (a*)*:
┌────────┬───────────────────────┐
│ Addr │ Instruction │
├────────┼───────────────────────┤
│ 0 │ Choice L3 (outer *) │
│ 1 │ Choice L2 (inner *) │
│ 2 │ Char 'a' │
│ 3 │ PartialCommit L1 │
│ 4 │ L2: Commit │
│ 5 │ PartialCommit L0 │
│ 6 │ L3: End │
└────────┴───────────────────────┘
For input "aaa": tries many paths!
Why potentially O(2^n)? Without memoization, the same sub-problem can be solved many times. Nested repetitions multiply the possibilities.
When to Use Each Backend
| Grammar Type | Recommended | Reason |
|---|---|---|
| JSON, XML, TOML, YAML | Either (use Auto) | Linear patterns, both perform well |
| Programming languages (C, Rust, Python) | Packrat | Complex grammar with many rules |
| Expression languages (arithmetic, boolean) | Either | Recursive but usually well-structured |
| Log formats, CSV, simple protocols | Bytecode | Simple patterns, lower memory |
Grammars with (a*)* |
Packrat (required) | Avoids exponential backtracking |
| Embedded systems, WebAssembly | Bytecode | Lower memory footprint |
| Safety-critical, real-time systems | Packrat | Guaranteed predictable performance |
| Large files (>100MB) | Bytecode | Lower memory per byte of input |
Problematic Grammar Patterns
The following patterns can cause exponential time complexity (O(2^n)) in the Bytecode backend. They are safe with Packrat due to memoization. If your grammar contains any of these patterns, use Packrat.
1 Nested Repetitions (CRITICAL)
When a repetition contains another repetition, the Bytecode backend tries all possible ways to divide the input. This creates exponential possibilities.
// CRITICAL - Exponential O(2^n): (a*)* // Outer * tries many ways to divide input (a+)+ // Similar issue with + quantifier ((a|b)*)* // Nested choice makes it worse ([0-9]*)* // Same issue with character classes // MODERATE - Polynomial O(n^k): (a*b*)* // k=2 repetitions, polynomial slowdown (a|b*c*)* // Mixed, still problematic // SAFE - Linear O(n): a* // Single repetition - no nesting (a)* // Same as above - just grouping (a b)* // Fixed sequence inside - O(n) (a+ b+)* // Multiple but bounded sequences
Example: For input "aaa" with pattern (a*)*:
The outer * can try:
(aaa) - one group
(aa)(a) - two groups
(a)(aa) - two groups, different split
(a)(a)(a) - three groups
(aaa)() - with empty group
(aa)(a)() - etc.
Total: O(2^n) possibilities for input of length n
2 Overlapping Choice Prefixes
When multiple alternatives share a common prefix, the parser must try each one and backtrack on failure. This increases the backtracking depth.
// PROBLEMATIC - Many backtrack points: ("a" | "aa" | "aaa" | "aaaa")+ // All start with 'a' ("if" | "iff" | "iffy")+ // Prefix overlap // BETTER - Distinct first characters: ("a" | "b" | "c")+ // No overlap ("ab" | "cd" | "ef")+ // Distinct prefixes // BEST - Use negative lookahead: ("aaa" | "aa" | "a")+ // Order longest first // Or with lookahead: ("a" ~"aa" | "aa" ~"aaa" | "aaa" | "a")+
Note: In PEG, order matters. Put longer alternatives first.
3 Indirect Left Recursion
PEG cannot handle direct left recursion. Indirect left recursion (A → B → A) can cause deep recursion or infinite loops.
// INVALID - Direct left recursion (infinite loop): expr = expr "+" term | term // PROBLEMATIC - Indirect left recursion: A = B | 'a' B = A | 'b' // A → B → A cycle // CORRECT - Right recursion or iteration: expr = term ("+" term)* // or expr = term ("+" expr)?
4 Deeply Nested Alternatives
Very deep nesting creates many choice points on the stack, increasing memory usage and backtracking cost.
// PROBLEMATIC - Depth > 10: (((((((((a | b) | c) | d) | e) | f) | g) | h) | i) | j) // BETTER - Flatten alternatives: (a | b | c | d | e | f | g | h | i | j) // Or use character class: [a-j]
5 Unbounded Lookahead
Positive/negative lookahead with unbounded patterns can be expensive, especially when combined with other patterns.
// EXPENSIVE - Lookahead with repetition: &(.*) "end" // Scans to end, then backtracks !"end" .* // Negative lookahead on every position // BETTER - Bounded or specific: &[a-z]+ "end" // Bounded lookahead !"end" [a-z]+ // Specific negation
Good news: Most practical grammars (JSON, programming languages, config files)
don't have these patterns. The Packrat backend handles ALL patterns efficiently with O(n) guarantee.
When in doubt, use Packrat or Backend::Auto.
Analyzing Your Grammar
Parsanol's grammar analysis is deliberately simple: it checks for nested repetitions, the only pattern that causes exponential time complexity in the Bytecode backend.
Rust API
Why Only Nested Repetitions?
The backend selection is based on a single hard rule:
has_nested_repetition == true→ Packrat (guaranteed O(n))has_nested_repetition == false→ Bytecode (lower memory)
Nested repetitions like (a*)* cause O(2^n) time in the
Bytecode backend because the parser must try all possible ways to divide the input. Packrat's memoization
solves this by caching each (position, rule) result, guaranteeing O(n) time.
Other patterns don't matter for backend selection:
| Pattern | Time Impact | Backend Difference |
|---|---|---|
| ("a" | "aa") | Linear backtracking | Both handle identically |
| Deep nesting | Stack depth increases | Both handle fine |
| Many alternatives | More choice points | Linear in alternative count |
| (a*)* | O(2^n) exponential! | Bytecode fails, Packrat O(n) |
When to Override Auto-Selection
The auto-selection only considers time complexity. You may want to manually select based on:
| Scenario | Manual Selection | Rationale |
|---|---|---|
| Memory-constrained (embedded, WASM) | Bytecode | Lower memory: O(depth) vs O(n×rules) |
| Very large files (>100MB) | Bytecode | Packrat table grows with input size |
| Predictable latency required | Packrat | Guaranteed O(n), no pathological cases |
| Streaming parsing | Bytecode | Packrat requires full input in memory |
| Incremental re-parsing | Packrat | Memo table can be reused |
| Input always small (bounded) | Either | Even O(2^n) is fine for small n |
GrammarAnalysis Fields
atom_count
Total number of atoms in the grammar. Informational only - not used for backend selection.
has_nested_repetition
THE CRITERION
Detects (a*)* patterns. This is the only field that matters.
If true, use Packrat. If false, Bytecode is efficient.
The recommended_backend() Method
// This is the entire logic:
pub fn recommended_backend(&self) -> Backend {
if self.has_nested_repetition {
Backend::Packrat // Prevents O(2^n)
} else {
Backend::Bytecode // Lower memory, faster for simple patterns
}
}
Usage Examples
Automatic Selection (Recommended)
Explicit Backend Selection
Pre-compiled Parser (Best for Repeated Parsing)
Ruby Bindings
Bytecode Optimization Passes
The Bytecode backend applies 11 optimization passes automatically during compilation. These reduce instruction count and improve runtime performance.
1. DeadCodeElimination
Remove unreachable code after returns/fails
2. JumpChainSimplification
Jump to Jump → Jump to final target
3. JumpToReturnSimplification
Jump to Return → Return directly
4. JumpToFailSimplification
Jump to Fail → Fail directly
5. CombineAdjacentChars
Multiple Char → Single String instruction
6. SpanOptimization
CharSet* loop → Span instruction
7. FullCaptureOptimization
Open+Close capture → FullCapture
8. TestCharOptimization
Choice+Char+Commit → TestChar (5→2 instrs)
9. TestSetOptimization
Choice+CharSet+Commit → TestSet
10. TailCallOptimization
Call+Return → Jump (removes frame)
11. LookaheadOptimization
Choice in lookahead → PredChoice
Known Behavioral Differences
Both backends produce identical results for the vast majority of patterns. However, there is one edge case where behavior differs:
Alternatives with Overlapping Prefixes in Sequences
Pattern: ("a" | "aa") "b"
Input: "aab"
Execution trace:
Packrat
1. Try "a" at pos 0 → Success
2. Try "b" at pos 1 → Fail (input is 'a')
3. Backtrack, try "aa" at pos 0 → Success
4. Try "b" at pos 2 → Success
5. Result: MATCH
Memoization allows re-evaluating
the alternative at pos 0.
Bytecode (Standard PEG)
1. Try "a" at pos 0 → Success
2. Try "b" at pos 1 → Fail (input is 'a')
3. No backtrack point → FAIL
Standard PEG semantics: once
"a" succeeds, "aa" is not tried.
Practical Impact: This difference only affects grammars with:
- Alternatives with overlapping prefixes ("a" vs "aa")
- The alternative inside a sequence
- Content after the alternative that can fail
Most practical grammars (JSON, programming languages, config files) don't have this pattern. If you encounter this case, prefer Packrat for the intuitive behavior.
Performance Benchmarks
{{ result.name }}
{{ result.input }}
Summary by Category
Running Benchmarks
Verify performance claims by running benchmarks on your own machine:
# Run bytecode vs packrat benchmarks
cargo bench --bench bytecode
# Output includes:
# - Packrat vs Bytecode comparison for each grammar
# - Compilation overhead measurement
# - Large input handling comparison
# - Memory usage comparison
Expected Results
| Grammar Type | Packrat | Bytecode | Winner |
|---|---|---|---|
| JSON (linear) | ~1x | ~1.1x | Similar |
| Calculator (recursive) | ~1x | ~0.9x | Similar |
| (a*)* (exponential) | O(n) | O(2^n) | Packrat |
| Memory (100MB input) | Higher | Lower | Bytecode |