1 of 47

MicroHs:

A Small Haskell Compiler

Lennart Augustsson

Haskell Symposium, September 7, 2024

2 of 47

Demo

3 of 47

Why?

Does the world need another Haskell compiler?

4 of 47

Why?

Does the world need another Haskell compiler?

😀 I don't care. I'm doing this for fun.

5 of 47

Why?

Does the world need another Haskell compiler?

😀 I don't care. I'm doing this for fun.

😐 Maybe. GHC isn't known for being minimal.

6 of 47

Why?

Does the world need another Haskell compiler?

😀 I don't care. I'm doing this for fun.

😐 Maybe. GHC isn't known for being minimal.

🙁 No. GHC is the defacto standard.

7 of 47

Why?

Does the world need another Haskell compiler?

😀 I don't care. I'm doing this for fun.

😐 Maybe. GHC isn't known for being minimal.

🙁 No. GHC is the defacto standard.

  • Goal: Must recompile itself in <10s

8 of 47

Why?

Does the world need another Haskell compiler?

😀 I don't care. I'm doing this for fun.

😐 Maybe. GHC isn't known for being minimal.

🙁 No. GHC is the defacto standard.

  • Goal: Must recompile itself in <10s
  • I failed, but there is a solution:

9 of 47

Why?

Does the world need another Haskell compiler?

😀 I don't care. I'm doing this for fun.

😐 Maybe. GHC isn't known for being minimal.

🙁 No. GHC is the defacto standard.

  • Goal: Must recompile itself in <10s
  • I failed, but there is a solution:�buy a new laptop

10 of 47

Why?

  • For my own entertainment
  • To show that combinators are viable (RIP David Turner)
  • A hackable implementation for other people to play with
  • Easily portable, even to small systems
    • Continuous integration tests MicroHs on:
      • x86, x86-64, ARM-32, ARM-64, PowerPC-64, RiscV-64, S390
      • Linux, MacOS, Windows, emscripten
    • Only needs a C compiler to port
    • Runs on microcontrollers, e.g.
      • ESP32-C3-DevKitM-1
      • STM32F407G-DISC1
  • Minimal runtime system requirements
    • only needs memory allocation
      • File IO, floating point, etc. are optional
    • can run on bare metal systems
      • Using C FFI

11 of 47

Why the name? Or, some numbers...

  • mhs - MicroHs compiled with MicroHs
  • gmhs - MicroHs compiled with ghc
  • ghc - ghc-9.8.2 (compiled with ghc)

An (unfair) comparison. All numbers from an M1 MacBook.

Binary

MB

Compiler

kLOC

Runtime

kLOC

Compile mhs

with libs s

Compile mhs

no libs s

mhs

0.45

10

6

18.2

12.0

gmhs

24.04

10

6

1.5

ghc

120??

515

133

11.2

12 of 47

Compiler pipeline structure (boring)

  • Lex: mostly standard, but with a state machine API
  • Parse: standard parsing combinator parser
  • Type check: pretty standard, same AST & checker for terms, types, kinds
  • Desugar:
    • Mostly from the report
    • Scott encoding for constructors
  • Optimizer: just one little optimization
  • Combinator generation: straight from David Turner's 1978 paper

Lex

Parse

Type

check

Desugar

Opt

Comb

13 of 47

Input Language

MicroHs compiles Haskell2010, with some always-on extensions:�{-# LANGUAGE �BangPatterns ConstraintKinds DefaultSignatures DoAndIfThenElse DuplicateRecordFields EmptyDataDecls ExistentialQuantification ExtendedDefaultRules FlexibleContexts FlexibleInstance ForeignFunctionInterface FunctionalDependencies GADTs GADTsyntax ImportQualifiedPost IncoherentInstances KindSignatures MonoLocalBinds MultiParamTypeClasses NamedFieldPuns NegativeLiterals NoMonomorphismRestriction NoStarIsType OverlappingInstances OverloadedRecordDot OverloadedRecordUpdate OverloadedStrings PolyKinds RankNTypes RecordWildCards QualifiedDo ScopedTypeVariables StandaloneKindSignatures TupleSections (only pairs right now) TypeLits TypeSynonymInstances UndecidableInstances UndecidableSuperClasses ViewPatterns�#-}

14 of 47

Input Language

Missing features

  • Due to my laziness
    • deriving Read
    • GeneralizedNewtypeDeriving
    • more base libraries
    • generics
  • Coming soon
    • PatternSynonyms
    • Concurrent Haskell
  • I don't like them
    • template Haskell
    • roles
    • implicit parameters
    • ...�

15 of 47

Output Language

Combinators, more later

The output contains the combinator graph in textual form.� A B _49 @_50 @:51 @� A C _46 @#0 @:50 @� A B C C S' @B _48 #91 @@_48 #93 @@@@@B B B B _48 #91 @@@@@S' B @C'B @B B Y @@B C'B B P @_48 #93 @@@@B B B B _48 #44 @@@@@C'B @@@@@@:49 @

But it is generated as a C byte array.� static unsigned char data[] = {� 118,55,46,48,10,50,49,55,10,65,32,95,48,32,95,50,52,32,64,95,� 52,49,32,102,114,111,109,85,84,70,56,32,34,83,111,109,101,32,102,97,� 99,116,111,114,105,97,108,115,34,32,64,64,64,95,52,51,32,95,53,50,� ...

This is compiled together with the runtime system to generate the binary.

16 of 47

Parsing layout

Haskell has one annoying lexing feature. This is the specification of how to insert missing '{', ';', and '}'. All easy, except... "parse-error(t)"

17 of 47

Parsing layout

Typical interface to the lexer:� lex :: [Char] -> [Token]

To communicate from the parser to the lexer MicroHs uses:� startLex :: [Char] -> LexState� nextToken :: LexState -> (Token, LexState)� popLayout :: LexState -> LexState

The change to the parser is minimal:� pBraces :: P a -> P a� pBraces p = pSpec '{' *> p <* pSpec '}'� <|> pSpec '{' *> p <* soft� where soft = nextToken >>= \case� TSpec '}' -> pSpec '}'� _ -> mapTokenState popLayout -- magic!

18 of 47

Type checking

Needed "type" checkers:

  • Check types of values
    • not True
  • Check types of patterns
    • Just True
  • Check kind of types
    • Maybe Bool
  • Check sort of kinds
    • Type -> Context

MicroHs uses the same type checker for all of those (and the same AST).

Based on Practical type inference for arbitrary-rank types� Peyton Jones, Vytiniotis, Weirich, Shields

19 of 47

Desugar

  • Desugaring is based on the Haskell report.�Output is λ-calculus, constructors, case, and primitives.
  • Constructors and case are then encoded using Scott encoding
    • E.g. False=\x y->x, (,)=\x y f->f x y, (:)=\x y n c->c x y
  • For large (>5) number of constructors a different encoding is used:�(constructor number, tuple of arguments)
    • This is 25% faster for the compiler itself
  • Final result, only λ-calculus and primitives (numbers etc.)

20 of 47

Optimization

A single optimization: remove initial prefix of constant arguments in recursion.�E.g.

map f [] = []�map f (x:xs) = f x : map f xs

turns into

map f = g� where g [] = []� g (x:xs) = f x : g xs

(The importance of this will become clear soon.)

21 of 47

Combinators

Combinator calculus was introduced by Moses Schönfinkel and Haskell Curry.

A typical combinator base:� S f g x = (f x) (g x) -- Haskell: ap� K x y = x -- Haskell: const� I x = x -- Haskell: id

These 3 are enough to encode the λ-calculus.�(Actually, S&K are enough since I=SKK.)

22 of 47

Combinators

Turn λ-calculus into combinators, bracket abstraction

λx. e = [x]e

[x]x = I� [x]y = K y xy� [x](e1 e2) = S ([x]e1) ([x]e2)� [x](λy. e) = [x]([y]e)

23 of 47

Graph reduction

David Turner's 1979 seminal paper A New Implementation Technique for Applicative Languages shows how to use combinators for execution.

To get efficient evaluation, we need graph reduction.

Interpret the combinator reduction rules as graph rewrites.

*

@

I

I x = x

*

@

K x y = x

@

K

x

x

y

y

x

x

24 of 47

Graph reduction

@

S f g x = f x (g x)

@

g

x

@

S

f

g

x

f

@

@

@

@

Y

Y f = f (Y f)

@

f

f

25 of 47

Normal order reduction

Getting normal order reduction and lazy evaluation is easy:

Start from the root of the combinator graph, and do the leftmost reduction,�repeat.

@

@

@

@

X

Reduce

here

26 of 47

Normal order reduction

Getting normal order reduction and lazy evaluation is easy:

Start from the root of the combinator graph, and do the leftmost reduction,�repeat.

@

@

@

@

X

.

.

.

To avoid repeatedly finding the leftmost redex the runtime keeps a stack of pointers to the spine.

27 of 47

Combinators in MicroHs

A very ad hoc set� Haskell In D.A.T. paper�S x y z = x z (y z) A (<*>) *�K x y = x * const, False, [] *�I x = x * id *�B x y z = x (y z) A (.) *�C x y z = x z y A flip *�S' x y z w = x (y w) (z w) A *�B' x y z w = x y (z w) A *�C' x y z w = x (y w) z A *�A x y = y * True�U x y = y x uncurry *�Z x y z = x y�P x y z = z x y A (,) *�R x y z = y z x A�O x y z w = w x y A (:)�n@(Y x) = x n fix *�K2 x y z = x *�K3 x y z w = x *�K4 x y z w v = x *

A - allocates, * - uses indirection node

28 of 47

Combinators

Primitive functions

seq not lambda definable

intAdd, intSub, ... arithmetic

intEq, ... comparisons

dblAdd, dblSub, ...

dblEq, ...

returnIO

bindIO

thenIO

performIO i.e., unsafePerformIO

throw

catch

ccall FFI call

malloc, free

29 of 47

Self-optimization

David said:

f x = (2+3) + x

f = intAdd (intAdd 2 3)

The first time f is used the (intAdd 2 3) reduction will happen and the new definition will be�f = intAdd 5

30 of 47

Implementing overloading (self-optimization)

Source

class Num a where

(+) :: a -> a -> a

fromInteger :: Integer -> a

...

instance Num Int where

(+) = primIntAdd

fromInteger = primIToInt

...

inc :: forall a . Num a => a -> a

inc x =

x + 1

incInt :: Int -> Int

incInt = inc

Transformed

data Num a = Num {

(+) :: a -> a -> a,

fromInteger :: Integer -> a

... }

instNumInt :: Num Int

instNumInt = Num {

(+) = primIntAdd,

fromInteger = primIToInt

... }

inc :: forall a . Num a -> a -> a

inc numDict x =

((+) numDict) x

(fromInteger numDict 1)

incInt :: Int -> Int

incInt = inc instNumInt

31 of 47

Implementing overloading (self-optimization)

Transformed

data Num a = Num {

(+) :: a -> a -> a,

fromInteger :: Integer -> a

... }

instNumInt :: Num Int

instNumInt = Num {

(+) = primIntAdd,

fromInteger = primIToInt

... }

inc :: forall a . Num a -> a -> a

inc numDict x =

((+) numDict) x

(fromInteger numDict 1)

incInt :: Int -> Int

incInt = inc instNumInt

Reduction (actually happens with combinators):

incInt

inc instNumInt

\x-> ((+) intNumInt) x

(fromInteger instNumInt 1)

\x-> primIntAdd x (primIToInt 1)

\x-> primIntAdd x #1

\x-> flip primIntAdd #1 x

flip primIntAdd #1

C primIntAdd #1

32 of 47

Compiler structure

Decode args,

read files, etc

Compiler pipeline

Cache

Package

database

Write file

C compiler

C rts

33 of 47

Cache

MicroHs has no separate compilation of modules, only packages.

MicroHs always operates in "make" mode, i.e., it automatically finds and compiles all imported modules.

There is cache during compilation:

  • at startup, possibly load the cache from file
    • each cached module is validated with an MD5 checksum
  • each compiled module goes into the cache
  • before compiling a module, consult the cache
  • at the end, possibly save the cache to file

A package is just a saved compilation cache.

34 of 47

Demo

inc :: Num a => a -> a�inc x = x + 1��incInt :: Int -> Int�incInt = inc

35 of 47

Demo

inc :: Num a => a -> a�inc x = x + 1��incInt :: Int -> Int�incInt = inc

inc' :: Dict (Num a) -> a -> a�inc' Dict = inc

inc' = (U (((S' C) (U (Z (Z (Z (Z (Z K))))))) ((C (U (K (K4 A)))) ((P K) ((O #1) K)))))

incInt = ((C +) #1)

36 of 47

Easy execution

After conversion to combinators we have this data type (assuming only Int literals):

data Exp = App Exp Exp | Comb String | LitInt Int

How can we convert an expression tree (an Exp) to the value it represent?

37 of 47

Easy execution

After conversion to combinators we have this data type (assuming only Int literals):

data Exp = App Exp Exp | Comb String | LitInt Integer

How can we convert an expression tree (an Exp) to the value it represent?

Ignoring types, it's very easy!

translate :: Exp -> Any

translate (LitInt i) = unsafeCoerce i

translate (Comb s) = lookupComb s

translate (App f a) = (unsafeCoerce (translate f)) (translate a)

lookupComb :: String -> Any

lookupComb "K" = unsafeCoerce const

lookupComb "I" = unsafeCoerce id

lookupComb "S" = unsafeCoerce $ \ f g x -> (f x) (g x)

lookupComb "+" = unsafeCoerce (+)

38 of 47

Combinator evaluation

The runtime system has two evaluators:

  • eval()
    • evaluate a pure term, IO terms are considered in normal form
  • execio() (which is really the same as unsafePerformIO)
    • execute an IO term, pure terms will not be encountered
    • implements return and (>>=) directly

39 of 47

eval(), some C code

for(;;) {

tag = GETTAG(n);

switch(tag) {

case T_IND: n = INDIR(n); break;

case T_AP: PUSH(n); n = FUN(n); break;

case T_INT: RET;

case T_S: GCCHECK(2); CHKARG3; GOAP(new_ap(x, z), new_ap(y, z)); /* S x y z = x z (y z) */

case T_SS: GCCHECK(3); CHKARG4; GOAP(new_ap(x, new_ap(y, w)), new_ap(z, w)); /* S' x y z w = x (y w) (z w) */

case T_K: CHKARG2; GOIND(x); /* K x y = *x */

case T_A: CHKARG2; GOIND(y); /* A x y = *y */

case T_U: CHKARG2; GOAP(y, x); /* U x y = y x */

case T_I: CHKARG1; GOIND(x); /* I x = *x */

case T_Y: CHKARG1; GOAP(x, n); /* n@(Y x) = x n */

case T_B: GCCHECK(1); CHKARG3; GOAP(x, new_ap(y, z)); /* B x y z = x (y z) */

case T_BB: GCCHECK(2); CHKARG4; GOAP(new_ap(x, y), new_ap(z, w)); /* B' x y z w = x y (z w) */

case T_Z: CHKARG3; GOAP(x, y); /* Z x y z = x y */

case T_C: GCCHECK(1); CHKARG3; GOAP(new_ap(x, z), y); /* C x y z = x z y */

case T_CC: GCCHECK(2); CHKARG4; GOAP(new_ap(x, new_ap(y, w)), z); /* C' x y z w = x (y w) z */

case T_P: GCCHECK(1); CHKARG3; GOAP(new_ap(z, x), y); /* P x y z = z x y */

case T_R: GCCHECK(1); CHKARG3; GOAP(new_ap(y, z), x); /* R x y z = y z x */

case T_O: GCCHECK(1); CHKARG4; GOAP(new_ap(w, x), y); /* O x y z w = w x y */

case T_ADD: ARITHBIN(+);

case T_SUB: ARITHBIN(-);

...

}

}

40 of 47

execio(), some C code

for(;;) {

eval(n);

tag = GETTAG(n);

switch(tag) {

case T_IND: n = INDIR(n); break;

case T_AP: PUSH(n); n = FUN(n); break;

case T_IO_BIND: CHECKIO(2); x = execio(ARG(TOP(1))); f = ARG(TOP(2)); n = new_ap(f, x); POP(3); break;

case T_IO_THEN: CHECKIO(2); (void)execio(ARG(TOP(1))); n = ARG(TOP(2)); POP(3); break;

case T_IO_RETURN: CHECKIO(1); n = ARG(TOP(1)); RETIO(n);

case T_IO_CCALL: ...

case T_IO_CATCH: ... setjmp(jbuf); ...

...

default: ERR("non-IO encountered");

}

}

41 of 47

Memory management

  • Fixed sized cells, with either
    • application, 2 pointers + 1 tag bit
    • primitive op (combinator, arithmetic, etc)
    • int, float, C pointer, etc
  • GC
    • mark, roots on the evaluation stack
      • uses a separate bit array for marks
    • scan, part of allocation
      • on every allocation, scan the mark array for the next free cell
      • fast using FFS instruction
  • One copy of the cells for each of the primitives
    • Also one copy for small integers
  • Also malloc(), free() for FFI

0

1

T_S

1

T_INT

42

1

T_PTR

C

application

combinator etc

base type

42 of 47

Serialization

The graph in memory has a very simple structure, only 3 kinds of nodes

  • application
  • primitive op
  • base type (int, float, etc)

This means that we can have a generic serializer than can write any (except C pointers) graph to a file

  • must keep sharing
  • must handle cycles

Both of these are easy with a 2 pass serializer, and a deserializer with a sharing table.

MicroHs uses an s-expression textual format. E.g.� 1 + 2 is ((+ #1) #2)let x = 1+2 in x*x is ((* :83 ((+ #1) #2)) _83)

43 of 47

Runtime system

  • Very few dependencies:
    • malloc, free, str*()
    • optional stdio
    • optional floating point math library
    • optional <unistd.h>
  • Prefers FFS (Find First Set) instruction for fast allocation
  • Easily portable
  • Reasonably small binaries
    • Combinator file size for hello-world: 720 bytes
    • Executable size for hello-world: 110k (yes, it's too big!)
    • mhs executable: 450k

44 of 47

Demo

module Blink where

import Control.Monad; import ESP32; import Morse

colorCycle = [ RGB 255 0 0, RGB 128 128 0, RGB 0 255 0,

RGB 0 128 128, RGB 0 0 255, RGB 128 0 128 ]

message = "MicroHs "

unit = 100 -- Time in ms for a dot

main = do led <- ledConfigure; blink led

blink led = zipWithM_ (send led) (cycle colorCycle) (cycle message)

send _ _ ' ' = delayMs (5 * unit) -- word gap (8 - 3)

send led rgb c = do

mapM_ (sendOne led rgb) (getMorseChar c)

delayMs (2 * unit) -- character gap (3 - 1)

sendOne led rgb m = do

ledRGB led rgb

delayMs $ unit * if m == '-' then 3 else 1

ledRGB led (RGB 0 0 0)

delayMs unit

45 of 47

Rant

  • Hackage is full of packages that requires GHC.
    • Number of packages: 17910
    • Number of packages not depending on template-haskell or ghc-prim: 1286
  • The cabal tool can only be compiled with GHC.
    • I wrote my own MicroCabal (really MicroStack)
  • Much work needed to use anything other than GHC.

46 of 47

Future work

Whatever I feel like, but a JIT would be cool.

I have a long list.

47 of 47

Questions