Несколько недель назад я обнаружил пост "Окисляем Source Maps с Rust и WebAssembly"
распространяющийся по Твиттеру и расказывающий о выигрыше в производительности от замены обычного JavaScript в библиотеке source-map на Rust, скомпилированный в WebAssembly.


Пост возбудил мой интерес не потому, что я большой фанат Rust или WASM, скорее потому что я всегда интересовался фичами языков и оптимизациями, которых не хватает Javascript для того чтобы достичь аналогичной производительности.


Так что я скачал библиотеку с GitHub и отправился в небольшое исследование производительности, которое я документирую здесь практически дословно.


Содержание



Получение кода


Для своих исследований я использую почти стандартную x64.release сборку V8, с коммита 69abb960c97606df99408e6869d66e014aa0fb51 от 20 января. Мое расхождение со стандартной конфигурацией заключается лишь в том, что я включил дизассемблер через GN-флаги, чтобы погружаться глубже в сгенерированный машинный код, если будет нужно.


?- ~/src/v8/v8 ‹master›
?-$ gn args out.gn/x64.release --list --short --overrides-only
is_debug = false
target_cpu = "x64"
use_goma = true
v8_enable_disassembler = true

Затем я получил исходники модуля source-map от:


  • коммит c97d38b, который был последним, обновившим dist/source-map.js перед началом внедрения Rust/WASM;
  • коммит 51cf770, который был последним коммитом на момент проведения исследования;

Профилируем версию на чистом JavaScript


Запуск бенчмарка для чистой JS-версии был прост:


?- ~/src/source-map/bench ‹ c97d38b›
?-$ d8 bench-shell-bindings.js
Parsing source map
console.timeEnd: iteration, 4655.638000
console.timeEnd: iteration, 4751.122000
console.timeEnd: iteration, 4820.566000
console.timeEnd: iteration, 4996.942000
console.timeEnd: iteration, 4644.619000
[Stats samples: 5, total: 23868 ms, mean: 4773.6 ms, stddev: 161.22112144505135 ms]

Первой вещью, что я сделал, было выключение сериализационной части бенчмарка:


diff --git a/bench/bench-shell-bindings.js b/bench/bench-shell-bindings.js
index 811df40..c97d38b 100644
--- a/bench/bench-shell-bindings.js
+++ b/bench/bench-shell-bindings.js
@@ -19,5 +19,5 @@ load("./bench.js");
 print("Parsing source map");
 print(benchmarkParseSourceMap());
 print();
-print("Serializing source map");
-print(benchmarkSerializeSourceMap());
+// print("Serializing source map");
+// print(benchmarkSerializeSourceMap());

Затем я закинул это в линуксовый perf профилировщик:


?- ~/src/source-map/bench ‹perf-work›
?-$ perf record -g d8 --perf-basic-prof bench-shell-bindings.js
Parsing source map
console.timeEnd: iteration, 4984.464000
^C[ perf record: Woken up 90 times to write data ]
[ perf record: Captured and wrote 24.659 MB perf.data (~1077375 samples) ]

Заметьте, что я передаю --perf-basic-prof флаг в бинарник d8, который заставляет V8 генерировать вспомогательный файл маппингов /tmp/perf-$pid.map. Этот файл позволяет perf report понимать JIT-генерированный машинный код.


Вот что я получаю от perf report --no-children после при просмотре основного потока исполнения:


Overhead  Symbol
  17.02%  *doQuickSort ../dist/source-map.js:2752
  11.20%  Builtin:ArgumentsAdaptorTrampoline
   7.17%  *compareByOriginalPositions ../dist/source-map.js:1024
   4.49%  Builtin:CallFunction_ReceiverIsNullOrUndefined
   3.58%  *compareByGeneratedPositionsDeflated ../dist/source-map.js:1063
   2.73%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
   2.11%  Builtin:StringEqual
   1.93%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
   1.66%  *doQuickSort ../dist/source-map.js:2752
   1.25%  v8::internal::StringTable::LookupStringIfExists_NoAllocate
   1.22%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
   1.21%  Builtin:StringCharAt
   1.16%  Builtin:Call_ReceiverIsNullOrUndefined
   1.14%  v8::internal::(anonymous namespace)::StringTableNoAllocateKey::IsMatch
   0.90%  Builtin:StringPrototypeSlice
   0.86%  Builtin:KeyedLoadIC_Megamorphic
   0.82%  v8::internal::(anonymous namespace)::MakeStringThin
   0.80%  v8::internal::(anonymous namespace)::CopyObjectToObjectElements
   0.76%  v8::internal::Scavenger::ScavengeObject
   0.72%  v8::internal::String::VisitFlat<v8::internal::IteratingStringHasher>
   0.68%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
   0.64%  *doQuickSort ../dist/source-map.js:2752
   0.56%  v8::internal::IncrementalMarking::RecordWriteSlow

Действительно, как и говорилось в посте "Окисляем Source Maps ...", этот бенчмарк в основном нагружет сортировку: функция doQuickSort появляется на вершине профиля, а также несколько раз ниже по списку (что означает, что она была оптимизирована и деоптимизирована несколько раз).


Оптимизируем сортировку – адаптация аргументов


Один момент, который выделяется в профайлере – подозрительные записи, а именно Builtin:ArgumentsAdaptorTrampoline и Builtin:CallFunction_ReceiverIsNullOrUndefined, которые, похоже, являются частью имплементации V8. Если мы попросим perf report раскрыть ведущие к ним цепочки вызовов, мы заметим, что эти функции в основном вызываются из кода сортировки:


- Builtin:ArgumentsAdaptorTrampoline
  + 96.87% *doQuickSort ../dist/source-map.js:2752
  +  1.22% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
  +  0.68% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
  +  0.68% Builtin:InterpreterEntryTrampoline
  +  0.55% *doQuickSort ../dist/source-map.js:2752

- Builtin:CallFunction_ReceiverIsNullOrUndefined
  + 93.88% *doQuickSort ../dist/source-map.js:2752
  +  2.24% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
  +  2.01% Builtin:InterpreterEntryTrampoline
  +  1.49% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894

А вот и время взглянуть на код. Реализация быстрой сортировки располагается в lib/quick-sort.js и вызывается из кода парсера в lib/source-map-consumer.js.
Функции сравнения (компараторы), используемые для сортировки, – это compareByGeneratedPositionsDeflated и compareByOriginalPositions.


Глядя на определения этих функций сравнения и на способ их вызова в реализации быстрой сортировки, оказывается, что места вызовов имеют несовпадающую арность (arity):


function compareByOriginalPositions(mappingA, mappingB, onlyCompareOriginal) {
  // ...
}

function compareByGeneratedPositionsDeflated(mappingA, mappingB, onlyCompareGenerated) {
  // ...
}

function doQuickSort(ary, comparator, p, r) {
  // ...
      if (comparator(ary[j], pivot) <= 0) {
        // ...
      }
  // ...
}

Просмотр исходников библиотеки показывает, что вне тестов quickSort вызывается лишь только с этими двумя функциями.


А что если мы исправим арность?


diff --git a/dist/source-map.js b/dist/source-map.js
index ade5bb2..2d39b28 100644
--- a/dist/source-map.js
+++ b/dist/source-map.js
@@ -2779,7 +2779,7 @@ return /* **** */ (function(modules) { // webpackBootstrap
            //
            //   * Every element in `ary[i+1 .. j-1]` is greater than the pivot.
            for (var j = p; j < r; j++) {
-             if (comparator(ary[j], pivot) <= 0) {
+             if (comparator(ary[j], pivot, false) <= 0) {
                i += 1;
                swap(ary, i, j);
              }

[Примечание: Я делаю правки напрямую в dist/source-map.js потому что не хотел тратить время на разбирательство с процессом сборки]


?- ~/src/source-map/bench ‹perf-work› [Fix comparator invocation arity]
?-$ d8 bench-shell-bindings.js
Parsing source map
console.timeEnd: iteration, 4037.084000
console.timeEnd: iteration, 4249.258000
console.timeEnd: iteration, 4241.165000
console.timeEnd: iteration, 3936.664000
console.timeEnd: iteration, 4131.844000
console.timeEnd: iteration, 4140.963000
[Stats samples: 6, total: 24737 ms, mean: 4122.833333333333 ms, stddev: 132.18789657150916 ms]

Простым исправлением несовпадения арности мы улучшили значения V8 в бенчмарке на 14% с 4774 мс до 4123 мс. Если мы запрофилируем бенчмарк снова, то мы обнаружим, что ArgumentsAdaptorTrampoline полностью исчез оттуда. Так почему он там был в первый раз?


Оказывается, что ArgumentsAdaptorTrampoline — это механизм V8 для поддержки джаваскриптовой вариантивности вызовов: вы можете вызвать функцию от трех аргументов всего с двумя — в этом случае третий аргумент будет заполнен значением undefined. V8 делает это созданием нового фрейма в стеке, копируя туда аргументы, затем вызывает целевую функцию:


Argument adaptation


[Если вы никогда не слышали о стеке исполнения, то ознакомьтесь с Википедией и постом Франциски Хинкельманн.]


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


Внимательный читатель также может заметить, что мы явно передаем значение false туда, где раньше использовалось неявное undefined. Это, кажется, тоже вносит вклад в улучшение производительности. Если мы заменим false на void 0, то мы получим немного худшие значения:


diff --git a/dist/source-map.js b/dist/source-map.js
index 2d39b28..243b2ef 100644
--- a/dist/source-map.js
+++ b/dist/source-map.js
@@ -2779,7 +2779,7 @@ return /* **** */ (function(modules) { // webpackBootstrap
            //
            //   * Every element in `ary[i+1 .. j-1]` is greater than the pivot.
            for (var j = p; j < r; j++) {
-             if (comparator(ary[j], pivot, false) <= 0) {
+             if (comparator(ary[j], pivot, void 0) <= 0) {
                i += 1;
                swap(ary, i, j);
              }

?- ~/src/source-map/bench ‹perf-work U› [Fix comparator invocation arity]
?-$ ~/src/v8/v8/out.gn/x64.release/d8 bench-shell-bindings.js
Parsing source map
console.timeEnd: iteration, 4215.623000
console.timeEnd: iteration, 4247.643000
console.timeEnd: iteration, 4425.871000
console.timeEnd: iteration, 4167.691000
console.timeEnd: iteration, 4343.613000
console.timeEnd: iteration, 4209.427000
[Stats samples: 6, total: 25610 ms, mean: 4268.333333333333 ms, stddev: 106.38947316346669 ms]

Как бы то ни было, адаптация аргументов кажется очень специфичной для V8. Когда я запускал бенчмарк для SpiderMonkey, я не увидел никакого значительного прироста производительности от совпадающей арности:


?- ~/src/source-map/bench ‹ d052ea4› [Disabled serialization part of the benchmark]
?-$ sm bench-shell-bindings.js
Parsing source map
[Stats samples: 8, total: 24751 ms, mean: 3093.875 ms, stddev: 327.27966571700836 ms]
?- ~/src/source-map/bench ‹perf-work› [Fix comparator invocation arity]
?-$ sm bench-shell-bindings.js
Parsing source map
[Stats samples: 8, total: 25397 ms, mean: 3174.625 ms, stddev: 360.4636187025859 ms]

[Оболочка SpiderMonkey теперь очень проста в установке, спасибо Матиасу Байенсу за инструемент jsvu]


Давайте вернемся обратно к коду сортировки. Если мы попрофилируем бенчмарк снова, то мы заметим что ArgumentsAdaptorTrampoline исчез из профиля, но CallFunction_ReceiverIsNullOrUndefined все еще здесь. Это не удивительно, ведь мы все еще вызываем comparator.


Оптимизируем сортировку — Мономорфизация


Что обычно исполняется быстрее, чем вызов функции? Его отсутствие!


Очевидный вариант здесь, это попытаться встроить функцию сравнения в doQuickSort. Однако, у нас на пути встает тот факт, что doQuickSort вызывается с разными функциями.


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


Начнем с оборачивания doQuickSort и других вспомогательных утилит в функцию SortTemplate:


function SortTemplate(comparator) {
  function swap(ary, x, y) {
    // ...
  }

  function randomIntInRange(low, high) {
    // ...
  }

  function doQuickSort(ary, p, r) {
    // ...
  }

  return doQuickSort;
}

Затем мы сможем производить клоны наших сортировочных процедур путем конвертации SortTemplate в строку, передачи и разбора обратно в функцию через конструктор Function:


function cloneSort(comparator) {
  let template = SortTemplate.toString();
  let templateFn = new Function(`return ${template}`)();
  return templateFn(comparator);  // Invoke template to get doQuickSort
}

Теперь мы можем использовать cloneSort, чтобы производить функцию сортировки для каждого используемого компаратора:


let sortCache = new WeakMap();  // Cache for specialized sorts.
exports.quickSort = function (ary, comparator) {
  let doQuickSort = sortCache.get(comparator);
  if (doQuickSort === void 0) {
    doQuickSort = cloneSort(comparator);
    sortCache.set(comparator, doQuickSort);
  }
  doQuickSort(ary, 0, ary.length - 1);
};

Перезапуск бенчмарка выдает нам:


?- ~/src/source-map/bench ‹perf-work› [Clone sorting functions for each comparator]
?-$ d8 bench-shell-bindings.js
Parsing source map
console.timeEnd: iteration, 2955.199000
console.timeEnd: iteration, 3084.979000
console.timeEnd: iteration, 3193.134000
console.timeEnd: iteration, 3480.459000
console.timeEnd: iteration, 3115.011000
console.timeEnd: iteration, 3216.344000
console.timeEnd: iteration, 3343.459000
console.timeEnd: iteration, 3036.211000
[Stats samples: 8, total: 25423 ms, mean: 3177.875 ms, stddev: 181.87633161024556 ms]

Мы видим, что среднее время опустилось с 4268 мс до 3177 мс (25% улучшения).


Профилирование показывает следующую картину:


 Overhead Symbol
   14.95% *doQuickSort :44
   11.49% *doQuickSort :44
    3.29% Builtin:StringEqual
    3.13% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    1.86% v8::internal::StringTable::LookupStringIfExists_NoAllocate
    1.86% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    1.72% Builtin:StringCharAt
    1.67% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
    1.61% v8::internal::Scavenger::ScavengeObject
    1.45% v8::internal::(anonymous namespace)::StringTableNoAllocateKey::IsMatch
    1.23% Builtin:StringPrototypeSlice
    1.17% v8::internal::(anonymous namespace)::MakeStringThin
    1.08% Builtin:KeyedLoadIC_Megamorphic
    1.05% v8::internal::(anonymous namespace)::CopyObjectToObjectElements
    0.99% v8::internal::String::VisitFlat<v8::internal::IteratingStringHasher>
    0.86% clear_page_c_e
    0.77% v8::internal::IncrementalMarking::RecordWriteSlow
    0.48% Builtin:MathRandom
    0.41% Builtin:RecordWrite
    0.39% Builtin:KeyedLoadIC

Накладные расходы связанные с вызовом comparator теперь полностью исчезли из профиля.


В этот момент мне стало интересно, насколько больше времени мы тратим на разбор маппингов по сравнению с их сортировкой. Я перешел к коду разбора и добавил несколько вызовов Date.now():


[Я бы хотел добавить performance.now(), но оболочка SpiderMonkey этого не поддерживает.]


diff --git a/dist/source-map.js b/dist/source-map.js
index 75ebbdf..7312058 100644
--- a/dist/source-map.js
+++ b/dist/source-map.js
@@ -1906,6 +1906,8 @@ return /* **** */ (function(modules) { // webpackBootstrap
            var generatedMappings = [];
            var mapping, str, segment, end, value;

+
+      var startParsing = Date.now();
            while (index < length) {
              if (aStr.charAt(index) === ';') {
                generatedLine++;
@@ -1986,12 +1988,20 @@ return /* **** */ (function(modules) { // webpackBootstrap
                }
              }
            }
+      var endParsing = Date.now();

+      var startSortGenerated = Date.now();
            quickSort(generatedMappings, util.compareByGeneratedPositionsDeflated);
            this.__generatedMappings = generatedMappings;
+      var endSortGenerated = Date.now();

+      var startSortOriginal = Date.now();
            quickSort(originalMappings, util.compareByOriginalPositions);
            this.__originalMappings = originalMappings;
+      var endSortOriginal = Date.now();
+
+      console.log(`${}, ${endSortGenerated - startSortGenerated}, ${endSortOriginal - startSortOriginal}`);
+      console.log(`sortGenerated: `);
+      console.log(`sortOriginal:  `);
          };

Получился такой результат:


?- ~/src/source-map/bench ‹perf-work U› [Clone sorting functions for each comparator]
?-$ d8 bench-shell-bindings.js
Parsing source map
parse:         1911.846
sortGenerated: 619.5990000000002
sortOriginal:  905.8220000000001
parse:         1965.4820000000004
sortGenerated: 602.1939999999995
sortOriginal:  896.3589999999995
^C

А так времена разбора и сортировки выглядят в V8 и SpiderMonkey на каждую итерацию запуска бенчмарка:


Parse and Sort times


В V8 мы, кажется, тратим примерно столько же времени на разбор маппингов, как и на их сортировку. В SpiderMonkey разбор значительно быстрее, — но медленнее сортировка. Это сподвигло меня посмотреть на код разбора.


Оптимизируем разбор — Удаляем кэш сегментов


Давайте посмотрим на профиль еще раз:


Overhead  Symbol
  18.23%  *doQuickSort :44
  12.36%  *doQuickSort :44
   3.84%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
   3.07%  Builtin:StringEqual
   1.92%  v8::internal::StringTable::LookupStringIfExists_NoAllocate
   1.85%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
   1.59%  *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
   1.54%  Builtin:StringCharAt
   1.52%  v8::internal::(anonymous namespace)::StringTableNoAllocateKey::IsMatch
   1.38%  v8::internal::Scavenger::ScavengeObject
   1.27%  Builtin:KeyedLoadIC_Megamorphic
   1.22%  Builtin:StringPrototypeSlice
   1.10%  v8::internal::(anonymous namespace)::MakeStringThin
   1.05%  v8::internal::(anonymous namespace)::CopyObjectToObjectElements
   1.03%  v8::internal::String::VisitFlat<v8::internal::IteratingStringHasher>
   0.88%  clear_page_c_e
   0.51%  Builtin:MathRandom
   0.48%  Builtin:KeyedLoadIC
   0.46%  v8::internal::IteratingStringHasher::Hash
   0.41%  Builtin:RecordWrite

Удаление JavaScript-кода, который мы уже знаем, оставляет нам следующее:


Overhead  Symbol
   3.07%  Builtin:StringEqual
   1.92%  v8::internal::StringTable::LookupStringIfExists_NoAllocate
   1.54%  Builtin:StringCharAt
   1.52%  v8::internal::(anonymous namespace)::StringTableNoAllocateKey::IsMatch
   1.38%  v8::internal::Scavenger::ScavengeObject
   1.27%  Builtin:KeyedLoadIC_Megamorphic
   1.22%  Builtin:StringPrototypeSlice
   1.10%  v8::internal::(anonymous namespace)::MakeStringThin
   1.05%  v8::internal::(anonymous namespace)::CopyObjectToObjectElements
   1.03%  v8::internal::String::VisitFlat<v8::internal::IteratingStringHasher>
   0.88%  clear_page_c_e
   0.51%  Builtin:MathRandom
   0.48%  Builtin:KeyedLoadIC
   0.46%  v8::internal::IteratingStringHasher::Hash
   0.41%  Builtin:RecordWrite

Когда я начал смотреть на цепочки вызовов для отдельных записей, я обнаружил, что немало из них проходит через KeyedLoadIC_Megamorphic в SourceMapConsumer_parseMappings.


-    1.92% v8::internal::StringTable::LookupStringIfExists_NoAllocate
   - v8::internal::StringTable::LookupStringIfExists_NoAllocate
      + 99.80% Builtin:KeyedLoadIC_Megamorphic

-    1.52% v8::internal::(anonymous namespace)::StringTableNoAllocateKey::IsMatch
   - v8::internal::(anonymous namespace)::StringTableNoAllocateKey::IsMatch
      - 98.32% v8::internal::StringTable::LookupStringIfExists_NoAllocate
         + Builtin:KeyedLoadIC_Megamorphic
      + 1.68% Builtin:KeyedLoadIC_Megamorphic

-    1.27% Builtin:KeyedLoadIC_Megamorphic
   - Builtin:KeyedLoadIC_Megamorphic
      + 57.65% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
      + 22.62% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
      + 15.91% *SourceMapConsumer_parseMappings ../dist/source-map.js:1894
      + 2.46% Builtin:InterpreterEntryTrampoline
      + 0.61% BytecodeHandler:Mul
      + 0.57% *doQuickSort :44

-    1.10% v8::internal::(anonymous namespace)::MakeStringThin
   - v8::internal::(anonymous namespace)::MakeStringThin
      - 94.72% v8::internal::StringTable::LookupStringIfExists_NoAllocate
         + Builtin:KeyedLoadIC_Megamorphic
      + 3.63% Builtin:KeyedLoadIC_Megamorphic
      + 1.66% v8::internal::StringTable::LookupString

Такая сортировка стеков вызова просигналила мне, что код исполняет множество сопоставлений по ключу в виде obj[key], где key — это динамически сформированная строка. Когда я глянул в исходник, то обнаружил следующий код:


// Because each offset is encoded relative to the previous one,
// many segments often have the same encoding. We can exploit this
// fact by caching the parsed variable length fields of each segment,
// allowing us to avoid a second parse if we encounter the same
// segment again.
for (end = index; end < length; end++) {
  if (this._charIsMappingSeparator(aStr, end)) {
    break;
  }
}
str = aStr.slice(index, end);

segment = cachedSegments[str];
if (segment) {
  index += str.length;
} else {
  segment = [];
  while (index < end) {
    base64VLQ.decode(aStr, index, temp);
    value = temp.value;
    index = temp.rest;
    segment.push(value);
  }

  // ...

  cachedSegments[str] = segment;
}

Этот код отвечает за раскодирование Base64 VLQ-кодированных последовательностей, то есть строка A будет раскодирована как [0], а UAAAA раскодируется в [10,0,0,0,0]. Я предлагаю посмотреть этот пост про внутренности source maps, если вы желаете лучше понять сам процесс кодировки.


Вместо того чтобы раскодировать каждую последовательность независимо, этот код пытается кэшировать расшифрованные сегменты: смотрит вперед до разделителя (, или ;), затем извлекает подстроку от текущей позиции до разделителя и проверяет, есть ли у нас уже такой сегмент в кэше — и если таковой нашелся, то вернет закэшированный сегмент, в противном случае, он разбирает его и кладет в кэш.


Кэширование (оно же мемоизация) – это очень сильная техника оптимизации — однако, она имеет смысл только если обслуживание самого кэша и поиск там результатов оказывается дешевле, чем собственно сам расчёт заново.


Абстрактный анализ


Давайте попробуем абстрактно сравнить эти две операции:


С одной стороны чистый разбор:


Разбирая сегмент, мы смотрим на каждый символ по одному разу. Для кадого символа производится несколько сравнений и арифметических операций, чтобы сконвертировать base64 символ в целочисленное значение. Затем производится несколько побитовых операций, чтобы объединить эти численные значения в одно большое. Затем раскордированное значение сохраняется в массив и мы переходим к следующей части сегмента. Сегменты ограничены пятью элементами.


С другой стороны кэширование:


  1. Чтобы найти закэшироваенное значение, мы обходим все символы сегмента один раз чтобы найти его конец;
  2. Мы извлекаем подстроку, которая требует размещения и, возможно, копирования, в зависимости от того, как строки реализованы в JS VM.
  3. Мы используем эту строку как ключ для словаря, который:
    1. Сначала требует от VM рассчитать хэш для этой строки (проходя её ещё раз и выполняя различные побитовые операции на символах), это также может потребовать от VM интернализовать строку (в зависимости от реализации).
    2. Затем VM должна выполнить сопоставление хэша по таблице, что требует обращения и сравнения ключа по значению с другими ключами (это может потребовать просмотра отдельных символов еще раз);

В целом это выглядит так, что прямой разбор будет быстрее, подразумевая, что JS VM хорошо работает с отдельными арифметическими и побитовыми операциями, просто потому, что он смотрит на каждый символ только раз, в то время как кэширование требует обхода сегмента 2-4 раза, просто чтобы установить, попали ли мы в кэш или нет.


Профилирование похоже подтверждает это тоже: KeyedLoadIC_Megamorphic используется в V8 для реализации обращения по ключу, вроде cachedSegments[str] в коде сверху.


Основываясь на этих наблюдениях, я провел несколько экспериментов. Во-первых я проверил, насколько большой кэш cachedSegments в конце разбора. Чем он меньше, тем более эффективным могло бы быть кэширование.


Оказывается, что он вырастает довольно сильно:


Object.keys(cachedSegments).length = 155478

Отдельные микробенчмарки


Теперь я решил написать маленький отдельный бенчмарк:


// Генерируем строку с [n] сегментами, сегменты повторяются в цикле длиной [v],
// т.е. сегменты 0, v, 2*v, ... равны, так же как и 1, 1 + v, 1 + 2*v, ...
// [base] используется как базовое значение сегмента - этот параметр позволяет
// делать сегменты длинными
//
// Примечание: чем больше [v], тем больше кэш [cachedSegments]
function makeString(n, v, base) {
  var arr = [];
  for (var i = 0; i < n; i++) {
    arr.push([0, base + (i % v), 0, 0].map(base64VLQ.encode).join(''));
  }
  return arr.join(';') + ';';
}

// Запустить функцию [f] для строки [str].
function bench(f, str) {
  for (var i = 0; i < 1000; i++) {
    f(str);
  }
}

// Измерить и сообщить прозводительность [f] для [str].
// Это делается для [v] разных сегментов
function measure(v, str, f) {
  var start = Date.now();
  bench(f, str);
  var end = Date.now();
  report(`${v}, ${f.name}, ${(end - start).toFixed(2)}`);
}

async function measureAll() {
  for (let v = 1; v <= 256; v *= 2) {
    // Создать строку с 1000 сегментов в целом и [v] уникальных,
    // так что [cachedSegments] будет иметь [v] зашекированных значений.
    let str = makeString(1000, v, 1024 * 1024);

    let arr = encoder.encode(str);

    // Запустить 10 итераций для каждого способа декодирования.
    for (var j = 0; j < 10; j++) {
     measure(j, i, str, decodeCached);
     measure(j, i, str, decodeNoCaching);
     measure(j, i, str, decodeNoCachingNoStrings);
     measure(j, i, arr, decodeNoCachingNoStringsPreEncoded);
     await nextTick();
    }
  }
}

function nextTick() { return new Promise((resolve) => setTimeout(resolve)); }

Здесь даны три различных способа декодировать сегменты Base64 VLQ, которые я тестировал.


Первый — decodeCached это то же самое, что и стандартная реализация в source-map — которую я уже показал выше:


function decodeCached(aStr) {
  var length = aStr.length;
  var cachedSegments = {};
  var end, str, segment, value, temp = {value: 0, rest: 0};
  const decode = base64VLQ.decode;

  var index = 0;
  while (index < length) {
    // Because each offset is encoded relative to the previous one,
    // many segments often have the same encoding. We can exploit this
    // fact by caching the parsed variable length fields of each segment,
    // allowing us to avoid a second parse if we encounter the same
    // segment again.
    for (end = index; end < length; end++) {
      if (_charIsMappingSeparator(aStr, end)) {
        break;
      }
    }
    str = aStr.slice(index, end);

    segment = cachedSegments[str];
    if (segment) {
      index += str.length;
    } else {
      segment = [];
      while (index < end) {
        decode(aStr, index, temp);
        value = temp.value;
        index = temp.rest;
        segment.push(value);
      }

      if (segment.length === 2) {
        throw new Error('Found a source, but no line and column');
      }

      if (segment.length === 3) {
        throw new Error('Found a source and line, but no column');
      }

      cachedSegments[str] = segment;
    }

    index++;
  }
}

Следующий участник — это decodeNoCaching. В основном то же самое, что и decodeCached, но без кэширования. Каждый сегмент декодируется независимо. Также я заменил Array на Int32Array для хранилища segment.


function decodeNoCaching(aStr) {
  var length = aStr.length;
  var cachedSegments = {};
  var end, str, segment, temp = {value: 0, rest: 0};
  const decode = base64VLQ.decode;

  var index = 0, value;
  var segment = new Int32Array(5);
  var segmentLength = 0;
  while (index < length) {
    segmentLength = 0;
    while (!_charIsMappingSeparator(aStr, index)) {
      decode(aStr, index, temp);
      value = temp.value;
      index = temp.rest;
      if (segmentLength >= 5) throw new Error('Too many segments');
      segment[segmentLength++] = value;
    }

    if (segmentLength === 2) {
      throw new Error('Found a source, but no line and column');
    }

    if (segmentLength === 3) {
      throw new Error('Found a source and line, but no column');
    }

    index++;
  }
}

И, наконец, третий вариант decodeNoCachingNoString пробует избежать работы с JavaScript-строками вообще, путем конвертирования строки в utf8-кодированный Uint8Array. Эта оптимизация вдохновлена тем, что, скорее всего, JS VM оптимизируют загрузку массива в единое место в памяти. Оптимизация String.prototype.charCodeAt до такого же уровня будет сложнее, ввиду сложности иерархии различных представлений строк, используемых JS VM.


Я измерил производительность обоих версий: той что раскодирует в строку в utf8 в рамках итерации, так и той что использует заранее раскодированную строку. С это второй "оптимистичной" версией я стараюсь оценить, как много мы могли бы выиграть, если бы смогли пропустить круг typed array ? string ? typed array. Что было бы возможно, если бы мы загружали source map напрямую в array buffer и разбирали её прямо из этого буфера, вместо того чтобы сначала раскодировать в строку.


let encoder = new TextEncoder();
function decodeNoCachingNoString(aStr) {
  decodeNoCachingNoStringPreEncoded(encoder.encode(aStr));
}

function decodeNoCachingNoStringPreEncoded(arr) {
  var length = arr.length;
  var cachedSegments = {};
  var end, str, segment, temp = {value: 0, rest: 0};
  const decode2 = base64VLQ.decode2;

  var index = 0, value;
  var segment = new Int32Array(5);
  var segmentLength = 0;
  while (index < length) {
    segmentLength = 0;
    while (arr[index] != 59 && arr[index] != 44) {
      decode2(arr, index, temp);
      value = temp.value;
      index = temp.rest;
      if (segmentLength < 5) {
        segment[segmentLength++] = value;
      }
    }

    if (segmentLength === 2) {
      throw new Error('Found a source, but no line and column');
    }

    if (segmentLength === 3) {
      throw new Error('Found a source and line, but no column');
    }

    index++;
  }
}

Вот результаты, что я получил запуская свой микробенчмарк в Chrome Dev
66.0.3343.3 (V8 6.6.189) и Firefox Nightly 60.0a1 (2018-02-11):


Different Decodes


Здесь можно заметить два момента:


  • Версия, которая использует кэширование, медленнее всех и в V8, и в SpiderMonkey. Её производительность круто проседает с ростом записей в кэше — в то время как прозводительность версий без кэша от этого не зависит;
  • В SpiderMonkey окупается конвертация строки в типизированный массив как часть разбора, однако, в V8 доступ к символам достаточно быстр — так что это окупится только если мы вынесем преобразование "строка-в-массив" за пределы бенчмарка (т.е. вы загружете свои данные в типизированный массив с самого начала);

Мне стало интересно, не делала ли команда V8 недавно каких-то улучшений в скорости charCodeAt – как я ясно помню, Crankshaft никогда не пытался специализировать charCodeAt на конкретном представлении строки в месте вызова, используя вместо этого расширенный charCodeAt, обрабатывающий множество различиных представлений, делая загрузку символов из строк медленнее, чем элементов из типизированных массивов.


Я пробежался по баг-трекеру V8 и нашел несколько активных репортов:



Некоторые из комментариев там ссылаются на коммиты от конца января 2018 года и свежее, что свидетельствует о том, что производительность charCodeAt активно улучшается. Из любопытства я решил перезапустить свой бенчмарк в Chrome Beta и сравнить с Chrome Dev.


Different Decodes


Это сравнение фактически подтверждает, что все эти коммиты от команды V8 были не зря: производительность charCodeAt кардинально улучшилась от версии 6.5.254.21 к 6.6.189. Сравнивая линии "no cache" и "using array", мы можем видить, что charCodeAt в старом V8 вел себя намного хуже, что это имело смысл конвертировать строку в Uint8Array, просто чтобы сделать обращение быстрее. Однако, накладные расходы от этого преобразования внутри разбора больше не окупаются в более новом V8.


Однако, использование массива вместо строки все ещё окупается, если нам не нужно тратиться на конвертацию. Почему так? Чтобы это выяснить, я запустил следующий код внутри V8:


function foo(str, i) {
  return str.charCodeAt(i);
}

let str = "fisk";

foo(str, 0);
foo(str, 0);
foo(str, 0);
%OptimizeFunctionOnNextCall(foo);
foo(str, 0);

?- ~/src/v8/v8 ‹master›
?-$ out.gn/x64.release/d8 --allow-natives-syntax --print-opt-code --code-comments x.js

Команда произвела гинантский листинг ассемблера, подтверждающий мое подозрение, что V8 всё ещё не специализирует charCodeAt на конкретное представление строки. Снижение, кажется, приходит из этого кода в исходниках V8, который раскрывает тайну, почему доступ к массиву всё ещё быстрее чем строковый charCodeAt.


Улучшения в разборе


В свете этих открытий, давайте удалим кеширование разобранных сегментов из кода source-map и измерим эффект.


Parse and Sort times


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


Оптимизируем сортировку – Улучшения в алгоритмах


После того как мы улучшили скорость разбора, давайте глянем еще раз на сортировку.


Тут сортируются два массива:


  1. originalMappings сортируется с использованием компаратора compareByOriginalPositions;
  2. generatedMappings сортируется с использованием compareByGeneratedPositionsDeflated.

Оптимизируем сортировку originalMappings


Сперва я взглянул на compareByOriginalPositions.


function compareByOriginalPositions(mappingA, mappingB, onlyCompareOriginal) {
  var cmp = strcmp(mappingA.source, mappingB.source);
  if (cmp !== 0) {
    return cmp;
  }

  cmp = mappingA.originalLine - mappingB.originalLine;
  if (cmp !== 0) {
    return cmp;
  }

  cmp = mappingA.originalColumn - mappingB.originalColumn;
  if (cmp !== 0 || onlyCompareOriginal) {
    return cmp;
  }

  cmp = mappingA.generatedColumn - mappingB.generatedColumn;
  if (cmp !== 0) {
    return cmp;
  }

  cmp = mappingA.generatedLine - mappingB.generatedLine;
  if (cmp !== 0) {
    return cmp;
  }

  return strcmp(mappingA.name, mappingB.name);
}

Я заметил, что маппинги упорядочены сперва по source компоненте, а затем уже по всем остальным. source обозначает, с какого исходного файла пришел маппинг. Приходит очевидная идея, что вместо использования гигантского плоского массива originalMappings, который смешивает маппинги от разных исходных файлов, мы можем превратить originalMappings в массив массивов: originalMappings[i] будет массивом маппингов из исходного файла под номером i. Таким образом, мы сначала отсортируем маппинги в различные массивы originalMappings[i] по источнику, по мере разбора, а затем отсортируем отдельные меньшие массивы.


[В принципе, это – блочная сортировка]


Вот что мы сделаем в цикле разбора:


if (typeof mapping.originalLine === 'number') {
  // Раньше этот код просто делал originalMappings.push(mapping).
  // Теперь он сортирует исходные маппинги по источнику сразу же во время разбора.
  let currentSource = mapping.source;
  while (originalMappings.length <= currentSource) {
    originalMappings.push(null);
  }
  if (originalMappings[currentSource] === null) {
    originalMappings[currentSource] = [];
  }
  originalMappings[currentSource].push(mapping);
}

Затем еще здесь:


var startSortOriginal = Date.now();
// Код сортировал целый массив:
//     quickSort(originalMappings, util.compareByOriginalPositions);
for (var i = 0; i < originalMappings.length; i++) {
  if (originalMappings[i] != null) {
    quickSort(originalMappings[i], util.compareByOriginalPositionsNoSource);
  }
}
var endSortOriginal = Date.now();

Компаратор compareByOriginalPositionsNoSource почти такой же как и compareByOriginalPositions за исключением того, что он не больше не сравнивает source компоненту — они гарантированно равны ввиду способа, которым мы собираем каждый массив originalMappings[i].


Parse and Sort times


Это изменение алгоритма улучшает времена сортировки и в V8, и в SpiderMonkey, а также дополнительно улучшает время разбора в V8.


[Улучшение времени разбора скорее всего вызвано уменьшением затрат на управление массивом originalMappings: выращивать один гигантский массив originalMappings будет дороже, чем растить меньшие массивы originalMappings[i] по отдельности. Однако это всего лишь моя догадка, которая не подтверждена никаким надежным анализом.]


Оптимизируем сортировку generatedMappings


Давайте взглянем на generatedMappings и его компаратор compareByGeneratedPositionsDeflated.


function compareByGeneratedPositionsDeflated(mappingA, mappingB, onlyCompareGenerated) {
  var cmp = mappingA.generatedLine - mappingB.generatedLine;
  if (cmp !== 0) {
    return cmp;
  }

  cmp = mappingA.generatedColumn - mappingB.generatedColumn;
  if (cmp !== 0 || onlyCompareGenerated) {
    return cmp;
  }

  cmp = strcmp(mappingA.source, mappingB.source);
  if (cmp !== 0) {
    return cmp;
  }

  cmp = mappingA.originalLine - mappingB.originalLine;
  if (cmp !== 0) {
    return cmp;
  }

  cmp = mappingA.originalColumn - mappingB.originalColumn;
  if (cmp !== 0) {
    return cmp;
  }

  return strcmp(mappingA.name, mappingB.name);
}

Здесь мы сперва сравниваем маппинги по generatedLine. Здесь, скорее всего, значительно больше сгенерированных строк, чем исходных файлов, так что это не имеет смысла разделять generatedMappings на множественные отдельные массивы.


Однако, затем я глянул на код разбора и заметил следующее:


while (index < length) {
  if (aStr.charAt(index) === ';') {
    generatedLine++;
    // ...
  } else if (aStr.charAt(index) === ',') {
    // ...
  } else {
    mapping = new Mapping();
    mapping.generatedLine = generatedLine;

    // ...
  }
}

Это единственные вхождения generatedLine в этом коде, что означает, что generatedLine монотонно растет, а зная, что generatedMappings уже отсортирован по generatedLine, не имеет смысла сортировать массив целиком. Вместо этого мы может сортировать каждый отдельный подмассив. Мы изменим код вот так:


let subarrayStart = 0;
while (index < length) {
  if (aStr.charAt(index) === ';') {
    generatedLine++;
    // ...

    // Сортируем подмассив [subarrayStart, generatedMappings.length].
    sortGenerated(generatedMappings, subarrayStart);
    subarrayStart = generatedMappings.length;
  } else if (aStr.charAt(index) === ',') {
    // ...
  } else {
    mapping = new Mapping();
    mapping.generatedLine = generatedLine;

    // ...
  }
}
// Отсортируем оставшийся хвост
sortGenerated(generatedMappings, subarrayStart);

Вместо использования быстрой сортировки для маленьких подмассивов я также решил взять сортировку вставками, похожую на гибридную стратегию, используемую некоторыми VM для Array.prototype.sort.


[Примечание: сортировка вставками также значительно быстрее чем быстрая сортировка, если входной массив уже отсортирован… и оказывается, что маппинги используемые в бенчмарке фактически отсортированы. Если мы ожидаем, что generatedMappings будет почти всегда отсортирован после разбора, то будет ещё более эффективно просто проверить, отсортирован ли generatedMappings, перед тем как пытаться его отсортировать.]


const compareGenerated = util.compareByGeneratedPositionsDeflatedNoLine;

function sortGenerated(array, start) {
  let l = array.length;
  let n = array.length - start;
  if (n <= 1) {
    return;
  } else if (n == 2) {
    let a = array[start];
    let b = array[start + 1];
    if (compareGenerated(a, b) > 0) {
      array[start] = b;
      array[start + 1] = a;
    }
  } else if (n < 20) {
    for (let i = start; i < l; i++) {
      for (let j = i; j > start; j--) {
        let a = array[j - 1];
        let b = array[j];
        if (compareGenerated(a, b) <= 0) {
          break;
        }
        array[j - 1] = b;
        array[j] = a;
      }
    }
  } else {
    quickSort(array, compareGenerated, start);
  }
}

Это выдает следующий результат:


Parse and Sort times


Времена сортировки падают значительно, в то время как времена разбора слегка увеличиваются – так получается, потому что код сортировки generatedMappings теперь часть цикла разбора, делая такое разделение немного бессмысленным. Давайте проверим суммарные времена (разбор и сортировка вместе)


Улучшения к общему времени


Parse and Sort times


Теперь это очевидно, что мы значительно улучшили общую прозиводителньость разбора маппингов.


Есть ли здесь что-то еще, что мы можем сделать для улучшения прозиводительности?


Оказывается, да: мы можем стянуть один приём из книги рецептов asm.js / WASM, без перехода целиком на Rust в нашем JavaScript коде.


Оптимизируем разбор – уменьшаем нагрузку на GC


Мы размещаем сотни тысяч объектов Mapping, которые создают ощутимую нагрузку на сборщик мусора (GC) – однако, нам не нужны эти объекты в виде объектов – мы можем упаковать их в типизированный массив. Вот как я это сделал.


[Несколько лет назад я был в полном восторге от предложения типизированных объектов, которые позволили бы JavaScript-разработчикам определять структуры и массивы структур и другие замечательные вещи, которые могли бы оказаться нереально полезными. К сожалению, авторы, работающие над этим предложением, переключились на работу над другими вещами, оставив нас перед выбором: либо писать вручную, либо через C++ :-(]


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


function Mapping(memory) {
  this._memory = memory;
  this.pointer = 0;
}
Mapping.prototype = {
  get generatedLine () {
    return this._memory[this.pointer + 0];
  },
  get generatedColumn () {
    return this._memory[this.pointer + 1];
  },
  get source () {
    return this._memory[this.pointer + 2];
  },
  get originalLine () {
    return this._memory[this.pointer + 3];
  },
  get originalColumn () {
    return this._memory[this.pointer + 4];
  },
  get name () {
    return this._memory[this.pointer + 5];
  },
  set generatedLine (value) {
    this._memory[this.pointer + 0] = value;
  },
  set generatedColumn (value) {
    this._memory[this.pointer + 1] = value;
  },
  set source (value) {
    this._memory[this.pointer + 2] = value;
  },
  set originalLine (value) {
    this._memory[this.pointer + 3] = value;
  },
  set originalColumn (value) {
    this._memory[this.pointer + 4] = value;
  },
  set name (value) {
    this._memory[this.pointer + 5] = value;
  },
};

Затем я подстроил код разбора и сортировки, чтобы использовать его вот так:


BasicSourceMapConsumer.prototype._parseMappings = function (aStr, aSourceRoot) {
  // Выделить 4 МБ буфер в памяти. Значение может быть пропорциональным
  // размеру aStr для сохраниния памяти в случае меньших маппингов
  this._memory = new Int32Array(1 * 1024 * 1024);
  this._allocationFinger = 0;
  let mapping = new Mapping(this._memory);
  // ...
  while (index < length) {
    if (aStr.charAt(index) === ';') {

      // Весь код, который мог до этого обращаться к маппингам напрямую,
      // теперь должен обращаться к ним косвенно через память
      sortGenerated(this._memory, generatedMappings, previousGeneratedLineStart);
    } else {
      this._allocateMapping(mapping);

      // ...

      // Массивы маппингов теперь хранят "указатели" вместо реальных маппингов.
      generatedMappings.push(mapping.pointer);
      if (segmentLength > 1) {
        // ...
        originalMappings[currentSource].push(mapping.pointer);
      }
    }
  }

  // ...

  for (var i = 0; i < originalMappings.length; i++) {
    if (originalMappings[i] != null) {
      quickSort(this._memory, originalMappings[i], util.compareByOriginalPositionsNoSource);
    }
  }
};

BasicSourceMapConsumer.prototype._allocateMapping = function (mapping) {
  let start = this._allocationFinger;
  let end = start + 6;
  if (end > this._memory.length) {  // Do we need to grow memory buffer?
    let memory = new Int32Array(this._memory.length * 2);
    memory.set(this._memory);
    this._memory = memory;
  }
  this._allocationFinger = end;
  let memory = this._memory;
  mapping._memory = memory;
  mapping.pointer = start;
  mapping.name = 0x7fffffff;  // Instead of null use INT32_MAX.
  mapping.source = 0x7fffffff;  // Instead of null use INT32_MAX.
};

exports.compareByOriginalPositionsNoSource =
  function (memory, mappingA, mappingB, onlyCompareOriginal) {
  var cmp = memory[mappingA + 3] - memory[mappingB + 3];  // originalLine
  if (cmp !== 0) {
    return cmp;
  }

  cmp = memory[mappingA + 4] - memory[mappingB + 4];  // originalColumn
  if (cmp !== 0 || onlyCompareOriginal) {
    return cmp;
  }

  cmp = memory[mappingA + 1] - memory[mappingB + 1];  // generatedColumn
  if (cmp !== 0) {
    return cmp;
  }

  cmp = memory[mappingA + 0] - memory[mappingB + 0];  // generatedLine
  if (cmp !== 0) {
    return cmp;
  }

  return memory[mappingA + 5] - memory[mappingB + 5];  // name
};

Как вы видите, читабельность слегка пострадала. В идеале я бы предпочел выделить временный объект Mapping, каждый раз, когда мне понадобилось бы работать с его полями. Однако такой стиль кода сильно зависел бы от способности VM убирать размещенные временные объекты путем allocation sinking, scalar replacement и других похожих оптимизаций. К сожалению, в моих экспериментах SpiderMonkey не смог работать с таким кодом достаточно хорошо, так что я пошел более многословным и ошибкоопасным путем.


[Такой вид почти ручного управления памятью выглядит чуждо для JS. Поэтому я думаю, здесь стоит заметить, что "окисленная" source-map на самом деле требует от пользователей управлять вручную её временем жизни, чтобы удостовериться в освобождении ресурсов WASM]


Перезапуск бенчмарка подтверждает, что облегчение нагрузки на GC выдает неплохое улучшение:


After reworking allocation


After reworking allocation


Довольно интересно, что SpiderMonkey при этом подходе улучшает время и разбора, и сортировки, что оказалось для меня сюрпризом.


Уступ производительности в SpiderMonkey


Играясь с кодом, я также обнаружил смущающий уступ в производительности SpiderMonkey: когда я увеличивал размер предразмещенного буфера в памяти с 4 до 64 МБ, чтобы откалибровать затраты на переразмещение, бенчарк показал мне внезапное падение производительности на 7й итерации.


After reworking allocation


Это казалось мне каким-то видом полиморфизма, но я не смог сразу же установить, как изменение размера массива смогло привести к полиморфному поведению.


Озадаченный я связался с разработчиком SpiderMonkey Jan de Mooij, который очень быстро определил виновника – связанную с asm.js оптимизацию от 2012 года… позже он взял и удалил её из SpiderMonkey, так что никто больше не споткнется об этот смущающий уступ.


Оптимизируем разбор – Использование Uint8Array вместо строки.


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


After reworking allocation


[Это улучшение основано на переписывании source-map на разбор маппингов напрямую из типизированных массивов, вместо использования JavaScript-строк и разбора их с помощью JSON.decode. Я не делал такого переписывания, но не ожидаю здесь никаких промблем.]


Общие улучшения по сравнению с начальным состоянием


Вот где мы начали:


$ d8 bench-shell-bindings.js
...
[Stats samples: 5, total: 24050 ms, mean: 4810 ms, stddev: 155.91063145276527 ms]
$ sm bench-shell-bindings.js
...
[Stats samples: 7, total: 22925 ms, mean: 3275 ms, stddev: 269.5999093306804 ms]

и вот где мы заканчиваем


$ d8 bench-shell-bindings.js
...
[Stats samples: 22, total: 25158 ms, mean: 1143.5454545454545 ms, stddev: 16.59358125226469 ms]
$ sm bench-shell-bindings.js
...
[Stats samples: 31, total: 25247 ms, mean: 814.4193548387096 ms, stddev: 5.591064299397745 ms]

After reworking allocation


After reworking allocation


Это улучшение в 4 раза!


Может быть, также стоит заметить, что мы все ещё сортируем все массивы originalMappings, даже если это на самом деле не нужно. Тут есть только две операции, которые используют originalMappings:


  • allGeneratedPositionsFor, которая возвращает все сгенерированные позиции для данной строки в исходном коде;
  • eachMapping(..., ORIGINAL_ORDER), которая обходит все маппинги в их изначальном порядке.

Если мы допустим, что allGeneratedPositionsFor является самой основной операцией и что мы только ищем внутри небольших массивов originalMappings[i], то мы можем значительно улучшить время разбора, лениво сортируя массивы только когда мы в самом деле что-то ищем в одном из них.


И наконец, сравнение V8 от 19 января, с V8 от 19 февраля с использованием и без ограничений для недоверенного кода (untrusted code mitigations).


After reworking allocation


Сравнение с "окисленной" версией source-map


После публикации этого поста 19 февраля, я получил несколько просьб сравнить source-map с моими правками с основной веткой source-map, которая использует Rust и WASM.


Беглый просмотр исходного кода на Rust для parse_mappings обнаружил, что Rust-версия не собирает исходные маппинги заранее, а производится и сортируется только эквивалент generatedMappings. Для сопоставления этого поведения я подправил свою JS-версию, закомментировав сортировку массивов originalMappings[i].


Вот результаты бенчмарка для только разбора (который также включает сортировку generatedMappings) и для разбора с последующим обходом всех generatedMappings.


Parse only times


Parse and iterate times


Заметьте, что сравнение немного не корректно, потому что Rust-версия не оптимизирует сортировку generatedMappings таким же образом, как это делает моя JS-версия.


Тем самым я не собираюсь здесь объявлять что "мы успешно достигли паритета с Rust+WASM версией". Однако, на таком уровне отличий в производительности имеет смысл пересмотреть, оправданы ли затраты на использование Rust в source-map.


Обновление (27-02-2018)


Ник Финцджеральд, автор source-map обновил версию на Rust+WASM, и добавил алгоритмические улучшения описанные в этой статье. Вот так выглядит дополненный график производительности для бенчмарка разбор и итерирование:


Parse and iterate times


Как вы можете видеть, теперь версия на WASM+Rust примерно на 15% быстрее в SpiderMonkey и примерно на такой же скорости в V8.


Усвоенные уроки


Для JavaScript разработчика


Профилировщик – твой друг


Профилирование и точечное отслеживание производительности в различных формах – это лучший способ оставаться на пике скорости. Это позволяет вам определять горячие точки своего кода и выявляет потенциальные проблемы в нижележащем рантайме. По этой конкретной причине я не постесняюсь использования низкоуровневых профилирующих инструментов типа perf – "дружественные" инструменты могут не рассказать вам полную историю, поскольку они скрывают нижний уровень.


Различные проблемы производительности требует различных подходов к профилированию и визуализации собранных профилей. Убедитесь, что вы знакомы с широким спектром доступных инструментов.


Алгоритмы важны


Способность рассуждать о своем коде в терминах абстрактной сложности – это важное умение. Что лучше, сортировать быстрой сортировкой один массив на 100 тысяч элементов, или 3333 подмассива из 30 элементов?


Кусочек наколенной математики покажет нам что $100000 \log 100000$ в 3 раза больше чем $3333 \times 30 \log 30$ – и чем больше ваши данные, тем более важно быть способным сделать эти небольшие вычисления.


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


VM ещё в процессе разработки. Доставайте разработчиков!


Не стесняйтесь обращаться к разработчикам и обсуждать странные проблемы производительности. Не все на свете может быть решено правками вашего собственного кода. Как говорит русская поговорка: "не боги горшки обжигают!". Разработчики виртуальных машин (VM) –тоже люди и, как и все остальные, они могут допускать ошибки. Также они довольно хороши в исправлении этих ошибок, когда вы о них сообщаете. Одно письмо или сообщение в чате или в личку сможет сохранить вам дни копания в чужом C++ коде.


VM все еще нужно немного помощи


Иногда вам нужно писать низкоуровневый код или знать низкоуровневые детали, чтобы выжать последние капли производительности из JavaScript.


Некоторые предпочтут языки другого уровня с другими возможностями, чтобы этого достичь, но это еще только предстоит выяснить, если мы когда-либо до этого доберемся.


Для разработчика/дизайнера языка


Умные оптимизации должны быть диагностируемы


Если ваш рантайм имеет некоторые встроенные умные оптимизации, то вам нужно предоставить простой инструмент для диагностики, когда эти оптимизации ломаются, и показывать разработчикам полезные сообщения.


В контексте языков вроде JavaScript это значит как минимум, что инструменты типа профайлера должны также давать возможность просмотреть отдельные операции, чтобы выяснить, хорошо ли их обрабатывает VM, а если нет – в чем причина этого.


Такой вид интроспекции не доджен требовать сборки особых версий VM с магическими флагами, а затем копания в мегабайтах недокументированного отладочного вывода. Такой вид инструментов должен быть прямо здесь, когда вы открываете окно DevTools.


Язык и оптимизации должны быть друзьями


И наконец, как дизайнер языка вы должны пытаться предвидеть, где языку недостает возможностей, которые могли бы сделать проще написание высокопроизводительного кода. Ваши пользователи ищут способ располагать и управлять памятью вручную? Я уверен, что да. Если ваш язык даже слегка популярен, то в конечном счете пользователи добьются успеха в написании кода с плохой производительностью. Измеряйте стоимость добавления возможностей языка, которые исправляют проблемы производительности против решения той же проблемы скорости другими путями (т.е. путем более сложных оптимизаций или прося пользователей переписать их код на Rust).


Это работает и обратную сторону тоже: если в вашем языке есть какие-то возможности, удостоверьтесь, что они работают достаточно хорошо и их производительность хорошо понятна пользователям, а также может быть легко диагностирована. Инвестируйте в становление вашего языка оптимизированным в целом, вместо того, чтобы иметь быстрое ядро и низкопроизводительные редко используемые фичи на периферии.


Послесловие


Большинство оптимизаций, найденных в этом посте попадают в три различные группы:


  1. алгоритмические улучшения;
  2. обходные пути для проблем, независимых от имплементации, но потенциально зависимых от языка;
  3. обходные решения проблем, специфичных для V8.

Неважно, на каком языке вы пишете, вам все еще нужно думать об алгоритмах. Легче заметить, когда вы используете худшие алгоритмы в по определению "медленных" язках, но простое переписывание тех же алгоритмов на "быстрый" язык не решит проблемы, даже если устранит симптомы. Большая часть этого поста уделена оптимизациям из этой группы:


  • улучшения сортировки путем сортировки подмассивов вместо целого массива;
  • дискуссия пользы кэширования или его отсутствия.

Вторая группа представлена приёмом мономорфизации. Страдание производительности от полиморфизма не является специфичной для V8 проблемой. Также это не специфичная для JS проблема. Вы можете применять мономорфизацию во многих реализациях и даже языках. Некоторые языки (Rust, например) применяют её в некоторой форме под капотом.


Последняя и самая спорная группа представлена адаптацией аргументов.


И наконец, оптимизация, которую я сделал для представления маппингов (упаковка отдельных объектов в единый типизированный массив) является оптимизацией всех трех групп. Это про понимание ограничений и стоимости систем со сборщиком мусора в целом. Также это про понимание силы существующих JS VM и использование их для нашей пользы.


Итак… Почему я выбрал такой заголовок? Это потому, что я думаю, что третья группа представляет все проблемы, которые должны быть исправлены со временем. Остальные группы представляют универсальное знание, которое подходит к разным реализациям и языкам.


Очевидно, каждый разработчик и каждая команда вольна выбирать между тратой N часов на тщательное профилирование, чтение и размышления об их JavaScript коде, или тратой M часов на переписывание своего хозяйства на язык X.


Однако: (а) каждый должен полностью отдавать отчет, что выбор на самом деле существует; и (б) дизайнеры языка и исполнители должны работать вместе, чтобы сделать этот выбор менее и менее очевидным – что значит работу над возможностями язка и инструментами и уменьшениие необходимости в "группе #3" оптимизаций.

Комментарии (39)


  1. leotsarev
    27.02.2018 11:00
    +1

    Крутейшая статья.
    Чуваку благодаря своему уму и знаниям глубин V8 удалось комбинацией улучшения алгоритма и глубинными оптимизациями догнать переписку с нуля Rust.
    Вот ответ на эту статью от авторов переписки: http://fitzgeraldnick.com/2018/02/26/speed-without-wizardry.html
    Они улучшили алгоритм в одном месте согласно советам mraleph и получили ещё х3.


    1. leotsarev
      27.02.2018 11:01
      +1

      Важнейший урок статьи: сначала проверь свой алгоритм.


      1. Halt
        27.02.2018 13:24
        +1

        Я бы процитировал эту часть:

        Most of the improvements that mraleph implemented are desirable regardless of the programming language that is our medium. Excessive allocation rates make any garbage collector (or malloc and free implementation) a bottleneck. Monomorphization and inlining are crucial to eking out performance in both Rust and JavaScript. Algorithm transcend programming languages.

        But a distinction between JavaScript and Rust+WebAssembly emerges when we consider the effort required to attain inlining and monomorphization, or to avoid allocations. Rust lets us explicitly state our desires to the compiler, we can rely on the optimizations occurring, and Rust’s natural idioms guide us towards fast code, so we don’t have to be performance wizards to get fast code. In JavaScript, on the other hand, we must communicate with oblique incantations to match each JavaScript engine’s JIT’s heuristics.

        We chose to rewrite a portion of our library in Rust and WebAssembly not just for the speed ups, although those are certainly nice, but also for maintainability and to get rid of the kludges added to gain JIT optimizations. We wanted to return to clean code and clearly expressed intent. It has been a great success.


    1. Mingun
      27.02.2018 12:30
      +1

      Это все конечно хорошо, но какова цена поддержки всего этого? Не случится ли так, что когда через пару лет опять возникнут проблемы с производительностью, никто кроме автора не сможет разобраться в этом "очевидном оптимизированном коде"? Ценность таких языков, как Rust не в том, что они делают что-то быстрее, а в том, что они делают это быстрее при той же понятности — те самые абстракции с нулевой стоимостью.


      1. mraleph
        27.02.2018 12:46

        Все оптимизации достаточно примитивны — к тому же с течением времени необходимость в оптимизациях из группы №3 должна сойти на нет.


        Единственная действительно спорная оптимзация состоит в использовании typed arrays руками — потому что она делает код действительно сложно читаемым. Но опять же — эту проблему можно решить просто добавив правильные фичи в JavaScript — а не приглашать людей переписывать их код на Rust.


        Да и почему предполагется, что цена поддержки Rust и WASM нулевая? Это совершенно отдельный язык и экосистема! Автор Вы посмотрите на список вещей, которые нужно установить, чтобы обновить ту часть source-map, которая написана на Rust.


        1. Halt
          27.02.2018 13:03

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

          Очень рекомендую прочитать статью-ответ, ссылку на которую уже дали выше. Там подробно разбираются сделанные изменения при взгляде с обеих сторон и взвешиваются все «за» и «против».


          1. mraleph
            27.02.2018 13:30
            +1

            Я на всякий случай напомню, что авторы библиотеки изначально так и делали.

            Нет, ничего они так не делали изначально. Максимум они сделали:


            • хак с кэшированием (без которого на самом-то деле быстрее)
            • сделали свою собственную быструю сортировку

            Все. Никаких других оптимизаций они не делали. Никаких особых проблем с поддержкой и гибкостью от двух этих изменений у них не было.


            Очень рекомендую прочитать статью-ответ, ссылку на которую уже дали выше.

            Я читал ответ, и я с ним не до конца согласен. (Если что, я это одна из двух сторон).


        1. Halt
          27.02.2018 13:10

          Ну и да, график тоже говорит сам за себя:

          Сравнение производительности
          image


          1. justboris Автор
            27.02.2018 13:35

            Для полной объективности этому графику не хватает результатов JS-версии от mraleph. Они будут примерно на уровне зеленых точек (WASM без улучшения алгоритмов).


            На таком фоне превосходство WASM будет не столь внушительно.


            1. Halt
              27.02.2018 13:38
              +1

              Еще раз: никто не говорит о превосходстве. Авторы хотели получить удобное и поддерживаемое решение. Они его получили. А то что оно оказалось еще и быстрым — приятный бонус.

              Никто вас не заставляет это использовать только потому, что «это круто и раст всех порвет». Статья вообще не про это.


              1. justboris Автор
                27.02.2018 13:45

                я отвечал на комментарий


                Ну и да, график тоже говорит сам за себя:

                А о чем он еще говорит, если не о превосходстве?


                1. Halt
                  27.02.2018 14:42
                  +2

                  Вы знаете, забавный момент: в мире Rust как-то не принято выпячивать себя и кричать о производительности или выразительности.

                  Обычно стараются подчеркнуть не то, что Rust быстрее, а то, что он не медленнее. Не медленнее C/C++ при большей безопасности памяти и защите от состояния гонок; не медленнее JS, при большей прозрачности кода и т.д.

                  Я просто настолько давно варюсь в этой экосистеме, что забыл уже, что бывает иначе. Меня вообще печалит такое однобокое восприятие, когда берут одну фичу языка и начинают меряться.


          1. mraleph
            27.02.2018 15:04

            1. humbug
              27.02.2018 15:41

              Хоть ты и пытаешься отодвинуть Rust, который я люблю, все равно ты крут)


              1. mraleph
                27.02.2018 15:57
                +1

                Я не пытаюсь Rust отодвинуть: у меня две причины, одна корыстная, одна не очень.


                Во-первых, причина простая: я не понимаю зачем писать такие вещи на Rust, если можно на JS (который я не особо-то и люблю). Ну пусть бы написали что-то осмысленное и большое, но такую мелкую штуку смысла на мой взгляд не имеет никакого. Только из любви к языку, ну пусть тогда так и пишут — а не наводят тень на плетень.


                Во-вторых, причина корыстная: в WASM компилировать по нормальному можно только языки типа C/C++/Rust, для всего остального надо за собой тащить собственный рантайм — GC, JIT, etc — скомпилированный в тот же WASM. Я считаю, что это неоправданные издержки. Поэтому получатся, что JS до сих пор единственный нормальный целевой язык для компиляции высокоуровневых языков (хотя он тоже не сахар, но хотя бы свой JIT/GC/exceptions, тащить не надо). А значит нужно поддерживать огонь под JS производительностью со всех возможных направлений.


                (Что инетерсно тот товарищ, который source-map делает он даже написал собственный аллокатор памяти для WASM — потому что встроенный в Rust был слишком большой… ну у меня даже слов нет.)


                1. torkve
                  27.02.2018 16:09

                  (Что инетерсно тот товарищ, который source-map делает он даже написал собственный аллокатор памяти для WASM — потому что встроенный в Rust был слишком большой… ну у меня даже слов нет.)

                  Не знаю про ту ситуацию и не очень в курсе, как это всё работает в WASM, но возможно он просто не захотел разбираться: в расте по умолчанию используется достаточно жирный jemalloc, но вообще при компиляции можно попросить использовать обычный системный аллокатор.


        1. PsyHaSTe
          27.02.2018 13:57
          +2

          Все оптимизации достаточно примитивны — к тому же с течением времени необходимость в оптимизациях из группы №3 должна сойти на нет.

          Для кого примитивны? Я бекенд-разработчик и JS знаю не так чтобы очень хорошо (хотя на бутстрапе и реакте что-то писал и правил), и для меня некоторые улучшения просто черная магия. Не говоря о том, каким образом получена эта информация — копание в ассемблерных листингах явно не моя сильная сторона, как думаю, и многих других разработчиков, особенно таких высокоуровневых языков вроде JS.


          о опять же — эту проблему можно решить просто добавив правильные фичи в JavaScript — а не приглашать людей переписывать их код на Rust.

          Добавление фич даже в раст, который выходит каждые 6 недель — это очень долгий процесс. Добавление фичи в JS я думаю займет не один год, причем до вашей фичи будет миллион других, более приоритетных. Это можно видеть и на примере других языков, например C#.


          Да и почему предполагется, что цена поддержки Rust и WASM нулевая? Это совершенно отдельный язык и экосистема! Автор Вы посмотрите на список вещей, которые нужно установить, чтобы обновить ту часть source-map, которая написана на Rust.

          Глянул, там говорится, что для компиляции rust нужно поставить компилятор rust. Ну офигеть. С тем же успехом вместо:


          $ curl https://sh.rustup.rs -sSf | sh
          $ rustup toolchain install nightly
          $ rustup target add wasm32-unknown-unknown --toolchain nightly

          там могло бы быть написано что-нибудь вроде:


          $ curl https://nodejs.org/dist/v9.6.1/node-v9.6.1-linux-x64.tar.xz -sSf | tar xzf
          $ pushd node-v9.6.1-linux-x64
          $ xdg-open INSTALL
          $ popd

          что вообще не проблема, ибо делается на разработческой машине один раз за 5 минут.


          В итоге, для меня очевидно, что раст более чем имеет смысл в такой задаче.


          • При написании кода на раст без использования какой-либо магии получился рост в 5 раз.
          • Для достижения похожего результата в случае с JS потребовался эксперт с огромным опытом, который потратил очень много времени и сил на оптимизацию. Не говоря про фразы в стиле "ну, я как раз вчера общался с разработчиками на похожую тему, и там в разговоре упомянули о новой функции/оптимизации Х, которую я сейчас и попробую заиспользовать". Не так много разработчиков сидят в кулуарах с разработчиками подобного софта, чтобы вообще знать о таких возможностях.
          • Ну и наконец, эксперт подобного уровня в экосистеме Rust смог получить прирост еще в 3 раза, или в 15 раз относительно оригинального JS кода.

          В итоге, вывод статьи можно озаглавить, как "вы возможно можете добиться такого же прироста производительности в JS, если у вас есть в команде мэтр по JS и профайлингу уровнем не ниже какого-нибудь Саши Гольдштейна".


          Мое мнение в итоге таково: раст, на таком уровне, чтобы переписать его так, как показано в оригинальной (неоптимизированной) статье, может изучить любой JS разработчик с опытом в 2-3 года. И соответственно написать такой код. Тот же разработчик с таким же опытом если дать ему то же время не сможет провести подобный анализ и оптимизацию, ему просто не хватит необходимых знаний.


          Раст не надо совать везде. Но там, где он хорош — он действительно хорош. Это не инопланетный язык вроде J, код на котором пишется в режиме write only и для познания которого требуется вечность. Раст очень простой для освоения язык, потому что многие вещи в нем выводятся, а некоторые фичи нужны просто для производительности. Вам не нравятся лайфтаймы? В 99% случаев они выводятся, и вы даже можете не знать, что они используются. Не нравятся сложные трейты в стиле Into/AsRef/younameit Не пользуйтесь ими, аллоцируйте память на каждый вызов функции, это все равно будет выгоднее, чем можно было бы написать на JS, зато интерфейс останется простым и понятным. Список можно продолжить.


          Итог один: эта задача хорошо ложится на раст, и иметь такой инструмент в копилке современному JS-разработчику не должно быть зазорным.


          1. mraleph
            27.02.2018 15:03

            Не говоря о том, каким образом получена эта информация — копание в ассемблерных листингах явно не моя сильная сторона, как думаю, и многих других разработчиков, особенно таких высокоуровневых языков вроде JS.

            Ни для каких оптимизаций не пришлось копаться в ассемблерных листингах, все были очевидны либо из профиля, либо из анализа алгоритма. На ассемблерный листинг я там в одним месте только сослался и то, только ради дополнительной информации читателю.


            и для меня некоторые улучшения просто черная магия.

            Какие именно? Вот допустим пишете вы бэкэнд на C# или Java — для вас мономорфный/полиморфный код имеет точно такое же значение с точки зрения перформанса! Это никакая не черная магия.


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

            Я потратил ~2 часа утром на диване перед работой, чтобы оптимизировать код. А затем я потратил гораздо больше времени, чтобы написать длинный пост с пошаговым объяснением.


            Глянул, там говорится, что для компиляции rust нужно поставить компилятор rust

            Там одним компилятором rust не обойтись, там еще всяких самописных тулов надо ставить.


            1. PsyHaSTe
              27.02.2018 16:07
              +1

              Ни для каких оптимизаций не пришлось копаться в ассемблерных листингах, все были очевидны либо из профиля, либо из анализа алгоритма. На ассемблерный листинг я там в одним месте только сослался и то, только ради дополнительной информации читателю.

              Да ну хотя бы эта картинка
              imagehttps://habrastorage.org/webt/bm/yt/sp/bmytspd4x3u5ymtabc26hcvbj9a.png


              Я более чем уверен, что JS разработчик с пару годами опыта в такие дебри залезть не сможет. Точнее залезть, разобраться, и еще и правильные выводы сделать.


              Когда я начал смотреть на цепочки вызовов для отдельных записей, я обнаружил, что немало из них проходит через KeyedLoadIC_Megamorphic в SourceMapConsumer_parseMappings.
              Такая сортировка стеков вызова просигналила мне, что код исполняет множество сопоставлений по ключу в виде obj[key], где key — это динамически сформированная строка.

              Вот этот вывод например требует нехило так разбираться в предмете. Возможно, за счет Данинга-Крюгера вы считаете, что JSник который не сможет его сделать не может себя считать таковым, но на самом деле это нетривиально.


              Какие именно? Вот допустим пишете вы бэкэнд на C# или Java — для вас мономорфный/полиморфный код имеет точно такое же значение с точки зрения перформанса! Это никакая не черная магия.

              Да хотя бы тот пример, где мы храним все по оффсетам. То есть я могу написать такой код, но я не буду, потому что мне бы в голову не пришло, что это может дать буст производительности.


              Я потратил ~2 часа утром на диване перед работой, чтобы оптимизировать код.

              Я все еще считаю, что это ваша высокая квалификация. Моё почтение.


              Там одним компилятором rust не обойтись, там еще всяких самописных тулов надо ставить.

              Так же, как и в npm надо какие-нибудь модули доставлять: например, на винде npm при сборке падает с ошибкой если не установить самостоятельно питон. Всё еще не вижу проблем запустить один-единственный шелл скрипт на пяток строк, который за 5-10 минут скачает и установит все, что нужно.


              1. mraleph
                27.02.2018 18:05

                Да ну хотя бы эта картинка

                Эта картинка для объяснения "почему". Можно без объяснения "почему", просто сказать "делай как я! будет счастье" (и между прочим говорили начиная с 2010 года, я сам помнится говорил пару раз).


                Тут надо повторится, что SpiderMonkey от такого не страдает и V8-товцы обещают подумать и починить.


                Вот этот вывод например требует нехило так разбираться в предмете.

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


                Я, кстати, об этом и в посте написал — что было бы очень хорошо если бы V8 умела все эти вещи объяснять понятным языком. Например, не KeyedLoadIC_Megamorphic, а что-нибудь более понятное человеку менее углубленному в детали реализации.


                1. PsyHaSTe
                  27.02.2018 18:32

                  Эта картинка для объяснения «почему». Можно без объяснения «почему», просто сказать «делай как я! будет счастье» (и между прочим говорили начиная с 2010 года, я сам помнится говорил пару раз).

                  Кому можно? Я представляю просто ситуацию, где к разработчику пришли и сказали "работает медленно, ускорь". И у него два варианта, попробовать переписать на раст или попробовать оптимизировать так, как сделали вы. Он не может "сделать как вы" потому что он на своем месте, сам он не знает, как к задаче подступиться, а на stackoverflow такой вопрос не задашь.


                  Я, кстати, об этом и в посте написал — что было бы очень хорошо если бы V8 умела все эти вещи объяснять понятным языком. Например, не KeyedLoadIC_Megamorphic, а что-нибудь более понятное человеку менее углубленному в детали реализации.

                  Человеческое описание ошибок всегда плюс. Кстати, в расте имеется, что мне тоже очень понравилось, когда я начал язык изучать. Иногда можно просто скопипастить хинт из сообщения об ошибке так, чтобы все заработало. В примере выше это могло бы выглядеть как:


                  warning: comparator function has '3' arguments, but used with '2', which may cause performance issues.

                  function doQuickSort(ary, comparator, p, r) {
                    // ...
                        if (comparator(ary[j], pivot) <= 0) {
                            ^^^^^^^^^^^^^^^^^^^^^^^^^
                          // ...
                        }

                  note: try use comparator(ary[j], pivot, 0)


          1. justboris Автор
            27.02.2018 16:01

            Глянул, там говорится, что для компиляции rust нужно поставить компилятор rust.

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


            В результате мы имеем закоммиченный в git бинарник и никакой возможности автоматически пересобрать код одной командой.


            Именно эти накладные расходы имелись в виду, говоря о ненулевой поддержке.


            1. PsyHaSTe
              27.02.2018 16:09

              Ну, в таком случае действительно неудобно. Ждем улучшения тулчейна.


        1. torkve
          27.02.2018 15:16

          > Автор Вы посмотрите на список вещей, которые нужно установить, чтобы обновить ту часть source-map, которая написана на Rust.

          Но ведь если я правильно понял статью, Вы тоже напрямую редактировали сгенерированный код, чтобы не возиться с пересборкой?


          1. mraleph
            27.02.2018 15:19

            Да, и это работало. А вот попробуйте WASM в текстовом редакторе поправить. Еще та черная магия!


            1. torkve
              27.02.2018 15:44

              Я скорее про затраты на поддержание. Легко говорить о сложностях сборки, когда сам всеми силами их избегаешь и правишь итоговый результат :)
              А если честно собирать и то, и другое, то вроде так на так и выходит, наверное.


        1. YetAnotherSlava
          27.02.2018 17:26
          +2

          В раннем рунете, еще до появления вездесущего JQuery, можно было встретить хорошее выражение:

          Смысл революции не в том, чтоб «нам лучше было». Смысл революции — «чтобы вас, блюдей, не было»

          Итак, цель протаскивания Webassembly и Rust в браузер — не в том, чтобы JS или кто-то еще улучшал свою производительность. Цель Rust в браузере — чтобы JS исчез, вместе со всеми ему присущими культурными явлениями, вплоть до вейпов и подворотов. Чтобы эта сущность была наконец засунута туда, откуда она вылезла, навсегда.


          1. mraleph
            27.02.2018 17:57
            +1

            Ну это все фантазии, к сожалению, из разряда вечерних мечтаний за чаркой среди единоверов. "Ах вот бы было хорошо, если бы!"


            Я бы может быть тоже хотел проснутся одним морозным утром и обнаружить, что JS скукожился. Но одним Rustом тут дело не решить, к сожалению. Нужен глобальный выбор — на чем хочешь, на том и пиши, а для этого нужен вменяемый compilation target. (… и все равно в конце-то внезапно обнаружится, что писать на все том же ЖС будут порядочно.)


          1. abyrkov
            27.02.2018 21:57
            +3

            эта сущность была наконец засунута туда, откуда она вылезла, навсегда
            Плохо скрытый намек на превосходство Rust. Причем непонятна такая огрессия в сторону JS. Хороший специалист напишет хороший код на чем угодно (если это, конечно, возможно). Эта статья тому подтверждение. А индусы и самоутверждающиеся подростки с «вейпами» и «подворотами» никуда не денутся от глобального перехода на другой ЯП. Наверняка проредеют, но никуда не денутся. Зато у нас будет Rust и сломанная обратная совместимость. Что тогда? Будем ругать Rust и переходить обратно на JS?
            охх чую, нахватаю я минусов от радикальных растеров и не только


            1. humbug
              28.02.2018 02:31

              Хороший специалист напишет хороший код на чем угодно (если это, конечно, возможно). Эта статья тому подтверждение.

              Вообще-то эта статья — подтверждение высокой квалификации mraleph. Да, он решил свою задачу, но я бы не назвал этот код хорошим с точки зрения поддержки.


  1. freeart
    27.02.2018 17:28
    +3

    С одной стороны можно порадоваться за v8, но с другой стороны — какой ценой, не лучше ли оптимизацию отдать компилятору, и сразу написать «не идеальный код» на rust, но быстро и который бы превосходил по скорости v8 из коробки. Сам поклонник nodejs но без фанатизма, там где надо быстрее — имеет место быть другому языку под конкретную задачу


    1. justboris Автор
      27.02.2018 21:26

      Вот именно, под конкретную задачу лучше пользоваться одним языком. У node.js экосистемы уже был такой опыт: библиотека libsass на C++, которой предлагалось пользоваться из Node.js. В результате получился проект с сотнями issues, связянными с проблемами установки. И малое число внешних контрибьюторов, которые могли бы помочь исправить, потому что совсем незнакомый стек.


      Поэтому SASS переезжает на JS (на самом деле Dart, но он будет компилироваться и поставляться в JS). Выигрыш в скорости не всегда оправдывает использование разношерстного мультиязычного стека.


  1. olleggerr
    27.02.2018 20:20

    Я может чего-то не понимаю, но разве инлайн функций не задача JIT компиляций? Или особенности javascript не позволяют JIT самостоятельно инлайнить функции?


    1. ReklatsMasters
      28.02.2018 04:36

      У v8 есть ограничения в плане инлайна функций, например на размер функции. Слишком большие функции точно не будут инлайниться. Также, у родительской функции тоже, кажется, есть ограничение на размер или количество встраиваемых функций. Ну и, насколько я помню, инлайнятся только неизменяемые функции, т.е. вы должны вызывать функцию с одними и теми же типами аргументов и количеством.


    1. mraleph
      28.02.2018 09:31
      +2

      JIT умеет инлайнить функции, но вызов функции должен быть мономорфным — те вы должны вызывать всегда одну и ту же функцию из данного места.


  1. olegchir
    28.02.2018 10:21

    Мы только-только договорились с автором об официальном переводе этой статьи… С одной стороны, немного обидно, я уже начал работать над переводом. С другой — хорошо что хорошие статьи переводятся в таком быстром темпе.


    1. mraleph
      28.02.2018 10:45
      +3

      надо было мне сказать, что ты будешь этот конкретный пост переводить, я бы justboris сказал, что кто-то уже переводит. а так ты меня спросил, можно ли статьи переводить и репостить — я сказал можно; но у меня это как-то в голове не сложилось 1+1, что ты эту конкретную статью будешь переводить. извиняюсь.


      1. olegchir
        28.02.2018 10:53

        Проехали :) Я просто подпишусь на твой блог, и буду с максимальным 146% приоритетом переводить всё, что ты пишешь про компиляторы и рантаймы.


  1. Dimensi
    01.03.2018 02:21

    Очень интересная статья, спасибо за нее. Впервые узнал, что такое asm и вообще был удивлен, что в js можно так управлять памятью (но сам такого делать не буду). В разрабах я всего год и для меня то, что тут написано, просто невозможно. Хотя тема с кешами мне показалась очевидной, просто хотя бы потому, что там была огромная итерация и на каждую итерацию искался нужный кусок в огромном хеше. Часто пытаюсь открыть devtools, сделать perfomance и попытаться, что-то понять, но даже видя подсвеченные красным функции, не понятно, что с ними делать.
    P.S. Статью читал 2-3 часа. Время потратил с пользой.