Оптимизация строк с использованием интернирования

Оптимизация строк с использованием интернирования

«String interning», иногда это называют «пулом строк» — это оптимизация (https://en.wikipedia.org/wiki/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 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

 

Источник

Читайте также