В этой короткой статье мы рассмотрим потенциальную проблему производительности, которая может возникнуть при использовании 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 ;-)

Комментарии (0)