Quantcast
Channel: vinxru
Viewing all articles
Browse latest Browse all 319

JIT + SQL

$
0
0
За прошлую неделю мне не один человек рассказал про MemSQL. Это новая база данных, которая работает исключительно в оперативной памяти. И со слов разработчиковмаркетологов это ноу-хау которое позволяет повысить производительность на несколько порядков. Маркетологи молодцы, все в курсе.

Да, здравый смысл в этом есть. Сейчас 16 Гб оперативной памяти может поставить любой, а этого объема хватит большинству баз данных. Причем, надо понимать, что файлы баз данных на диске занимают в 10-100 раз больше места, чем полезные данные. Если мы будем данные хранить в ОЗУ, то места потребуется меньше.

Почему? Диск позволяет "быстро"читать данные только блоками, притом расположенными подряд. И пытаясь выжать максимум скорости, объем приносится в жертву. И никто его не жалеет. А ОЗУ работает одинаково быстро во всем объеме. Такие жертвы не нужны и даже вредны. ОЗУ все таки мало.

А ненадежность хранения в ОЗУ решается тем, что данные в MemSQL так же записываются на диск :)

Это всё лишь маркетинг. То что они говорят о работе в памяти как о главном факторе - это маркетинг. Потому что в памяти работают многие БД. Из самых популярных: MySQL умеет создавать таблицы в ОЗУ, SQLite то же умеет всю базу держать в ОЗУ (:memory:). SQL-подобная технология LINQ в C# то же работает с данными в памяти.

Более того, печальный факт. Как только размер базы у обычной СУБД превышает размер ОЗУ, производительность начинает сильно падать. Так что уже давно оперативная память наращивается под размер БД.

Что же действительно позволяет обогнать конкурентов, так это грамотное программирование. Видимо парни пораскинули мозгами и написали более оптимальный код. И там есть куда оптимизировать. Многие вещи SQL-сервера делают очень медленно. Например вставку данных в таблицу. Разархивация БД (выполненная на основе INSERT INTO) размером в 50 Мб может длиться несколько часов.

Вот и я подумал, пора бы то же сделать свой маленький быстрый SQL с преферансом и дамами.

JIT



Делаю я его по своей инициативе по выходым, так как это потребует много времени. Неделю или две. А больше двух дней на задачу на работе мне не дают. Но даже если я не сделаю SQL, собственный JIT компилятор мне очень даже пригодится.

До этого момента я писал все компиляторы в байт-код. Интерпретатор байт-кода можно написать на чистом Си, что позволяет компилировать его под любую архитектуру, избежать множества ошибок и легально работать с любым антивирусом. Антивирусы не очень хорошо относятся к модификации исполняемого кода. На идею байт-кода меня натолкнули исходные коды эмулятора процессора Z80 в далеком прошлом :) Я когда то писал свой эмулятор Спектрума, подглядывая в чужие эмуляторы.

Причем, мой интерпретатор байт кода работал не медленнее PHP (язык программирования). И на этом я успокоился. Сейчас же нужна максимальная скорость, поэтому я перейду на уровень машинного кода. И первым моим экспериментом была простейшая программа

// Выделяем память из которой можно выполнять программы. Это основной момент
auto code = (unsigned char*)VirtualAlloc(0, 256, MEM_RESERVE|MEM_COMMIT, PAGE_EXECUTE_READWRITE);
VirtualLock(code, 256);
// Записываем программу
code[0] = 0xC3; // код команды RET 
// Выполняем
((void(*)())code)();


И она запустилась :) Далее начинаем писать грамотно, точнее пишем компилятор ассемблера. Это пока для 32-х битной версии, а для 64-х битной придется вносить небольшие изменения.

// Все условия перехода
enum Cc {
  JA=7, JAE=3, JB=2, JBE=6, JC=2, JE=4, JG=0xF, JGE=0xD, JL=0xC, JLE=0xE, JNA=6, 
  JNAE=2, JNB=3, JNBE=7, JNC=3, JNE=5, JNG=0xE, JNGE=0xC, JNL=0xD, JNLE=0xF, JNO=1,
  JNP=0xB, JNS=9, JNZ=5, JO=0, JP=0xA, JPE=0xA, JPO=0xB, JS=8, JZ=4 
};

// Используемые регистры
enum Reg { EAX=0, ECX=1, EDX=2, EBX=3 };

// Команды ALU
enum Alu { ADD=0, OR=1, ADC=2, SBB=3, AND=4, SUB=5, XOR=6, CMP=7 };

class Compiler {
protected:
  unsigned char *codeStart; // Начало кода
  unsigned char *code; // Конец кода

  unsigned char *prev; int cmd, cmdA, cmdB; // Для оптимизации
  enum { CMD_mov_reg_imm=1, CMD_mov_reg_EBX_rel, CMD_mov_reg_reg, CMD_SET_A, CMD_set };// Для оптимизации

  void s(char opcode) { cmd = 0; prev = code; *code++ = opcode; }
  void s(char opcode1, char opcode2) { s(opcode1); *code++ = opcode2; }
  void i(int imm) { *(int*)code = imm; code+=4; }

public:
  Compiler() {
    prev = code = codeStart = (unsigned char*)VirtualAlloc(0, 256, MEM_RESERVE|MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    VirtualLock(code, 256);
  }

  void execute() {
    ((void(*)())codeStart)();
  }

  // Команды без оптимизации

  void mov_reg_reg(Reg reg1, Reg reg2)   { s(0x8B, 0xC0|(reg1<<3)|reg2); cmd=CMD_mov_reg_reg; cmdA=reg1; cmdB=reg2; }
  void mov_reg_imm(Reg reg, int imm)     { s(0xB8|reg); i(imm); cmd=CMD_mov_reg_imm; cmdA=reg; cmdB=imm; }
  void mov_reg_EBX_rel(int reg, int rel) { s(0x8B, 0x83|(reg<<3)); i(rel); cmd=CMD_mov_reg_EBX_rel; cmdA=reg; cmdB=rel; }
  void mov_EBX_rel_reg(int rel, Reg reg) { s(0x89, 0x83|(reg<<3)); i(rel); }
  void push_reg(Reg reg)                 { s(0x50|reg); }
  void pop_reg(Reg reg)                  { s(0x58|reg); }
  void jcc(Cc cc, int label)             { s(0x0F, 0x80|cc); fixup(code, label); i(-4); }
  void jmp(int label)                    { s(0xE9); fixup(code, label); i(-4); }
  void ret()                             { s(0xC3); }
  void set(Cc cc, Reg reg)               { auto p=code; mov_reg_imm(reg,0); s(0x0F, 0x90|cc); *code++ = 0xC0|reg; prev=p; cmd=CMD_set; cmdA=cc; } 

  // Команды с оптимизацией

  void needFlags_jcc(Cc cc, int label)   {
    if(cc==JZ && cmd==CMD_set) { code = prev; jcc((Cc)cmdA, label); return; }
    alu_reg_reg(OR, EAX, EAX); 
    jcc(cc, label);
  }
  
  void alu_reg_imm(Alu alu, Reg reg, int imm) { 
    if(reg==EAX) { /* alu eax, imm */  s(0x05+(alu<<3)); i(imm); return; }
    /* alu reg1, imm */ s(0x81, 0xC0+(alu<<3)+reg); i(imm);
  }

  void alu_reg_reg(Alu alu, Reg reg1, Reg reg2) { 
    /* alu reg1, imm */      if(cmd==CMD_mov_reg_imm     && cmdA==reg2 ) { code=prev; alu_reg_imm(alu, reg1, cmdB); return; }
    /* alu reg1, [ebx+rel] */if(cmd==CMD_mov_reg_EBX_rel && cmdA==reg2 ) { code=prev; s(0x03+(alu<<3),  0x83+(reg1<<3)); i(cmdB); return; }
    s(0x03|(alu<<3)); *code++ = 0xC0|(reg1<<3)|reg2;
  } 

  void idiv_reg(Reg reg) { 
    /* idiv [ebx+rel] */ if(cmd == CMD_mov_reg_EBX_rel) { code = prev; s(0xF7, 0xBB); i(cmdA); return; }
    /* idiv reg */ s(0xF7, 0xF8|reg); 
  }

  void imul_reg(Reg reg) {
    /* imul [ebx+rel] */ if(cmd==CMD_mov_reg_EBX_rel && cmdA==reg) { code = prev; s(0xF7); *code++ = 0xAB; i(cmdB); return; }
    /* imul reg */ s(0xF7); *code++ = 0xE8|reg;
  }

  // Метки

  struct Fixup {
    unsigned char* code;
    int label;
    Fixup() {};
    Fixup(unsigned char* _code, int _label) { code=_code; label=_label; }
  };
  std::vector<Fixup> fixups;
  std::vector<unsigned char*> labels;

  void fixup(unsigned char* code, int label) { fixups.push_back(Fixup(code, label)); }
  void label(int label) { labels[label] = code; }
  int allocLabel() { labels.push_back(code); return labels.size()-1; }  
  void prepare() { for(auto f=fixups.begin(), fe=fixups.end(); f!=fe; f++) *(int*)f->code += labels[f->label] - f->code; }
};


Ради облегчения себе жизни в будущем я буду использовать самые простые варианты инструкций. Например команду умножения работающую только с регистрами. А уже компилятор ассемблера (код выше) будет объединять пару простых инструкций в более сложные. Например: MOV EDX, [EBX+?] + MUL EDX => MUL [EBX+?].

И во вторых, этот объект занимается компиляцией меток и переходов. Во время формирования кода, команд перехода, мы не знаем адрес перехода, если метка расположена дальше по коду. Для этого в команде перехода мы вызываем метод fixup(allocLabel()), он запоминает меcто куда надо будет записать адрес. А когда настает очередь метки, мы вызываем label, она запоминает адрес метки. А в конце вызывается метод prepare. Который расставляет все адреса.

Ну и ради теста пишем программу:

struct Item {
  int a, b;
};

std::vector<Item> data;

#define OFFSET(X) ((char*)&Item.X-(char*)&Item.a)

void do() {
  Compiler c;
  c.mov_reg_imm(EBX, (int)data.begin()._Ptr);
  int l0 = c.allocLabel();
  c.mov_reg_EBX_rel(EAX, OFFSET(a));
  c.alu_reg_imm(CMP, EAX, 16);
  int l = c.allocLabel();
  c.jcc(JA, l);
  c.mov_EBX_rel_imm(OFFSET(b), 1);
  c.label(l);
  c.alu_reg_imm(ADD, EBX, sizeof(item));
  c.alu_reg_imm(CMP, EBX, (int)data.end()._Ptr);
  c.jcc(JNZ, l0);
  c.ret();

  c.prepare();
  c.execute();
}


Эта программа выполняет действие "if(a > 16) b = 1". Выполняется быстро, но пока это совсем не похоже на SQL.

Теперь нужен компилятор c SQL. Пока что компилятор простейшего выражения. Причем, компилятор мог быть маленьким. Но я ради оптимизации решил разделить его на две части. На разбор кода и компиляцию кода собственно.

Заброшенный компилятор Си для 8080 был написан одним куском, что насколько усложнило код, что я год к нему не хотел возращаться. С новой архитектурой всё проще и опять захотелось :)

enum Type { tAcc, tVar, tImm, tStack };

struct Var {
  Type type;
  int n;

  void set(Type _type, int _n) { type=_type; n=_n; }
  ~Var();
};

class Node {
public:
  Var v; // Переменная или константа
  Operator o; // Оператор
  Cc cc; // Условие перехода
  Node *a, *b; // Аргументы оператора. Надо заменить на list args

  Node(Var& _v) { o=oNone; a=0; b=0; v=_v; }
  Node(int& n) { o=oNone; a=0; b=0; v.n=n; v.type=tImm; }
  Node(Operator _o, Cc _cc, Node* _a, Node* _b) { o=_o; cc=_cc; a=_a; b=_b; }
};

Node* parseVar(int level=0) {
  Node* a;
  if(p.ifToken("(")) { a = parseVar(0); p.needToken(")"); } else
  if(p.ifToken(ttInteger)) { a = new Node(p.i); } else
  if(p.ifToken(varNames)) { a = new Node(vars[p.i]); } else p.syntaxError();
  while(true) {
    Operator o; int l; Cc cc=JZ;
    if(level<1 && p.ifToken("Or")) l=1, o=oOr; else
    if(level<2 && p.ifToken("And")) l=2, o=oAnd; else
    if(level<3 && p.ifToken("<" )) l=3, o=oCmp, cc=JB;  else
    if(level<3 && p.ifToken("<=")) l=3, o=oCmp, cc=JBE; else
    if(level<3 && p.ifToken(">" )) l=3, o=oCmp, cc=JA;  else
    if(level<3 && p.ifToken(">=")) l=3, o=oCmp, cc=JAE; else
    if(level<3 && p.ifToken("==")) l=3, o=oCmp, cc=JE;  else
    if(level<3 && p.ifToken("!=")) l=3, o=oCmp, cc=JNE; else
    if(level<4 && p.ifToken("+" )) l=4, o=oAdd; else
    if(level<4 && p.ifToken("-" )) l=4, o=oSub; else
    if(p.ifToken("*")) l=5, o=oMul; else
    if(p.ifToken("/")) l=5, o=oDiv; else return a;
    Node* b = parseVar(l);
    a = new Node(o, cc, a, b);
  }
}


Эта функция строит дерево выражения. Некий аналог Expression Trees в .NET или еще его можно представить как программу на LISP. (это Abstract syntax tree (с) ex0_panet) Помимо разделения кода на две части, это древеро очень удобно оптимизировать. Но этот момент я пока опущу. Компилятор получился не очень большим:

void compileVar(Var* x, Node* n, int jmpIfFalse=-1) {
  // Это переменная или константа
  if(n->o==oNone) {
    *x = n->v;
    return;
  }

  // AND и OR обрабатываем по особому.
  if(n->o==oAnd || n->o==oOr) {
    if(x) jmpIfFalse = c.allocLabel();
    if(n->o==oOr) {
      int jmpIfTrue = c.allocLabel();
      int elseLabel = c.allocLabel();
      compileVar(0, n->a, elseLabel);
      c.jmp(jmpIfTrue);
      c.label(elseLabel);
      compileVar(0, n->b, jmpIfFalse);
      c.label(jmpIfTrue);
    } else {
      compileVar(0, n->a, jmpIfFalse);
      compileVar(0, n->b, jmpIfFalse);
    }
    if(x) {
      c.mov_reg_imm(EAX, 1);
      int l = c.allocLabel();
      c.jmp(l);
      c.label(jmpIfFalse);
      c.mov_reg_imm(EAX, 0);
      c.label(l);
      accUsed = x;
      x->type = tAcc;
    }
    return;
  }

  // Получаем аргументы оператора
  Var a; compileVar(&a, n->a);
  Var b; compileVar(&b, n->b);

  // Помещаем арументы в EAX, EDX
  if(b.type==tAcc) push(EDX, b);
  push(EAX, a);
  if(b.type!=tAcc) push(EDX, b);

  // Выполняем оператор
  switch(n->o) {
    case oCmp: c.alu_reg_reg(CMP, EAX, EDX); /* Требуется значение */ if(x) c.set(n->cc, EAX); break;
    case oAdd: c.alu_reg_reg(ADD, EAX, EDX); break;
    case oSub: c.alu_reg_reg(SUB, EAX, EDX); break;
    case oDiv: c.idiv_reg(EDX); break;
    case oMul: c.imul_reg(EDX); break;
    default: FatalAppExitA(1, ":)");
  }

  if(x) {
    // Требуется значение
    accUsed = x;
    x->type = tAcc;
  } else {
    // Требуется условие
    c.jcc(n->cc, jmpIfFalse);
  }
}

// Поместить значение в регистр
void push(Reg reg, Var& x, bool freeAcc=false) {
  switch(x.type) {
    case tVar: c.mov_reg_EBX_rel(reg, x.n); break;
    case tImm: c.mov_reg_imm(reg, x.n); break;
    case tAcc: if(reg!=EAX) c.mov_reg_reg(reg, EAX); break;
    case tStack: c.pop_reg(reg); break;
  }
  x.type = tAcc;
  if(freeAcc) accUsed=0;
}


И его проверка. Здесь мы компилируем выражение (a > b OR a > 18) AND b > c и его результат записываем в Item::result.

  // Начало цикла
  // for(EBX=data.begin(); EBX!=data.end(); EBX++) {

  c.mov_reg_imm(EBX, (int)data.begin()._Ptr);
  int l0 = c.allocLabel();

  // Компиляция выражения
  p.load("(a > b OR a > 18) AND b > c");
  Var out;
  compileVar(&out, parseVar());
  p.needToken(ttEof);

  // Сохраняем выражение в EBX->resut
  push(EAX, out, true);
  c.mov_EBX_rel_reg(OFFSET(result), EAX);

  // Конец цикла for(EBX=data.begin(); EBX!=data.end(); EBX++)
  c.alu_reg_imm(ADD, EBX, sizeof(item));
  c.alu_reg_imm(CMP, EBX, (int)data.end()._Ptr);
  c.jcc(JNZ, l0);
  c.ret();

  // Запуск
  c.prepare();
  c.execute();


Добавить несколько простых строк и этот код уже сможет выполнять запрос типа: SELECT выражение, выражение, выражение FROM таблица WHERE выражение.

Но надо еще сделать поддержку типов данных std::string, double, функций. Это не сложно.

Чуть сложнее будет с компиялцией слов GROUP BY, HAVING. Но я это уже делал, только транслировал не в машинный код в программу на Паскале.

SIMD



Что действительно заставляет задуматься, дак это применение SIMD инструкций (MMX/SSE/AVX/AVX-512). Это инструкции выполняющие одинаковую операцию для несколькими числами одновременно (за один такт). SSE инструкция обрабатывает за такт 128 бит данных, то есть 4 целых 32-х битных числа или 2 дробных 64-битных. Более поздние наборы инструкций AVX или AVX-512 позволяют обрабатывать 256 или 512 бит за раз.

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

Как то мне один весьма умный человек, преподаватель по программирования в университете, сказал: SIMD тебе не нужен, даже обычные инструкции обрабатывают данные быстрее, чем работает ОЗУ. Прироста скорости не будет...

Viewing all articles
Browse latest Browse all 319

Trending Articles