thedeemon: (Default)
Dmitry Popov ([personal profile] thedeemon) wrote2021-03-24 02:50 pm

A fast interpreter in < 90 lines

У меня новая игрушка. Недавно в рамках праздных раздумий о языкостроении пошел я с пустым ведром зачерпнуть мудрости у лиспоакадемиков. Стал смотреть на Racket (который вырос из PLT Scheme), почитал книжку Beautiful Racket и немного поигрался с описанными там инструментами. А тут еще как раз Racket 8 вышел с новым бэкендом. В предыдущих версиях там компилятор делал байткод, а тот уже в рантайме JITился. А в 8.0 они перешли на компилятор и бэкенд от Chez Scheme, где сразу нативный код генерится.
Сам Racket преподносится как платформа для создания языков. И стандартный рецепт, которому придерживается Beautiful Racket, это если захотел ты сделать свой бэйсик, то регистрируешь свой пакет basic из двух определенных частей (парсер и экспандер), потом пишешь исходник на бейсике, ставишь в нем первой строчкой #lang basic и используешь компилятор Racket. Тот видит первую строчку, достает твой пакет, скармливает остаток файла твоему парсеру (тут у тебя полная свобода по синтаксису), твой парсер производит parse tree, которое передается второй части твоего пакета, которая то дерево преобразует в дерево кода на самом Рэкете. Полученный код на Рэкете уже компилируется, и вот твой бэйсик и скомпилирован.
Но есть один момент, который та книжка почти не упоминает, но который позволяет не опираться на компилятор Рэкета. Дело в том, что в Рэкете есть eval, и вся машинерия компилятора уже содержится в твоем бинарнике. Можно сделать свой один бинарник, который будет парсить новый язык, также разворачивать его в конструкции Рэкета, и тут же выполнять через eval. Под капотом там генерация нативного кода, и скорость получается ничуть не хуже.
В качестве proof of concept сделаем интерпретатор, который сможет выполнять код такого вида:
fac(n) => if n < 2 then 1 else n * fac(n-1);

fib(n) => 
  if n < 2 then 1 
  else {
    a = fib(n-1);
    b = fib(n-2)
    in a + b 
  };

loop(x) => 
  if x < 20 then {
    print(fib(x), fac(x))
    in loop(x+1)
  } else 0;

loop(1)

Назовем его silly. Это младший брат языка SIL, что я на работе делаю, только в оригинале точки с запятой не нужны, но тут проще было парсер сделать с ними. Для парсинга используем библиотеку brag из книжки. Достаточно сделать вот такой файл grammar.rkt:
#lang brag
s-prog: s-stmt (/";" s-stmt)*
@s-stmt: s-assn | s-fundef | s-expr
s-funcall: NAME /"(" [s-expr (/"," s-expr)*] /")"
s-fundef:  NAME /"(" [NAME (/"," NAME)* ] /")" "=>" s-expr
s-block: /"{" [s-stmt (/";" s-stmt)* /"in"] s-expr /"}"
s-assn: NAME /"=" s-expr
s-expr: s-compterm | if
if: /"if" s-expr /"then" s-expr /"else" s-expr
s-compterm: s-term [("<" | "==") s-term]
s-term: [s-term ("+" | "-")] s-summand
s-summand: [s-summand ("*" | "/")] s-factor
@s-factor: s-funcall | s-block | NUMBER | s-name | "-" s-summand | s-parens
@s-parens: /"(" s-expr /")" 
s-name: NAME

Префиксы s- никто не заставлял делать, это я выбрал такую нотацию, чтобы в получаемых деревьях отличать узлы нашего AST от других конструкций. Если этот файл импортировать в нашу программу, то волшебным образом будет доступна функция parse, которая из потока токенов делает дерево нужного вида,
например "f(x,y) => { a = 1; b = 2 in x+b }" распарсится в s-expression
'(s-prog
  (s-fundef
   f
   x
   y
   "=>"
   (s-expr
    (s-compterm
     (s-term
      (s-summand
       (s-block
        (s-assn a (s-expr (s-compterm (s-term (s-summand 1)))))
        (s-assn b (s-expr (s-compterm (s-term (s-summand 2)))))
        (s-expr (s-compterm (s-term (s-term (s-summand (s-name x))) "+" (s-summand (s-name b))))))))))))

Но чтобы parse использовать, нужно ей передать лексер, который произведет поток токенов из входного потока, будь то строка или файл. Выглядит он так:
(define-lex-abbrev digits (:+ numeric))
(define-lex-abbrev reserved-terms
  (:or "+" "-" "*" "/" "=" "in" ";" "{" "}" "(" ")" "," "=>" "if" "then" "else" "==" "<"))

(define (tokenize ip)
    (port-count-lines! ip)
    (define my-lexer
      (lexer-srcloc
        [whitespace (token lexeme #:skip? #t)]
        [reserved-terms (token lexeme lexeme)]
        [(:seq digits "." digits)
           (token 'NUMBER (exact->inexact (string->number lexeme)))]
        [digits (token 'NUMBER (string->number lexeme))]        
        [(:seq alphabetic (:* (:or alphabetic numeric)))
           (token 'NAME (string->symbol lexeme))]
        [(eof) (void)] ))
    (lambda () (my-lexer ip)))

Тут простые комбинаторы, работающие как регекспы, матчат поток символов и для разных вариантов мы говорим, какой токен произвести.
Интерпретатор будет брать из командной строки имя файла с исходником на нашем языке, читать его и исполнять, заодно замеряя время исполнения (включая парсинг). Если имя файла не передали, выполним свою заготовку.
(require 'm)
(define ns (namespace-anchor->namespace anchor))

(define (run input)
  (define stx (parse (tokenize input)))
  (eval stx ns))

(define argv (current-command-line-arguments))
(if ((vector-length argv) . > . 0)
    (time (run (open-input-file (vector-ref argv 0))))
    (run (open-input-string "print(4, 6, 2+2*3)")))

Тут (parse (tokenize input)) производит AST дерево, a (eval stx ns) выполняет его в нэймспейсе ns, в котором все узлы вроде s-fundef, s-block, s-assn, s-expr и print имеют смысл, а ничего лишнего там не будет. Для этого опишем подмодуль, где эти штуки определены, и его задействуем в качестве оного нэймспейса. И здесь трюк в том, что s-fundef, s-block, s-assn и т.п. это будут макросы, которые будут раскрываться в код на Рэкете (возможно, через другие макросы). Используя макросоописательные средства из книжки, выглядит это так:
(module m br
  (provide anchor)
  (define-namespace-anchor anchor)  
  
  (define-macro-cases s-term
    [(_ VAL) #'VAL]
    [(_ LEFT "+" RIGHT) #'(+ LEFT RIGHT)]
    [(_ LEFT "-" RIGHT) #'(- LEFT RIGHT)])

  (define-macro-cases s-summand
    [(_ VAL) #'VAL]
    [(_ LEFT "*" RIGHT) #'(* LEFT RIGHT)]
    [(_ LEFT "/" RIGHT) #'(/ LEFT RIGHT)]
    [(_ "-" VAL) #'(- VAL)])

  (define-macro-cases s-compterm
    [(_ VAL) #'VAL]
    [(_ LEFT "<" RIGHT) #'(< LEFT RIGHT)]
    [(_ LEFT "==" RIGHT) #'(eq? LEFT RIGHT)])
  
  (define-macro (s-name X) #'X)
  (define-macro (s-expr X) #'X)
  (define-macro (s-prog STMT ...) #'(begin (s-top STMT) ... ))
  
  (define-macro-cases s-top
    [(s-top (s-assn V E)) #'(define V E)]
    [(s-top (s-fundef FUN VS ... "=>" E)) #'(define (FUN VS ...) E)]
    [(s-top (s-expr E)) #'E])

  (define-macro-cases s-block
    [(s-block E) #'E]
    [(s-block (s-assn VAR VAL) MORE ... E) #'(let ((VAR VAL)) (s-block MORE ... E))]
    [(s-block (s-fundef FUN VS ... "=>" VAL) MORE ... E)
       #'(begin (define (FUN VS ...) VAL) (s-block MORE ... E))]
    [(s-block (s-expr VAL) MORE ... E)
       #'(begin VAL (s-block MORE ... E))])
  
  (define-macro (s-funcall FUN ARGS ...) #'(FUN ARGS ...))  
  (define (print . xs) (displayln xs))
)

Макросы такой формы матчат деревья заданного вида (маленькими буквами и литералами обозначается то, что в дереве должно стоять на заданном месте, большими буквами - переменные, принимающие любое значение/поддерево), и внутри #' отдают новые деревья или значения. Переменная с троеточием после нее означает последовательность, и в дереве-ответе тоже может задавать последовательность, в том числе преобразованную. Так (s-prog a b c d) здесь превратится в (begin (s-top a) (s-top b) (s-top c) (s-top d)). А (s-top something) уже раскрывается в разные вещи в завимости от something. Тут у меня top-level определения функций и констант раскрываются не так, как внутри блока с кодом. Например, "a = 1" в top-level превратится в (define a 1), а внутри блока в (let ((a 1)) остаток-блока).
print здесь не макрос, а обычная функция, выводящая на экран все аргументы в виде списка. Нам не обязательно все макросами описывать, мы можем любые функции в неймспейс для нашего интерпретируемого языка добавлять.
Еще момент: в грамматике есть правило для if then else, но тут мы его не упоминаем, т.к. "if a then b else c" парсер вернет как (if a b c), что уже годное выражение для Рэкета.

Собственно, это вся реализация. Весь код в двух файлах, 72 и 15 строк.
Делаем бинарник командой raco exe -o silly.exe silly.rkt.
Запускаем
> silly.exe pi.txt
cpu time: 2609 real time: 2596 gc time: 125
3.1415926380318524

где pi.txt
f(n, res) => 
  if 200000000 < n then res 
                   else f(n+2, res*n*n/((n+1)*(n-1))); 
f(2, 2.0)

Цикл на 100 миллионов итераций с целочисленной и вещественной арифметикой, с работающей оптимизацией хвостовых вызовов. Работает 2.6 секунды. Аналогичный цикл на Питоне 3.7.5
res = 2.0
for n in range(2, 200000000, 2):
  res = res*n*n/((n+1)*(n-1))
print(res)

у меня работает 30 секунд, более чем в 10 раз медленнее.
Похожий цикл на Ruby 3.0
res = 2.0
n = 2
for i in 0..100000000
  res = res*n*n/((n+1)*(n-1))
  n += 2
end
print(res)

у меня работает 16 секунд. Заметно быстрее Питона, но наш интерпретатор в 90 строк все равно в разы быстрее.
До полноценно типизированного нативного кода еще далеко, конечно, скомпилированная программа на Хаскеле тот цикл делает за полсекунды, еще в 5 раз быстрее. Но для интерпретатора все ж неплохо.

Еще занятный момент. Когда говорят про лисп, первым делом вспоминают гомоиконность, что дескать он такой удобный для преобразования программ за счет того, что синтаксис кода совпадает с синтаксисом данных, этот код описывающих. Но в Рэкете от этого по сути ушли. Парсер в Рэкете производит отнюдь не обычные списки с атомами, числами, строками и другими списками, и макросы на входе и на выходе тоже имеют дело не с обычными списками и атомами. Парсер производит syntax object, и все макросы на входе и на выходе имеют syntax object, который хотя по структуре своей и повторяет структуру дерева кода, но является совершенно отдельным типом данных, с множеством дополнительной информации. В первую очередь о том, где что было в оригинальном исходнике, чтобы если вдруг какая ошибка (не только при парсинге, но и при разворачивании), не говорить "у вас все сломалось где-то", а показать где именно. Т.е. у нас есть обычные деревья из списков, а есть отдельный тип для AST. И разница с другими языками, где можно оперировать AST, уже не так велика.

Post a comment in response:

This account has disabled anonymous posting.
If you don't have an account you can create one now.
HTML doesn't work in the subject.
More info about formatting