Внутреннее представление и оптимизации строк в JavaScript-движке V8
- 2023-08-08
- ru
- CC BY-SA 4.0
- habr
- read in english
С самого рождения JavaScript, в каком-то смысле, был, во многом, языком для манипулирования текстом — от веб-страничек в самом начале, до полноценных компиляторов сейчас. Неудивительно, что в современных JS-движках достаточно много сил уделено оптимизации внутреннего представления строк и операций над ними.
В этой статье я хочу рассмотреть, как могут быть представлены строки в движке V8. Попытаюсь продемонстрировать их эффект, обогнав C++ в очень честном бенчмарке. А также покажу, в каких случаях они могут, наоборот, привести к проблемам с производительностью, и что в таких случаях можно сделать.
#Инструменты для исследования
Для того, чтобы наглядно увидеть, какая реализация строк используется в каждый конкретный момент, будем использовать отладочные функции V8. Для этого достаточно запустить Node.js с параметром --allow-natives-syntax
:
$ node --allow-natives-syntax
Welcome to Node.js v20.3 0.
Type ".help" for more information.
> %123
DebugPrint: Smi: 0x7b 123
123
Для строк эта функция печатает довольно много информации, поэтому я буду заменять на /* ... */
то, что не важно для рассмотрения.
#Какие в V8 есть строки?
Список внутренних реализаций строк можно найти в исходниках V8.
String
SeqString
SeqOneByteString
SeqTwoByteString
SlicedString
ConsString
ThinString
ExternalString
ExternalOneByteString
ExternalTwoByteString
InternalizedString
SeqInternalizedString
SeqOneByteInternalizedString
SeqTwoByteInternalizedString
ConsInternalizedString
ExternalInternalizedString
ExternalOneByteInternalizedString
ExternalTwoByteInternalizedString
Большая часть этого многообразия получается из комбинации нескольких основных признаков:
#OneByte
и TwoByte
Стандарт определяет строки как последовательности 16-битных значений. Но зачастую хранить по 2 байта на символ бывает слишком расточительно. На практике, очень многие строки не выходят за рамки ASCII. Поэтому внутри V8 строки могут быть как одно-, так и двухбайтовыми.
К примеру, вот ASCII-строка. Обратите внимание на ее type
:
> %"hello"
DebugPrint: 0x209affbb9309: in OldSpace: #hello
0x30f098a80299: in ReadOnlySpace
- type: ONE_BYTE_INTERNALIZED_STRING_TYPE
/* ... */
А вот строка с не-ASCII символами, представлена как двухбайтовая:
> %"привет"
DebugPrint: 0x1b10a9ba2291: in OldSpace: u#\u043f\u0440\u0438\u0432\u0435\u0442
0x30f098a81e29: in ReadOnlySpace
- type: INTERNALIZED_STRING_TYPE
/* ... */
#Internalized
Некоторые строки — в частности, все строковые литералы интернирутся — собираются движком в единый пул строк, вне кучи. При использовании одинаковых строковых литералов эти объекты переиспользуются. Такие строки имеют внутренний тпи Internalized
:
> %"hello"
DebugPrint: 0x209affbb9309: in OldSpace: #hello
0x30f098a80299: in ReadOnlySpace
- type: ONE_BYTE_INTERNALIZED_STRING_TYPE
/* ... */
Если значение строки известно только в рантайме, то она, как правило, не будет интернироваться. Обратите внимание на отсутствие INTERNALIZED
в ее type
:
>
> "hello.txt", "hello", "utf8"
>
> %s
DebugPrint: 0x2c6f46782469: : "hello"
0xd2880ec0879: in ReadOnlySpace
- type: ONE_BYTE_STRING_TYPE
/* ... */
Конечно, ничего не мешает движку интернировать эту строку позже. Один из простых способов — заставить движок прочитать ее как строковый литерал при помощи eval
:
>
undefined
> %ss
DebugPrint: 0x80160fa1809: in OldSpace: #hello
0xd2880ec0299: in ReadOnlySpace
- type: ONE_BYTE_INTERNALIZED_STRING_TYPE
/* ... */
#External
External
-строки хранятся не на куче, а в отдельных областях памяти, специально для них выделенных. Как правило, это применяется для очень больших строк. Для примера, давайте запустим Node.js с очень маленьким размером кучи, но выделим для строки много памяти:
// test.js
// создаем строку размером в 16 МБ
;
s.length;
Запустим c ограничением кучи в 8 МБ:
#Sliced
Для экономии памяти и времени на копирование данных, операция взятия подстроки возвращает Sliced
-строку. Это аналог string view из других языков — то есть, просто указатель на строку-родителя, смещение и длина.
>
undefined
> %0, 15
DebugPrint: 0x80e9bea9851: : "AAAAAAAAAAAAAAA"
0xd2880ec1d09: in ReadOnlySpace
- type: SLICED_ONE_BYTE_STRING_TYPE
/* ... */
Но если подстрока достаточно короткая, то выгоднее все-таки ее скопировать:
> %0, 5
DebugPrint: 0x18a9c2e10169: : "AAAAA"
0xd2880ec0879: in ReadOnlySpace
- type: ONE_BYTE_STRING_TYPE
/* ... */
#Cons
Аналогично, операция конкатенации возвращает Cons
-строку, содержащую только ссылки на левую и правую исходные строки:
> %s + s
DebugPrint: 0x2c6f467b3e09: : c"AAAAAAAAAA/* ... */AA"
0xd2880ec1be9: in ReadOnlySpace
- type: CONS_ONE_BYTE_STRING_TYPE
При этом, опять-таки, для коротких строк это не применяется:
> %0, 2 + 0, 3
DebugPrint: 0xec9b3412501: : "AAAAA"
0xd2880ec0879: in ReadOnlySpace
- type: ONE_BYTE_STRING_TYPE
/* ... */
#Преимущества оптимизаций: "обгоняем" C++
Итак, мы разобрались с тем, как именно представлены строки в V8. Давайте применим это на практике в одной из моих любимых дисциплин: нечестных сравнениях разных языков!
Правила просты: нам нужно придумать такую задачу, в которой JS-код окажется быстрее, чем строчка-в-строчку аналогичный код на C++. К примеру, давайте эксплуатировать то, что Cons
-строки дают нам очень быструю конкатенацию, а Sliced
-строки — очень быстрое взятие подстроки.
// unethical-benchmark.js
// дана строка text длиной 1500
// найти суммарную длину тех её подстрок, у которых длина больше 200
;
;
for ; i < text.length; i++
result.length;
Для пущей честности запустим на голом V8, скачав его при помощи jsvu:
А теперь аналогичный строка-в-строку код на C++:
// unethical-benchmark.cxx
// дана строка text длиной 1500
// найти суммарную длину тех её подстрок, у которых длина больше 200
int
&&
Разумеется, это отвратительный код и отвратительное сравнение. Однако на нем хорошо виден именно эффект от Cons
- и Sliced
-строк. А именно: максимально наивный код, без всяких оптимизаций, может получить значительное ускорение.
#Недостатки оптимизаций: учимся "отмывать" строки
Недостаток таких оптимизаций в том, что ими довольно трудно управлять. В других языках программист может явно указать, где ему нужет string view, где string builder, а где однобайтовые строки — но в JS приходится либо терпеть умолчания движка, либо заниматься колдунством.
Для примера, давайте напишем небольшой скрипт, который вытащит нам все адреса ссылок из пары страниц комментов Хабра:
// urls-1.js
;
Посмотрим, с каким минимальным размером кучи получится его запустить:
<-
;
) ) ) ()
) ) ) )
<-
Получается, ограничения в 10 МБ хватает, а вот при 9 МБ уже падает.
Давайте попробуем исправить. Из очевидных идей — в памяти всегда остается предыдущий html
, даже когда он уже не нужен. Давайте занулим переменную, чтобы его утащила сборка мусора:
// urls-2.js
;
<-
;
) ) ) ;
) ) )
<-
Не помогло! Причина, на самом деле, именно в особых представлениях строк: все urls
— подстроки html
, представленные как Sliced
-строки; они хранят ссылку на исходный html
, не давая ему собраться в мусор.
Давайте отмоем эти строки!
// urls-3.js
;
Выглядит как магия. Работает ли?
# работает!
<-
;
) ) ) ;
) ) ) ;
) ) )
<-
Как видно выше, код стал падать только при ограничении в 7 МБ — мы выиграли порядка 2 МБ!
Потребление памяти можно улучшить еще больше, если вспомнить про еще одну особенность представления строк — TwoByte
и OneByte
. Воспользуемся тем, что Хабр, как и почти все остальные, отдает свои страницы в кодировке UTF-8:
// urls-4.js
;
Выиграли еще около 1 МБ:
# работает!
<-
;
) ) ) ;
) ) )
<-
#Что в итоге?
Движок V8, как и другие современные среды исполнения JavaScript, включает множество оптимизаций для разных сценариев работы. Сегодня мы рассмотрели его внутреннее устройство строк. Намеренно эксплуатировать их, скорее всего, не получится — хоть мы и "обогнали" C++ на нашем нечестном бенчмарке. Но, с другой стороны, знание внутренностей строк может помочь отловить совершенно неожиданные проблемы с производительностью кода.