JavaScript е впечатляваща технология. Не защото е особено добре проектиран (не е). Не защото почти всяко едно потребителско устройство с достъп до интернет в света е изпълнило JavaScript програма. Вместо това, JavaScript е впечатляващ, защото почти всяка една функция на езика го прави кошмар за оптимизиране и въпреки това е бърз.

Помисли за това. Няма информация за типа. Всеки един обект може да придобие и да загуби свойства през целия живот на програмата. Има шест (!) различни вида фалшиви стойности и всяко число е 64-битово плаващо число. Сякаш това не е достатъчно, очаква се JavaScript да се изпълнява бързо, така че не можете да отделите много време за анализиране и оптимизиране.

И все пак JavaScript е бърз.

Как е възможно това?

В тази статия ще разгледаме по-подробно няколко техники, които различните двигатели на JavaScript използват за постигане на добра производителност по време на изпълнение. Имайте предвид, че нарочно пропускам някои подробности и опростявам нещата. Целта на тази статия не е да научите как точно работят нещата, а да разберете достатъчно, за да разберете теорията зад експериментите, които ще проведем по-късно в тази серия.

Моделът на изпълнение

Когато вашият браузър изтегли JavaScript, неговият основен приоритет е той да работи възможно най-бързо. Той прави това, като преобразува кода в байт код, инструкции на виртуална машина, който след това се предава на интерпретатор или виртуална машина, която разбира как да ги изпълни.

Може да се запитате защо браузърът би преобразувал JavaScript във виртуални машинни инструкции вместо действителни машинни инструкции. Това е добър въпрос. Всъщност преобразуването директно в машинни инструкции е това, което V8 (JavaScript двигателят на Chrome) правеше доскоро.

Виртуална машина за конкретен език за програмиране обикновено е по-лесна цел за компилиране, защото има по-тясна връзка с изходния език. Действителната машина има много по-генеричен набор от инструкции и затова е необходима повече работа, за да се преведе езикът за програмиране, за да работи добре с тези инструкции. Тази трудност означава, че компилирането отнема повече време, което отново означава, че отнема повече време на JavaScript да започне да се изпълнява.

Като пример, виртуална машина, която разбира JavaScript, вероятно ще разбира и JavaScript обекти. Поради това виртуалните инструкции, необходими за изпълнение на израз като object.x, могат да бъдат една или две инструкции. Една действителна машина, без разбиране за това как работят JavaScript обектите, ще се нуждае от много повече инструкции, за да разбере къде се намира .x в паметта и как да го получи.

Проблемът с виртуалната машина е, че тя е виртуална. Не съществува. Инструкциите не могат да се изпълняват директно, но трябва да се интерпретират по време на изпълнение. Интерпретирането на код винаги ще бъде по-бавно от директното изпълнение на код.

Тук има компромис. По-бързо време за компилация спрямо по-бързо време за изпълнение. В много случаи по-бързото компилиране е добър компромис. Малко вероятно е потребителят да се интересува дали едно натискане на бутон отнема 20 или 40 милисекунди, особено ако бутонът е натиснат само веднъж. Бързото компилиране на JavaScript, дори ако полученият код е по-бавен за изпълнение, ще позволи на потребителя да вижда и взаимодейства със страницата по-бързо.

Има ситуации, които са скъпи от изчислителна гледна точка. Неща като игри, осветяване на синтаксис или изчисляване на шумен низ от хиляда числа. В тези случаи комбинираното време за компилиране и изпълнение на машинни инструкции вероятно ще намали общото време за изпълнение. И така, как JavaScript се справя с подобни ситуации?

Горещ код

Всеки път, когато двигателят на JavaScript открие, че дадена функция е гореща (т.е. изпълнена много пъти), той предава тази функция на оптимизиращ компилатор. Този компилатор превежда инструкциите на виртуалната машина в действителни машинни инструкции. Нещо повече, тъй като функцията вече е изпълнявана няколко пъти, оптимизиращият компилатор може да направи няколко предположения въз основа на предишни изпълнения. С други думи, той може да извършва спекулативни оптимизации, за да направи още по-бърз код.

Какво ще стане, ако по-късно тези спекулации се окажат грешни? Механизмът на JavaScript може просто да изтрие оптимизираната, но грешна функция и да се върне към използването на неоптимизираната версия. След като функцията бъде изпълнена още няколко пъти, тя може да се опита отново да я предаде на оптимизиращия компилатор, този път с още повече информация, която може да използва за спекулативни оптимизации.

Сега, когато знаем, че често изпълняваните функции използват информация от предишни изпълнения по време на оптимизация, следващото нещо, което трябва да проучим, е какъв вид информация е това.

Проблем с превода

Почти всичко в JavaScript е обект. За съжаление JavaScript обектите са трудни неща, с които да научите машината да се справя. Нека да разгледаме следния код:

function addFive(obj) {
    return obj.method() + 5;
}

Една функция е доста лесна за превеждане в машинни инструкции, както и връщането от функция. Но машината не знае какво представляват objects, така че как бихте превели достъпа до свойството method на obj?

Би било полезно да знаем как изглежда obj, но в JavaScript никога не можем да сме сигурни. Към всеки обект може да се добави свойство method или да се премахне от него. Дори когато съществува, всъщност не можем да сме сигурни дали е функция, още по-малко какво извикване връща.

Нека се опитаме да преведем горния код в подмножество на JavaScript, което няма обекти, за да добием представа какво може да бъде превеждането на машинни инструкции.

Първо, имаме нужда от начин за представяне на обекти. Също така се нуждаем от начин да извлечем стойности от един. Масивите са тривиални за поддръжка в машинния код, така че можем да използваме представяне като това:

// An object like { method: function() {} }
// could be represented as:
// [ [ "method" ], // property names
//   [ function() {} ] ] // property values

function lookup(obj, name) {
  for (var i = 0; i < obj[0].length; i++) {
    if (obj[0][i] === name) return i;
  }

  return -1;
}

С това можем да се опитаме да направим наивна реализация на addFive:

function addFive(obj) {
  var propertyIndex = lookup(obj, "method");
  var property = propertyIndex < 0 
      ? undefined 
      : obj[1][propertyIndex];

  if (typeof(property) !== "function") {
      throw NotAFunction(obj, "method");
  }
  var callResult = property(/* this */ obj);
  return callResult + 5;
}

Разбира се, това не работи в случай, когато obj.method() връща нещо различно от число, така че трябва малко да коригираме внедряването:

function addFive(obj) {
  var propertyIndex = lookup(obj, "method");
  var property = propertyIndex < 0 
      ? undefined 
      : obj[1][propertyIndex];

  if (typeof(property) !== "function") {
      throw NotAFunction(obj, "method");
  }
  var callResult = property(/* this */ obj);
  if (typeof(callResult) === "string") {
      return stringConcat(callResult, "5");
  } else if (typeof(callResult !== "number") {
      throw NotANumber(callResult);
  }
  
  return callResult + 5;
}

Това би проработило, но се надявам, че е очевидно, че този код може да пропусне няколко стъпки (и по този начин да бъде по-бърз), ако можем по някакъв начин да знаем предварително каква е структурата на obj и какъв е типът на method.

Скрити класове

Всички основни машини на JavaScript следят формата на обекта по някакъв начин. В Chrome тази концепция е известна като скрити класове. Така ще го наричаме и в тази статия.

Нека започнем, като разгледаме следния кодов фрагмент:

var obj = {}; // empty object
obj.x = 1; // shape has now changed to include a `x` property
obj.toString = function() { return "TODO"; }; // shape changes
delete obj.x; // shape changes again

Ако трябваше да преведем това в машинни инструкции, как ще следим формата на обекта при добавяне и премахване на нови свойства? Ако използваме идеята от предишния пример за представяне на обекти като масиви, това може да изглежда по следния начин:

var emptyObj__Class = [ 
  null, // No parent hidden class
  [],   // Property names
  []    // Property types
];

var obj = [ 
  emptyObj__Class, // Hidden class of `obj`
  []               // Property values
];

var obj_X__Class = [ 
  emptyObj__Class, // Contains same properties as empty object
  ["x"],           // As well as one property called `x`
  ["number"]       // Where `x` is a number
];

obj[0] = obj_X__Class; // Shape changes
obj[1].push(1);        // value of `x`

var obj_X_ToString__Class = [
  obj_X__Class, // Contains same properties as previous shape
  ["toString"], // And one property called `toString`
  ["function"]  // Where `toString` is a function
];

obj[0] = obj_X_ToString__Class;             // shape change
obj[1].push(function() { return "TODO"; }); // `toString` value

var obj_ToString__Class = [
  null, // Starting from scratch when deleting `x`
  ["toString"], 
  ["function"] 
];

obj[0] = obj_ToString__Class;
obj[1] = [obj[1][1]];

Ако трябваше да генерираме инструкции за виртуална машина като тази, сега ще имаме начин да проследим как изглежда даден обект във всеки един момент. Това обаче само по себе си не ни помага наистина. Трябва да съхраняваме тази информация някъде, където би била ценна.

Вградени кешове

Всеки път, когато кодът на JavaScript извършва достъп до свойство на обект, двигателят на JavaScript съхранява скрития клас на този обект, както и резултата от търсенето (съпоставянето на името на свойство към индекс) в кеш. Тези кешове са известни като вградени кешове и служат за две важни цели:

  • Когато изпълняват байт код, те ускоряват достъпа до свойства ако участващият обект има скрит клас, който е в кеша.
  • По време на оптимизацията те съдържат информация за това какъв тип обекти са били включени при достъп до свойство на обект, което помага на оптимизиращия компилатор да генерира код, специално подходящ за тези типове.

Вградените кешове имат ограничение за това колко скрити класове съхраняват информация. Това запазва паметта, но също така гарантира, че извършването на търсения в кеша е бързо. Ако извличането на индекс от вградения кеш отнема повече време от извличането на индекса от скрития клас, кешът няма никаква цел.

От това, което мога да кажа, вградените кешове ще следят най-много 4 скрити класа, поне в Chrome. След това вграденият кеш ще бъде деактивиран и вместо това информацията ще се съхранява в глобален кеш. Глобалният кеш също е ограничен по размер и след като достигне своя лимит, по-новите записи ще презапишат по-старите.

За да се използват най-добре вградените кешове и да се подпомогне оптимизиращият компилатор, човек трябва да се опита да напише функции, които извършват достъп до свойства само на обекти от един тип. Нещо повече и производителността на генерирания код ще бъде неоптимална.

Вграждане

Отделен, но значим вид оптимизация е вграждането. Накратко, тази оптимизация замества извикване на функция с изпълнението на извиканата функция. Пример:

function map(fn, list) {
    var newList = [];
    for (var i = 0; i < list.length; i++) {
        newList.push(fn(list[i]));
    }
    
    return newList;
}

function incrementNumbers(list) {
    return map(function(n) { return n + 1; }, list);
}

incrementNumbers([1, 2, 3]); // returns [2, 3, 4]

След вграждането кодът може да изглежда така:

function incrementNumbers(list) {
    var newList = [];
    var fn = function(n) { return n + 1; };
    for (var i = 0; i < list.length; i++) {
        newList.push(fn(list[i]));
    }
    return newList;
}

incrementNumbers([1, 2, 3]); // returns [2, 3, 4]

Едно предимство от това е, че извикването на функция е премахнато. Още по-голямо предимство е, че двигателят на JavaScript вече има още по-добра представа за това какво всъщност прави функцията. Въз основа на тази нова версия двигателят на JavaScript може да реши да извърши вграждане отново:

function incrementNumbers(list) {
    var newList = [];
    for (var i = 0; i < list.length; i++) {
        newList.push(list[i] + 1);
    }
	
    return newList;
}

incrementNumbers([1, 2, 3]); // returns [2, 3, 4]

Друго извикване на функция е премахнато. Нещо повече, оптимизаторът може сега да спекулира, че incrementNumbers се извиква само със списък от числа като аргумент. Може също така да реши да вгради самото повикване incrementNumbers([1, 2, 3]) и да открие, че list.length е 3, което отново може да доведе до:

var list = [1, 2, 3];
var newList = [];
newList.push(list[0] + 1);
newList.push(list[1] + 1);
newList.push(list[2] + 1);
list = newList;

Накратко, вграждането позволява оптимизации, които не биха били възможни за извършване през границите на функциите.

Има обаче ограничения за това какво може да бъде вградено. Вграждането може да доведе до по-големи функции поради дублиране на код, което изисква допълнителна памет. Двигателят на JavaScript има бюджет за това колко голяма може да стане една функция, преди да пропусне изцяло вграждането.

Някои извиквания на функции също са трудни за вграждане. Особено когато дадена функция се предава като аргумент.

В допълнение, функциите, предавани като аргументи, могат да бъдат трудни за вграждане, освен ако не е винаги една и съща функция. Въпреки че това може да ви се стори странно, в крайна сметка може да се окаже така поради вграждането.

Заключение

Двигателите на JavaScript имат много трикове за подобряване на производителността по време на изпълнение, много повече от това, което е разгледано тук. Въпреки това оптимизациите, описани в тази статия, се отнасят за повечето браузъри и е лесно да се провери дали се прилагат. Поради това ще се фокусираме основно върху тези оптимизации, когато се опитваме да подобрим производителността на Elm по време на изпълнение.

Но преди да започнем да се опитваме да оптимизираме нещо, имаме нужда от начин да идентифицираме какъв код може да бъде подобрен. Инструментите, които ни дават тази информация, са темата на следващата статия.

Допълнителни справки

Не съм първият, който се опитва да обясни как работят двигателите на JavaScript. Ето няколко статии, които са по-задълбочени и имат различен начин за обяснение на подобни концепции: