thedeemon: (office)
[personal profile] thedeemon
Несколько дней назад закончилось соревнование ICFP Contest 2009, мой небольшой отчет об участии в котором можно почитать здесь. Там в задании была описана несложная виртуальная машина, на которой запускались данные организаторами программы для моделирования орбитальной механики. Тут я расскажу, как, используя возможности функционального языка программирования, можно заметно ускорить интерпретатор.

Update: Это НЕ попытка сравнить скорости С++ и Окамла. Это НЕ попытка описать самый быстрый подход к интерпретации. Это просто техническая часть отчета об ICFPC с рассказом о примененном мною там приеме.
ВМ имеет память из 16384 вещественных чисел и программу не большей длины. Программа состоит из последовательности команд, выполняющихся по порядку, без ветвлений. Команды бывают без аргументов, с одним или с двумя аргументами. Для большинства команд результат выполнения (вещественное число) записывается в ячейку памяти с номером, равным номеру команды в программе. Аргументы берутся из памяти, их адреса закодированы в одном двойном слове с командой. Есть одноаргументные операции сравнения с нулем, их результат (истина или ложь) сохраняется в неком регистре статуса, который потом используется в команде выбора, записывающей в свою ячейку содержимое либо первого аргумента, либо второго, в зависимости от текущего значения статуса.
С учетом способа кодирования инструкций и аргументов, интерпретатор этой ВМ на С++ выглядит так:

  void run()
  {
    for(int ip=0; ip<prglen; ip++)   {
      const int dop = prg[ip]>>28;
      const int dr1 = (prg[ip] >> 14) & 0x3FFF; 
      const int dr2 = prg[ip] & 0x3FFF;
      switch(dop) {
        case 1: mem[ip] = mem[dr1] + mem[dr2]; break;
        case 2: mem[ip] = mem[dr1] - mem[dr2]; break;
        case 3: mem[ip] = mem[dr1] * mem[dr2]; break;
        case 4: mem[ip] = (mem[dr2]==0.0) ? 0.0 : mem[dr1] / mem[dr2]; break;
        case 5: out[dr1] = mem[dr2]; break;
        case 6: mem[ip] = status ? mem[dr1] : mem[dr2]; break;
        case 0:  { 
            const int sop = prg[ip] >> 24;
            const int imm = (prg[ip] >> 21) & 7;
            const int sr1 = dr2;
            switch(sop) {          
              case 0: break;
              case 1: switch(imm) {
                    case 0: status = mem[sr1] < 0; break;
                    case 1: status = mem[sr1] <= 0; break;
                    case 2: status = mem[sr1] == 0; break; 
                    case 3: status = mem[sr1] >= 0; break; 
                    case 4: status = mem[sr1] > 0; break; 
                    default: printf("bad imm=%d ip=%d\n", imm, ip); return;
                  } break;
              case 2: mem[ip] = sqrt(mem[sr1]); break;
              case 3: mem[ip] = mem[sr1]; break;
              case 4: mem[ip] = inp[sr1]; break;
              default: printf("bad sop=%d ip=%d\n", sop, ip); return;
            }
            }
            break;
        default: printf("bad dop=%d ip=%d\n", dop, ip); return;
      } 
    }
  }

Такая реализация на моей рабочей машинке 2,33 ГГц выполняет первую программу из 266 команд 377 000 раз в секунду. Но при участии в ICPFC я все писал на OCaml'e. На нем систему команд ВМ я описал в виде алгебраического типа, расшифровку команд и аргументов вынес отдельно, и интерпретатор выглядел так:

let eval_instr ip vm = function
  | Add(r1,r2) -> vm.mem.(ip) <- vm.mem.(r1) +. vm.mem.(r2)
  | Sub(r1,r2) -> vm.mem.(ip) <- vm.mem.(r1) -. vm.mem.(r2) 
  | Mul(r1,r2) -> vm.mem.(ip) <- vm.mem.(r1) *. vm.mem.(r2)
  | Div(r1,r2) -> let v2 = vm.mem.(r2) in vm.mem.(ip) <- if v2=0.0 then 0.0 else vm.mem.(r1) /. v2 
  | Out(r1,r2) -> vm.outports.(r1) <- vm.mem.(r2)
  | Phi(r1,r2) -> vm.mem.(ip) <- if vm.status then vm.mem.(r1) else vm.mem.(r2)
  | Nop -> ()
  | Cmpz(LTZ, r1) -> vm.status <- vm.mem.(r1) < 0.0
  | Cmpz(LEZ, r1) -> vm.status <- vm.mem.(r1) <= 0.0
  | Cmpz(EQZ, r1) -> vm.status <- vm.mem.(r1) = 0.0
  | Cmpz(GEZ, r1) -> vm.status <- vm.mem.(r1) >= 0.0
  | Cmpz(GTZ, r1) -> vm.status <- vm.mem.(r1) > 0.0
  | Sqrt r1 -> vm.mem.(ip) <- if vm.mem.(r1) >= 0.0 then sqrt vm.mem.(r1) else 0.0
  | Copy r1 -> vm.mem.(ip) <- vm.mem.(r1)
  | Inp r1 -> vm.mem.(ip) <- vm.inports.(r1);;

let interpret vm = 
  for ip = 0 to vm.prglen - 1 do
    eval_instr ip vm vm.prg.(ip)
  done;
  vm.time <- vm.time + 1;
  if vm.outports.(0)>0.0 && vm.scoretime=0 then vm.scoretime <- vm.time;;


Тут после собственно исполнения программы идет увеличение счетчика времени и проверка на выполнение задания, в варианте на С++ их нет. Такой интепретатор на Окамле наглядней, меньше подвержен ошибкам, и написался очень быстро, но работал несколько медленнее: только 263 000 итераций в секунду. Захотелось его ускорить, и были сделаны две оптимизации.

Оптимизация первая.
В исходном виде интерпретатора у нас в цикле крутится switch. Но от него можно избавиться, вынеся его за пределы цикла. Для этого мы каждую команду программы превратим в функцию. Она будет выполнять предписанное командой действие и передавать управление следующей такой функции. Вместо пробегания по программе в цикле мы будем вызывать функцию для первой команды, она - функцию для второй и т.д. При "компиляции" каждой команды нам нужно иметь уже "скомпилированную" функцию для следующей, поэтому эту псевдокомпиляцию будем проводить с конца программы. Самой последней команде в качестве такой функции-продолжения мы дадим функцию, выполняющую действия, которые нужно сделать в конце каждой итерации - увеличение счетчика времени и проверка очков. У ВМ есть особая инструкция Noop (у меня она обозначена более привычно Nop), которая ничего не делает. Для нее новой функции создаваться не будет, а просто полученное продолжение будет передаваться дальше. Благодаря этому все nop'ы и их последовательности будут вообще бесплатны. Отдельным образом будут "компилироваться" команды сравнения и выбора. Они всегда идут подряд парами - сначала сравнение аргумента с нулем Cmp*, затем выбор результата Phi. По сути это одна трехаргументная команда. Поэтому последовательность этих двух команд будет "компилироваться" в одну функцию. Для этого результатом "компиляции" Phi (которая в программе идет позже, поэтому "компилируется" раньше) будет не функция-продолжение, а структура с ее аргументами. При компиляции команды сравнения эта структура будет использоваться для создания функции, объединяющей в себе Cmp* и Phi.
Опишем тип результата псевдокомпиляции. Это или функция-продолжение, или аргументы Phi и ее продолжение:

type comp_res = Cont of (unit -> unit) | Phiargs of int * int * int * (unit -> unit);;   

Псевдокомпиляция команды ВМ в такой результат делается просто:

let comp_instr ip vm ins nxt = 
  match ins, nxt with
  | Add(r1,r2), Cont k -> Cont(fun () -> vm.mem.(ip) <- vm.mem.(r1) +. vm.mem.(r2); k ())    
  | Sub(r1,r2), Cont k -> Cont(fun () -> vm.mem.(ip) <- vm.mem.(r1) -. vm.mem.(r2); k ())    
  | Mul(r1,r2), Cont k -> Cont(fun () -> vm.mem.(ip) <- vm.mem.(r1) *. vm.mem.(r2); k ())    
  | Div(r1,r2), Cont k -> Cont(fun () -> 
      let v2 = vm.mem.(r2) in vm.mem.(ip) <- if v2=0.0 then 0.0 else vm.mem.(r1) /. v2; k ())  
  | Out(r1,r2), Cont k -> Cont(fun () -> vm.outports.(r1) <- vm.mem.(r2); k ())    
  | Phi(r1,r2), Cont k -> Phiargs(r1, r2, ip, k)
  | Nop, Cont k -> Cont k 
  | Cmpz(LTZ, r1), Phiargs(p1,p2, pip, k) -> Cont(fun () -> 
      vm.mem.(pip) <- if vm.mem.(r1) < 0.0 then vm.mem.(p1) else vm.mem.(p2); k ())
  | Cmpz(LEZ, r1), Phiargs(p1,p2, pip, k) -> Cont(fun () -> 
      vm.mem.(pip) <- if vm.mem.(r1) <= 0.0 then vm.mem.(p1) else vm.mem.(p2); k ())
  | Cmpz(EQZ, r1), Phiargs(p1,p2, pip, k) -> Cont(fun () -> 
      vm.mem.(pip) <- if vm.mem.(r1) = 0.0 then vm.mem.(p1) else vm.mem.(p2); k ())
  | Cmpz(GEZ, r1), Phiargs(p1,p2, pip, k) -> Cont(fun () -> 
      vm.mem.(pip) <- if vm.mem.(r1) >= 0.0 then vm.mem.(p1) else vm.mem.(p2); k ())
  | Cmpz(GTZ, r1), Phiargs(p1,p2, pip, k) -> Cont(fun () -> 
      vm.mem.(pip) <- if vm.mem.(r1) > 0.0 then vm.mem.(p1) else vm.mem.(p2); k ())
  | Sqrt r1, Cont k -> Cont(fun () -> vm.mem.(ip) <- if vm.mem.(r1) >= 0.0 then sqrt vm.mem.(r1) else 0.0; k ())
  | Copy r1, Cont k -> Cont(fun () -> vm.mem.(ip) <- vm.mem.(r1); k ())
  | Inp r1,  Cont k -> Cont(fun () -> vm.mem.(ip) <- vm.inports.(r1); k ())
  | _, _ -> Printf.printf "compile error: ip=%d\n" ip; failwith "comp_instr";;  

А псевдокомпиляция всей программы будет выглядеть так:

let comp_prg vm = 
  let rec loop ip nxt = 
    if ip < 0 then nxt else
    let ci = comp_instr ip vm vm.prg.(ip) nxt in
    loop (ip-1) ci  in
  match loop (vm.prglen-1) (Cont(fun ()-> 
    vm.time <- vm.time + 1;  if vm.outports.(0)>0.0 && vm.scoretime=0 then vm.scoretime <- vm.time)) 
  with Cont k -> k  | Phiargs _ -> failwith "comp_prg: Phiargs returned";;  

Функция comp_prg получает на вход структуру ВМ, содержащую программу и память, а на выход выдает функцию, которая выполняет один проход программы данного экземпляра ВМ. Ее можно вызывать вместо interpret vm, и скорость получается уже 417 000 итераций в секунду, т.е. уже быстрее, чем реализация ВМ на С++. Но и это не предел.

Оптимизация вторая.
На каждой итерации ВМ читает какие-то данные из входных портов, производит вычисления, зависящие от них и от состояния памяти, пишет что-то в память и выдает что-то на выходные порты. В данном случае на входе были моментальные изменения скорости спутника, а на выходе его координаты, остаток топлива и другие данные. Большую часть времени спутник просто летит по орбите без использования двигателей, т.е. значения входных портов для большинства итераций одни и те же. Это можно использовать и убрать из цикла вычисления, зависящие от неизменяющихся данных, записав вместо них Nop'ы с уже посчитанным результатом. Алгоритм оптимизации очень прост. Заводим массив булевых значений, показывающих для каждой команды является ли ее результат константой при условии константности входов. Изначально это истина для команд Input и Nop и ложь для всех остальных. Дальше проходим по программе и для каждой операции кроме Output смотрим не являются ли константами ее аргументы. Если да, то вычисляем результат операции, записываем в нужную ячейку памяти, а команду заменяем на Nop, пометив ее как константу. Так делаем несколько раз: на каждом проходе число константных операций или увеличивается (когда заменили очередную команду) или не изменяется (когда больше уже нечего заменить). Если после прохода ничего не изменилось, значит мы попали в Fixed Point и оптимизация готова, возвращаем новый экземпляр ВМ с новой программой и новым содержимым памяти:

let optimize vm = 
  let prg = Array.sub vm.prg 0 (vm.prglen) in  
  let propagate cons mem p = 
    let con = Array.copy cons in
    let p' = p |> Array.mapi (fun ip ins ->    
      match ins with 
      | Add(r1,r2) -> if con.(r1) && con.(r2) then (mem.(ip) <- mem.(r1) +. mem.(r2); con.(ip)<-true; Nop) else ins 
      | Sub(r1,r2) -> if con.(r1) && con.(r2) then (mem.(ip) <- mem.(r1) -. mem.(r2); con.(ip)<-true; Nop) else ins
      | Mul(r1,r2) -> if con.(r1) && con.(r2) then (mem.(ip) <- mem.(r1) *. mem.(r2); con.(ip)<-true; Nop) else ins
      | Div(r1,r2) -> if con.(r1) && con.(r2) then 
                        (mem.(ip) <- if mem.(r2)=0.0 then 0.0 else mem.(r1) /. mem.(r2); con.(ip)<-true; Nop) else ins 
      | Out(r1,r2) -> ins
      | Phi(r1,r2) -> if ip>0 then begin
                        match prg.(ip-1) with 
                        | Cmpz _ ->  if con.(ip-1) then
                                      if mem.(ip-1) > 0.0 then Copy r1 else Copy r2
                                    else ins
                        | _ -> ins
                      end  else ins
      | Nop -> ins
      | Cmpz(LTZ, r1) -> if con.(r1) then (mem.(ip) <- if mem.(r1) < 0.0 then 1.0 else 0.0; con.(ip)<-true; Nop) else ins
      | Cmpz(LEZ, r1) -> if con.(r1) then (mem.(ip) <- if mem.(r1) <= 0.0 then 1.0 else 0.0; con.(ip)<-true; Nop) else ins
      | Cmpz(EQZ, r1) -> if con.(r1) then (mem.(ip) <- if mem.(r1) = 0.0 then 1.0 else 0.0; con.(ip)<-true; Nop) else ins
      | Cmpz(GEZ, r1) -> if con.(r1) then (mem.(ip) <- if mem.(r1) >= 0.0 then 1.0 else 0.0; con.(ip)<-true; Nop) else ins
      | Cmpz(GTZ, r1) -> if con.(r1) then (mem.(ip) <- if mem.(r1) > 0.0 then 1.0 else 0.0; con.(ip)<-true; Nop) else ins
      | Sqrt r1 -> if con.(r1) then (mem.(ip) <- if mem.(r1) >= 0.0 then sqrt mem.(r1) else 0.0; con.(ip)<-true; Nop) else ins
      | Copy r1 -> if con.(r1) then (mem.(ip) <- mem.(r1); con.(ip)<-true; Nop) else ins
      | Inp r1 -> mem.(ip) <- vm.inports.(r1); con.(ip)<-true; Nop) in
    con, p'   in
  let mem = Array.copy vm.mem in
  let rec loop p con =
    let con', p' = propagate con mem p in
    if con' = con && p = p' then p, con else loop p' con'  in 
  let const = prg |> Array.map (function Nop | Inp _ -> true | _ -> false) in
  let p, con = loop prg const  in
  { prg = p; prglen = vm.prglen; mem = mem; 
    inports = Array.copy vm.inports; outports = Array.copy vm.outports; status = vm.status;
    time = vm.time; scenario = vm.scenario; log = vm.log; scoretime = vm.scoretime  };;      

Такая оптимизация на первой задаче, например, увеличивает количество пустых команд с 11% до 39%. Оптимизированная программа подвергается описанной выше псевдокомпиляции, и скорость возрастает до 650 000 итераций в секунду, что в 1,7 раза быстрее варианта на С++ и в 2,5 раза быстрее исходного интепретатора на Окамле. Если наш спутник меняет траекторию, то в этом момент используется неоптимизированный вариант, а после того, как он ляжет на новую орбиту с выключенным двигателем, текущий экземпляр ВМ проходит оптимизацию и опять получаем быстрый ваприант уже с новыми значениями.

Date: 2009-07-04 05:52 pm (UTC)
From: [identity profile] nealar.livejournal.com
Красиво.
Но странно, что связка через продолжения быстрей перебора в цикле.

Date: 2009-07-04 07:28 pm (UTC)
From: [identity profile] thedeemon.livejournal.com
Тут все вызовы хвостовые, они в jmp превращаются. А в цикле были бы не хвостовые, пришлось бы туда-сюда вызывать-возвращаться.

логично

Date: 2009-07-05 08:55 am (UTC)
From: [identity profile] nealar.livejournal.com
отдельные операции ВМ - они коротенькие, к каждой цепляется стековый пролог/эпилог + call/ret. Получается заметно. И как я сам не сообразил?

Date: 2009-07-06 02:52 am (UTC)
From: [identity profile] thedeemon.livejournal.com
По почте пришло уведомление об ответе от dmzlj, но самого сообщения тут я не вижу. Или глюк ЖЖ, или ответ был удален автором.
На всякий случай отвечу: мне кажется, там в compile_and_run_vm ошибка, fold_left чего-то не то собирает.

Date: 2009-07-06 09:15 am (UTC)
From: [identity profile] thornik.livejournal.com
Интересная реализация, но мне кажется, что и "обычный Сипипи" можно оптимизировать.

По "наглядности" могу только сказать, что японский для японцев тоже нагляден, но большинству проще понимать испанский. :) (т.е. сипипи)

А у вас есть тестовая программа, где вы получили эти 650 попугаев? Хочу потренироваться, может удав и не такой длинный... :)

Date: 2009-07-06 10:12 am (UTC)
From: [identity profile] thedeemon.livejournal.com
Вот эта программа (бинарник для ВМ):
http://www.icfpcontest.org/binaries/bin1.obf

Описание ВМ и формата бинарников:
http://www.icfpcontest.org/task-1.9.pdf

Date: 2009-07-06 10:45 am (UTC)
From: [identity profile] thornik.livejournal.com
Ага, спасибки!
Вопрос по первой оптимизации: я правильно понимаю, что вы меняете порядок обработки инструкций на обратный, что, вообще говоря, не обязательно допустимо? (скажем, инструкции подаются с обычной ленты)

По второй оптимизации: в правилах нигде не оговорена возможность ИЗМЕНЕНИЯ инструкций (как общий случай их переупорядочивания из первой оптимизации). Как быть? :)

Date: 2009-07-06 11:16 am (UTC)
From: [identity profile] thedeemon.livejournal.com
Нет, в первой порядок исполнения у меня не меняется. Просто "компиляция" идет с конца к началу.

Вторая оптимизация - это специализация, когда в программу подставляются известные данные, и ряд вычислений выносится в compile-time.

Date: 2009-07-06 11:53 am (UTC)
From: [identity profile] thornik.livejournal.com
Да, я не совсем определённо выразился. _ДОСТУП_ к инструкциям в первой оптимизации всё же обратный? (от конца к началу)

Идея второй оптимизации понятна - memoization. Но ведь вы пишете:
> вычисляем результат операции, записываем в нужную ячейку памяти, а команду заменяем на Nop, пометив ее как константу.

Так меняются команды или нет? Или виртуальная машина настолько виртуальна, что творит всё, что хочет? :)

Date: 2009-07-06 03:01 pm (UTC)
From: [identity profile] thedeemon.livejournal.com
Да, в процессе "компиляции" доступ в обратном порядке.
Да, во время второй оптимизации идет по сути оптимизация самой программы, а не ВМ. Правила ни то, ни другое не запрещают, они вообще ничего не запрещают. :) Многие делали кодогенерацию в С.

Date: 2009-07-06 03:35 pm (UTC)
From: [identity profile] thornik.livejournal.com
Я бы сказал, для "спутника" это слишком мягкие правила... :)
В любом случае, тут скорее важна сама техника оптимизации, нежели требования реальной жизни, мне понравился материал! Спасибо.

А можно пару дурацких вопросов? (Дмитрий, я так понимаю?)
У меня тут появилась идея обучиться ОКамлу, особенно в свете его возможностей, но мне он нужен с практической перспективой переписать на него нашу систему. И тут появляются требования, на которые я не уверен, что ОКамл потянет. Как у Окамла обстоят дела с SSL, SHA512, MS SQL Server (ну или хотя бы ODBC), GUI, TCP/IP? ГУЙ нужен хотя бы под Винду&Линукс, ОЧЕНЬ желательно с нормальным дизайнером (а-ля Delphi/VS). И есессно, хорошо бы, чтобы эти возможности были в актуальном состоянии (т.е. постоянно обновлялись и работали без бубнов/гемороя). Желательно иметь и развитую Web-составляющую (веб-сервисы, ASP). Я был бы весьма признателен за освещение этого вопроса (это на целый пост тянет), да и другим новичкам интересно - прежде чем прыгать в новые дебри, хорошо бы заранее очертить деревья. :)
Заранее спасибо за любую помощь!

PS
Я не убогий, гуглом тоже гуглил, но как-то тихо там у вас. :)

Date: 2009-07-07 07:35 am (UTC)
From: [identity profile] thedeemon.livejournal.com
Ага, Дмитрий.
Подробно осветить вопрос не смогу, т.к. большую часть из перечисленного я сам в Окамле не использовал. В целом, библиотеки для всего (или почти всего) этого есть, и в unix'e даже несложно ставятся, а вот под виндой нередко нужен бубен. Сам Окамл многоплатформенный, есть 32- и 64-битные варианты. Для гуя есть GTK, нормально работающий и под виндой, и под линуксом. Для него и дизайнер какой-то был, но не шибко интегрированный с IDE. Есть какая-то поддержка WinAPI и даже объектно-ориентированная надстройка над ней для виндовых гуев, но последняя устарела. TCP/IP поддерживается из коробки на уровне POSIX'a, более специфичные вещи могут потребовать дописать кое-что на С, благо связь с С есть (столкнулся, когда нужно было сокету хитрое свойство выставить).
Для веба есть навороченные решения типа Ocsigen, включающего в себя вебсервер с кучей фич и фреймворк, где система типов обеспечивает валидность xhtml. Но не знаю, насколько легко его поднять под виндой. Есть и более простые решения.
В целом, стоит смотреть тут:
http://forge.ocamlcore.org/

В плане IDE мой выбор - OcaIDE, плагин для Эклипса.

Пользовательская база у Окамла очень небольшая, поэтому количество и качество библиотек не сравнить с промышленными языками типа джавы и питона. Кроме того, большая часть из тех, кто пишет для Окамла библиотеки, обычно живет в Unix'e, и в Windows они не всегда работают или требует плясок с бубном.

Я для себя писал на Окамле утилиты, работающие с файлами и сетью (в том числе с простым веб-интерфейсом). Такие писать на Окамле под виндой (а я обычно в ней) несложно.

Date: 2009-07-07 08:08 am (UTC)
From: [identity profile] thornik.livejournal.com
Спасибо, Дмитрий! Весьма познавательно...
Это очень хреновая ситуация, замкнутый круг: новички не юзают ОКамл ввиду отсутствия библиотек, библиотек нету ввиду малой юзерской базы. Кстати, та же самая ситуация с D, но он хотя бы "Си-подобный"...

Винда мне нужна для клиентского ПО, но его можно писать на чём угодно, просто я хотел унифицированную базу - на едином языке. А сервер как раз наоборот, крайне желательно под Линукс. Если у Окамла либы ставятся хотя бы на уровне rpm или ppm, было бы прекрасно.
Ну что же, "будем искать!" (ц), спасибо за наводку. :)

А самих ОКамлов(реализаций) много? Есть какой-то явный фаворит? Я тут недавно Редхат поставил, там шёл ОКамл 3.11 - это юзабельное?

Date: 2009-07-07 08:08 am (UTC)
From: [identity profile] antilamer.livejournal.com
А мы просто написали на хаскелле+питоне программу, компилировавшую VM в код на си. Не мерили, но быстрее вроде тупо некуда.

Date: 2009-07-07 09:00 am (UTC)
From: [identity profile] maard.livejournal.com
вопрос по окамлу, не совсем понял, что тут происходит:
  Add(r1,r2), Cont k -> Cont(fun () -> vm.mem.(ip) <- vm.mem.(r1) +. vm.mem.(r2); k ())
встречая Add, мы создаем continuation, состоящий из 2-х операций - создания анонимной функции, реализующей Add и вызова следующей операции. не понял, в какой момент будет вызвана сама fun(). или запись fun () -> ... является не декларацией, а вызовом?

Date: 2009-07-07 09:03 am (UTC)
From: [identity profile] cd-riper.livejournal.com
Плюсовый код это настоящий си, с горячо любимым printf'ом.
Введите enum в плюсовый код, и он будет такой же "наглядный", как на OCalm.

Главное. Как я понимаю, все эти оптимизации можно спокойно внести и в плюсовый код, от чего он станет снова быстрее OCalm'а.

Date: 2009-07-07 09:06 am (UTC)
From: [identity profile] thedeemon.livejournal.com
Реализация одна, от французского института INRIA. 3.11 - вроде последняя версия, должна быть юзабельной. Я пока пользуюсь 3.10.

Говорят, F# от MS имеет режим совместимости с Окамлом и может его компилять под .NET, но насколько я понимаю, он все-таки не все поддерживает, в частности там не было функторов (параметризованные модули, что-то типа generic'ов).

Есть еще родственные языки, например SML с несколькими реализациями и AliceML, описание которого обещает много вкусного, но я сам не пробовал.

Date: 2009-07-07 09:14 am (UTC)
From: [identity profile] thedeemon.livejournal.com
Ага, так многие делали. Некоторые еще и оптимизировали при этом: http://pastebin.com/f7da69d
тут

Date: 2009-07-07 09:20 am (UTC)
From: [identity profile] thedeemon.livejournal.com
Вторая действительно переносится элементарно. А вот первую (с созданием цепочки замыканий) на С++ я бы посмотрел.

Date: 2009-07-07 10:15 am (UTC)
From: [identity profile] cd-riper.livejournal.com
> А вот первую (с созданием цепочки замыканий) на С++ я бы посмотрел.

Не дословно, но идею реализовать можно.

Смысла только нет, т.к. самый быстрый способ это все одно кодогенерация.

Date: 2009-07-07 10:18 am (UTC)
From: [identity profile] thedeemon.livejournal.com
Если на входе операция Add и нам дано продолжение k, полученное от "компиляции" остальной части программы, то вернуть функцию-продолжение, которая производит сложение и запись результата и вызывает k, т.е. переходит к следующей операции (а та - к следующей, и так до конца).
В comp_instr эти функции только создаются, но не выполняются. Выполняются они позже, когда из основной программы будет вызвана функция, которую comp_instr нам вернула.
"fun () -> ..." - декларация анонимной функции с одним параметром типа unit (он же void в других языках), обозначаемым как ().

Date: 2009-07-07 10:39 am (UTC)
From: [identity profile] cd-riper.livejournal.com
без прямой кодогенерации, все что можно выиграть -- ускорить битовый разбор команды + убрать switch.

Делается проход, который создает список объектов-команд с виртуальным методом Execute(), под которые создаются конкретные классы-команды.

Получаемый выигрыш -- один косвенный вызов vs два свича + битовые операции.

Date: 2009-07-07 02:56 pm (UTC)
From: [identity profile] gds.livejournal.com
ssl -- библиотека ocaml-ssl).
sha512 -- вроде есть, стоит посмотреть в cryptokit или что-то вроде, но не уверен. В любом случае, при наличии сишной реализации хеширования несложно оформить для одной несложной функции окамловский интерфейс.
Биндинги к ms sql не видел, но, если там сишные или com, то всё реально.
odbc точно умеет, но не вспомню, как именно.
Гуй -- штатный tcl/tk (библиотека labltk), можно также gtk2 взять (lablgtk2). В обоих -- layout-based gui, там не нужны дебильные проставления координат в пикселях для каждого элемента. Впрочем, для layout'ов тоже иногда нужен визуальный редактор -- для gtk2 есть glade, но результаты работы glade не пробовал прикручивать к lablgtk2, вполне возможен облом.
TCP/IP умеет хорошо (штатная библиотека unix). Разве что виндово-специфичные вещи надо будет руками. В том числе select() на больше, чем 63*64 (вроде бы) сокетов.
Веб -- ocsigen, ocamlnet. Первое -- специализированное, второе -- общесетевое. Однако под виндой дела не супер -- запустить ocamlnet (и зависимый от него ocsigen) не получилось, даже изрядно попатчив как сам окамл, так и ocamlnet. С другой стороны, у меня особо интереса не было в этом деле -- так, поиграться.
Конечно, ASP-сервера на окамле нет.
Под юниксами все библиотеки ставятся, компилируются и заводятся без проблем. Под виндой -- я сделал одну штуку, и теперь имею как способ собрать нужные библиотеки, так и сами бинарные билды. Если интересно, кое-как описал (лицензия будет gpl, исподники будут лежать в публичном репозитории, но пока я чуть другими делами занимаюсь).

Date: 2009-07-07 03:44 pm (UTC)
From: [identity profile] thornik.livejournal.com
Спасибо за ценную инфу, gds! Эх, чего б оккамелщикам не внести это в какие-нть migration howto? Ведь трудно сразу въехать в библиотеку, особенно избалованным .НЕТом :)

Мне Окамл нужен на Линуксе, Винда - просто для унификации клиентов. Но пока основная база крутится под MS SQL, нужно из Линуксового Окамла стучаться к виндовой базе - вот точная постановка вопроса. Потом возможно на ПостгреСКЛ перейдём. Я уже нашёл ОДБЦ конекшын, так что думаю разрулю.

Date: 2009-07-07 03:46 pm (UTC)
From: [identity profile] thornik.livejournal.com
эээ... Вы имеете ввиду, что для КАЖДОЙ входной программы генерится новый сишный сорс, компилируется и исполняется?

Date: 2009-07-07 04:00 pm (UTC)
From: [identity profile] gds.livejournal.com
самая гадкая часть во всём этом окамле -- это как раз социальные вопросы. Шайка академиев развивает язык, с соответствующими результатами. Страшно далеки они от народа. Язык развивают, а инфраструктуры нормальной нет. http://forge.ocamlcore.net/ -- уже круто.

Кстати, если будут какие-нибудь вопросы, которые проще обсудить относительно оперативно, у нас есть жаббер-чятик ocaml@conference.jabber.ru.

Date: 2009-07-07 04:14 pm (UTC)
From: [identity profile] antilamer.livejournal.com
Ну да, для каждой из четырех. А что?

Date: 2009-07-08 08:13 am (UTC)
From: [identity profile] dmzlj.livejournal.com
да, я сам нашел ошибку, поэтому и стер. но в моем случае не будет прироста скорости, так как я исполняю поток команд только один раз

Date: 2009-07-08 08:37 am (UTC)
From: [identity profile] lazyreader.livejournal.com
Вообще-то в программе на C нет никаких вызовов - все операции выполняются inline. Непонятно, почему программа на ocaml быстрее. То есть то, что ocaml с continuation может быть быстрее, чем ocaml с циклом - это понятно; непонятно, как всё это быстрее прямолинейного и простого C.

Ключи компиляции C-программы не покажете?

Date: 2009-07-08 08:49 am (UTC)
From: [identity profile] thornik.livejournal.com
Не, задача решена, всё нормально - просто само решение настолько специфично, что с трудом натягивается на "космическую" тематику :) Насколько я понимаю, первое требование к таким системам - надёжность(включая самовосстанавливаемость), потом ограниченность ресурсов. Я как-то не уверен, что "компилябельное" решение тут применимо.

Date: 2009-07-08 09:03 am (UTC)
From: [identity profile] antilamer.livejournal.com
Дык в настоящей космической задаче и не будет никакой виртуальной машины, симулирующей физику. Будет настоящая живая физика с живыми датчиками.

Date: 2009-07-08 09:41 am (UTC)
From: [identity profile] vkorenev.livejournal.com
А мы сначала написали VM на Scala в виде интерпретатора. Потом написали кодогенератор в код на Scala - оказалось медленнее. Более медленный, но более компактный код может быть быстрее за счёт того, что он лучше кэш использует.

Date: 2009-07-08 10:32 am (UTC)
From: [identity profile] antilamer.livejournal.com
Хм, действительно, это возможно. Казалось бы, кода-то не так и много..

Date: 2009-07-10 01:39 pm (UTC)
From: [identity profile] snaury.livejournal.com
Либо я чего-то не понимаю, либо Вы сравниваете производительность совершенно разных программ. Я не вижу у Вас в окамле разбора упакованного в 32 бита байткода, не вижу вообще никакого байткода, у вас уже само собой магически всё типизировано. К тому же у вас на C++ постоянно идёт обращение [ip], вместо использования инкрементирующегося указателя на иструкцию. Аналогично можно было бы и на C++ отказаться от байткода и всё держать в структуре, где данные уже выравнены по int'у (и никаких op/sop - сразу один opcode, один switch).

В данном случае, как мне кажется, на окамле программа получилась куда более запутанная, чем на C++ (а уж сколько памяти она ест и подумать страшно). При этом в общем случае всё сводится к тому же switch'у, никакого выигрыша (если только вся "программа" заранее компилятору известна - тогда и switch'а конечно никакого не будет, вот вам и выигрыш в производительности).

Date: 2009-07-10 02:14 pm (UTC)
From: [identity profile] thedeemon.livejournal.com
1. Да, я сравниваю производительность разных программ на Окамле - до оптимизации и после. Текст на С++ не является прямым аналогом, и вместе со своей скоростью приведен лишь для примера, чтобы понятно было устройство ВМ. Еще раз: это НЕ сравнение скорости С++ и Окамла.

2. Суть оптимизации Вы не поняли, похоже. Памяти ест совсем немного, а свитча и его накладных расходов во время исполнения больше нет вообще.

Date: 2009-07-10 10:51 pm (UTC)
From: [identity profile] snaury.livejournal.com
Суть оптимизации я возможно действительно не понял. Я сомневаюсь, что окамл что-либо компилирует на этапе выполнения, а тогда при условии, если данные берутся, например, откуда-то с диска, то каждый Ваш match - это именно switch. Каждый ваш k - это tail recursion, оно же jump, оно же goto, почти что for в C. Памяти же нужно будет явно больше чем 32 байта на инструкцию - тут и описание типа, и данные на каждый instance. Кстати на C можно было бы сделать вторую оптимизацию, она бы получилась даже короче и органичнее - не надо ничего никуда map'ать - просто по живому сделать препроцессинг, и где надо prg[ip] = NOP.

А если вся программа известна компилятору заранее, то это уже никакая не ВМ, всё зависит от того, насколько хорошо окамл может оптимизировать Ваш же код. Вот у Вас был цикл, который окамл не стал разворачивать, а вот вы ввели Cont k, и заставили окамл весь код выстроить в огромную цепочку. Со второй оптимизацией моих (очень) скромных знаний окамла не хватает чтобы полностью понять синтаксис, но общую идею вычисления результата и замены инструкции на NOP я понял.

Мне просто кажется, что пример уж слишком оторван от реальности. На boost_pp или чём-нибудь подобном тоже можно всю работу спихнуть на препроцессор, так что всё что конечная программа будет делать - это писать уже готовый результат в память, нечего будет вычислять. Только это очень сложно и не нужно, в реальности всё работает совсем не так.

Так всё-таки, осталось не понятным, prg объявлен статически или откуда-то подгружается? В остальном же все Ваши оптимизации - это феноменально более длинный способ сделать элементарные в C вещи. В первом случае - goto на длинные танцы с Cont k. Во стором случае - prg[ip] = NOP на ещё более длинные if then else в каждой строке. IMHO, опять же, задача была совсем не удачная. Примеры интерпретаторов лиспа или тот же pugs (интерпретатор perl 6) - гораздо живее и эффектнее.

P.S. Извиняюсь если мысли не последовательны - много редактировал и прыгал туда сюда по мере того как лучше понимал смысл ваших оптимизаций.

Date: 2009-07-11 05:39 am (UTC)
From: [identity profile] thedeemon.livejournal.com
Предлагаю все же читать пост с самого начала. Там еще ссылки есть.

1. Было соревнование, которое длилось 72 часа. На нем была конкретная задача и описана конкретная ВМ. Говорить о неудачности задачи или ее оторванности от реальности неуместно - задача и ВМ даны извне. Они и есть реальность.

2. Дальше, был выбранный мною язык программирования - Окамл. Выбран не потому, что он лучше всех для создания ВМ, а потому что основная задача была в алгоритмах, взаимодействующих с ВМ, а алгоритмы на нем описывать часто удобнее и безопаснее в плане багов. На нем же был реализован простейший интерпретатор ВМ. Дальше он уже разгонялся с учетом специфики задачи, об этом и рассказ. Как теоретически можно было бы сделать на С меня не интересует вообще. Я это и так знаю.

3. В данном конкретном, реальном и жизненном случае ВМ применялась следующим образом, в частности: алгоритм поиска оптимального вектора скорости моделировал одиночный импульс спутника и просчитывал результат на 1000 итераций вперед. И так для сотен или тысяч вариантов векторов. Т.е. программа для ВМ исполнялась миллионы раз. Исходная программа загружалась из файла, данного организаторами. Вторая оптимизация ее меняла, подстраивая под конкретные данные. Первая оптимизация превращала программу для ВМ в сконструированную в динамике функцию Окамла, которая работала существенно быстрее простого интерпретатора. Ибо во время ее работы никаких match'ей уже нет (ключевой момент!). Об этом и рассказ.

4. Один проход программы для ВМ - одна итерация. Полученные данные используются на следующей итерации, т.е. в компайл-тайм на С++ все просчитать нельзя (кроме того, что считается во второй оптимизации). Более того, генерировать код на С/С++ тоже бессмысленно - в алгоритме выбора вектора пришлось бы вызывать компилятор тысячи раз, а он медленный.

Date: 2009-07-11 05:51 am (UTC)
From: [identity profile] thedeemon.livejournal.com
>Я сомневаюсь, что окамл что-либо компилирует на этапе выполнения,

Правильно, он это не делает.

> а тогда при условии, если данные берутся, например, откуда-то с диска,

Да, программа читается с диска. После чего исполняется очень много раз.

> то каждый Ваш match - это именно switch.

Верно, но он делается один раз при преобразовании "массив байткодов -> функция". Эта функция выполняется потом по тысячи или миллионы раз уже без match'ей. Ключевой момент в том, что в функциональном языке можно описать функцию, возвращающую функцию. Именно это и использовалось.

>Каждый ваш k - это tail recursion, оно же jump, оно же goto,

Именно так. Эти goto и выполнялись. Причем сразу в нужное место, пропуская все нопы, например.

Date: 2009-07-11 06:28 am (UTC)
From: [identity profile] snaury.livejournal.com
Верно, но он делается один раз при преобразовании "массив байткодов -> функция". Эта функция выполняется потом по тысячи или миллионы раз уже без match'ей. Ключевой момент в том, что в функциональном языке можно описать функцию, возвращающую функцию. Именно это и использовалось.

Хмм... наверное да, хотя есть небольшая непонятка. Вот у Вас в итоги получаются цепочки вида:

Cont(fun () -> ...; k ())

Фактически каждый такой Cont должен выливаться в указатель на тело, плюс полезная нагрузка (следующий в цепочке k). Однако уже в самом начале есть match, что первый аргумент должен быть fun (). Или это просто конкретизирует тип на этапе компиляции? (если последнее - то тогда это, наверное, круто) Плюс, если первый аргумент фактически нигде не используется - то зачем он нужен?

Вообще хотелось бы полный исходник варианта с оптимизацией 1 и чем оно компилировалось, чтобы дисассемблировать и посмотреть что реально происходит... а не гадать.

Date: 2009-07-11 08:12 am (UTC)
From: [identity profile] thedeemon.livejournal.com
"fun () -> ..." это просто запись безымянной функции без параметров. Совсем без параметров в математике функций не бывает, поэтому в языке введен специальный тип unit (аналог void), значение этого типа обозначается (). Т.е. match'a здесь нет, т.к. параметр фиктивный, ничего на самом деле не передается.

Цепочек Cont там не получается. Разберем один шаг псевдокомпиляции на примере:
let comp_instr ip vm ins nxt =
match ins, nxt with
тут смотрим, чему равны текущая инструкция ins и переданный нам результат псевдокомпиляции остатка программы (т.к. идем с конца) nxt. Допустим, ins - инструкция сложения, а nxt - собранное продолжение в виде Cont k. nxt имеет тип comp_res и содержит тег Cont и данные k. k имеет тип "функция из воида в воид" (unit -> unit).

| Add(r1,r2), Cont k ->
попадаем в эту ветку, заодно получив значения r1, r2 и k, отбросив теги Add и Cont.
Создаем новую функцию, которая не имеет параметров (т.е. имеет один фиктивный - () ) производит нужное действие (сложение и запись) и вызывает k:
fun () -> vm.mem.(ip) <- vm.mem.(r1) +. vm.mem.(r2); k ()
Здесь ";" - разделитель между двумя последовательными действиями (как в С, только в конце он не ставится, т.к. он именно разделитель, а не завершитель). Функция эта возвращает то, что вернет k, и т.к. это последнее действие в ней, то вместо call будет jump. Но тип k известен, она ничего не возвращает, т.е. возвращает все то же фиктивное (). Тип построенной безымянной функции в результате тоже unit->unit и ее мы в качестве данных оборачиваем с тегом Cont, получив значение типа comp_res, которое и возвращаем. При псевдокомпиляции следующей команды этот тег Cont будет отброшен, а использована только сама построенная только что функция, она станет k.

Исходник ВМ, который использовался в соревновании:
с подсветкой http://stuff.thedeemon.com/orbitvm.html
оригинал http://stuff.thedeemon.com/orbitvm.ml

Компилял версией OCaml 3.10.2 под Windows/MSVC.

Date: 2009-07-11 10:15 am (UTC)
From: [identity profile] snaury.livejournal.com
А! Так вот где я ошибся. Я почему-то подумал, что unit - это сокращение от чего-то вроде unit of execution, и думал что unit -> unit - это функция, которая на вход получает функцию и возвращает функцию, а () - это пустая функция. %)

Теперь всё стало понятно, спасибо...

Кодогенерация на .NET

Date: 2009-07-12 11:01 am (UTC)
From: [identity profile] dimchansky.livejournal.com
Я сейчас попробовал просто тупо сгенерировать код в .NET сборку (с единственной оптимизацией - для Nop'ов ничего не генерирую).
На моем компе для первой задачи Hohmann получается:
Total time: 10,7695036 sec.
Steps count: 10000000
Opcodes in one step: 266
Speed: 928547,904473517 steps/sec

    Компьютер:
      Тип компьютера                                    ACPI компьютер на базе x64
      Операционная система                              Microsoft Windows Vista Ultimate
      Пакет обновления ОС                               Service Pack 1
      Internet Explorer                                 8.0.6001.18783

    Системная плата:
      Тип ЦП                                            DualCore Intel Core 2 Duo E8500, 3166 MHz (9.5 x 333)
      Системная плата                                   Asus P5E64 WS Evolution  (2 PCI, 1 PCI-E x4, 4 PCI-E x16, 4 DDR3 DIMM, Audio, Dual Gigabit LAN, IEEE-1394)
      Чипсет системной платы                            Intel Beachwood X48
      Системная память                                  4096 Мб  (DDR3-1333 DDR3 SDRAM)
      DIMM1: Kingston                                   2 Гб DDR3-1333 DDR3 SDRAM  (9-9-9-24 @ 666 МГц)  (8-8-8-22 @ 592 МГц)  (6-6-6-16 @ 444 МГц)
      DIMM3: Kingston                                   2 Гб DDR3-1333 DDR3 SDRAM  (9-9-9-24 @ 666 МГц)  (8-8-8-22 @ 592 МГц)  (6-6-6-16 @ 444 МГц)

Сложно сравнивать, но если по пропорции:
3,091796875 - 928547,904473517
2,33        - x
скорость на вашем компьютере должна получиться
x = 699760,27044897463065066329753632

Могу заслать бинарники для сравнения.

Re: Кодогенерация на .NET

Date: 2009-07-12 11:17 am (UTC)
From: [identity profile] dimchansky.livejournal.com
А вот так (http://pastebin.com/m748f0ab9) выглядит сгенерированный код (скопировал из рефлектора).

Re: Кодогенерация на .NET

Date: 2009-07-12 11:43 am (UTC)
From: [identity profile] thedeemon.livejournal.com
Интересно, спасибо! Ожидал большей скорости от JIT'a.

Re: Кодогенерация на .NET

Date: 2009-07-12 11:52 am (UTC)
From: [identity profile] dimchansky.livejournal.com
Надо попробовать еще принудительно в разных режимах компиляции попробовать: 32 и 64 бита.

Re: Кодогенерация на .NET

Date: 2009-07-14 09:25 pm (UTC)
From: [identity profile] dimchansky.livejournal.com
Я немного оптимизировал использование памяти. Просто разделил ячейки памяти на локальные, глобальные и константы. Программу не оптимизировал. Не пользуюсь массивами для памяти и сам класс ни от кого не наследуется. Новый сгенерированный код (http://pastebin.com/m174ff44b) для первой задачи.
Результат на моем компе:
Total time: 2,4674865 sec.
Steps count: 10000000
Opcodes in one step: 266
Speed: 4052707,0766142 steps/sec

Скорость выросла в 4,36 раза. :)

Код (http://pastebin.com/m751ebff8), которым меряю скорость.

Re: Кодогенерация на .NET

Date: 2009-07-14 09:29 pm (UTC)
From: [identity profile] dimchansky.livejournal.com
Кстати заметил ошибку при старой генерации инструкции для Div - не делал проверки на 0.

Profile

thedeemon: (Default)
Dmitry Popov

December 2025

S M T W T F S
 12 3456
789101112 13
14151617181920
21222324252627
28293031   

Most Popular Tags

Page Summary

Style Credit

Expand Cut Tags

No cut tags
Page generated Jan. 29th, 2026 12:21 pm
Powered by Dreamwidth Studios