В начале апреля на хабре была опубликована статья «JavaScript: Стек вызовов и магия его размера» — её автор пришёл к выводу, что каждый кадр стека занимает (72 + 8 * число_локальных_переменных) байтов: «Получается, что мы посчитали все верно и можем утверждать, что размер пустого ExecutionStack в Chrome равен 72 байтам, а размер коллстэка — чуть меньше одного мегабайта. Отличная работа!»
Для затравки — немного изменим код, использованный AxemaFr для экспериментов:
{let i = 0;
const func = () => {
i += 1.00000000000001;
func();
};
try {
func();
} catch (e) {
console.log(i);
}}
Вместо 1 теперь на каждом шаге прибавляем чуточку больше, и в результате вместо 13951 получаем 12556.000000000002 — как будто бы в функции добавилась локальная переменная!
Повторим вопросы, которыми задавался Senior Frontend Developer AxemaFr: «Почему же так? Что изменилось? Как понять, посмотрев на функцию, сколько раз она может выполниться рекурсивно?!»
Готовим инструменты
В командной строке Chrome можно передавать аргументы для JS-движка; в частности, можно поменять и размер стека с 984 КБ на любой другой ключом
--js-flags=--stack-size=
. Разобраться в том, сколько стека требуется каждой функции, нам поможет ключ
--print-bytecode
, уже упоминавшийся ранее. Не упоминалось то, что отладочный вывод направляется в stdout, которого у Chrome под Windows тупо нет, потому что он скомпилирован как GUI-приложение. Исправить это несложно: сделайте копию chrome.exe, и в своём любимом hex-редакторе исправьте байт 0xD4
со значения 0x02
на 0x03
(тем, кто с hex-редактором не дружит, этот байт поможет исправить скрипт на Python). Но если вы прямо сейчас читаете эту статью в Chrome, и просто запустите исправленный файл — предположим, что вы назвали его cui_chrome.exe — то откроется новое окно в уже существующем экземпляре браузера, и аргумент --js-flags
будет проигнорирован. Чтобы запустить новый экземпляр Chrome, нужно передать ему какую-нибудь новую --user-data-dir
:cui_chrome.exe --no-sandbox --js-flags="--print-bytecode --print-bytecode-filter=func" --user-data-dir=\Windows\Temp
Без
--print-bytecode-filter
вы утонете в километровых дампах байткода функций, встроенных в Chrome.После запуска браузера откройте консоль разработчика и введите код, использованный AxemaFr:
{let i = 0;
const func = () => {
i++;
func();
};
func()}
Ещё до того, как вы нажмёте на Enter, в консольном окне позади Chrome появится дамп:
[generated bytecode for function: func (0x44db08635355 <SharedFunctionInfo func>)] Parameter count 1 Register count 1 Frame size 8 36 S> 000044DB086355EE @ 0 : 1a 02 LdaCurrentContextSlot [2] 000044DB086355F0 @ 2 : ac 00 ThrowReferenceErrorIfHole [0] 000044DB086355F2 @ 4 : 4d 00 Inc [0] 000044DB086355F4 @ 6 : 26 fa Star r0 000044DB086355F6 @ 8 : 1a 02 LdaCurrentContextSlot [2] 37 E> 000044DB086355F8 @ 10 : ac 00 ThrowReferenceErrorIfHole [0] 000044DB086355FA @ 12 : 25 fa Ldar r0 000044DB086355FC @ 14 : 1d 02 StaCurrentContextSlot [2] 44 S> 000044DB086355FE @ 16 : 1b 03 LdaImmutableCurrentContextSlot [3] 000044DB08635600 @ 18 : ac 01 ThrowReferenceErrorIfHole [1] 000044DB08635602 @ 20 : 26 fa Star r0 44 E> 000044DB08635604 @ 22 : 5d fa 01 CallUndefinedReceiver0 r0, [1] 000044DB08635607 @ 25 : 0d LdaUndefined 52 S> 000044DB08635608 @ 26 : ab Return Constant pool (size = 2) Handler Table (size = 0) Source Position Table (size = 12)
Как изменится дамп, если строчку
i++;
заменить на i += 1.00000000000001;
?[generated bytecode for function: func (0x44db0892d495 <SharedFunctionInfo func>)] Parameter count 1 Register count 2 Frame size 16 36 S> 000044DB0892D742 @ 0 : 1a 02 LdaCurrentContextSlot [2] 000044DB0892D744 @ 2 : ac 00 ThrowReferenceErrorIfHole [0] 000044DB0892D746 @ 4 : 26 fa Star r0 000044DB0892D748 @ 6 : 12 01 LdaConstant [1] 000044DB0892D74A @ 8 : 35 fa 00 Add r0, [0] 000044DB0892D74D @ 11 : 26 f9 Star r1 000044DB0892D74F @ 13 : 1a 02 LdaCurrentContextSlot [2] 37 E> 000044DB0892D751 @ 15 : ac 00 ThrowReferenceErrorIfHole [0] 000044DB0892D753 @ 17 : 25 f9 Ldar r1 000044DB0892D755 @ 19 : 1d 02 StaCurrentContextSlot [2] 60 S> 000044DB0892D757 @ 21 : 1b 03 LdaImmutableCurrentContextSlot [3] 000044DB0892D759 @ 23 : ac 02 ThrowReferenceErrorIfHole [2] 000044DB0892D75B @ 25 : 26 fa Star r0 60 E> 000044DB0892D75D @ 27 : 5d fa 01 CallUndefinedReceiver0 r0, [1] 000044DB0892D760 @ 30 : 0d LdaUndefined 68 S> 000044DB0892D761 @ 31 : ab Return Constant pool (size = 3) Handler Table (size = 0) Source Position Table (size = 12)
Теперь разберёмся, что поменялось и почему.
Исследуем примеры
Все опкоды V8 описаны в github.com/v8/v8/blob/master/src/interpreter/interpreter-generator.cc
Первый дамп расшифровывается так:
LdaCurrentContextSlot [2] ; a := context[2] ThrowReferenceErrorIfHole [0] ; if (a === undefined) ; throw("ReferenceError: %s is not defined", const[0]) Inc [0] ; a++ Star r0 ; r0 := a LdaCurrentContextSlot [2] ; a := context[2] ThrowReferenceErrorIfHole [0] ; if (a === undefined) ; throw("ReferenceError: %s is not defined", const[0]) Ldar r0 ; a := r0 StaCurrentContextSlot [2] ; context[2] := a LdaImmutableCurrentContextSlot [3] ; a := context[3] ThrowReferenceErrorIfHole [1] ; if (a === undefined) ; throw("ReferenceError: %s is not defined", const[1]) Star r0 ; r0 := a CallUndefinedReceiver0 r0, [1] ; r0() LdaUndefined ; a := undefined Return
Последний аргумент опкодов
Inc
и CallUndefinedReceiver0
задаёт feedback slot, в котором для оптимизатора накапливается статистика об использованных типах. На семантику байткода это не влияет, так что нас сегодня совсем не интересует.Под дампом есть приписка: «Constant pool (size = 2)» — и действительно видим, что в байткоде используются две строки —
"i"
и "func"
— для подстановки в сообщение исключения, когда символы с такими именами undefined. Есть приписка и над дампом: «Frame size 8» — в соответствии с тем, что в функции используется один регистр интерпретатора (r0
).Стековый кадр нашей функции состоит из:
- единственного аргумента
this
; - адреса возврата;
- числа переданных аргументов (
arguments.length
); - ссылки на constant pool c используемыми строками;
- ссылки на context с локальными переменными;
- ещё трёх указателей, нужных движку; и наконец,
- места для одного регистра.
Итого 9*8=72 байта, как синьор AxemaFr и вычислил.
Из семи перечисленных слагаемых, теоретически, меняться могут три — число аргументов, наличие constant pool, и число регистров. Что у нас получилось в варианте с 1.00000000000001?
LdaCurrentContextSlot [2] ; a := context[2] ThrowReferenceErrorIfHole [0] ; if (a === undefined) ; throw("ReferenceError: %s is not defined", const[0]) Star r0 ; r0 := a LdaConstant [1] ; a := const[1] Add r0, [0] ; a += r0 Star r1 ; r1 := a ; ...дальше как раньше
Во-первых, прибавляемая константа заняла третье место в constant pool; во-вторых, для её загрузки понадобился ещё один регистр, так что стековый кадр функции вырос на 8 байтов.
Если не использовать в функции именованные символы, то можно обойтись без constant pool. На github.com/v8/v8/blob/master/src/execution/frame-constants.h#L289 описан формат стекового кадра V8 и указано, что когда constant pool не используется, то размер стекового кадра сокращается на один указатель. Как в этом удостовериться? На первый взгляд кажется, что функция, не использующая именованные символы, не может быть рекурсивной; но взгляните-ка:
{let i = 0;
function func() {
this()();
};
const helper = () => (i++, func.bind(helper));
try {
helper()();
} catch (e) {
console.log(i);
}}
[generated bytecode for function: func (0x44db0878e575 <SharedFunctionInfo func>)] Parameter count 1 Register count 1 Frame size 8 37 S> 000044DB0878E8DA @ 0 : 5e 02 02 00 CallUndefinedReceiver1 <this>, <this>, [0] 000044DB0878E8DE @ 4 : 26 fa Star r0 43 E> 000044DB0878E8E0 @ 6 : 5d fa 02 CallUndefinedReceiver0 r0, [2] 000044DB0878E8E3 @ 9 : 0d LdaUndefined 47 S> 000044DB0878E8E4 @ 10 : ab Return Constant pool (size = 0) Handler Table (size = 0) Source Position Table (size = 8)
Цель — «Constant pool (size = 0)» — достигнута; но переполнение стека, как и раньше, происходит через 13951 вызов. Это значит, что даже когда constant pool не используется, стековый кадр функции всё равно содержит указатель на него.
А удастся ли добиться меньшего размера стекового кадра, чем вычисленное AxemaFr минимальное значение? —да, если внутри функции не использовать ни один регистр:
{function func() {
this();
};
let chain = ()=>null;
for(let i=0; i<15050; i++)
chain = func.bind(chain);
chain()}
[generated bytecode for function: func (0x44db08c34059 <SharedFunctionInfo func>)] Parameter count 1 Register count 0 Frame size 0 25 S> 000044DB08C34322 @ 0 : 5d 02 00 CallUndefinedReceiver0 <this>, [0] 000044DB08C34325 @ 3 : 0d LdaUndefined 29 S> 000044DB08C34326 @ 4 : ab Return Constant pool (size = 0) Handler Table (size = 0) Source Position Table (size = 6)
(При этом цепочка из 15051 вызова уже приводит к «RangeError: Maximum call stack size exceeded».)
Таким образом, вывод синьора AxemaFr о том, что «размер пустого ExecutionStack в Chrome равен 72 байтам», успешно опровергнут.
Уточняем предсказания
Мы можем утверждать, что минимальный размер стекового кадра для JS-функции в Chrome равен 64 байтам. К этому нужно прибавить по 8 байтов за каждый объявленный формальный параметр, ещё по 8 байтов за каждый фактический параметр сверх числа объявленных, и ещё по 8 байтов за каждый использованный регистр. По регистру отводится для каждой локальной переменной, для загрузки констант, для обращения к переменным из внешнего контекста, для передачи фактических параметров при вызовах, и т.д. Точное число использованных регистров по исходному тексту на JS вряд ли возможно определить. Стоит отметить, что интерпретатор JS поддерживает неограниченное число регистров — они не имеют отношения к регистрам процессора, на котором интерпретатор выполняется.
Теперь понятно, почему:
- добавление неиспользуемого формального параметра (
func = (x) => { i++; func(); };
) потребляет столько же памяти, как дополнительная локальная переменная; - передача необъявленного фактического параметра (
func = () => { i++; func(1); };
) потребляет вдвое больше памяти, чем дополнительная локальная переменная — потому что для передачи понадобился дополнительный регистр:[generated bytecode for function: func (0x44db08e12da1 <SharedFunctionInfo func>)] Parameter count 1 Register count 2 Frame size 16 34 S> 000044DB08E12FE2 @ 0 : 1a 02 LdaCurrentContextSlot [2] 000044DB08E12FE4 @ 2 : ac 00 ThrowReferenceErrorIfHole [0] 000044DB08E12FE6 @ 4 : 4d 00 Inc [0] 000044DB08E12FE8 @ 6 : 26 fa Star r0 000044DB08E12FEA @ 8 : 1a 02 LdaCurrentContextSlot [2] 35 E> 000044DB08E12FEC @ 10 : ac 00 ThrowReferenceErrorIfHole [0] 000044DB08E12FEE @ 12 : 25 fa Ldar r0 000044DB08E12FF0 @ 14 : 1d 02 StaCurrentContextSlot [2] 39 S> 000044DB08E12FF2 @ 16 : 1b 03 LdaImmutableCurrentContextSlot [3] 000044DB08E12FF4 @ 18 : ac 01 ThrowReferenceErrorIfHole [1] 000044DB08E12FF6 @ 20 : 26 fa Star r0 000044DB08E12FF8 @ 22 : 0c 01 LdaSmi [1] 000044DB08E12FFA @ 24 : 26 f9 Star r1 39 E> 000044DB08E12FFC @ 26 : 5e fa f9 01 CallUndefinedReceiver1 r0, r1, [1] 000044DB08E13000 @ 30 : 0d LdaUndefined 48 S> 000044DB08E13001 @ 31 : ab Return Constant pool (size = 2) Handler Table (size = 0) Source Position Table (size = 12)
- изменение в предыдущем примере добавляемого значения на 1.00000000000001 не влияет на размер стекового кадра — потому что один и тот же
r1
используется и для загрузки константы, и для передачи фактического параметра.
kai3341
Целая статья строится вокруг поливания грязью Senior Frontend Developer AxemaFr
Что сложного было дополнить или уточнить? Зачем было тыкать человека носом, если статья была далеко не поверхностной?
owlofmacloud Автор
Как раз и приведены факты, которые доказывают обратное — в полном соответствии с вашей пирамидой. Про личность AxemaFr упомянуто только то, что он Senior Frontend Developer — ровно то, что он сам про себя указал в профиле. Где вы видите «поливание грязью»?