Brainf*** interpreter optimization excercise (part 4)

In this post the brainf*** interpreter is further optimized by translating the source code to an internal instruction set.

In Part 3 a second "jump table" was introduced to make the interpreter a little faster.

The result was an interpreter that could generate the mandelbrot example in 43 seconds.

While useful, it wasn't very creative compared to the earlier optimization. The following optimization is a lot more impactful.

Preprocessing

The idea is that a lot of time is spent reading through the code itself and that code is in memory as a string.

We have to go through that code a lot during loops, reading characters from the string. Additionally, we have to maintain those jump tables from the previous optimizations. This all adds up.

What if we run through the code once, record its meaning into instruction sets? Then we can loop through instructions that contain all the information we need.

If we do that we won't need to figure out every time how much to add or substract somewhere, we'll just know from looking at a single Instruction.

Instruction Set

Instead of working with "brainf***" directly, we'll make our own instruction set and build up our instructions from the original code.

The plan is to have a list of Instruction objects that contain all the necessary information to perform a certain instruction. This Instruction will need a "Type" and an optional numerical parameter.

For example: Add 5 to current memory location is an instruction of the type "Add" with a "5" as parameter.

Each instruction will have a type, indicating what needs to be done:

private enum InstructionType
{
    None = 0,
    Add, Subtract, ShiftLeft, ShiftRight, Print, Read, BeginLoop, EndLoop
}

Of these, Add, Subtract, ShiftLeft and ShiftRight have parameters. The others don't.

The Instruction is a C# class:

private class Instruction
{
    public InstructionType Type { get; private set; }

    public int Parameter { get; set; }

    public Instruction(InstructionType type) : this(type, 0)
    { }

    public Instruction(InstructionType type, int parameter)
    {
        Type = type;
        Parameter = parameter;
    }
}

After looping once through the code we'll have a list of Instruction objects.

Running the Code

Running the code is now a three step process:

  • Strip all irrelevant characters from the source
  • Build the Instruction set
  • Execute the Instructions
public void Run(FileStream source)
{
    var strippedSource = Strip(source);

    var instructions = BuildInstructions(strippedSource);

    Execute(instructions);
}

The step to strip all irrelevant characters is so that we don't have to worry about skipping those characters when reading through the code.

Building Instructions

Some bf instructions can be directly converted to an Instruction. For example Print and Read:

case '.':
    instructions.Add(new Instruction(InstructionType.Print));
    break;
case ',':
    instructions.Add(new Instruction(InstructionType.Read));
    break;

For instruction Add (+), Subtract (-), ShiftLeft (<) and ShiftRight (>) we also need a parameter to know how much to add, substract or shift.

When we encounter such a character, we count how many times the same character follows, and we progress further in the code by that many steps.

For Add that looks like this:

case '+':
    instructions.Add(new Instruction(InstructionType.Add, CountSeries(reader)));
    break;

The CountSeries method progresses the reader until it encounters a character different from + and returns how many times it saw that character being repeated.

private int CountSeries(CharReader cr)
{
    var count = 0;
    var currentChar = cr.GetChar();
    while (cr.HasCharacters() && cr.GetChar() == currentChar)
    {
        count++;
        cr.Forward();
    }
    cr.Back();
    return count;
}

Reading the Loop Instructions

For the loops ([ and ]) we're still using the jumptable from before. But only when building the instruction sets.

For the Instruction itself, the Parameter for BeginLoop indicates the location in the code to jump to when the loop can be skipped. For EndLoop it's the location where to jump to if we need to continue the loop.

The tricky part is the "location to jump to" refers to one of the instructions in the instruction set and no longer a location in the original code. Luckily we can use the Stack from Part 2 again:

case '[':
    instructions.Add(new Instruction(InstructionType.BeginLoop));
    jumpTable.Push(instructions.Count - 1);
    break;
case ']':
    var beginPosition = jumpTable.Pop();
    var beginInstruction = instructions[beginPosition];
    instructions.Add(new Instruction(InstructionType.EndLoop, beginPosition));
    beginInstruction.Parameter = instructions.Count - 1;
    break;

Executing the Instructions

With an array of Instruction objects, actualy executing the code becomes relatively straight-forward. This is good because some of the Instructions will be executed many times. Things that happen a lot are preferably really simple.

For example, the Add instruction is now:

 case InstructionType.Add:
    memory[pointer] += (byte)instruction.Parameter;
    break;

Even the most complicated part, the loops aren't too bad:

We either jump to the specified location or continue to the next instruction, depending on the condition.

 case InstructionType.BeginLoop:
    if (memory[pointer] == 0)
    {
        instructionPointer = instruction.Parameter;
    }
    break;
 case InstructionType.EndLoop:
    if (memory[pointer] != 0)
    {
        instructionPointer = instruction.Parameter;
    }
    break;

Results

The result is the mandelbrot can now be generated in 10 seconds. Quite something knowing our previous optimization left us at 43 seconds.

Drawbacks

There is a drawback to this approach: it requires looping through the whole code before we run, and keeping everything in memory as an Instruction object. So we put a little more strain on the memory. But in this case that's not too bad.

If the source code is really large, this could become a bigger issue.

This optimization required significant changes to the existing code.

Conclusion

There's still more room for optimization, but going from 43 s to 10 s is quite something.

It shows that a good preparation can really make a lot of difference. In this case the preparation was building a set of useful instructions. This extra work payed off in the end.

Please find the full code here on Github: Optimization05.csetty sweet.