Оптимізація надмірних виділень рядків у C ++


10

У мене досить складний компонент C ++, продуктивність якого стала проблемою. Профілювання показує, що більшість часу на виконання просто витрачається на виділення пам'яті для std::strings.

Я знаю, що серед цих рядків багато надмірності. Жменька значень повторюється дуже часто, але є також багато унікальних значень. Струни, як правило, досить короткі.

Зараз я просто замислююся, чи не було б сенсу якось повторно використовувати ті часті виділення. Замість 1000 покажчиків на 1000 чітких значень "foobar" я міг би мати 1000 покажчиків на одне значення "foobar". Те, що це було б більш ефективною пам’яттю, є приємним бонусом, але мене тут найбільше хвилює затримка.

Я думаю, одним із варіантів було б підтримання якогось реєстру вже виділених значень, але чи можливо зробити пошук реєстру швидше, ніж надмірні розподіли пам'яті? Це життєздатний підхід?


6
Здійсненно? Так, звичайно - інші мови роблять це рутинно (наприклад, Java - пошук інтернації String). Однак важливо враховувати, що кешовані об'єкти повинні бути незмінні, а std :: string - ні.
Халк

2
Це питання є більш актуальним: stackoverflow.com/q/26130941
rwong

8
Ви проаналізували, які типи маніпуляцій із струнами домінують у вашій програмі? Це копіювання, вилучення підрядків, конкатенація, маніпулювання символами за символом? Кожен тип операцій вимагає різних методик оптимізації. Крім того, перевірте, чи підтримує ваш компілятор і стандартна бібліотечна програма "оптимізація невеликих рядків". Нарешті, якщо ви використовуєте рядкове інтернування, важливим є також виконання хеш-функції.
rwong

2
Що ти робиш із цими струнами? Вони просто використовуються як якийсь ідентифікатор або ключ? Або вони об'єднані для створення певного результату? Якщо так, як ви робите струнні конкатенації? З +оператором або з потоковими потоками? Звідки беруться струни? Літерали у вашому коді чи зовнішній вхід?
амон

Відповіді:


3

Я сильно сперся на інтерновані рядки, як пропонує Базіле, де пошук рядків переводиться на 32-розрядний індекс для зберігання та порівняння. Це корисно в моєму випадку, оскільки я інколи маю сотні тисяч - мільйони компонентів із властивістю під назвою "x", наприклад, яка все-таки повинна бути зручною ім'ям рядка, оскільки до неї часто звертаються скриптери на ім'я.

Я використовую трие для пошуку (експериментував також, unordered_mapале мій налаштований трійник, підкріплений пулами пам’яті, принаймні почав працювати краще, а також було легше зробити безпеку для потоків, не замикаючись щоразу, коли структура була доступна), але це не так швидко для будівництва як створення std::string. Сенс полягає в тому, щоб прискорити наступні операції, такі як перевірка рівності рядків, яка, в моєму випадку, просто зводиться до перевірки рівності двох цілих чисел і різко скоротити використання пам'яті.

Я думаю, одним із варіантів було б підтримання якогось реєстру вже виділених значень, але чи можливо зробити пошук реєстру швидше, ніж надмірні розподіли пам'яті?

Це буде важко зробити пошук через структуру даних набагато швидше, ніж єдину malloc, наприклад, Якщо у вас є випадок, коли ви читаєте човновий набір рядків із зовнішнього входу, як-от файл, наприклад, тоді моя спокуса полягала б у використанні послідовного розподільника, якщо це можливо. Це пов'язано з недоліком того, що ви не можете звільнити пам'ять окремої струни. Усю пам'ять, об'єднану алокатором, потрібно вивільнити відразу або взагалі не звільнити. Але послідовний розподільник може бути корисним у тих випадках, коли вам потрібно просто виділити човновий набір крихітних шматочків пам'яті змінного розміру прямим послідовним способом, аби потім викинути все це пізніше. Я не знаю, чи це стосується у вашому випадку чи ні, але, коли це застосовується, це може бути простим способом виправити точку доступу, пов’язану з частими виділеннями підліткової пам'яті (що може мати більше спільного з помилками кешу та помилками сторінки, ніж базові алгоритм, який використовується, скажімо, malloc).

Виділення фіксованого розміру простіше пришвидшити без послідовних обмежень розподільника, які не дозволяють вам звільнити конкретні шматки пам'яті, щоб згодом повторно їх використовувати. Але зробити розподіл змінного розміру швидше, ніж розподільник за замовчуванням, досить складно. В основному зробити який-небудь розподільник пам'яті швидше, ніж mallocце, як правило, надзвичайно важко, якщо ви не застосовуєте обмежень, які звужують його застосовність. Одне рішення - використовувати алокатор фіксованого розміру для, скажімо, всіх рядків, розміром яких 8 байт або менше, якщо у вас є навантаження на човні, а довші рядки - рідкісний випадок (для якого ви можете просто використовувати розподільник за замовчуванням). Це означає, що 7 байтів витрачається на 1-байтні рядки, але це повинно усунути точки доступу, пов'язані з розподілом, якщо, скажімо, у 95% часу ваші рядки дуже короткі.

Ще одне рішення, яке мені щойно прийшло в голову, - це використовувати розкручені зв'язані списки, які можуть здатися божевільними, але мене почують.

введіть тут опис зображення

Ідея тут полягає в тому, щоб зробити кожен розкручений вузол фіксованого розміру замість змінного розміру. Коли ви це зробите, ви можете використовувати надзвичайно швидкий розподільник шматок фіксованого розміру, який об'єднує пам'ять, виділяючи шматки фіксованого розміру для струн змінного розміру, пов'язаних між собою. Це не зменшить використання пам'яті, вона, як правило, додасть її через вартість посилань, але ви можете пограти з нерозкрученим розміром, щоб знайти баланс, відповідний вашим потребам. Це якась дурна ідея, але повинна усунути гарячі точки, пов'язані з пам'яттю, оскільки тепер ви можете ефективно об'єднати пам'ять, вже виділену в об'ємних суміжних блоках, і все ж мати переваги звільнення рядків окремо. Ось простий "фіксований розподільник", який я написав (ілюстративний, який я створив для когось іншого, позбавленого пуху, пов'язаного з виробництвом), яким ви можете вільно користуватися:

#ifndef FIXED_ALLOCATOR_HPP
#define FIXED_ALLOCATOR_HPP

class FixedAllocator
{
public:
    /// Creates a fixed allocator with the specified type and block size.
    explicit FixedAllocator(int type_size, int block_size = 2048);

    /// Destroys the allocator.
    ~FixedAllocator();

    /// @return A pointer to a newly allocated chunk.
    void* allocate();

    /// Frees the specified chunk.
    void deallocate(void* mem);

private:
    struct Block;
    struct FreeElement;

    FreeElement* free_element;
    Block* head;
    int type_size;
    int num_block_elements;
};

#endif

#include "FixedAllocator.hpp"
#include <cstdlib>

struct FixedAllocator::FreeElement
{
    FreeElement* next_element;
};

struct FixedAllocator::Block
{
    Block* next;
    char* mem;
};

FixedAllocator::FixedAllocator(int type_size, int block_size): free_element(0), head(0)
{
    type_size = type_size > sizeof(FreeElement) ? type_size: sizeof(FreeElement);
    num_block_elements = block_size / type_size;
    if (num_block_elements == 0)
        num_block_elements = 1;
}

FixedAllocator::~FixedAllocator()
{
    // Free each block in the list, popping a block until the stack is empty.
    while (head)
    {
        Block* block = head;
        head = head->next;
        free(block->mem);
        free(block);
    }
    free_element = 0;
}

void* FixedAllocator::allocate()
{
    // Common case: just pop free element and return.
    if (free_element)
    {
        void* mem = free_element;
        free_element = free_element->next_element;
        return mem;
    }

    // Rare case when we're out of free elements.
    // Create new block.
    Block* new_block = static_cast<Block*>(malloc(sizeof(Block)));
    new_block->mem = malloc(type_size * num_block_elements);
    new_block->next = head;
    head = new_block;

    // Push all but one of the new block's elements to the free stack.
    char* mem = new_block->mem;
    for (int j=1; j < num_block_elements; ++j)
    {
        void* ptr = mem + j*type_size;
        FreeElement* element = static_cast<FreeElement*>(ptr);
        element->next_element = free_element;
        free_element = element;
    }
    return mem;
}

void FixedAllocator::deallocate(void* mem)
{
    // Just push a free element to the stack.
    FreeElement* element = static_cast<FreeElement*>(mem);
    element->next_element = free_element;
    free_element = element;
}

2

Можливо, ви хочете мати кілька інструментів для інтернованих струн (але рядки повинні бути незмінними, тому використовуйте const std::string-s). Можна захотіти деяких символів . Ви можете заглянути в розумні покажчики (наприклад, std :: shared_ptr ). Або навіть std :: string_view в C ++ 17.


0

Колись у побудові компілятора ми використовували щось, що називалося data-file (замість банку даних, розмовний німецький переклад для БД). Це просто створило хеш для рядка і використовувало його для розподілу. Таким чином, будь-яка рядок не була частиною пам'яті на купі / стеку, а хеш-кодом у цьому кріслі даних. Ви можете замінити Stringтаким класом. Хоча, можливо, деякі коди переробляються. І звичайно, це корисно лише для r / o струн.


А як щодо копіювання на запис. Якщо ви зміните рядок, ви б перерахували хеш і відновили його. Або це б не спрацювало?
Джеррі Єремія

@JerryJeremiah Це залежить від вашої заявки. Ви можете змінити рядок, представлений хешем, і коли ви отримаєте представлення хеша, ви отримаєте нове значення. У контексті компілятора ви створили новий хеш для нового рядка.
qwerty_so

0

Зверніть увагу, як розподілення пам’яті та фактична використовувана пам'ять стосуються низької продуктивності:

Вартість фактичного розподілу пам'яті, звичайно, дуже висока. Тому std :: string вже може використовувати місцеве розподілення для невеликих рядків, тому кількість фактичних виділень може бути меншою, ніж ви могли припустити спочатку. Якщо розмір цього буфера недостатньо великий, то вас може надихнути напр. Струнний клас Facebook ( https://github.com/facebook/folly/blob/master/folly/FBString.h ), який використовує 23 символи внутрішньо перед виділенням.

Варто також зазначити вартість використання великої кількості пам'яті. Це, мабуть, найбільший правопорушник: у вас може бути багато оперативної пам’яті у вашій машині, однак розміри кешу все ще досить малі, щоб це погіршило продуктивність під час доступу до пам'яті, яка ще не кешована. Про це можна прочитати тут: https://en.wikipedia.org/wiki/Locality_of_reference


0

Замість того, щоб робити строкові операції швидше, інший підхід полягає у зменшенні кількості рядкових операцій. Чи можна було б замінити рядки enum, наприклад?

Інший підхід, який може бути корисним, застосовується в какао: Є випадки, коли у вас є сотні чи тисячі словників, всі з одним і тим же ключем. Тож вони дозволяють вам створити об’єкт, який є набором словникових ключів, і є конструктор словника, який бере такий об'єкт як аргумент. Словник веде себе так само, як і будь-який інший словник, але коли ви додаєте ключ / значення пари з ключем у цей набір ключів, ключ не дублюється, а зберігається лише вказівник на ключ у наборі ключів. Отже, цим тисячам словників потрібна лише одна копія кожного ключового рядка в цьому наборі.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.