Archive

Articles taggués ‘ocaml’

Un interpreteur / optimiseur de BrainFuck en OCaml

23/04/2012

Le langage BrainFuck est un langage ésotérique (comprendre par la qu’il ne sert pas a grand chose), minimaliste.

Il suppose une machine avec un ruban de mémoire, un pointeur sur une cellule de cette mémoire, et ne contient que 8 opérations, chacune représentée par un caractère dans le code source :

> incrémente le pointeur
< décrémente le pointeur
+ incrémente la valeur de la cellule sur laquelle est positionnée le pointeur
- décrémente la valeur de la cellule courante
. affiche la valeur ascii de la cellule courante
, lit un caractère et stocke sa valeur ascii dans la cellule courante
[ saute a l'instruction après le ] correspondant si la cellule courante est à 0.
] retourne au [ correspondant si la cellule courante est différente de 0.

En résumé, > et < font bouger un pointeur, + et - modifient la valeur de la cellule courante, . et , servent d'entrées-sorties, et [ ] fait une boucle.

A titre d’exemple, ce programme affiche “Hello World!” : hello.bf

Voici le code complet de l’interpréteur que cet article vise a expliquer.

Interpréteur basique, sans optimisations

Représentation des opérations basiques de BrainFuck

La première chose à faire, c’est de décider de la représentation interne des opérations. J’ai choisi de regrouper les opérations de déplacement et de modification, et de représenter [..] comme une sous liste d’opération.

  type operation =
    | Move of int 
    | Add of int
    | Output
    | Input
    | Loop of operation list

Conversion du code BF en arbre d’opérations

Maintenant, il nous faut un moyen de convertir le source BrainFuck en un arbre d’opérations, en utilisant le type que nous venons de définir. En dehors des boucles, la conversion est assez directe.

  let rec build_ast tokens =
    let rec loop tokens acc =
      match tokens with
      | [] -> acc
      | '>' :: rest -> loop rest (Move 1 :: acc)
      | '<' :: rest -> loop rest (Move (-1) :: acc)
      | '+' :: rest -> loop rest (Add 1 :: acc)
      | '-' :: rest -> loop rest (Add (-1) :: acc)
      | '.' :: rest -> loop rest (Output :: acc)
      | ',' :: rest -> loop rest (Input :: acc)
      | '[' :: rest ->
          let sublist, rest = build_loop tokens in
          let cond = build_ast sublist in
          loop rest ((Loop cond) :: acc)
      | ']' :: rest -> failwith "Close should have been consumed by build_loop"
      | _ :: rest -> loop rest acc
    in
    List.rev (loop tokens [])

Je n’ai pas cité la fonction build_loop pour des raisons de place, mais elle est disponible dans le source complet.

Interprétation dans une machine virtuelle basique, avec sa mémoire fixe et son unique pointeur

Voilà notre arbre d’opérations, il nous reste à l’exécuter.

On utilise une “machine virtuelle”, avec sa mémoire et son pointeur :

  let memory = Array.make 30_000 0
  let pointer = ref 0

Par simplification, la mémoire est limitée a 30000 cellules, sous forme de tableau plutôt que de ruban.

Les fonctions de base, qui correspondent aux opérations de BF :

  let move i = pointer := !pointer + i
 
  let add i = memory.(!pointer) <- memory.(!pointer) + i
 
  let output () = Printf.printf "%c%!" (char_of_int memory.(!pointer))
 
  let input () =
    let c = input_char stdin in
    memory.(!pointer) <- int_of_char c

Et enfin, l’exécution d’une liste d’opérations, obtenue à partir du source BF.

  let rec exec ast =
    let exec_node = function
      | Move i -> move i
      | Add i -> add i
      | Output -> output ()
      | Input -> input ()
      | Loop nodes ->
          while memory.(!pointer) <> 0 do
            exec nodes;
          done
    in
    List.iter exec_node ast

L’interprete basique est terminé. Il n’est pas particulièrement rapide, mais il fonctionne.

Voyons comment améliorer tout ca.

Optimisations simples

Il est possible de faire une optimisation simple, sans changer notre type opération et en conservant la structure du programme a l’identique.

Regroupement d’opérations similaires
Avec une traduction simple, “+++” devient [Add(1);Add(1),Add(1)]. En les regroupant, on peut n’avoir qu’une seule instruction, Add(3). La même chose est possible pour les instructions Move.

  let rec group = function
    | Move a :: Move b :: rest ->
        let lst = if a + b <> 0 then Move (a + b) :: rest else rest in
        group lst
    | Add a :: Add b :: rest ->
        let lst = if a + b <> 0 then Add (a + b) :: rest else rest in
        group lst
    | Loop a :: rest -> (Loop (group a)) :: (group rest)
    | other :: rest -> other :: (group rest)
    | [] -> []

Et tant qu’a faire, on se débrouille pour ne pas conserver de Add(0), ce qui serait un peu dommage.

Optimisations avancées

On a besoin d’ajouter un type macro, et un constructeur Macro au type “operation” défini précédemment

  type macro =
    | Set of int * int
    | AddMultToCell of int * int
    | AddMultToCell2 of int * int * int
    | CopyMultToCell of int * int
    | AddTo of int * int
 
  type operation =
     ...
     | Macro of macro

Voici ce qu’elles représentent :

Set(n, i) Ecrit la valeur n dans la cellule distante de i
AddMultToCell(n, i) Ajoute (n*valeur courante) a la cellule distante de i
AddMultToCell2(n, i, j) Ajoute (n*valeur courante) aux cellules distantes de i et j
AddTo(n, i) Ajoute n a la cellule distante de i

Les programmes BrainFuck contiennent fréquemment des séquences similaires, que nous allons remplacer par ces macros.

Remplacement de boucles courantes par des macros

Commençons par les boucles. Celles-ci sont :

[Add -1] Met la valeur de la cellule courante a 0
[Move i, Add n, Move -i, Add -1] Ajoute n*(valeur courante) a la cellule distante de i
[Move i, Add n, Move j, Add n, Move -(i+j), Add -1) Ajoute n*(valeur courante) aux cellules distantes de i et (i+j)
[Set (n, i), Add -1] Met la cellule distante de i a la valeur n si la cellule courante est différente de 0, puis met la cellule courante a 0

Passer de la boucle a sa signification sémantique, puis sa macro, n’est pas forcément évident.
Si vous ne voyez pas le lien entre la boucle et sa description, ca peut valoir le coup de creuser.

Une fois la relation entre les opération brainfuck et ce que le programme essaye d’accomplir établie, on peut enfin les remplacer par nos macros.

  let rec unroll ast =
    let replace = function
      | [Add (-1)] -> Macro (Set (0, 0))
      | [Move a; Add n; Move b; Add (-1)]
      when a = - b -> Macro (AddMultToCell (n, a))
      | [Move a; Add n1; Move b; Add n2; Move c; Add (-1)]
      when a + b = - c && n1 = n2 -> Macro (AddMultToCell2 (n1, a, a + b))
      | [Macro (Set (n, i)); Add (-1)] -> Loop [Macro (Set (n, i)); Macro (Set (0, 0))]
      | other -> Loop (unroll other)
    in
    match ast with
    | Loop ops :: rest -> replace ops :: unroll rest
    | other :: rest -> other :: (unroll rest)
    | [] -> []

Exécution directe, sans déplacement préalable
C’est bon pour les boucles, mais il reste d’autres séquences qu’on peut réduire.

On retrouve souvent la séquence [Move i, Action, Move -i]. Si on peut le remplacer par une action distante, on gagne deux déplacements.

  let in_place ast =
    let rec loop = function
      | Move i :: Add n :: Move j :: rest
        when i = - j -> Macro (AddTo (n, i)) :: loop rest
      | Move i :: Add n :: Move j :: rest -> 
        Macro (AddTo (n, i)) :: loop (Move (i+j) :: rest)
      | Move i :: Macro (Set (n, j)) :: Move k :: rest 
        when i = -k -> Macro (Set (n, i)) :: loop rest
      | Loop ops :: rest -> Loop (loop ops) :: loop rest
      | other :: rest -> other :: loop rest
      | [] -> []
    in loop ast

Et voila le travail.

Retour sur les regroupements
On avait déjà regroupé les Add et Move (c’était notre première optimisation).
Il est aussi possible de regrouper les opérations impliquant nos macros, dans une deuxième passe.

  let rec group = function
    ...
    | Add a :: Macro (Set (n, 0)) :: rest -> group (Macro (Set (n, 0)) :: rest)
    | Macro (Set (_, i)) :: Macro (Set (n, j)) :: rest 
      when i = j -> group (Macro (Set (n, j)) :: rest)
    | Macro (Set (n, 0)) :: Add a :: rest -> group (Macro (Set (n+a, 0)) :: rest)
    ...

Et notre optimisation va s’arreter ici. Il reste possible de faire d’autres macros pour d’autres séquences communes, mais cela n’apporterait pas grand chose d’un point de vue pédagogique.

Le résultat

Le code complet de l’interpreteur.
Le programme benchmark, qui calcule des nombres premiers : prime.bf

Sans optimisation

$ time (echo 99 | ./brainfuck.native prime.bf)
Primes up to: 2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 

real	0m1.871s
user	0m1.868s
sys	0m0.000s

Avec optimisations

$ time (echo 99 | ./brainfuck.native -optimize prime.bf)
Primes up to: 2 3 5 7 11 13 17 19 23 29 31 37 41 43 47 53 59 61 67 71 73 79 83 89 97 

real	0m0.064s
user	0m0.056s
sys	0m0.004s

En gros, ca va 30x plus vite. Si seulement tout pouvait s’optimiser aussi facilement !

A vous de jouer

Grace au projet js_of_ocaml, j’ai pu porter le compilateur en javascript. Il est utilisable ici.

Développement ,