«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 &amp;strs, const char *str );
    const char* GetString( xstrings &amp;strs, uint32_t id );
    bool LoadFromDisk( xstrings &amp;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)


  1. mynameco
    13.01.2025 18:00

    В юньке появились Span и и стандартные парсеры с его поддеркой. Мне в очень многих местах получиось убрать выделения памяти.


    1. dalerank Автор
      13.01.2025 18:00

      Вы не путайте личную шесть с государственной, то появилось в Mono и позже, это отдельная боль была, потому что по факту было две копии строки - одна в моно и одна в редакторе. В 2014 году не то что std внутри не было, там даже не все "велики" вроде TArray работали нормально.


  1. azTotMD
    13.01.2025 18:00

    Всё это, конечно, интересно, но непонятно самое главное - зачем нужны строки в играх и особенно их сравнение. Я делал игру, там с NPC можно взаимодействовать посредством ввода текста и сравнение строк нужно. Но других таких игр я не помню со времен спектрума (там были The hobbit и Gremilins и не сказать, что они много места занимали).

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


    1. 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,
      }


    1. dalerank Автор
      13.01.2025 18:00

      Неудобно хранить и работать с бинарными данными в редакторе, во первых не отследить историю изменений, потому что все более-менее нормальные VCS рассчитаны на текстовые данные. Во-вторых если сломали левел, то текст, usd, lua, json, кастомный текстовый формат починить намного проще, чем откатывать и фиксить бинарь. Бинарные данные это для релиза, когда уже все проверили и надо быстро это грузить


      1. azTotMD
        13.01.2025 18:00

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


        1. dalerank Автор
          13.01.2025 18:00

          Ага, есть два подхода для таких парсеров, для текста как выше, он же парсер, он же writer, он же чекер и в 99% он текстовый, но умеет сейвить бинарную версию. Для реализа, отдельный ридер для бинарного формата, который только и умеет что читать его, но быстро. Но этот подход лишён возможности патчить релизные бины. Второй подход это текстовый формат, который позволяет накладывать патчи, и пусть он немного медленнее в чтении, зато намного удобнее в работе. Вы можете мержить два конфига, удалять или добавлять новые поля отдельными патчами, как это делает гит, и что самое важное делать это рантайм или на загрузке игры. С бинарными конфигами это на порядок сложнее делать, я не говорю что невозможно но проблем будет много. Это было одним из критериев, почему в симсах был выбран именно такой формат данных


          1. azTotMD
            13.01.2025 18:00

            спасибо, стало немного понятнее


  1. fujinon
    13.01.2025 18:00

    1. dalerank Автор
      13.01.2025 18:00

      Скорее нет, статья больше про механизм "string interning", но пересечений с железом много да, на консолях мы тоже недалеко от железа. Ту статью я посматривал в процессе подготовки материала, как и другие - ссылки в конце статьи. Вас наверное смутили картинка и примеры?


  1. dalerank Автор
    13.01.2025 18:00

    del


  1. shybovycha
    13.01.2025 18:00

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

    похожий подход подумал использовать в своей небольшой игре для шейдеров - нету смысла хранить строки для имен uniform variables в GLSL, поэтому вытягиваю локейшн переменной сразу после компиляции шейдера и просто храню адрес. только не уверен как это реализовать кросс-платформенно - шейдеры компилировать надо на каждой машине где запускается приложение


    1. Jijiki
      13.01.2025 18:00

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


      1. shybovycha
        13.01.2025 18:00

        насколько я понял, это компилятор так обрабатывает макросы - в макросе строки существуют, а в сгенерированном коде - нет. в С++ так не будет, т.к. макросы - по сути чисто find-and-replace строк, а в nim (я так понимаю так же как и в zig) это код, который выполняется во время компиляции отдельной стадией


  1. Panzerschrek
    13.01.2025 18:00

    Самая лучшая оптимизация работы со строками - не использовать строки. Вместо них enum в коде. Как вариант можно использовать очень короткие строки с длиною до 8 байт - такие можно в один uint64_t запихать. В том же Doom, помнится, имена ресурсов в WAD файле как раз 8-байтные были и этого было вполне достаточно.