Parsanol

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:

  1. Alternatives with overlapping prefixes ("a" vs "aa")
  2. The alternative inside a sequence
  3. 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

Generated from actual benchmark runs • Version {{ benchmarkData.version }}

{{ result.name }}

{{ result.input }}
Bytecode {{ result.speedup.factor }}x Packrat {{ result.speedup.factor }}x Tie
Packrat {{ result.packrat.formatted }}
Bytecode {{ result.bytecode.formatted }}

Summary by Category

{{ cat }} {{ summary.avgSpeedup.winner === 'bytecode' ? 'Bytecode' : summary.avgSpeedup.winner === 'packrat' ? 'Packrat' : 'Tie' }} {{ summary.avgSpeedup.factor }}x
Avg: {{ summary.avgPackrat }} vs {{ summary.avgBytecode }}
{{ summary.count }} tests

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