A std::vector<char> is an easy choice when you need a buffer for an I/O operation. It owns its memory, gives you a pointer through data(), and lets you resize the result to the number of bytes you received. That last part is especially convenient: the rest of your code can simply use the vector’s size.
So you grow the vector before the call, let the driver fill it, and shrink it to the returned byte count. Just a couple of size changes, and the buffer is ready to use.
I first ran into this issue in production code. I’ve simplified the example here to make the underlying performance problem easier to see.
On a busy I/O path, those two convenient calls can quietly add a surprising amount of work. Let’s follow what happens to the buffer between calls, then make a small change that keeps the code straightforward.
A straightforward first version
Here is the pattern in a C++20 caller. The read_chunk helper, defined below, performs synchronous I/O and returns a view of a successful result. The buffer limit max_bytes, handle, IOCTL code and initialized input come from the application; more_work, report_error and consume stand in for its surrounding logic.
std::vector<std::byte> output(max_bytes);
while (more_work()) {
output.resize(max_bytes); // Recreates removed elements after a short result.
const auto received = read_chunk(device, code, input, output);
if (!received) { report_error(GetLastError()); break; }
output.resize(received->size());
consume(std::span<const std::byte>(output));
}
The first resize does not grow the vector on the first pass because it already has that size. After a short result shrinks it, however, the next iteration has removed elements to create again. That is where the convenient pattern can start doing more work than we intended.
Where the extra work comes from
Suppose our buffer can hold a 10 MiB result. Before each call, we resize the vector to that maximum; afterwards, we resize it to the bytes actually received. If a call returns 64 KiB, the vector now has 64 KiB of elements, with the larger allocation still available for reuse.
It is tempting to think that the next resize is cheap because the memory is already there. But growing the vector back to 10 MiB also means creating almost 10 MiB of elements again.
For a std::vector<char> with the usual allocator, those newly added characters are initialized to zero. Shrinking the vector removes the elements, even though it keeps their allocation. The next grow therefore repeats the initialization. When results are small, most of that freshly zeroed space will not even be part of the next result.
This is easy to miss when looking for allocation overhead. Growing within the existing capacity avoids a new allocation, but the library still has to prepare the newly added elements. A short, readable call can ask for quite a lot of memory to be initialized.
reserve() alone is not a workaround: it changes capacity without changing size. The standard defines the vector’s valid data range using size(), and documents reserve and resize separately. Establish the writable element range before handing it to I/O; do not write into spare capacity as though it were already part of the vector.
A small change to how we use the buffer
We can keep the vector and make a small change: size it once, then leave it at that writable size between calls. Keep the number of received bytes separately, or return a std::span covering just those bytes.
That gives the buffer and the result their own sizes. The vector owns the space we can write into; the span tells the rest of the application which bytes to process. There is no need to grow the same elements back before every call.
| Quantity | Role |
|---|---|
| Size | The live elements available as a writable buffer. |
| Capacity | How many elements the allocation can accommodate before reallocation is needed. |
| Valid bytes | The result length reported by this particular I/O operation. |
Keep the writable buffer and the result separate
The caller then becomes:
std::vector<std::byte> output(max_bytes);
while (more_work()) {
const auto received = read_chunk(device, code, input, output);
if (!received) { report_error(GetLastError()); break; }
consume(*received); // Keep the writable size; consume only the valid prefix.
}
Both versions stop on failure and process only the valid returned bytes. The change is small: we remove the two per-call resizes and pass the result view directly to consume. Consumption finishes before the next call reuses the buffer; it must not retain the view for later use.
Here is the read_chunk helper used by both loops for synchronous DeviceIoControl. The caller owns the buffers, initializes the input, and supplies a handle opened without FILE_FLAG_OVERLAPPED. The selected IOCTL must define an output buffer and a meaningful returned byte count.
// C++20. The caller owns the buffers and a synchronous device handle.
#include <windows.h>
#include <cstddef>
#include <optional>
#include <span>
#include <vector>
std::optional<std::span<const std::byte>> read_chunk(
HANDLE device, DWORD code,
std::span<std::byte> input, std::span<std::byte> output)
{
if (input.size() > MAXDWORD || output.size() > MAXDWORD) {
SetLastError(ERROR_BAD_LENGTH);
return std::nullopt;
}
DWORD used = 0;
if (!DeviceIoControl(device, code,
input.data(), static_cast<DWORD>(input.size()),
output.data(), static_cast<DWORD>(output.size()),
&used, nullptr)) {
return std::nullopt;
}
if (used > output.size()) {
SetLastError(ERROR_INVALID_DATA);
return std::nullopt;
}
return std::span<const std::byte>(output.data(), used);
}
// Allocate and size this vector once, outside the I/O loop:
// std::vector<std::byte> output(10 * 1024 * 1024);
// auto received = read_chunk(device, code, input, output);
// Use only *received on success; keep output.size() unchanged for reuse.
The output vector stays at its full writable size between calls. A successful result is a view of only the returned bytes, including a possible empty view. Do not read the rest of the buffer as part of the result. The view remains usable only while its storage is alive and unchanged; the next operation can overwrite it.
The helper rejects lengths that do not fit the API’s DWORD parameters and an impossible returned length. Its error policy is deliberately simple: any failed call discards the output. Some IOCTLs report useful partial data with ERROR_MORE_DATA; those require their own continuation policy. Check GetLastError() immediately after failure. Asynchronous I/O also needs a separate completion and buffer-lifetime design; see the DeviceIoControl contract.
When an array is a useful alternative
You may prefer a preallocated std::unique_ptr<char[]>, with capacity and valid length stored alongside it. That works well too. One detail is worth keeping in mind: std::make_unique<char[]>(n) value-initializes the array once, so switching to it does not by itself avoid the first zeroing.
If the producer will initialize every byte that the application reads, C++20 provides std::make_unique_for_overwrite<char[]>(n). It default-initializes the characters instead. That can avoid the language-level initial zero-initialization; it does not promise that allocating or first touching physical pages is free. Write each byte before reading it and expose only the initialized result. The smart-pointer creation specification defines the two initialization forms.
Wrappers with an empty default constructor and custom allocators can also change how vector elements are initialized. They introduce additional type and construction rules, however, and are unnecessary when a fixed writable size plus a result view already expresses the operation. In particular, casting one vector specialization to another is not a valid optimization.
Putting a number on the extra work
To see how much those size changes can cost, I compared four ways of reusing a 10 MiB buffer: a grow/shrink vector, a fixed-size vector, and the two array allocation forms discussed above. Each iteration writes the same known payload and reads a small checksum from the written bytes. Only the grow/shrink vector changes its size between calls.
The producer performs a memory copy in a separate translation unit, compiled without link-time optimization. This keeps its implementation out of the caller’s view, so the compiler cannot remove buffer preparation based on knowing exactly what the producer will do.
Allocation, initial initialization and first-touch page costs are outside the timed region for every strategy. These are measurements of warmed, repeated buffer use. They do not measure driver calls, disk or network I/O, or the cost of the first allocation.
| Buffer strategy | 64 KiB result | 1 MiB result | 10 MiB result |
|---|---|---|---|
| Vector, grow/shrink | 140.276 | 144.460 | 384.324 |
| Vector, fixed size | 0.914 | 16.742 | 384.403 |
| Array, value-initialized | 0.898 | 16.672 | 384.593 |
| Array, overwrite | 0.898 | 16.940 | 384.592 |
Measurements taken on 19 October 2026: AMD Ryzen 7 7745HX, Windows 11 build 26200, x64, MSVC 19.51.36260 with STL 202604, C++20, /O2 /GL- /W4 /WX. Each cell is the median of 14 batch averages: two process runs, each with seven trials of approximately 100 ms. The thread is pinned to one logical processor, strategy order rotates between trials, and output correctness is checked outside timing.
The small results show the surprise most clearly. To receive just 64 KiB, the grow/shrink vector repeatedly prepares almost 10 MiB of elements for reuse. Those convenient size changes bring the measured time to about 140 microseconds per operation, compared with about 0.9 for the fixed-size vector in this test.
With a full 10 MiB result, the vector stays at its maximum size. There are no removed elements to create again on the next iteration, and its timing is close to the other strategies. The difference follows the work we asked the buffer to do.
The fixed-size vector and both array forms are close in this workload. This is not evidence that one ownership type universally outperforms another. Cache behavior, payload size, compiler, hardware and the real I/O operation all affect the result. An actual device workload can be dominated by entirely different costs.
Benchmark source and build command
Save these as buffer-benchmark.cpp and producer.cpp. Build from an x64 Native Tools command prompt with the command below. Keep the producer in a separate translation unit and disable link-time optimization.
buffer-benchmark.cpp
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#include <windows.h>
#include <algorithm>
#include <array>
#include <chrono>
#include <cstddef>
#include <cstdint>
#include <cstring>
#include <iomanip>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <vector>
extern "C" std::size_t produce_output(
char*, std::size_t, const char*, std::size_t);
constexpr std::size_t max_bytes = 10 * 1024 * 1024;
constexpr int trials = 7;
enum class mode { resize_vector, fixed_vector, value_array, overwrite_array };
constexpr std::array<const char*, 4> names{
"vector_grow_shrink", "vector_fixed", "array_value", "array_overwrite" };
struct storage {
mode kind;
std::vector<char> vector;
std::unique_ptr<char[]> array;
explicit storage(mode m) : kind(m) {
if (m == mode::resize_vector || m == mode::fixed_vector)
vector.resize(max_bytes);
else if (m == mode::value_array)
array = std::make_unique<char[]>(max_bytes);
else
array = std::make_unique_for_overwrite<char[]>(max_bytes);
// Exclude allocation and first-touch page costs from every strategy.
std::fill_n(data(), max_bytes, '\0');
}
char* data() { return array ? array.get() : vector.data(); }
};
std::uint64_t checksum = 0;
using clock_type = std::chrono::steady_clock;
template<mode M> double measure(storage& s, const std::vector<char>& source,
std::size_t payload, std::size_t iterations)
{
if constexpr (M == mode::resize_vector) s.vector.resize(payload);
const auto start = clock_type::now();
std::uint64_t sum = 0;
for (std::size_t i = 0; i < iterations; ++i) {
if constexpr (M == mode::resize_vector) s.vector.resize(max_bytes);
const auto used = produce_output(s.data(), max_bytes,
source.data(), payload);
// Read only bytes that the producer has written. All test payloads > 0.
sum += static_cast<unsigned char>(s.data()[0]);
sum += static_cast<unsigned char>(s.data()[used / 2]);
sum += static_cast<unsigned char>(s.data()[used - 1]);
sum += used;
if constexpr (M == mode::resize_vector) s.vector.resize(used);
}
const auto end = clock_type::now();
checksum += sum;
if (std::memcmp(s.data(), source.data(), payload) != 0)
throw std::runtime_error("Output prefix mismatch");
return std::chrono::duration<double, std::micro>(end - start).count();
}
double run(mode m, storage& s, const std::vector<char>& source,
std::size_t payload, std::size_t iterations)
{
switch (m) {
case mode::resize_vector:
return measure<mode::resize_vector>(s, source, payload, iterations);
case mode::fixed_vector:
return measure<mode::fixed_vector>(s, source, payload, iterations);
case mode::value_array:
return measure<mode::value_array>(s, source, payload, iterations);
case mode::overwrite_array:
return measure<mode::overwrite_array>(s, source, payload, iterations);
}
throw std::runtime_error("Unknown strategy");
}
void self_test()
{
const std::array<char, 4> source{ 'a', 'b', 'c', 'd' };
std::array<char, 6> target{ '#', '#', '#', '#', '#', '#' };
if (produce_output(target.data() + 1, 4, source.data(), 3) != 3 ||
target != std::array<char, 6>{ '#', 'a', 'b', 'c', '#', '#' })
throw std::runtime_error("Prefix/bounds self-test failed");
const auto previous = target;
if (produce_output(target.data() + 1, 4, source.data(), 0) != 0 ||
produce_output(target.data() + 1, 2, source.data(), 3) != 0 ||
target != previous)
throw std::runtime_error("Zero/oversize self-test failed");
}
int main()
{
try {
self_test();
DWORD_PTR process_mask = 0, system_mask = 0;
if (!GetProcessAffinityMask(GetCurrentProcess(), &process_mask,
&system_mask) || process_mask == 0)
throw std::runtime_error("Cannot query CPU affinity");
const DWORD_PTR selected = process_mask & (~process_mask + 1);
if (!SetThreadAffinityMask(GetCurrentThread(), selected))
throw std::runtime_error("Cannot pin benchmark thread");
std::cout << "# MSVC=" << _MSC_FULL_VER
<< ",STL=" << _MSVC_STL_UPDATE
<< ",affinity_mask=" << selected
<< ",max_bytes=" << max_bytes << '\n';
std::cout << "trial,payload_bytes,strategy,iterations,total_us,us_per_operation\n";
std::vector<char> source(max_bytes);
for (std::size_t i = 0; i < source.size(); ++i)
source[i] = static_cast<char>((i * 17 + 31) % 251);
for (const auto payload : { std::size_t(64 * 1024),
std::size_t(1024 * 1024), max_bytes }) {
std::array<storage, 4> buffers{ storage(mode::resize_vector),
storage(mode::fixed_vector), storage(mode::value_array),
storage(mode::overwrite_array) };
std::array<std::size_t, 4> iterations{};
for (std::size_t m = 0; m < buffers.size(); ++m) {
std::size_t n = 16;
double us = 0;
do {
us = run(static_cast<mode>(m), buffers[m], source, payload, n);
if (us < 10000) n *= 2;
} while (us < 10000 && n < (1U << 22));
iterations[m] = std::clamp(
static_cast<std::size_t>(static_cast<double>(n) * 100000 / us),
std::size_t(16), std::size_t(1U << 22));
}
for (int trial = 0; trial < trials; ++trial) {
// Rotate order between trials; no strategy always runs first.
for (std::size_t offset = 0; offset < buffers.size(); ++offset) {
const auto m = (static_cast<std::size_t>(trial) + offset) % 4;
const auto us = run(static_cast<mode>(m), buffers[m], source,
payload, iterations[m]);
std::cout << trial << ',' << payload << ',' << names[m]
<< ',' << iterations[m] << ',' << std::fixed
<< std::setprecision(6) << us << ','
<< us / static_cast<double>(iterations[m]) << '\n';
}
}
}
std::cout << "# checksum=" << checksum << '\n';
return 0;
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
return 1;
}
}
producer.cpp
#include <cstddef>
#include <cstring>
// A separate translation unit makes the output operation opaque to the caller.
// This copies memory; it does not call a device, driver, disk or network API.
extern "C" __declspec(noinline) std::size_t produce_output(
char* output, std::size_t capacity, const char* source, std::size_t bytes)
{
if (bytes > capacity) return 0;
if (bytes != 0) std::memcpy(output, source, bytes);
return bytes;
}
Build and run
cl /nologo /std:c++20 /O2 /GL- /W4 /WX /EHsc /permissive- /Zc:__cplusplus buffer-benchmark.cpp producer.cpp /Fe:buffer-benchmark.exe /link /LTCG:OFF
buffer-benchmark.exe > run-1.csv
buffer-benchmark.exe > run-2.csv
The program emits every batch result in CSV form. For each payload and strategy, take the median of the 14 us_per_operation values from the two runs. Comparing batch averages is different from measuring a distribution of individual-call latency.
Keeping the solution straightforward
For a reusable bounded buffer, a sized vector and a separate result length are a good place to start. You keep familiar container behavior and avoid repeatedly growing the buffer. If the first initialization also matters, an overwrite allocation is another option, with the same responsibility to read only bytes the producer has written.
Short, readable code is a good starting point. On a busy I/O path, it also helps to look at how much work those lines ask the library to do. Here, keeping the storage size and the result length separate preserves that simplicity and removes the repeated initialization. Measure the complete application before reaching for a specialized wrapper or allocator.