«String interning», иногда это называют «пулом строк» — это оптимизация, при которой хранится только одна копия строки, независимо от того, сколько раз программа ссылается на нее. Среди других оптимизаций по работе со строками (SWAR, SIMD-cтроки, immutable strings, StrHash, Rope string, и немного других), часть которых была описана тут, она считается одной из самых полезных оптимизаций в игровых движках, есть правда небольшие недостатки у этого подхода, но экономия памяти и скорость работы при правильной подготовке ресурсов и работе с лихвой их перекрывают.
Вы 100% когда-нибудь писали одну и ту же строку несколько раз в одной программе. Например:pcstr color = "black";
А позже в коде пришлось написать: strcmp(color, "black");
Как видите, строковый литерал "black"
встречается несколько раз. Означает ли это, что программа содержит две копии строки "black"? Более того, означает ли это, что в оперативную память загружаются две копии этой строки? На оба вопроса ответ — зависит от компилятора и вендора. Благодаря некоторым оптимизациям в сlang (Sony) и GCC, каждая строка-литерал хранится в программе только в одном экземпляре, и, следовательно, только одна копия загружается в оперативную память, поэтому иногда cтановятся возможными разные фокусы.
Например такой (https://onlinegdb.com/ahHo6hWn7)
const char* color = "black";
int main()
{
const char *color2 = "black";
printf("%p - %p, %d", color, color2, color == color2);
return 0;
}
>>>>
0x603cc2b7d004 - 0x603cc2b7d004, 1
А вот на XBox этот трюк работать не будет
00007FF6E0C36320 - 00007FF6E0C36328, 0
Виноват компилятор?
Да вроде нет, в стандарте вобще ничего не говорится об интернировании строк, поэтому это фактически является расширением компилятора, который может заниматься удалением дубликатов, а может и не заниматься как вы поняли. И работать это будет только только строк, значения которых известны во время компиляции, что означает если уже в рантайме код собирает одинаковые строки, то будет создано две копии. В других языках, таких как C#
или Java
, интернирование строк происходит во время выполнения, потому что .NET Runtime или Java VM реализуют эту оптимизацию что называется из коробки. В C++ у нас нет среды выполнения, которая могла бы выполнить эту оптимизацию, поэтому она происходит только на этапе компиляции, но зато у нас есть игровой движок и приданные программисты, которые могут это сделать.
Ой, хватит заниматься этой черной магией
Если по каким-то причинам вы вдруг захотите отключить эту оптимизацию, и работать MS-стайл в кланге - есть флаг -fno-merge-constants. Эта же оптимизация за удаление дубликатов для числовых констант с плавающей запятой.
К сожалению, эта оптимизация очень хрупкая: существует множество способов её нарушить. Вы видели, что она работает нормально с const char*
, и то не везде:
// Количество копий: 1 в rodata, 1 в RAM
const char* s1 = "black";
const char* s2 = "black";
Если же мы изменим тип на char[],
программа создаст копию литерала при создании переменных:
// Количество копий: 1 в rodata, 3 в RAM
const char s1[] = "black";
const char s2[] = "black";
Аналогично, если мы изменим тип на string
, конструктор создаст копию строки:
// Количество копий: 1 в rodata, xз сколько в RAM будет, если такое положить в хедере
const string s1 = "black";
const string s2 = "black";
Сравнение строк
Теперь, когда вы увидели некоторые особенности при работе с такими строками на разных платформах, а также знаете как можно сломать эту оптимизацию, можно посмотреть на сравнении строк с помощью оператора ==. Все ведь знают что использование оператора == для типов указателей, в том числе и для литералов, приводит к сравнивнению адреса, а не содержимого? Два одинаковых строковых литерала могут иметь одинаковый адрес, когда работает компилятор смог их объединить, или разные адреса, когда не смог.
const char* color = "black";
if (color == "black") { // true, потому что работает интернирование строк
// ...
}
Магия - но на плойке работает. Все хорошо, но плохо, потому что это перестает работать, как только одна из строк не попадет в оптимизацию.
const char color[] = "black";
if (color == "black") { // false, потому что color хранит копию
// ...
}
Может кому-то покажется это прописной истиной, почему крайне нельзя использовать оператор == для сравнения строк типа char*
? Но такое, блин, сплошь и рядом: за прошлый год я поймал 6 (шесть! В 2024 году, 4 случая в пятницу, 1 в пятницу 13, два случая от одного и того же человека) случаев когда литералы сравнивались через указатели и приводили к разного рода багам, и примерно столько же потыток были отловлены на кодревью. Похоже некоторые забыли, что надо использовать функцию strcmp(), которая является частью стандартной библиотеки и сравнивает символы по одному, возвращая 0, если строки совпадают (она также возвращает -1 или +1 в зависимости от лексикографического порядка, но это здесь не важно).
const char color[] = "black";
if (strcmp(color, "black") == 0) { // true, потому что сравниваются каждый символ
// ...
}
Читаемость конечно уменьшилась, подвержена ошибкам и надо помнить правила работы с strcmp
, но это наше наследие от plain C и все более менее понимают как с этим жить. Ну и перф конечно страдает, особенно на синтаксически похожих данных.
Не совсем строки
Есть идеи как растет фрагментация памяти при использовании строковых данных? Из прошлого опыта по использованию обычных string
и их аналогов в движке Unity - суммарный размер составлял от 3% до 6% от размеров дебажного билда, 3% набегали из-за фрагментации, когда мелкая строка удалялась, но в этот участок памяти ничего другого не помещалось. Средний размер строковых данных (в основном ключей) составял 14-40 байт и эти дырки были везде. Согласитесь от 30 до 60 мегабайт "свободной памяти" на гиговом iPhone 5S - достаточная причина, чтобы попытаться оптимизировать её и забрать себе, ну, например для текстур.
К тому же эти данные не нужны в релизе, они нужны только для отладки. Сами строковые данные можно вобще безопасно удалить из релизных сборок, оставив только хеши. Как минимум - это обезопасит или усложнит возможность взлома ресурсов игры . Добавьте сюда линейный аллокатор в отдельном участке памяти и вы получите возможность убрать вызванную строками фрагментацию из билда насовсем, когда все будет сделано. И вот эти 6% тестовых данных превращаются в менее чем 1% хешей (4 байта на хеш), а освободившуюся память мы точно найдем куда пристроить.
xstring color = "black";
xstring color2 = "black"
if (color == color2) { // true, потому что вызывается xstring::operator==()
// ..
}
if (color == "black") { // true, потому что вызывается xstring::operator==()
// ..
}
На кончиках пальцев
Разработчики давно придумали разные реализации, которые позволяют делать interning данных. Когда моя команда интегрировала это решение в Unity 4, мы вдохновлялись доступными исходниками на гитхабе и решениями из GCC, но по условиям open-source лицензии тот код нельзя было напрямую взять для использования, поэтому писали свое. Что-то подобное я недавно увидел уже в библиотеке stb, вот прям дежавю какое-то (https://graemephi.github.io/posts/stb_ds-string-interning/).
Есть несколько областей где применяется много, очень много сырых текстовых данных, но они (строки известны заранее) могут быть захешированы: либо во время выполнения, либо в процессе обработки контентного пайплайна. В движке это префабы, экземпляры сцен, модели и части процедурных генераций. Обычно они используются как независимые экземпляры или как шаблоны, которые могут быть дополнены другими данными или компонентами. Еще это:
Литералы, хешируемые в скриптах
Имена тегов в префабах и сценах
Строковые значения свойств
Как идея это выглядит довольно просто: это довольно простая таблица поиска, которая сопоставляет идентификаторы строкам.
namespace utils {
struct xstrings { eastl::hash_map< uint32_t, std::string > _interns; };
namespace strings
{
uint32_t Intern( xtrings &strs, const char *str );
const char* GetString( xstrings &strs, uint32_t id );
bool LoadFromDisk( xstrings &strs, const char *path );
}
}
В релизе во время выполнения движок или игра может загрузить файл с хешами и значениями строк, если это требуется для отладки. В дебаге же можно создавать такие строки прямо по месту вызова, это конечно немного дороже, но код выглядит читатемым. Когда мы только начинали интегрировать эту систему в Unity у нас был отдельный объект для генерации xstring c различными масками, это было связано с тем, как Unity внутри хранил строковые данные и было выгоднее заранее нагенерить себе нужны идентификаторов, чтобы они все лежали друг за другом и быстрее обрабатывались если нужно было пройтись по какому-то массиву свойств. К тому же в четверке Юньки был еще локальный для объекта кеш компонентов, который подгружал несколько следующих компонентов объекта для более эффективного доступа.
xstring tableId = Engine.SetXString( 'table', 'prop', 0 );
Эта функция приводила к созданию и хешированию таких строк, как table0prop
, table1prop
вплоть до table15prop
. Уже не нужно было отдельно создавать table15prop
, потому что движок уже сделал это. Но это все особенности устройства конкретного движка, не вижу смысла на них останавливаться, к тому же прошло уже почти 10 лет, может там вообще всё новое придумали.
Позже, благодаря простоте и всеядности этой системы, я использовал её с незначительными вариациями в других проектах и движках. Что до конкретной реализации, то её можно посмотреть здесь (https://github.com/dalerank/Akhenaten/blob/master/src/core/xstring.h). Вкратце расскажу как работает код, он и на самом деле очень простой.
struct xstring_value {
uint32_t crc; // контрольная сумма строки
uint16_t reference; // сколько осталось ссылок
uint16_t length; // длина строки
std::string value; // сырые данные, сейчас тут std::string
// но могут быть любые структуры, заточенные под проект
};
class xstring {
xstring_value* _p;
protected:
void _dec() {
if (0 == _p)
return;
_p->reference--;
if (0 == _p->reference)
_p = 0;
}
public:
xstring_value *_dock(pcstr value);
void _set(pcstr rhs) {
xstring_value* v = _dock(rhs);
if (0 != v) {
v->reference++;
}
_dec();
_p = v;
}
...
}
Структура xstring_value
хранит метаданные для строки, в конкретной реализации в качестве хранилище std::string
выбран просто для удобства, в каноническом виде там был bump-аллокатор, который просто размещал новую строку в конце (надо помнить про грамотное использование таких xstring) буфера, так гарантировалось что строки всегда живы. Класс xstring
создавал новую строку (если такой еще не было) и запоминал указатель на то место в памяти, где она размещена, либо получал указатель на существующую копию, если совпадал хеш. В принципе это все основные моменты, которые требуются для работы, я же говорил что всё очень просто. Ниже приведен код, который размещает строку в пуле, здесь опять же выбрана std::map
ради удобства, да и просто лень было возиться с написанием бамп-аллокатора, он лишь немногим выигрывает по скорости, но немного проигрывает по памяти. Но общий подход серьезно выигрывает у стандартных строк std::string по времени создания, если они идут через системый аллокатор (malloc/new), и по скорости сравнения.
Неинтересная реализация
std::map<uint32_t, xstring_value*> data;
mutex _m; // spinlock ?
xstring_value *dock(pcstr value) {
if (nullptr == value) {
return nullptr;
}
std::scoped_lock _(_m);
// calc len
const size_t s_len = strlen(value);
const size_t s_len_with_zero = s_len + 1;
assert(sizeof(xstring_value) + s_len_with_zero < 4096);
// setup find structure
uint16_t length = static_cast(s_len);
uint32_t crc = crc32(value, uint32_t(s_len));
// search
auto it = data.find(crc);
// it may be the case, string is not found or has "non-exact" match
if (it == data.end()) {
xstring_value *new_xstr = new xstring_value;
new_xstr->reference = 0;
new_xstr->length = length;
new_xstr->crc = crc;
new_xstr->value = value;
data.insert({crc, new_xstr});
return new_xstr;
}
return it->second;
}
xstring_container *g_xstring = nullptr;
xstring_value *xstring::_dock(pcstr value) {
if (!g_xstring) { g_xstring = new xstring_container(); }
return g_xstring->dock(value);
}
Как использовать
Классический вариант использования таких строк сводится к генерации из скриптов и ресурсов, либо объявлению в коде строк-констант. Если вы обратили внимание на класс xstring
, то у него есть дефолтный оператор сравнения, и так как сам класс по сути это POD int32_t
и все проверки сводятся к сравнению целых чисел. Десять лет назад это дало буст к перфу анимаций чуть меньше 30%, и в целом использование таких строк вкупе с другими оптимизациями сделало возможным запустить Sims Mobile
на iPhone4S, когда игра позиционировалась для шестого поколения, и немножко для пятого, а наши заокеанские коллеги не считали такое в принципе возможным.
struct time_tag {
float12 time;
xstring tag;
};
struct events {
static const xstring aim_walk;
static const xstring aim_turn;
};
void ai_unit::on_event(const time_tag &tt) {
if (tt.tag == events().aim_walk) {
ai_start_walk(tt);
} else if (tt.tag == events().aim_turn) {
ai_start_turn(tt);
}
ai_unit_base::on_event(tt);
}
Еще немного по этой теме:
https://github.com/foonathan/string_id
https://alexpolt.github.io/intern.html
https://blog.heycoach.in/understanding-string-interning/
https://github.com/RipcordSoftware/libstringintern
https://cpp4arduino.com/2018/10/23/what-is-string-interning.html
https://alexpolt.github.io/intern.html
Комментарии (15)
azTotMD
13.01.2025 18:00Всё это, конечно, интересно, но непонятно самое главное - зачем нужны строки в играх и особенно их сравнение. Я делал игру, там с NPC можно взаимодействовать посредством ввода текста и сравнение строк нужно. Но других таких игр я не помню со времен спектрума (там были The hobbit и Gremilins и не сказать, что они много места занимали).
В современных рпг нужно кликать в один из вариантов диалогов, в стратегиях и шутерах вообще текстов почти нет. Все что есть - помощь, мануалы и прочее - статичный текст, известный на этапе компиляции. Динамическая аллокация, которой посвящалась предыдущая статья для этого не нужна. Наверное есть какое-то внутридвижковое неочевидное использование строк, вот об этом бы услышать.
dalerank Автор
13.01.2025 18:00Вот так выглядит конфиг карты в UE
Begin Map Begin Level Begin Actor Class=StaticMeshActor Name=787_chairs_economy_10 Archetype=StaticMeshActor'/Script/Engine.Default__StaticMeshActor' Begin Object Class=StaticMeshComponent Name="StaticMeshComponent0" Archetype=StaticMeshComponent'/Script/Engine.Default__StaticMeshActor:StaticMeshComponent0' End Object Begin Object Name="StaticMeshComponent0" StaticMesh=StaticMesh'/Game/Meshes/B787/Chairs/787_chairs_economy.787_chairs_economy' StaticMeshDerivedDataKey="STATICMESH_34081786561B425A9523C94540EA599D_359a029847e84730ba516769d0f19427_08B81D3F4753513B93E720832A7E9708000000000100000001000000000000000000803F0000803F00000000000000000000344203030300000000" bHasCachedStaticLighting=True VisibilityId=100 BodyInstance=(Scale3D=(X=2.540000,Y=2.540000,Z=2.540000),bAutoWeld=False) RelativeLocation=(X=0.000200,Y=-415.462006,Z=540.482971) RelativeScale3D=(X=2.540000,Y=2.540000,Z=2.540000) CustomProperties End Object StaticMeshComponent=StaticMeshComponent0 RootComponent=StaticMeshComponent0 Layers(0)="Chairs" ActorLabel="787_chairs_economy_10" FolderPath="Chairs" End Actor End Level Begin Surface End Surface End Map
Вот нераспакованный левел того же UEВот левел из CryEngine, не помню уже какой ревизии, чтото было на харде
objects = create_section { } objects_base_params = create_section { version = 0, } bbox = create_section { bmax = create_section { 1, 9.516, 146.429, 1, }, bmin = create_section { -135.448, -30.102, 0, 0, }, } dtool = create_section { } dtool_base_params = create_section { version = 0, }
dalerank Автор
13.01.2025 18:00Неудобно хранить и работать с бинарными данными в редакторе, во первых не отследить историю изменений, потому что все более-менее нормальные VCS рассчитаны на текстовые данные. Во-вторых если сломали левел, то текст, usd, lua, json, кастомный текстовый формат починить намного проще, чем откатывать и фиксить бинарь. Бинарные данные это для релиза, когда уже все проверили и надо быстро это грузить
azTotMD
13.01.2025 18:00Всё равно у меня осталось какое-то недопонимание. Понятно, что проще редактировать человекочитаемый формат. Но потом же, наверняка, всё это втягивается каким-то парсером и превращается в числовые идентификаторы? Опять же в статье упоминалась симс на мобилках, там явно речь о продуктовой версии, нет?
dalerank Автор
13.01.2025 18:00Ага, есть два подхода для таких парсеров, для текста как выше, он же парсер, он же writer, он же чекер и в 99% он текстовый, но умеет сейвить бинарную версию. Для реализа, отдельный ридер для бинарного формата, который только и умеет что читать его, но быстро. Но этот подход лишён возможности патчить релизные бины. Второй подход это текстовый формат, который позволяет накладывать патчи, и пусть он немного медленнее в чтении, зато намного удобнее в работе. Вы можете мержить два конфига, удалять или добавлять новые поля отдельными патчами, как это делает гит, и что самое важное делать это рантайм или на загрузке игры. С бинарными конфигами это на порядок сложнее делать, я не говорю что невозможно но проблем будет много. Это было одним из критериев, почему в симсах был выбран именно такой формат данных
fujinon
13.01.2025 18:00Это ведь отсюда? https://cpp4arduino.com/2018/10/23/what-is-string-interning.html
dalerank Автор
13.01.2025 18:00Скорее нет, статья больше про механизм "string interning", но пересечений с железом много да, на консолях мы тоже недалеко от железа. Ту статью я посматривал в процессе подготовки материала, как и другие - ссылки в конце статьи. Вас наверное смутили картинка и примеры?
shybovycha
13.01.2025 18:00буквально вчера видел видео о похожей оптимизации для микроконтроллеров в nim. в примере (на макросах) строки которые всречаются только в качестве констант существують только на этапе компиляции программы и не попадают в бинарник.
похожий подход подумал использовать в своей небольшой игре для шейдеров - нету смысла хранить строки для имен uniform variables в GLSL, поэтому вытягиваю локейшн переменной сразу после компиляции шейдера и просто храню адрес. только не уверен как это реализовать кросс-платформенно - шейдеры компилировать надо на каждой машине где запускается приложениеJijiki
13.01.2025 18:00а как делают что строки константы не попадают в бинарник? я вот строки себе в одном месте константами записал и спецом написал комент что это будут таблицы в будущем если руки дорастут до таблиц, ну строки можно в файл записывать вроде первое что сейчас подумал, ааа макросы наверно
shybovycha
13.01.2025 18:00насколько я понял, это компилятор так обрабатывает макросы - в макросе строки существуют, а в сгенерированном коде - нет. в С++ так не будет, т.к. макросы - по сути чисто find-and-replace строк, а в nim (я так понимаю так же как и в zig) это код, который выполняется во время компиляции отдельной стадией
Panzerschrek
13.01.2025 18:00Самая лучшая оптимизация работы со строками - не использовать строки. Вместо них enum в коде. Как вариант можно использовать очень короткие строки с длиною до 8 байт - такие можно в один uint64_t запихать. В том же Doom, помнится, имена ресурсов в WAD файле как раз 8-байтные были и этого было вполне достаточно.
mynameco
В юньке появились Span и и стандартные парсеры с его поддеркой. Мне в очень многих местах получиось убрать выделения памяти.
dalerank Автор
Вы не путайте личную шесть с государственной, то появилось в Mono и позже, это отдельная боль была, потому что по факту было две копии строки - одна в моно и одна в редакторе. В 2014 году не то что std внутри не было, там даже не все "велики" вроде TArray работали нормально.