DEV Community

Tomasz Wegrzanowski
Tomasz Wegrzanowski

Posted on

100 Languages Speedrun: Episode 65: Randomized Finite Automaton for Fast Thue Interpreter in Crystal

This is probably the most Computer Science heavy episode so far. If that's not your thing, feel free to skip and come back for the next episode.

Deterministic Finite Automaton is a computer science concept. It's a program with these properties:

  • at every point it's in one of limited number of states
  • it goes through the string one character at a time
  • based on current state, current character, and nothing else, it choses the next state
  • once it's done with the string, we extract some information from whichever state it ended up in

DFA Example

So for example let's write a program that matches a string of digits. It can have some extra spaces at the beginning and end, and it can have _ between digits (but not elsewhere, an not multiples), and it's not allowed to have leading 0s unless the whole number is zero. In regular expression terms it's /^\s*(0|[1-9]\d*(_\d+)*)\s*$/.

Let's try to make a DFA for it:

  • state Start, if character space, go to state Start:
  • state Start, if character 1-9, go to state Digit:
  • state Start, if character 0, go to state End:
  • state Digit, if character 0-9, go to state Digit
  • state Digit, if character space, go to state End
  • state Digit, if character _, go to state Underscore
  • state Undersor, if character 0-9, go to state Digit
  • state End, if character space go to state End

Any state, any character not listed, go to state Fail.

If we reached end of the string, and state is either Digit or End, the string matches. Otherwise it doesn't. Hopefully I didn't mess it up.

Other Finite Automata

DFAs are nice because it's obvious how to make them super fast - it's just one table lookup per character. It's also possible to combine them - OR, AND, and NOT of any number of DFAs is still a DFA (even if potentially a much bigger one).

This is all nice, but we usually want to know more than "matches" or "doesn't match" is. So we came up with so damn many variants of the DFA idea - including the engine behind regular expressions.

What we want for Thue is something like that, except:

  • we want to know which rule matched
  • we want to know where it matched
  • we want to know exactly one of all possible matches

So I came up with Randomized Finite Automaton - a DFA variant that's perfect for Thue.

Trie

First, let's organize all the rules into a "trie". Trie is like a tree-like structure which lets us lookup a lot of strings at once. At root of the tree is a hash with keys being all the first characters. Then at every node there's some data for strings that finish there, and more tries for strings that continue.

For example a trie for this collection of strings and heir associated data: {"no": 1, "no": 2, "not": 3, "nu": 4} would be:

  • root has data: [], and children {'n': trie_n}
  • trie_n has data: [], and children {'o': trie_no, 'u': trie_nu}
  • trie_nu has data: [4], and children {}
  • trie_no has data: [1, 2], and children {"t": trie_not}
  • trie_not has data: [3], and children {}

The goal of this is that we can have very big number of rules, and we match them all at once, instead of trying every single rule. If we have thousands of rules, this can be a lot faster than hash table based solution, since trie-based solution can just look at one character and instantly eliminate hundreds or thousands of potential matches, and only the relevant ones stay.

For example if we have this trie, and string "maybe", we do a check for root.children['m'], root.children['a'], root.children['y'], root.children['b'], root.children['e'], get empty 5 times, and we're done. No matter how many rules starting with n or whatnot we had.

I found one Trie implementation for Crystal but it wasn't doing quite what I wanted. I wanted multiple data per node, it had just one. It wouldn't be too hard to adapt, but tries are super simple so I just wrote my own implementation:

class Trie(T)
  getter :data

  def initialize
    @data = [] of T
    @children = Hash(Char, Trie(T)).new
  end

  def insert(str : String, data : T)
    if str.empty?
      @data.push(data)
    else
      c = str[0]
      @children[c] ||= Trie(T).new
      @children[c].insert(str[1..-1], data)
    end
  end

  def [](c : Char)
    @children[c]?
  end
end
Enter fullscreen mode Exit fullscreen mode

RFA

Now there are two ways to go forward. The first (NFA style), is to remember every partially matched trie. This can potentially mean checking N tries for every character if maximum length of the rule is N.

The other would be to precalculate every combination (DFA style). In principle that would be faster as we guarantee just one lookup per character. The cost would be extra calculation, and potentially a lot bigger tries.

If we expect rules to be fairly short (let's say 10 characters or less), even if there are thousands of rules in our Thue program, then the NFA style solution is just better. If we expect rules to be very long, then DFA solution would win, but I don't think Thue programs would have very big rules.

NFA solution would also be better at ignoring fake rules - like if you use impossible rules as comments (# this is a comment ::= it will never be matched), NFA solution is pretty much unaffected, while DFA solution would have significantly bigger state.

So here's the Randomized Finite Automaton - the core of this episode:

class RFA
  def initialize(@rules : Array(ThueRule))
    @trie = Trie(ThueRule).new
    @rules.each do |rule|
      @trie.insert(rule.left, rule)
    end
  end

  # No empty matches allowed
  def random_match(str : String)
    count = 0
    active = [@trie]
    match = nil

    str.chars.each_with_index do |char, idx|
      next_tries = active.map{|t| t[char]}.compact
      matching_rules = next_tries.flat_map(&.data)

      unless matching_rules.empty?
        count += matching_rules.size
        if rand(count) < matching_rules.size
          rule = matching_rules.sample
          match = {rule: rule, idx: (idx - rule.left.size + 1)}
        end
      end

      active = [@trie, *next_tries]
    end

    match
  end
end
Enter fullscreen mode Exit fullscreen mode

In the constructor we just insert every rule into the main trie.

As we match, we go character by character, and remember every potential trie. That's the main trie, plus any trie which we started matching already. The number of that is bound by length of the longest rule, but in principle there would be very few tries at the same time. (DFA-style solution would have only 1 trie, basically result of merging those NFA tries).

Then we go through all the tries and get all the rules matching at current character.

Now here's the fun part - we could use it to generate list of all possible matches in the string, but that's not what we want, we just want one. So we know we had N matches so far, and M matches at current character. We pick one of M at random, then we roll M / (N + M) to decide if we want to keep new or old match.

The final thing we need to adjust is subtract number of characters minus one from the match. The RFA gives us address of last matching character, but it's generally more convenient to know the first. All rules have fixed number of characters, so it's very easy.

Complete Thue Interpreter

Here's the whole program:

#!/usr/bin/env crystal

require "./trie"

class ThueRule
  getter :left

  def initialize(@left : String, @right : String)
    @right = "~\n" if @right == "~"
  end

  def apply(str, idx)
    before = str[0, idx]
    after = str[idx+@left.size .. -1]

    if @right[0] == '~'
      print @right[1..-1]
      replacement = ""
    elsif @right == ":::"
      replacement = STDIN.gets.not_nil!.chomp
    else
      replacement = @right
    end

    before + replacement + after
  end

  def to_s(io)
    io << "Rule<#{@left.inspect}::=#{@right.inspect}>"
  end
end

class ThueSideParser
  getter :results
  @toparse : Array(Char)

  def initialize(@str : String)
    @results = [""]
    @toparse = @str.chars
    parse
  end

  private def parse
    until @toparse.empty?
      case @toparse[0]
      when '['
        chars = parse_range
        if @results.size == 1
          @results = chars.map{|c| @results[0]+c}
        elsif @results.size == chars.size
          @results = @results.zip(chars).map{|s,c| s+c}
        else
          raise "Sizes of character classes mismatch in #{@str}"
        end
      else
        c = parse_character
        @results = @results.map{|s| s + c}
      end
    end
    @results
  end

  private def parse_character
    if @toparse[0] == '\\'
      @toparse.shift
      raise "Unmatched \\ in #{@str}" if eos?
      c = @toparse.shift
      case c
      when 'n'
        '\n'
      when 's'
        ' '
      else
        c
      end
    else
      @toparse.shift
    end
  end

  private def parse_range
    chars = [] of Char
    @toparse.shift
    loop do
      raise "Character range never closed in #{@str}" if eos?
      if @toparse[0] == ']'
        @toparse.shift
        return chars
      end
      c = parse_character
      raise "Character range never closed in #{@str}" if eos?
      if @toparse[0] == '-'
        @toparse.shift
        e = parse_character
        raise "Invalid character range in #{@str}" if e < c
        chars.concat(c..e)
      else
        chars << c
      end
    end
  end

  private def eos?
    @toparse.empty?
  end
end

class ThueRuleParser
  def initialize(@str : String)
    if @str =~ /\A(.*)::=(.*)\z/
      @valid = true
      @left = $1
      @right = $2
    else
      @left = ""
      @right = ""
      @valid = false
    end
  end

  def valid_rule?
    @valid
  end

  def empty_rule?
    @valid && @left.empty?
  end

  def call
    lefts = ThueSideParser.new(@left).results
    rights = ThueSideParser.new(@right).results

    # Support N-to-1 and 1-to-N rules
    lefts *= rights.size if lefts.size == 1
    rights *= lefts.size if rights.size == 1

    unless lefts.size == rights.size
      raise "Mismatched side of rule #{@str}"
    end

    lefts.zip(rights).map do |left, right|
      ThueRule.new(left, right)
    end
  end
end

class RFA
  def initialize(@rules : Array(ThueRule))
    @trie = Trie(ThueRule).new
    @rules.each do |rule|
      @trie.insert(rule.left, rule)
    end
  end

  # No empty matches allowed
  def random_match(str : String)
    count = 0
    active = [@trie]
    match = nil

    str.chars.each_with_index do |char, idx|
      next_tries = active.map{|t| t[char]}.compact
      matching_rules = next_tries.flat_map(&.data)

      unless matching_rules.empty?
        count += matching_rules.size
        if rand(count) < matching_rules.size
          rule = matching_rules.sample
          match = {rule: rule, idx: (idx - rule.left.size + 1)}
        end
      end

      active = [@trie, *next_tries]
    end

    match
  end
end

class ThueProgram
  def initialize
    @rules = [] of ThueRule
    @initial = ""
    @state = ""
  end

  def load(path)
    lines = File.read_lines(path).map(&.chomp).zip(1..)

    while lines.size > 0
      line, line_no = lines.shift
      # Ignoring invalid rules, they are sometimes used as comments
      parser = ThueRuleParser.new(line)
      next unless parser.valid_rule?
      break if parser.empty_rule?
      @rules.concat parser.call
    end

    @rfa = RFA.new(@rules)

    @initial = lines.map(&.first).join("\n")
  end

  def run(debug)
    @state = @initial
    if debug
      @rules.each do |rule|
        STDERR.puts rule
      end
    end

    while match = @rfa.not_nil!.random_match(@state)
      rule = match[:rule]
      idx = match[:idx]
      if debug
        STDERR.puts "Applying rule #{rule} at #{idx} to #{@state.inspect}"
      end
      @state = rule.apply(@state, idx)
    end

    if debug
      STDERR.puts "No more matches. Final state: #{@state.inspect}"
    end
  end
end

unless ARGV.size == 1
  STDERR.puts "Usage: #{PROGRAM_NAME} <file.thue>"
  exit 1
end

prog = ThueProgram.new
prog.load(ARGV[0])

# Crystal doesn't handle SIGPIPE well and we want to support:
# crystal thue.cr examples/fizzbuzz.thue | head -n 100
begin
  prog.run(!!ENV["DEBUG"]?)
rescue e : IO::Error
  exit if e.os_error == Errno::EPIPE
  raise e
end
Enter fullscreen mode Exit fullscreen mode

Performance

Doing just this change, we got decent performance improvement, 51s to 21s on 100k FizzBuzz Thue program:

$ time ./thue_rx.cr examples_rx/fizzbuzz.thue | head -n 100000 >/dev/null
./thue_rx.cr examples_rx/fizzbuzz.thue  51.50s user 0.81s system 101% cpu 51.601 total
head -n 100000 > /dev/null  0.16s user 0.39s system 1% cpu 51.590 total

$ time ./thue_rfa.cr examples_rx/fizzbuzz.thue | head -n 100000 >/dev/null
./thue_rfa.cr examples_rx/fizzbuzz.thue  21.47s user 13.90s system 165% cpu 21.418 total
head -n 100000 > /dev/null  0.11s user 0.21s system 1% cpu 21.408 total
Enter fullscreen mode Exit fullscreen mode

Comparing release builds, the difference is consistent but small, 41s to 39s on 500k FizzBuzz Thue program. Both finish 100k FizzBuzz in ~7s:

$ crystal build --release thue_rfa.cr
$ crystal build --release thue_rx.cr
$ time ./thue_rx examples_rx/fizzbuzz.thue | head -n 500000 >/dev/null
./thue_rx examples_rx/fizzbuzz.thue  41.05s user 3.38s system 106% cpu 41.762 total
head -n 500000 > /dev/null  0.45s user 0.88s system 3% cpu 41.760 total
$ time ./thue_rfa examples_rx/fizzbuzz.thue | head -n 500000 >/dev/null
./thue_rfa examples_rx/fizzbuzz.thue  39.44s user 64.53s system 272% cpu 38.119 total
head -n 500000 > /dev/null  0.52s user 0.95s system 3% cpu 38.117 total
Enter fullscreen mode Exit fullscreen mode

I'm not sure if this counts as a win or not. It's very big improvement on the development build, but small one on the release build. It's definitely going to be more significant when running Thue programs with a huge number of rules, I guess FizzBuzz with less than 100 rules didn't really benefit from that.

There's probably a lot of small optimizations that can be applied to RFA#random_match, even without precomputing a single big trie.

Code

All code examples for the series will be in this repository.

Code for the Better Thue Interpreter in Crystal episode is available here.

Oldest comments (2)

Collapse
 
asterite profile image
Ary Borenszweig

Really nice post, as usual!

Some small things to further improve the performance:

  • Use str.each_char_with_index instead of str.chars.each_with_index (the former doesn't allocate memory while the latter does)
  • Use active.compact_map { |t| t[char] } instead of active.map { |t| t[char] }.compact (one less intermediate array)
  • Do something like active.clear; active.push @trie; active.concat next_tries instead of creating a new array for each char

That said, I totally understand that the goal is to improve performance compared to before while also keeping the code as readable as possible. I'm just suggesting these because you said "There's probably a lot small optimizations" and because I like optimizing things :-)

Collapse
 
taw profile image
Tomasz Wegrzanowski

Oh for sure. I think the biggest performance savings would come from switching from processing characters to processing bytes, as in UTF-8 this works perfectly well without any changes, and then we could just use 256 entry arrays instead of hashes for the trie.

Not like this really makes much difference for programs with so few rules.