Как продеть слона через игольное ушко. Обработка максимальных объемов данных за минимальное время

  • Tutorial


Чего только ни услышишь от апологетов тех же Java или C# про разработку на C/C++! Якобы этот язык устарел и на нем никто не пишет. Вот только когда требуется создать no latency или low latency сервис или нужно сэкономить память и время выполнения узкого места обработки больших объемов данных, то тут же прибегают за помощью к «архаичным» разработчикам на C/C++. Просто потому, что эти ребята умеют вручную управлять памятью и прекрасно представляют, что за начинка у той или иной высокоуровневой операции. Сегодня наша задача — стать на шаг ближе к этим ребятам.

Под капотом гоночной машины


Бизнес-логика сетевого приложения — это обычно не то место, где предпочитают использовать C++ как язык разработки. Как правило, для сетевого взаимодействия между клиентами приложения и серверами базы данных выбирают более высокоуровневые языки. Но рано или поздно приложение разрастается до уровня, когда его дешевле оптимизировать, чем закупать новые серверы. В этом случае нам предстоит увлекательный аттракцион встраивания реализации части бизнес-логики на C/C++ в устоявшуюся логику на C#, Java, Python, Ruby или JavaScript. Тут тебя, вероятно, ждет презабавный сюрприз: на C++ нужно уметь обрабатывать большие объемы данных эффективно. Навыки в Java или C# быстро сведут на нет все попытки оптимизации, если ты просто попробуешь написать примерно такой же код на C++.

Дело в том, что применять new следует максимально экономно, а нерациональное использование не совсем подходящих к ситуации контейнеров сделает совершенно логичный код абсолютно непригодным на практике. Вполне возможно, что после «оптимизации», проведенной сотрудником, не вполне квалифицированным именно в C++, время выполнения может остаться примерно тем же или даже увеличиться. Кто-то разведет руками, мол, старались, но тут все и так оптимизировано донельзя. Кто-то попытается убедить коллег в немыслимой скорости высокоуровневого языка. Наша задача в том, чтобы деньги фирмы не были потрачены зря. Чтобы затраты ресурсов на встраивание C/C++ в критические участки кода не только не оказались напрасными, но многократно окупились. Ценятся не те специалисты, что разводят руками и говорят «ну не смогли», а те, что добиваются невозможного. Ведь невозможным оно только кажется, и ничего сложного в этом уроке не будет. Все, что потребуется, — это запомнить несколько важных вещей, которые пригодятся при обработке данных на C++.

Выбираем инструменты тщательно


Если тебе еще не посчастливилось прочитать замечательную книгу «Эффективное использование STL» Скотта Мейерса, я ее крайне рекомендую. Без детального понимания того, для какой ситуации в C++ нужен тот или иной контейнер, использование STL будет сродни ремонту асфальта в дождь. Некоторые основные советы я все же дам, но важно досконально разбираться в предназначении разных контейнеров и устройстве их методов.

Первое, что следует всегда помнить: `std::vector` — это не массив, а именно вектор. Используй этот контейнер, если нужна именно векторизация непрерывного куска памяти в виде однотипных элементов. Если же требуется регулярное добавление и удаление при неконтролируемом размере, то `std::vector` вряд ли пригодится. Когда нужен именно массив с поэлементным доступом и недорогим увеличением размера, смотри лучше в сторону `std::deque`. Ведь если нам не будет хватать зарезервированной памяти, то произойдет сначала выделение нового непрерывного (!) блока, а затем перенос данных из старой памяти объекта `std::vector` в новую поэлементно. Поскольку мы рассматриваем обработку больших объемов данных за наименьшее время, перераспределение памяти под уже существующие объекты — это совсем не то, на что хочется тратить процессорное время.

Второе необходимое условие — это тщательный выбор контейнера с соотношением уникального ключа и значения. В случае больших данных, вероятно, проще всего сразу построить `std::unordered_map` и стараться как можно реже его изменять. Дело в том, что взятие по ключу в `std::unordered_map` куда эффективнее, чем в `std::map`, опять же для больших объемов данных не нужно выстраивать и поддерживать в памяти красно-черное дерево с непомерным количеством узлов. Но если соотношение ключ — значение часто изменяется (удаляются соотношения, добавляются новые, и это делается достаточно интенсивно), то проще смириться с поддержанием `std::map`, чем раз за разом перестраивать внутреннее представление `std::unordered_map`. Ведь внутри `std::unordered_map`, по сути, массив цепочек значений, и чем чаще мы изменяем соотношение ключ — значение, тем менее эффективным становится его использование. Здесь не спасет даже более быстрое извлечение по ключу: перестроение больших массивов — это всегда дорого.

Третий важный момент — это логика. Сначала напиши наиболее эффективный алгоритм, а затем смотри, что логически подходит для хранения данных при его работе. Всегда старайся выбирать контейнер единственно очевидным способом. Нужен набор уникальных значений — бери `std::set`, нужен словарь, который редко меняется — смело используй `std::unordered_map`, нужен массив, и заранее не знаешь его размер — скорее всего, понадобится `std::deque`, если же размер массива заранее известен, то может подойти и обычный `std::vector`.

Четвертое — это замеры производительности. Всегда следует проверять свое решение о выборе контейнера или алгоритма сравнительным анализом с тестированием времени выполнения на схожих контейнерах. Так, может статься, что отсортированный `std::vector` пар ключ — значение может быть эффективнее в обработке, чем логично подходящий `std::map`, построенный по этому соотношению.

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

Затраты на текст


Первое и главное, что следует усвоить при работе с текстом: `std::string` не единственный способ сохранить и обработать текст или его часть. В случае подстроки совершенно не обязательно заводить под каждый кусок большой строки миллионы новых контейнеров `std::string`, достаточно указать на начало и конец каждой подстроки. Лучше завести свою структуру с парой итераторов begin/end на исходной строке, чем для каждой подстроки строить новый непрерывный блок памяти и копировать в него часть и так хранящегося в исходной строке текста.

Пример: находим все слова в тексте, представленном в виде указателя на null-terminated строку. Пусть для простоты наши слова разделены символом точки с запятой.

template <typename word_type>
void find_words(char const *text, std::deque<word_type>& result)
{
    char const *start = text;
    char const *finish = text + std::strlen(text);
    while (start < finish)
    {
        char const* last = std::strchr(start, ';');
        if (!last) last = finish;
        result.push_back(word_type(start, last));
        start = last + 1;
    }
}

В качестве `word_type` попробуем как стандартный `std::string`, так и собственный тип, сохраняющий указатель на начало и конец подстроки в исходной строке.

struct one_word
{
    one_word(char const *begin, char const *end)
    	    : m_begin(begin), m_end(end) {
    }
    char const *m_begin, *m_end;
};

В результате несложных сравнительных замеров выясняется, что, если не тратить время на генерацию абсолютно ненужных промежуточных строк в контейнерах `std::string`, код начинает выполняться в 15–20 раз быстрее. Если большинство слов не помещается в изначально зарезервированный в `std::string` буфер размером 16 char, то дополнительно нам приходится динамически выделять новые блоки памяти для хранения подстрок. Наш класс `one_word` при инициализации заполняет только два поля типа указатель на символ, и этого хватает, чтобы потом пройти по подстрокам.

Особым пренебрежением ко всякой оптимизации грешит библиотека Boost, поэтому, если вдруг решишь использовать `boost::split` или `boost::regex`, вспомни об этом решении, когда профилировщик покажет проседание производительности именно при разборе строки с массовым неявным созданием всевозможной ненужной чепухи.

JSON, XML и все-все-все


К работе с текстовыми протоколами следует подходить как можно аккуратнее. Как показывает предыдущий пример, малейший просчет при создании вспомогательных объектов может убить производительность совершенно безобидным на первый взгляд кодом.

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

Первое и главное. Тебе почти никогда не стоит строить полное дерево для структуры XML/JSON/YAML при ее чтении откуда бы то ни было. Обычно все сводится к извлечению ряда однотипных значений.

Второе: не гнушайся изобретать велосипед, если задача критична по производительности, а случай у тебя настолько частный, что отказаться от протоптанной тропинки будет верным решением.

Третье: при сериализации, пожалуйста, постарайся обойтись генерацией в буфер на стеке. Если это невозможно, то пиши в `std::string` с заблаговременным вызовом reserve. Код никогда не получится эффективным, если ты сначала мусоришь по всей оперативной памяти использованием `std::stringstream`, а затем еще и собираешь из него строку, дополнительно склеивая то, что можно сразу собрать в результат.

В качестве домашнего задания сравни по производительности генерацию большой текстовой конфигурации в тот же XML с использованием `std::stringstream` и без него. Оперативная память в виде сыра с кучей дырок фрагментации не располагает к быстродействию.

База ответит


При запросе к базе данных мы на этапе компиляции не знаем, какого типа значения к нам придут. Точнее, мы можем построить строку запроса, можем даже обернуть это в простенький SQL-like ORM на стороне C++, но главное — на этапе компиляции мы почти никогда не знаем, что база данных говорит в ответ.

В этом плане динамически типизируемые языки с генерацией атрибутов на лету вроде тех же Python, Ruby и JavaScript имеют перед компилируемыми языками со статической типизацией несомненное преимущество.

Можно, конечно, понаделать всевозможных типов вроде `IntField`, `FloatField` и прочих `*Field` с общим предком наподобие `BaseField`, а затем мучиться по всем веткам кода, используя приведение ссылок и указателей. Это приведет к фрагментации единого, в общем-то, ответа от базы данных — он окажется распихан по маленьким ячейкам памяти.

Однако, вспомнив первые три урока нашей академии, мы можем легко обойти ограничения языка C++ и при этом получить удобоваримый API. Все, что нам остается, — это минимизировать затраты на динамическое выделение памяти на каждое поле в каждой записи. Это сделать не так уж и сложно.

Классические СУБД в ответ на SQL-запрос выдают нам табличные данные, то есть мы знаем формат каждой записи, пусть и на этапе выполнения, а не на этапе компиляции. По структуре записи мы можем изначально выделить память под все данные всех полей в сумме. В дальнейшем рассовывать значения полей по заготовленным ячейкам памяти нам поможет старый добрый `placement new`. Выглядит это примерно так:

  1. Из метаданных запроса мы узнаем тип данных каждого поля в результатах запроса. Для каждого типа на стороне базы данных у нас есть аналогичный тип на стороне бизнес-логики. Скалярные данные — это данные с фиксированным размером, выделять память для них означает оставлять место в буфере, им не нужен даже конструктор. Чуть сложнее с данными, которые выделяют дополнительную память в куче, как, например, `std::string` для представления типа `text`. Однако сам `std::string` имеет определенный размер, так что можем сказать, будто для любого типа поля в базе данным мы знаем тип и размер на стороне бизнес-логики.
  2. Далее банально складываем размеры типов полей записи и получаем размер блока данных под каждую запись. Для выделения памяти под весь результат запроса мы можем выделить память один раз для всех полей. Получится, что в кучу за памятью мы лезем лишь однажды, сразу после чтения полей из метаданных результата запроса. Сложность операции кратна количеству полей в результате запроса: даже если запрошена тысяча полей, это куда проще, чем выделять память под 1000 × *количество записей в результате* под малопонятные IField*.
  3. Для удобства обработки данных некий класс field нам все же придется построить. Он будет представлять собой контейнер с динамически типизируемыми данными из первых двух лекций «Академии C++». По сути, в каждом будет по значению, но опционально хранится тип — он соответствует типу в результате запроса в соответствующей ячейке данных, либо NULL.
  4. Поскольку подавляющее большинство хранящихся в базе типов данных — текстовые, возможно, имеет смысл инлайнить небольшие строки с ограниченным размером на стороне базы данных не в `std::string`, который полезет в кучу, а непосредственно в память `field`. В этом случае мы получим неплохую оптимизацию при выделении памяти, но придется помучиться с реализацией нужных методов, поскольку сам по себе `char m_text[SIZE]` делать ничего не будет, а возможности чистого си по работе со строками и памятью не адаптированы для работы с базой данных.
  5. Теперь главное: выделив память под каждый тип в записи, создаем данные поля с помощью конструкции `new(<куда>) Тип(<параметры>)`.

Вот как это должно выглядеть в реализации. Главный класс — это контейнер поля, динамически типизируемый от любого используемого тобой типа в базе данных. Если у тебя только скалярные типы, текстовые данные и NULL, то получится примерно вот так:

class field
{
public:
    template <typename value_type>
    field(void* address, value_type const& value);

    template <typename value_type>
    field& operator = (value_type const& value);

    template <typename value_type>
    value_type get_as() const;

    bool is_null() const;

protected:
    class data;

private:
    data* m_data;
};

class field::data {...};

template <typename value_type>
class data_holder : public field::data
{
public:
data_holder(); // NULL
data_holder(value_type const& value);
    	    <реализуем нужный интерфейс>
private:
        value_type m_value;
};

template <typename value_type>
field::field(void* address, value_type const& value)
: m_data(new(address) data_holder(value))
{
}

Если операции перестановки и удаления полей в результате запроса для тебя редкость, то смело векторизуй память под весь блок. Если же активно играешь в пятнашки с данными внутри результатов, то твой выбор — массив деков памяти, где память для каждой ячейки выделяется отдельно. В этом случае больше подойдет модель из второй лекции «Академии C++», где мы храним память под имплементацию объекта в данных класса и инициализируем через `placement new` уже внутри реализации, что в данном случае, с одной стороны, позволит использовать запись как полноценный `std::deque` однородных объектов, а с другой — ограничит использование больших данных внутри объектов. Уже нельзя будет инлайнить строки внутрь памяти записей, зато можно будет легко играть наличием и порядком полей, что важно для, например, нереляционных баз данных, либо для прокси-логики с дообработкой результатов полученных от другой бизнес-логики, на которую мы напрямую повлиять не можем. Тогда сам тип поля будет выглядеть немного по-другому:

class field
{
public:
    // Больше не нужен адрес
    template <typename value_type>
    field(value_type const& value);

    	<остальное не меняется>
    	
private:
    data* m_data;
    uint8_t m_buffer[MAX_FIELD_SIZE];
};

Теперь тебе понятна и тщательность, с которой я описывал механизмы динамической типизации в начале курса «Академии», и то, почему еще во втором уроке говорилось об экономии памяти при размещении данных заранее неизвестного типа внутри класса.

Какой бы путь ты ни выбрал, единый блок памяти для всей записи или даже для всего результата запроса на стороне бизнес-логики либо гибкое управление деком полей в каждой записи все равно будет лучше, чем массовое выделение памяти под каждое поле и работа с ними через указатели на интерфейсы, между которыми будет постоянное приведение типов.

Тихо! Идет запись!


В обоих случаях класс записи будет работать примерно так:

  1. Инициализация некоторым набором информации о полях.
  2. Выделение памяти по метаинформации произойдет лишь однажды.
  3. Затем в цикле заполняется список полей записи, со смещением относительно общей памяти для каждого последующего поля записи.
  4. Если информация о полях пришла сразу со значениями, то сразу инициализируем поля вместе со значениями прямо по нужному адресу.

Реализация записи будет выглядеть немного по-разному в зависимости от того, будет ли у тебя монолитный блок данных под всю запись, или же это набор взаимозаменяемых однотипных элементов с динамически типизуемым содержимым. Приведу пример того, как может выглядеть реализация записи для единого блока памяти:

class record
{
public:
	record(query_result const& result, size_t row);

	// Доступ к данным через контейнер field
	field [const]& operator[](<здесь нужны перегрузки
от int, char const* и std::string const&>) [const];

private:
	std::vector<uint8_t> m_buffer;
	std::vector<field> m_fields;
};

record::record(query_result const& result, size_t row)
{
	size_t buffer_size = std::accumulate(
result.types().begin(),
result.types().end(),
0,
[](size_t init, field::type type){
	return init + type.size();
});
		m_buffer.resize(buffer_size);
		m_fields.reserve(result.types().size());
		for(size_t offset=0, index=0;
index<result.types().size(); ++index)
		{
			m_fields.push_back(field(offset,result[row][index]));
			offset += result.type_of(index).size();
}
}

Если же мы будем использовать максимальное ускорение и для всего результата запроса использовать один блок памяти, то код записи поменяется незначительно:

class record
{
public:
	// Добавится параметр адреса в конструктор
	record(void* address, query_result const& result,
size_t row);

	<здесь все останется как было>

private:
	std::vector<field> m_fields;
	<буфер переедет в класс набора записей>
};

Самая главная оптимизация


Важно помнить, что раскладывать поля из результатов запроса по полям представления на стороне бизнес-логики — это круто, но очень часто совершенно не нужно. Если нужно по результату из клиентского API базы данных напрямую сгенерировать JSON, то совершенно не обязательно создавать тьму объектов `record` с кучей объектов `field` в каждом. Пусть даже оптимизированное, это действие лишнее. Просто создай буфер на стеке, сложи результат запроса в виде JSON, XML, YAML или другого ожидаемого клиентом формата и отправь ему.

А вот если некий код ждет от тебя на обработку именно набор данных во внутреннем формате для сложной обработки, то здесь, вне всяких сомнений, нужно генерировать удобное представление. Если же от тебя ждут простого ответа типа bool, означающего, пришло от базы hello или нет, то совершенно излишне будет генерировать структуру `[[«hello»]]`, чтобы ответить true.

Не добавляй в алгоритм лишних шагов — это и есть самая главная оптимизация.

Бессмертный и бессменный


Главное, что нужно усвоить, — язык C++, как и си, предоставляет прямой доступ к памяти процесса, причем в первую очередь важна память в стеке, а во вторую — память внутри заранее выделенных и подготовленных к использованию буферов. Никакие Java, C# или Python и близко не подойдут к показателям программ, грамотно написанных на C/C++, именно потому, что защищают программиста от неправильной работы с памятью. Мы можем выделить на стеке несколько килобайтов памяти под пакет протокола, заполнить ее, пробежав указателем, и выдать ссылку на буфер на стеке в функцию отправки по сети. Нам не нужно городить никаких `std::vector<uint8_t>` для этого, достаточно `uint8_t packet_buffer[MAX_PACKET_SIZE]` в стиле чистого и незамутненного си.

Язык C++, в свою очередь, предоставляет возможность надстраивать удобные высокоуровневые языковые конструкции поверх конструкций языка си, и этим нужно пользоваться. Грех не использовать конструкторы и деструкторы и генерацию исключений, не говоря уже о шаблонах. Если этой кухней владеешь, то и печеньки получатся годными, а если нет, то извини: по соседству есть автоматическая микроволновка (та же Java), попробуй испечь печеньки в ней.

Язык C++ отлично подходит как для оптимизаций алгоритмов, реализованных на высокоуровневых языках, так и для того, чтобы выжать все из своего кода. Для этого совсем не обязательно знать сложность обхода `std::map` — порой нужно просто взять и использовать вместо `vector of vector` обычный `T**`. Или вместо генерации непотребных размеров `std::string` для отправки по сети просто взять и сделать все на стеке.

Главное — иметь светлую голову и немножко думать над тем, что ты делаешь в каждой строчке кода. Не делается ли что-то лишнее? Ведь именно отсекая лишнее, подобно скульптору, мы оптимизируем скорость выполнения нашего кода. Успехов тебе в высоком искусстве оптимизации!

image

Впервые опубликовано в журнале Хакер #195.
Автор: Владимир Qualab Керимов, ведущий С++ разработчик компании Parallels


Подпишись на «Хакер»
Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 27

    +3
    Совсем пропущены языки нового поколения (Go/Rust), от которых есть ожидание революции и чуда по смещению Си с на«Си»женных позиций.
      +3
      --Совсем пропущены языки нового поколения (Go/Rust), от которых есть ожидание революции и чуда по смещению Си с на«Си»женных позиций.

      Что-то может и изменится если на этих языках напишут полноценную ОС с драйверами, блекжеком и шлюхами.
        0
        А зачем переписывать то, что уже и так отлично работает? Просто чтобы доказать «можем»?

        Я думаю, появление быстрого браузера будет достаточным аргументом. Пока что ни один интерпретируемо-опекаемый-gc-в-уютной-песочнице язык приличного браузера не породил, а задачи производительности, которые стоят перед создателями браузеров весьма и весьма близки к задачам, которые решают программисты в районе «ой, как бы мне MSI-X по ядрам распихать поудобнее».
          0
          А как насчет оптимизации по попаданию в кэш?
            0
            … А так же per-process queue, потому что в NUMA доступ к памяти соседнего сокета очень болезенный. И?

            В Си ровно так же нет никаких языковых инструментов для «оптимизации по попаданию в кеш». Нет ни понятия кеш, ни «виртуальной памяти». Даже аргументы в каноническом Си передаются через стек, а не через регистры.

            Если же кто-то хочет указать на тесную спайку всяких gcc-шных расширений для полу-ассемблерного написания, а то и самого что ни на есть ассембера в виде вставок, так их в любой компилируемый (в нативный код) язык можно делать с одинаковым успехом. Некоторые умники мне даже показывали «ассемблер в хаскеле», что уж про Rust/Go говорить.
              0
              Я говорил про NUMA?

              Как оптимизировать программы на Си вполне известно. Для Си есть качественные компиляторы с различными оптимизациями. Модель памяти в Си проще, в ней нет GC/подсчета ссылок за которые надо платить.
                0
                В Rust'е подсчёта ссылок тоже нет, кстати. И GC нет.

                А «есть качественный компилятор» — какое это отношение к языку-то имеет? Кстати, предполагается, что компилятор rust'а некачественный?
    • UFO just landed and posted this here
        +3
        Я тоже так думал, пока не столкнулся с embedded разработкой. Там, на одном очень редком, маломощном процессоре приходилось держать несколько tcp соединений, обрабатывать данные по последовательному порту, и предоставить api для управления модулем по сети. Вот тут то я и начал оптимизировать свой код, и, как оказалось, к очень многим выводам пришел как и автор. Только я в итоге понял их методом проб и ошибок, больше года, а здесь всего за семь статей можно было это узнать.
        И, мое имхо, на читаемости кода это никак не сказалось в худшую сторону, а во многим местах код стал прозрачнее. Особенно про сереализацию и десериализацию это актуально. Когда вы передает json и храните его представление, и чтобы что -то извлечь читаете его — это выглядит значительно сложнее, чем получить массив данных, единожды засунуть его в константный shared_pointer, а нужные данные извлекать просто по индексу в массиве, разименовывая указатель.
        • UFO just landed and posted this here
            0
            Ну так я заметил, что код в читаемости и гибкости не утратил, но стал быстрее. Поэтому если знаешь, то лучше писать так сразу.
          0
          Замечательно, действительно, надо запретить себе даже думать об указателях и представить что их нет. Правда что ли? 14-й стандарт отменяет использование указателей? Наиболее эффективное и оптимизированное построение алгоритма происходит именно на основе умелого управления памятью напрямую из кода C/C++. Можно сколько угодно отговаривать новичков от использования указателей в коде, но в этом случае из них не вырастет специалистов, умело управляющихся с памятью вручную. Именно такая магия и нужна как крупным компаниям, так и маленьким стартапам, и именно от получения этих бесценных навыков ты отговариваешь новичков? Не скажу, что очень уж ценный совет для начинающих.
            0
            В 17ом стандарте обещают класс string_view для работы над подстроками, но всегда можно его самому навелосипедить или взять StringRef из llvm, но зачем копирования то заведомо лишние плодить?
              0
              Или boost::string_ref.
            +1
            Если большинство слов не помещается в изначально зарезервированный в `std::string` буфер размером 16 char

            Хм, я понимаю, что это SSO, но не утверждал бы так категорично, как будто это всегда так.

            char const *finish = text + std::strlen(text);

            Зачем пробегаться по тексту лишний раз, ели всё равно strlen расчитан на null-terminated строку.

            Когда нужен именно массив с поэлементным доступом и недорогим увеличением размера, смотри лучше в сторону `std::deque`

            Ну вот смотрю я push_back(), а сложность для deque — константная, а для vector — амортизированная константная. Что это значит? Утверждение, без каких либо объяснений.

            Для класса field, как по мне нужно хотя бы упомянуть о конструкторе копирования/операторе присваивания и, вообще, m_buffer это целая эпопея для тех, кто знаком с memory alignment.
              0
              Это всё здорово, но вычисление длины null-terminated строки фактически не влияет на замеры, по сравнению с заполнение N-го числа std::string это как раз копейки. Можно нивилировать стандартной передачей указателя на конец, аналогичного итератору end(). push_back() у std::vector запросто может пойти за новой памятью, и поэлементно будет перемещать все элементы из старого блока в новый, std::deque'у это не грозит. С alignment знакомы, пожалуй, все, m_buffer на это никак не влияет, разница между выделением памяти внутри самого объекта или в куче, да или на стеке, где угодно, для alignment нулевая.
              +4
              Вот только когда требуется создать no latency или low latency сервис или нужно сэкономить память и время выполнения узкого места обработки больших объемов данных, то тут же прибегают за помощью к «архаичным» разработчикам на C/C++. Просто потому, что эти ребята умеют вручную управлять памятью и прекрасно представляют, что за начинка у той или иной высокоуровневой операции. Сегодня наша задача — стать на шаг ближе к этим ребятам.

              Ну и пафос

              Используй этот контейнер, если нужна именно векторизация непрерывного куска памяти в виде однотипных элементов.

              Термин «векторизация» общепринято означает нечто совершенно другое. Вообще, конечно, «вектор» — дебильное имя для структуры, которая на самом деле называется «динамический массив».

              Но если соотношение ключ — значение часто изменяется (удаляются соотношения, добавляются новые, и это делается достаточно интенсивно), то проще смириться с поддержанием `std::map`, чем раз за разом перестраивать внутреннее представление `std::unordered_map`. Ведь внутри `std::unordered_map`, по сути, массив цепочек значений, и чем чаще мы изменяем соотношение ключ — значение, тем менее эффективным становится его использование. Здесь не спасет даже более быстрое извлечение по ключу: перестроение больших массивов — это всегда дорого.

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

              Главное, что нужно усвоить, — язык C++, как и си, предоставляет прямой доступ к памяти процесса, причем в первую очередь важна память в стеке, а во вторую — память внутри заранее выделенных и подготовленных к использованию буферов. Никакие Java, C# или Python и близко не подойдут к показателям программ, грамотно написанных на C/C++, именно потому, что защищают программиста от неправильной работы с памятью.

              Все можно и в Java, и в C#.
                0
                Векторизация памяти — это фактически оптимизация работы с оперативной памятью на уровне инструкций CPU, и std::vector для этого отлично подходит. Имя структуры std::vector совсем никак не связано с понятием «динамический массив», как и std::deque. В Java и C# доступ к памяти напрямую настолько же убог и специфичен, насколько и ограничен, сколько всего нельзя в том же unsafe блоке C#, по факту это аналог ассемблерной вставки в С/C++ зачастую абсолютно ненужная операция для высокоуровневых языков. В С/C++ доступ к памяти осуществляется на уровне понятий языка, оптимизация работает как надо и всё можно, что и обычно, это нормальная практика, в отличие от Java/C#.
                0
                Главное, что нужно усвоить, — язык C++, как и си, предоставляет прямой доступ к памяти процесса, причем в первую очередь важна память в стеке, а во вторую — память внутри заранее выделенных и подготовленных к использованию буферов. Никакие Java, C# или Python и близко не подойдут к показателям программ, грамотно написанных на C/C++, именно потому, что защищают программиста от неправильной работы с памятью.

                В C# и прямая работа с памятью есть и stackalloc и unsafe блоки. Очередное некачественное сравнение?
                  0
                  Как я уже написал выше, unsafe блок крайне ограниченная по своему применению структура языка C#, что именно там можно, а что нельзя. Можно также вспомнить про Marshal и IntPtr, но это вообще доступ к коду, фактически написанному на C/C++ и больше подходит для обёртки, чем для полноценной работы.
                    0
                    Ни ранее, ни выше, ни между строк я не увидел описания этих ограничений. Marshal и IntPtr вообще не являются средствами языка, а просто обертка для unsafe-операций. Вроде такой.

                    public static unsafe byte ReadByte(IntPtr ptr, int ofs)
                    {
                    	…
                    	byte *addr = (byte *)ptr + ofs;
                    	return *addr;
                    	…
                    }
                    
                    

                    К чему Вы их упомянули? В CLR есть много более эффективных способов работы с памятью. В том числе safe, умеющие работать с динамическими структурами, которые не определены на этапе компиляции и т.п. По скорости на порядок опережающие Marshal. Или Вы не знали?
                      0
                      Я знаю C# достаточно хорошо, как и .NET Framework в целом и CLR в частности, я говорю лишь о том, что unsafe конструкции не являются нормальной практикой языка и их использование всегда несёт некоторые ограничения, не говоря уже об обёртках из namespace Marshal. Их применение сродни ассемблерной вставке и в этом случае я предпочитаю написать честный нативный модуль и работать с памятью из C/C++, а уж точно не из C#, который в этом случае использовать неэффективно иначе, кроме как для вызова кода обёрток методов.
                        0
                        Ну раз Вы такой профи, то ситуация еще проще. Давайте расчехлим отладчики и пройдемся по коду. Там и увидим, насколько непопулярен unsafe, как редко используются динамические «ассемблерные вставки». Прям на Вашем же примере JSON'а посмотрим, как NetJSON или Jil «небезопасны», как JSON.NET и почти все другие сериализаторы осуществляют эмиссию кода. Померяемся бенчмарками. А то за все время, что Вы говорите о факте существования ограничений с unsafe их можно было уже десять раз показать. У Вас есть перед глазами результат JITа unsafe кода, что Вы так о нем говорите? Должно же быть какое-то подкрепление всех этих буков.
                          0
                          Ну и чего обижаться-то? Как можно сравнивать листинг IL-кода против нативных инструкций скомпилированного и оптимизированного кода на C/C++? Каким бы шустрым CLR ни был, это всё равно лишняя трансляция в машинные инструкции. Хотите померяться с сишником скоростью выполнения managed кода против нативного. Весьма похвально стремление, с каким отстаивается точка зрения и любимые технологии, но это малость опрометчиво. Сериализацию вообще на C# или Java писать не очень эффективно, но если есть пример, где прямо по бенчмаркам C# библиотека уделывает все сишные, я бы на это посмотрел.
                  0
                  За всю мою жизнь std::map был только один раз быстрее unordered_map, да и то потому, что хеш считался долго, а operator< был быстрым, и данные были весьма специфичными.

                  В конце концов, ещё есть std::unordered_map::reserve().
                    0
                    Не стоит забывать о том, что мапа всё-таки сортирована по ключам. Иногда это важно.
                      0
                      Бесспорно.

                      Кстати, опять же, из той же практики и из того же проекта: алгоритм был таков, что данные записывались в контейнер один раз, а считывались много-много раз. И данные представляли из себя 24 байта, 16 на один идентификатор и 8 на другой. Оказалось, что быстрее всё это напихать в std::vector, один раз его отсортировать и потом бегать по нему бинарным поиском. Векторы, как правило, были короткие, и в кешлайн помещалось более одного элемента вектора, поэтому профит от этого был виден весьма.

                  Only users with full accounts can post comments. Log in, please.