В этой короткой статье мы рассмотрим потенциальную проблему производительности, которая может возникнуть при использовании std::vector<char>
в качестве выходного буфера для операций ввода-вывода. В частности, мы обсудим последствия использования методов resize
и reserve
и то, как их неправильное использование может привести к нежелательным проблемам производительности.
Как мы помним, std::vector
- это динамический массив, который обеспечивает удобное управление непрерывным блоком памяти. Он является популярным выбором для операций ввода-вывода, особенно при работе с данными разных размеров. При использовании std::vector<char>
в качестве выходного буфера важно эффективно выделять и управлять памятью, чтобы избежать узких мест производительности.
Одна из распространенных ошибок - использование метода resize
вместо метода reserve
для выделения памяти для выходного буфера. Хотя оба метода могут выделять память, они служат разным целям:
reserve
: этот метод увеличивает емкость вектора без изменения его размера или инициализации новых элементов. Он полезен, когда вы хотите заранее выделить память, чтобы избежать повторных перераспределений памяти во время операций ввода-вывода.resize
: этот метод изменяет размер вектора, инициализируя новые элементы при необходимости. Хотя он может выделять память, у него также есть побочный эффект в виде инициализации новых элементов, что может привести к ненужным накладным расходам, особенно при работе с большими буферами.
Проблема производительности возникает при использовании метода resize
для выделения памяти для выходного буфера, так как он не только выделяет память, но и инициализирует элементы. Этот процесс инициализации может потреблять значительное количество ресурсов процессора и существенно сказаться на производительности.
Предположим, что у нас есть следующий класс C++, отвечающий за чтение данных из драйвера, и что функция io_handler::read_data ниже очень часто вызывается из основного кода.
class io_handler
{
static constexpr size_t input_buffer_size = 10 * 1024; // 10KB
static constexpr size_t output_buffer_size = 10 * 1024 * 1024; // 10MB
HANDLE file_handle_{ nullptr };
std::vector<char>& input_buffer_;
std::vector<char>& output_buffer_;
public:
io_handler()
{
input_buffer_.reserve(input_buffer_size); // Reserve space in the input buffer
output_buffer_.reserve(output_buffer_size); // Reserve space in the output buffer
// TODO: Create the file handle using ::CreateFile() function
}
~io_handler()
{
CloseHandle(file_handle_); // Close the file handle
}
io_handler(const io_handler& other) = delete;
io_handler(io_handler&& other) = delete;
io_handler& operator=(const io_handler& other) = delete;
io_handler& operator=(io_handler&& other) = delete;
// Read data from the input buffer and return true if successful, false otherwise
[[nodiscard]] bool read_data()
{
// Resize the output buffer to maximum size
output_buffer_.resize(output_buffer_.capacity());
DWORD out_bytes = 0;
const BOOL result = ::DeviceIoControl(
file_handle_, // File handle to use
IOCTL_READ_DATA_CHUNK, // IOCTL code
input_buffer_.data(), // Input buffer data
static_cast<DWORD>(input_buffer_.size()), // Input buffer size
output_buffer_.data(), // Output buffer data
static_cast<DWORD>(output_buffer_.size()), // Output buffer size
&out_bytes, // Number of bytes received
nullptr // No overlapped structure
);
// Resize the vector to the actual number of bytes received
output_buffer_.resize(out_bytes);
// Return true if DeviceIoControl returned non-zero value
return result ? true : false;
}
};
Приведенный код работает должным образом и на первый взгляд кажется, что проблем тут нет. Тем не менее строка output_buffer_.resize(output_buffer_.capacity())
при профилировании показывает значительные затраты ресурсов процессора превосходящие даже вызов DeviceIoControl
. В чем может быть проблема?
Действительно, изменение размера вектора предполагает выделение новой памяти и перенос существующих элементов, что само по себе довольно дорогая операция. Однако output_buffer_.resize(output_buffer_.capacity())
не должно вызывать реаллокацию вектора. Тем не менее, как упоминалось ранее, метод resize
делает больше, чем просто выделяет память; он также инициализирует элементы их значениями по умолчанию, и если вектор достаточно большой, этот процесс может потреблять значительное количество ресурсов CPU.
Задумаемся, как можно предотвратить инициализацию по умолчанию с минимальными изменениями кода. Давайте рассмотрим шаблон класса no_init_primitive_type
, который является оберткой вокруг примитивных типов и отключает ненужную нам инициализацию по умолчанию. Используя этот класс, вы можете создавать объекты с неинициализированными значениями, что может быть полезно в подобных ситуациях, где инициализация не требуется.
/**
* @brief A template class for creating wrapper objects around primitive
* types without default initialization.
*
* This template class restricts the type `T` to be a primitive type except
* `void`, and disables default initialization of the wrapped value.
*
* @tparam T The type of the wrapped value.
*/
template <typename T,
typename = std::enable_if_t<std::is_trivial_v<T> && !std::is_void_v<T>>>
class no_init_primitive_type {
public:
/**
* @brief Constructs a new `NoInitPrimitiveType` object.
*
* This constructor creates a new `no_init_primitive_type` object
* with uninitialized wrapped value.
* Static assertions are used to ensure that the alignment and size of the
* type `T` match the alignment and size of the `no_init_primitive_type` class.
*/
no_init_primitive_type() {
static_assert(alignof(T) == alignof(no_init_primitive_type),
"Alignment of no_init_primitive_type does not match type alignment");
static_assert(sizeof(T) == sizeof(no_init_primitive_type),
"Size of no_init_primitive_type does not match type size");
}
/**
* @brief The wrapped value.
*/
T value;
};
Или если гнаться за модой, то вот модернизированная версия шаблона класса с использованием концепций C++20:
#include <concepts>
/**
* @brief A template class for creating wrapper objects around primitive
* types without default initialization.
*
* This template class restricts the type `T` to be a primitive type except
* `void`, and disables default initialization of the wrapped value.
*
* @tparam T The type of the wrapped value.
*/
template <typename T>
requires std::is_trivial_v<T> && !std::is_void_v<T>
class no_init_primitive_type {
public:
/**
* @brief Constructs a new `no_init_primitive_type` object.
*
* This constructor creates a new `no_init_primitive_type` object
* with uninitialized wrapped value.
* Static assertions are used to ensure that the alignment and size of the
* type `T` match the alignment and size of the `no_init_primitive_type` class.
*/
no_init_primitive_type() {
static_assert(alignof(T) == alignof(no_init_primitive_type),
"Alignment of no_init_primitive_type does not match type alignment");
static_assert(sizeof(T) == sizeof(no_init_primitive_type),
"Size of no_init_primitive_type does not match type size");
}
/**
* @brief The wrapped value.
*/
T value;
};
Эта версия использует концепции C++ 20 для упрощения ограничений на параметр шаблона T. Ключевое слово requires используется для применения ограничений непосредственно в объявлении класса, делая код более читаемым и лаконичным.
Используя этот шаблон, мы можем переписать код функции read_data() следующим образом:
// Read data from the input buffer and return true if successful, false otherwise
[[nodiscard]] bool read_data()
{
// By default, resizing a vector initializes the new elements
// to their default values, which can significantly impact performance
// when dealing with large vectors. To avoid this, we omit the resize
// and pass the entire vector to the IoControl function.
// After the function returns, we resize the vector to the actual number
// of bytes received.
// This avoids default initialization and improves performance.
// output_buffer_.resize(output_buffer_.capacity());
DWORD out_bytes = 0;
const BOOL result = ::DeviceIoControl(
file_handle_, // File handle to use
IOCTL_READ_DATA_CHUNK, // IOCTL code
input_buffer_.data(), // Input buffer data
static_cast<DWORD>(input_buffer_.size()), // Input buffer size
output_buffer_.data(), // Output buffer data
static_cast<DWORD>(output_buffer_.size()), // Output buffer size
&out_bytes, // Number of bytes received
nullptr // No overlapped structure
);
// Resize the vector to the actual number of bytes received
reinterpret_cast<std::vector<no_init_primitive_type<char>>&>(output_buffer_)
.resize(out_bytes);
// Return true if DeviceIoControl returned non-zero value
return result ? true : false;
}
Что полностью решает нашу проблему.
В заключение, эта статья осветила возможную проблему производительности, которая может возникнуть при использовании std::vector в качестве выходного буфера для операций ввода-вывода. Мы углубились в последствия использования методов resize
и reserve
и то, как их неправильное использование может привести к нежелательным проблемам с производительностью. Понимая разницу между reserve и resize и используя специализированный шаблон класса, такой как no_init_primitive_type
, разработчики могут минимизировать узкие места производительности и оптимизировать свой код для эффективного управления памятью. Примеры, представленные в этой статье, могут послужить справочным материалом для тех, кто хочет улучшить свое понимание выделения и инициализации памяти в контексте операций ввода-вывода с использованиемstd::vector
.
P.S. Описанная выше проблема была отловлена и исправлена в дикой природе. Оригинал статьи был сгенерирован на английском с помощью ChatGPT и переведен на русский с минимальной авторской правкой.
P.P.S. Картинка тоже сгенерирована AI ;-)