STATUS: This article is a little bit rough, but I’ve been working on it long enough that its worth publishing now, and editing to fix anything. I really should have published it earlier, because I actually broke the character limit on posts and had to split it up - that’s a first. I tried to write a shorter article, and it ended up being my longest ever.
Consider it to be a first draft of what will be more properly edited and published in the future as part of a series.
NOTE: The source repository is not up yet, it will be available soon.
A joke to set the tone
A SECD machine tries to walk into a Haskell bar, but gets stopped by the bouncer.
“Hey, I have to see your \ x -> x before you can come inside.”
“Oh, sure. Here it is.” The SECD machine hands the bouncer an [ LD, Index 0 0, RTN ] and the bouncer looks at them through it. The bouncer stares at the odd-looking code, which doesn’t do seem to do anything, nor should it.
“Its valid Haskell syntax, I promise.” The bouncer looks closer, and sure enough, it is. And when he looks through it at the strange machine standing before him, nothing has changed.
“It’s a little hard to parse, but I guess it checks out”
“Yes, I still have a bit of a Lisp, but I’m working on it - I hope that’s okay.”
“Yeah, so long as you’re functional. Alright, you can go on in.”
“Thanks.”
The bouncer opens the door to the bar, and the SECD machine walks inside.
Advantages of the SECD machine
I’ve always been a fan of Ben Lynn’s Compiler Quest series on building a self-hosted compiler with minimal fuss, and I’ve always wanted to emulate its practical approach that gets straight to the point. So, I’m building a self-hosted Haskell compiler based on the SECD machine. I call it the Little Haskell Compiler, or LHC for short.
The SECD machine is a tiny, easy-to-understand virtual machine invented by Peter Landin way back in 1971 as the first ever machine designed to evaluate lambda calculus. More than a historical curiosity, it presents many advantages:
- It is easy to understand and implement.
A working implementation of a SECD machine can be written C or Haskell in just a couple hundred lines of code.
- It is easy to hand-debug
It has only 4 registers, and 24-ish opcodes, most of which are short sequences of stack pushes and pops.
- It is homoiconic
The register / control stacks are themselves cons-cells, and you can print the registers and machine memory to trace what has happened. Its how Pure Lisp was implemented.
- It is relocatable and portable
A SECD program can easily be paused, moved to another region of memory or even another computer, and resumed there for execution.
- It is write-once, read-only
A SECD machine is a tape machine with linear access to read-only memory. This has practical implications for distributed and append-only data structures.
- It can easily be extended with new functionality
It is easy to optimize a SECD machine with custom opcodes or by giving it a traditional heap for random-access memory and data structures
- It is easy to integrate foreign function calls
A SECD VM can share atom data types with the host language, making it a free implementation of closures for languages like C. If it is running on bare metal, it uses the architecture word type as its atom type.
- It is excellent for scripting
I can write a hello world program that fits in a few hundred cells, making it possible to pass around tiny programs.
These are the reasons I have chosen it as the base machine of my compiler. This also makes it fairly unique among Haskell variants:
- GHC is a STG machine
- MicroHS is a combinator machine
- Hugs is a bytecode interpreter
Now, the LHC is a SECD machine.
Disadvantages of the SECD machine
The SECD machine comes with a few challenges. A naive implementation will face:
- Slow environment variable lookup
- High memory overhead
- Memory fragmentation and cache misses
- Low code density and inflated binary size
Most of these can be mitigated with additional opcodes for efficiency, or by adding a traditional heap, and by flattening some of the linked lists data structures into arrays and structs - and we all know linked lists have their advantages too since Haskell uses linked lists as a control structure quite heavily.
Another challenge, is the relative obscurity of the SECD machine itself. Almost all the materials I found while researching it fell into one or more of the following categories:
- Non-OCR PDF scans of literal paper documents
- Were written in Lisp
- Made heavy usage of non-canonical opcodes
- Weren’t even SECD machines because they didn’t even have the right registers
- Used host pointers instead of cell addresses
- Used malloc and free everywhere all the time
- Implemented basic things wrong
Of all of these, the most helpful resources were:
- LispKit’s C source code, which despite being plagued with pointer and malloc and free usage, it was the only correct implementation I found.
- This blog which is in Lisp
- The Wiki article on SECD machines which only describes things at a high level
These are the only materials that actually correlated with each other, so I assumed that they were the best resources, and I’d just have to deal with translating Lisp for a bit, and de-snarling the Lispkit’s direct pointer usage in favor of relocatable addresses.
Even the
secdpackage in Haskell isn’t a full SECD machine - it only has three registers and four opcodes not counting the int atom and arithmetic functions, and it relies almost entirely on the Haskell runtime to perform the missing functions such as managing the dump or handling recursion. I can’t translate that to C, I’d need to have a full Haskell runtime to do that, which is what I’m writing in the first place.
What is a SECD machine?
This is mostly a deep dive into a Haskell translation of the Lisp blog. I envy its compactness and succinctness, and if you can read Lisp, its worth consulting to follow along.
The SECD machine is a 4-register stack machine that uses cons-cells in the same way that Haskell uses lists. In a SECD machine, everything is a cons-cell.
So, what is a cons-cell?
Cons Cells
A cons cell is either nil, or a pair of cons cells, plus any sort of ancillary data.
data Cell
= Nil
| Cons Cell Cell
| ... ATOMS AND OPCODES LATER
You may notice that it is isomorphic to Fix List aka a list that contains more lists as values, so we could define it this way, using actual Haskell lists.
data List a
= Nil
| Cons a (List a)
data Fix f = Fix (f (Fix f))
type Cell = Fix List
This means that it can play the part of a pair OR a list, because it is both. Remember, a pair is isomorphic to a nil-terminated list of two values. You can turn Cons x last into Cons x (Cons last Nil) and vice versa by stripping or adding the implied last two Cons and Nil, depending on whether you want to either save space, or have simpler push and pop operations.
In my machine, I am currently reserving pairs for closures and indices, and require that all my cons-lists be properly encoded as nil-terminated lists for better compatibility with Haskell syntax and to simplify push / pop operations in the C VM. I think this is a minor deviation that is worthwhile and has no effect on the machine otherwise.
It turns out you can encode anything using cons-cells, just like you can encode anything using lambda calculus. This is why I am using cons-cells. It is easy to encode a peano number using a cons-list:
three = Cons Nil (Cons Nil (Cons Nil Nil)) -- Equivalent to [[],[],[]]
Atoms
Encoding integers using peano numbers is inefficient, and I want to be able to interact with the host environment, so I have added Int as an Atom type for our cons-cell. An atom is just a value that fits in a single cell.
Theoretically, if we had a lot of atom types, I could make a separate Atom data type, like so:
data Atom
= Unit
| Bool Bool
| Int8 Int8
| Int16 Int16
| Int32 Int32
| Int64 Int64
| Word8 Word8
| Word6 Word6
| Word32 Word32
| Word64 Word64
| Heap (Ptr Word8)
| Char Char
| String String
| Bytes ByteString
| Fn (Atom -> Atom)
data Cell
= Nil
| Cons Cell Cell
...
| Atom Atom
Implementing that many atoms would get in the way of demonstrating the SECD machine, so instead I’ve chosen to inline the single Int atom constructor into the Cell data type for simplicity:
data Cell
= Nil
| Cons Cell Cell
| Atom Int
With atoms, we can start defining meaningful values:
five :: Cell
five = Atom 5
nums :: Cell
nums = Cons (Atom 1) (Cons (Atom 2) (Cons (Atom 3) Nil))
That’s ugly. I’ll give Cell a few incomplete instances to make it easier to work with:
instance IsList Cell where
type Item Cell = Cell
fromList [] = Nil
fromList [x] = Cons x Nil
fromList (x:xs) = Cons x (fromList xs)
instance Num Cell where
fromInteger n = Atom (fromInteger n)
Don’t do this.
Having to fall back on incomplete instances like this are why I want to extract integer literals from the Num class, which I’m going to do in my compiler. Consider the LHC to be a testbed for several of my unofficial proposals.
Normally this is extremely bad, but this is an educational article, and it helps me declare data a lot more clearly:
five :: Cell
five = 5
nums :: Cell
nums = [ 1, 2, 3 ]
This allows me to begin making Haskell homoiconic, by making Haskell list and literal syntax actually generate cons-cells. Unfortunately, OverloadedLists and IsList do not seem to allow me to overload : to mean Cons - another potential future proposal for the LHC to test.
This is one of several examples of hardcoded or typeclass-defined operators in base that get in the way of Haskell becoming homoiconic, and so this is another way LHC Haskell will deviate from GHC Haskell. In the LHC, : will actually mean Cons as in Cons cells, and the exposed List type will be backed by it. This generalizes Haskell list syntax to cons cells. Until then, we’re stuck with pattern matching a lot of Cons x y instead of x:y.
If I took
.away fromcompose, I could make[ x . y ]mean the same thing as(x,y)giving us the ability to use the pair-terminated list syntax like lisp. I’m not entirely sure I want to, but I’m also kind of curious, because it furthers the homoiconicism which means it is possibly worth losing dot-compose, especially since we have<<<which matches<=<so maybe its not fatal.
Continuing the direction towards homoiconicism, I have also given it an instance (not shown) of Show to pretty print cons lists using Haskell List syntax:
instance Show Cell where
showsPrec Nil = "[]"
showsPrec (Cons car cd) = ...
putStrLn $ show $ [ LDC, 1, LDC, 2, ADD, STOP ]
-- "[ LDC, 1, LDC, 2, ADD, STOP ]'
The opcodes
In addition to atom values, cons cells can also hold opcode instructions. I’ll list them all here briefly, and I’ll explain each of them individually later, one by one.
Each opcode falls into a category:
data Opcode
= ABRT -- Non-canonical panic
-- Constructing cons cells
| NIL
| NULL
| CONS
| CAR
| CDR
-- Loading constants and variables
| LDC
| LD
-- Loading and applying functions
| LDF
| AP
| RTN
-- Branching
| SEL
| JOIN
-- Recursive contexts
| DUM
| RAP
-- Atom functions
| ATOM
| EQ
| LEQ
| ADD
| SUB
| MUL
| DIV
| REM
-- Stop the program
| STOP
-- ... OPCODE EXTENSIONS
Only 14 opcodes, NIL through RAP, are actually required - the other 10 are optional. DUM and RAP are optional, too, if you do not have recursive code or the host environment handles it. NIL is also optional, if defined as LDC Nil. I believe that CAR and CDR can also be defined using LDF, leaving you with just 11 opcodes, but it is significantly inefficient to do so.
I could integrate the opcodes into our cell like so:
data Cell
= Nil
| Cons Cell Cell
| Atom Int
| Opcode Opcode
And this is how I want you to think of it, but, like with the atoms, I will actually need to inline these opcode constructors into the Cell data type. Otherwise, I would have to preface each opcode with “Opcode”, breaking the homoiconicism.
So it actually looks like this:
data Cell
= Nil
| Cons Cell Cell
| Atom Int
| ABRT
| NIL
| NULL
| CONS
| CAR
| CDR
| LDC
| LD
| LDF
| AP
| RTN
| SEL
| JOIN
| DUM
| RAP
| ATOM
| EQ
| LEQ
| ADD
| SUB
| MUL
| DIV
| REM
| STOP
That’s it! That’s all you need to write control sequences like [ LDC, 1, LDC, 2, ADD ] or full programs with nested code branches [ NIL, NULL, SEL, [LDC, 99, JOIN], [LDC, 100, JOIN], STOP ] - that’s a full program defined in syntax that is both valid SECD and Haskell.
But how are these programs run?
Continued in the next post due to breaking the character limit