[Перевод] Сверхбыстрый анализатор, часть 2: Ленивый парсинг

Node.js задняя часть Программа перевода самородков V8

Это вторая часть серии статей о том, как V8 максимально быстро анализирует JavaScript. В первой части уже объяснили как сделать V8сканерБыстрее.

Синтаксический анализ — это компилятор (в V8 компилятор байт-кодаIgnition) предоставляет шаги для преобразования исходного кода в промежуточное представление. Разбор и компиляция происходят во время критического процесса запуска веб-страницы, не все эти функции должны быть доступны браузеру сразу во время запуска страницы. Хотя разработчики могут использовать асинхронные и отложенные сценарии, это не всегда работает. Кроме того, многие веб-страницы содержат только код, используемый некоторыми функциями, которые могут быть вообще недоступны пользователю во время выполнения небольшой части страницы.

Усердная компиляция ненужного кода может привести к реальному потреблению ресурсов:

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

По этим причинам все основные браузеры реализуютЛенивый разбор. Вместо того, чтобы генерировать абстрактное синтаксическое дерево для каждой функции и затем компилировать его в байт-код, синтаксический анализатор «предварительно выполняет синтаксический анализ» на основе функций, с которыми он действительно сталкивается, а не анализирует их все. Это делается путем перехода к использованиюпрепарсерДля этого это копия парсера, которая делает только самую основную работу, иначе пропускает функцию. Препарсер проверяет, что функция, которую он пропускает, синтаксически допустима, и выдает всю информацию, необходимую для правильной компиляции внешней функции. Когда подготовленная функция вызывается позже, она будет полностью проанализирована и скомпилирована по мере необходимости.

присвоение переменной

Основная проблема, усложняющая подготовку, — это присваивание переменных.

По соображениям производительности активация функции управляется в стеке машины. Например, если функцияgИспользовать параметры1и2функция называетсяf:

function f(a, b) {
  const c = a + b;
  return c;
}

function g() {
  return f(1, 2);
  // 这里返回的是 `f` 的指针调用,返回结果指向这儿
  // (因为当 `f` 返回时,它会返回到这里)。
}

Во-первых, получатель (например, какfизthisценность, то естьglobalThis, так как это вызов произвольной функции) помещается в стек, за которым следует вызываемая функцияf. затем параметр1и2помещается в стек. В это время функцияfВызов. Для того, чтобы выполнить вызов, мы сначала сохранили в стекеgСтатус: возвратfуказатель инструкции (rip; какой код нам нужно вернуть) и "указатель кадра" (fp; как должен выглядеть стек по возвращении). Затем мы входимf, которая является локальной переменнойcВыделите пространство и любое временное пространство, которое может понадобиться. Это гарантирует, что если функция вызывается вне области видимости, данные, используемые функцией, будут недоступны: они просто выталкиваются из стека.

функция вызоваfРазметка при выделении стека по параметрам стекаa,bи локальные переменныеc.

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

function make_f(d) { // ← `d` 的声明
  return function inner(a, b) {
    const c = a + b + d; // ← `d` 的引用
    return c;
  };
}

const f = make_f(10);

function g() {
  return f(1, 2);
}

В приведенном выше примере изinnerприбытьmake_fлокальные переменные, объявленные вdцитаты вmake_fРассчитывается после возвращения. Для этого языковая виртуальная машина с помощью лексических замыканий выделяет ссылки на переменные из внутренних функций в куче в структуре, называемой «контекст».

перечислитьmake_fРазметка стека на момент копирования параметров в контекст, выделенный в куче для последующегоinnerпойманdпри использовании.

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

Проще говоря, мы по крайней мере должны отслеживать переменные ссылки в препарате.

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

Код верхнего уровня является исключением из этого правила. Сценарии верхнего уровня всегда выделяют память в куче, потому что переменные видны во всех сценариях. Простой способ приблизиться к лучшей реализации этой архитектуры — просто запустить препарсер без необходимости отслеживать переменные для быстрого анализа функций верхнего уровня; использовать полный парсер только для внутренних функций и пропустить шаг их составление. Хотя это дороже, чем подготовка, потому что мы без необходимости создаем весь AST, это позволяет нам начать работу. Это именно то, что V8 сделал в V8 v6.3 / Chrome 63 и более поздних версиях.

информация, чтобы сообщить переменной препарсера

Отслеживание присвоения переменных и ссылок в препарсере затруднено, потому что в JavaScript с самого начала неясно, что означает часть выражения. Например, пусть у нас есть параметрdФункцияf, который имеет внутреннюю функциюgвыражение выглядит вероятным ссылкеd.

function f(d) {
  function g() {
    const a = ({ d }

это может закончиться цитированиемd, потому что мы видим этиtokenявляется частью выражения присваивания деструктора.

function f(d) {
  function g() {
    const a = ({ d } = { d: 42 });
    return a;
  }
  return g;
}

Он также может оказаться параметром с деструкторомdстрелочная функция, в этом случаеfсерединаdне будетgЦитировать.

function f(d) {
  function g() {
    const a = ({ d }) => d;
    return a;
  }

  return [d, g];

}

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

На самом деле неправильно игнорировать объявления переменных и ссылки или даже функции верхнего уровня. Спецификация ECMAScript требует, чтобы различные типы конфликтов переменных обнаруживались при первом анализе сценария. Например, если переменная объявлена ​​дважды в одной и той же области видимости, она считаетсяранняя грамматическая ошибка. Поскольку наш препарсер просто пропускает объявления переменных, он будет неправильно пропускать код на этапе подготовки. В то время то, что мы считали оптимизацией производительности, нарушило спецификацию. Однако теперь, когда препарсер правильно отслеживает переменные, мы устранили такие нарушения спецификации, как анализ переменных, и нет значительного снижения производительности.

пропустить внутреннюю функцию

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

// 这是顶层作用域
function outer() {
  // 预解析完成
  function inner() {
    // 预解析完成
  }
}

outer(); // 全面解析并且编译 `outer`,而不是 `inner`。

Функция указывает непосредственно на внешний контекст, где внутренней функции необходимо использовать значение объявленной переменной. Чтобы разрешить отложенную компиляцию функций (и поддержку отладчиков), контекст указывает наScopeInfoОбъект метаданных.ScopeInfoОбъекты описывают переменные, перечисленные в контексте. Это означает, что во внутренней функции компилятора мы можем вычислить местонахождение переменной в цепочке контекстов.

Однако, чтобы вычислить, нужен ли контекст самой лениво скомпилированной функции, нам нужно снова выполнить разрешение области видимости: нам нужно знать, ссылается ли функция, вложенная в лениво скомпилированную функцию, на переменную, объявленную лениво скомпилированной функцией. Мы можем вычислить его, снова выполнив предварительную компиляцию. Это именно то, что делал V8 до V8 v6.3/Chrome 63. Но это не идеально для оптимизации производительности, потому что делает зависимость между размером исходного кода и стоимостью парсинга нелинейной: мы подготовим как можно больше вложенных функций. Помимо естественной вложенности динамических программ, сборщики JavaScript часто инкапсулируют код в "непосредственно вызываемые функциональные выражения” (IIFE), что позволяет большинству программ JavaScript иметь несколько уровней вложенности.

Каждая повторная обработка как минимум увеличит стоимость функции разбора

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

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

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

Влияние пропуска встроенных функций на производительность нелинейно, как и накладные расходы на повторную подготовку встроенных функций. Некоторые сайты поднимают все функции в область верхнего уровня. Поскольку их уровень вложенности всегда равен 0, накладные расходы всегда равны 0. Однако многие современные веб-сайты на самом деле имеют глубоко вложенные функции. На этих сайтах мы увидели значительный прирост производительности, когда эта функция была включена в V8 v6.3/Chrome 63. Главное преимущество в том, что глубина вложенности кода сайта уже не имеет значения: для любой функции происходит максимум один препарс, а для полного парсинга — один.[1].

Время разбора основного и неосновного потоков, оптимизация «пропустить внутреннюю функцию» до и после запуска.

Возможно-вызванные функциональные выражения

Как упоминалось ранее, упаковщики объединяют несколько модулей в один файл, инкапсулируя код модуля в замыкание, которое они вызывают немедленно. Это обеспечивает изоляцию между модулями, позволяя им выполняться как единственный код в скрипте. Эти функции по сути являются вложенными скриптами, они вызываются сразу же при выполнении скрипта. обертки обычно предоставляютнепосредственно вызываемые функциональные выражения(IIFE; произносится как «iffies») как функции в квадратных скобках:(function(){…})().

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

Вы можете спросить, а почему бы просто не скомпилировать вызываемую функцию? Хотя разработчикам легко заметить это при вызове функций, для синтаксических анализаторов это не так. Парсер должен принять решение — еще до того, как он начнет анализировать функцию! - Торопиться ли компилировать функцию или отложить ее. Неоднозначности в грамматике затрудняют простое быстрое сканирование до конца функции, и стоимость быстро становится такой же, как у обычных препараторов.

Таким образом, V8 имеет два простых режима, которые могут быть распознаны каквозможно вызываемые функциональные выражения(PIFEs; произносится как «пиффи») быстрее разобрать и скомпилировать функцию по этому шаблону:

  • Если функция представляет собой функциональное выражение в скобках вида(function(){…}), мы предполагаем, что это называется. Мы видим начало этой закономерности с первого взгляда, а именно(function.
  • Начиная с V8 v5.7/Chrome 57, мы также обнаруживаемUglifyJSСгенерированный шаблон!function(){…}(),function(){…}(),function(){…}(). Мы видим!function,или,functionЭто обнаружение вступает в силу, если за ним сразу следует PIFE.

Поскольку V8 преждевременно компилирует PIFE, их можно использовать какОбратная связь управляющей информации [2], обратная связь сообщает браузеру, какие функции необходимы для запуска.

В то время как V8 по-прежнему постоянно выполняет синтаксический анализ внутренних функций, некоторые разработчики заметили, что синтаксический анализ JS оказывает значительное влияние на запуск. этоoptimize-jsПакет преобразуется в пикс, основанные на статическом аспекте. Это оказывает большое влияние на производительность нагрузки V8 при создании пакета. Беги на v8 v6.1optimize-jsС предоставленными тестами мы воспроизвели эти результаты, просто взглянув на уменьшенный скрипт после сжатия.

Синтаксический анализ и компиляция PIFE преждевременно ускоряют холодную и горячую загрузку (загрузка первой и второй страниц, измерение общего времени анализа + компиляции + времени выполнения и т. д.). Однако из-за значительных улучшений синтаксического анализатора это дает V8 v7.5 гораздо меньший прирост производительности, чем V8 v6.1.

Однако теперь мы не анализируем внутреннюю функцию повторно, и, поскольку анализатор работает достаточно быстро, передаемoptimize-jsПолученный прирост производительности также значительно снижается. На самом деле конфигурация по умолчанию версии 7.5 уже намного быстрее, чем оптимизированная версия, работающая на версии 6.1. Даже в версии 7.5 PIFE можно экономно использовать для кода, необходимого во время запуска: мы избегаем предварительной обработки, потому что знали, что эта функция нужна нам с самого начала.

optimize-jsРезультаты тестов не совсем соответствуют действительности. Скрипты загружаются синхронно, и все время синтаксического анализа + компиляции считается временем загрузки. В реальном сценарии вы можете использовать<script>Скрипт загрузки вкладок. Это позволяет предварительному загрузчику Chrome оцениваться в сценариях.ДоОткройте его и загрузите, разбирайте и компилируйте сценарии, не блокируя нити. Все, что мы решили скомпилировать заранее, автоматически компилируется от основного потока и должны быть минимально вычислены только при запуске. Компиляция и работа с неманными резьбовыми сценариями увеличивает удар с использованием пикс.

Но есть еще затраты, особенно затраты памяти, так что не стоит компилировать все слишком рано:

предварительная компиляциявсеКод JavaScript влечет за собой значительные накладные расходы памяти.

Хотя рекомендуется добавлять скобки к функциям, которые вам нужны во время запуска (например, на основе анализа после запуска), используйтеoptimize-jspackage для применения простого статического вывода не является хорошим подходом. Например, предполагается, что функция вызывается в начале компиляции и что эта функция является аргументом функции. Однако, если такая функция, реализующая целый модуль, требует много времени для компиляции, вы в конечном итоге скомпилируете слишком много вещей. Преждевременная компиляция плохо влияет на производительность: V8 без ленивой компиляции значительно сокращает время загрузки. также,optimize-jsНекоторые преимущества исходят из UglifyJS и других проблем с компрессором, которые удаляют заключенные в скобки части из PIFE, которые не являются PIFE, тем самым удаляяОбщее определение модуля— Полезные советы по стилям модулей. Это, вероятно, проблема, которую компрессор должен исправить, чтобы получить максимальную производительность в браузерах, которые преждевременно компилируют PIFE.

Эпилог

Ленивый синтаксический анализ ускоряет запуск и снижает нагрузку на память для приложений, которые предоставляют более качественный код, чем им нужно. Необходимо иметь возможность правильно отслеживать объявления переменных и ссылки в препарсере, чтобы препарсер мог быть выполнен правильно (согласно спецификации) и быстро. Назначение переменных в препарсере также позволяет нам сериализовать информацию о назначении переменных для использования в последующих парсерах, так что мы можем полностью избежать повторной подготовки внутренних функций, избегая нелинейного поведения парсинга глубоко вложенных функций.

Parser-Aware Pifes Избегайте накладных расходов, необходимых немедленно инициализации вступительного кода во время запуска. Использование пикс экономно для загрузочных профилей или Packagers, также может обеспечить скорость холодной начты. Однако излишне упаковочные функции в скобках для срабатывания этого способа вывода следует избегать, поскольку это приведет к тому, что это приведет к съемке большего количества кода, что приведет к увеличению производительности запуска и большей памяти.


  1. Не используйте V8 некоторое время из-за проблем с памятьюобновить байт-код. Если код понадобится позже, мы проанализируем и скомпилируем его. Поскольку мы допускаем аннулирование метаданных переменных во время компиляции, это приведет к тому, что внутренние функции будут снова разрешаться при отложенной перекомпиляции. На этом этапе мы воссоздаем метаданные для его внутренней функции, поэтому нет необходимости снова предварительно анализировать внутреннюю функцию в ее внутренней функции.↩︎

  2. PIFE также можно рассматривать как функциональные выражения, основанные на краткой информации.↩︎

Если вы обнаружите ошибки в переводе или в других областях, требующих доработки, добро пожаловать наПрограмма перевода самородковВы также можете получить соответствующие бонусные баллы за доработку перевода и PR. начало статьиПостоянная ссылка на эту статьюЭто ссылка MarkDown этой статьи на GitHub.


Программа перевода самородковэто сообщество, которое переводит высококачественные технические статьи из Интернета сНаггетсДелитесь статьями на английском языке на . Охват контентаAndroid,iOS,внешний интерфейс,задняя часть,блокчейн,продукт,дизайн,искусственный интеллектЕсли вы хотите видеть более качественные переводы, пожалуйста, продолжайте обращать вниманиеПрограмма перевода самородков,официальный Вейбо,Знай колонку.