Nihilai Collective Logo

memcpy-decomp

Nihilai Collective

Power-of-2 memcpy decomposition · header-only · C++20

What It Is

memcpy-decomp turns a memcpy whose length is only known at runtime into a short sequence of copies whose lengths are known at compile time. Every copy it issues is a power of two, so every copy lowers to plain register loads and stores — no call into the C runtime and no size-class dispatch hidden behind it.

Small, variable-length copies are everywhere in parsers and serializers: string fields, escaped runs, key names, number buffers. They are almost always shorter than a cache line, and they are almost always paid for with a full out-of-line library call. memcpy-decomp is the technique Jsonifier uses internally to avoid that call, pulled out on its own so it can be measured in isolation.

Why

For any length n with std::bit_width(n) == k, n lies in [2k−1, 2k). Let b = 2k−1. Then b ≤ n < 2b, which means a copy of b bytes anchored at the front and a copy of b bytes anchored at the back together cover every byte exactly once or twice and never step outside [0, n). The overlap is harmless: it rewrites the same bytes with the same values.

So the only runtime decision left is which bucket. That decision is a balanced tree of comparisons against powers of two, generated at compile time: three or four conditional branches straight on the length, with no lookup table and no indirect jump. Everything after that is straight-line code with constant sizes the compiler can schedule freely. Buckets wider than the vector register are split into register-width chunks, and a compile-time upper bound (memcpy_decomp_up_to<N>) removes every comparison and bucket that can't occur.

Earlier versions dispatched with a switch on std::bit_width(n). GCC compiled it to a jump table that cost extra taken branches on every copy, and that was enough to lose to glibc on a few ranges. The compare tree compiles to the same branch-only shape on GCC and Clang alike.

A few comparisons. No jump table. Two overlapping fixed-size copies.

The Algorithm

bucket runner — front and back, in register-width chunks
template<uint64_t bucket_size> struct memcpy_runner { static constexpr uint64_t chunk_size{ bucket_size < register_width ? bucket_size : register_width }; static constexpr uint64_t chunk_count{ bucket_size / chunk_size }; template<uint64_t... indices> static void impl(std::byte* dst, const std::byte* src, uint64_t n, std::integer_sequence<uint64_t, indices...>) { std::byte* dst_tail = dst + n - bucket_size; const std::byte* src_tail = src + n - bucket_size; (pow2_memcpy<chunk_size>(dst + indices * chunk_size, src + indices * chunk_size), ...); (pow2_memcpy<chunk_size>(dst_tail + indices * chunk_size, src_tail + indices * chunk_size), ...); } };
bucket — one fixed-size copy pair per bit-width class
template<uint64_t bucket_index> void copy_bucket(std::byte* dst, const std::byte* src, uint64_t n) { if constexpr (bucket_index == 1) { *dst = *src; } else if constexpr (bucket_index == fallback_bucket_index) { std::memcpy(dst, src, n); } else if constexpr (bucket_index > 1) { memcpy_runner<1ull << (bucket_index - 1)>::impl(dst, src, n); } }
dispatch — a balanced compare tree, built at compile time
template<uint64_t low_index, uint64_t high_index> void dispatch_buckets(std::byte* dst, const std::byte* src, uint64_t n) { if constexpr (low_index == high_index) { copy_bucket<low_index>(dst, src, n); } else { constexpr uint64_t split_index{ (low_index + high_index + 1) / 2 }; if (n < (1ull << (split_index - 1))) dispatch_buckets<low_index, split_index - 1>(dst, src, n); else dispatch_buckets<split_index, high_index>(dst, src, n); } } template<uint64_t max_bytes> void memcpy_decomp_up_to(void* dst, const void* src, uint64_t n) { dispatch_buckets<0, top_bucket_index<max_bytes>>(dst, src, n); }
what the unbounded tree unrolls to
n < 16 ? n < 2 ? n < 1 ? nothing : byte : n < 4 ? [2] : n < 8 ? [4] : [8] : n < 64 ? n < 32 ? [16] : [32] : n < 128 ? [64] : n < 256 ? [128] : std::memcpy

top_bucket_index<max_bytes> is the bit width of the bound, capped at the std::memcpy fallback, so a bounded call site only builds the part of the tree it can reach — memcpy_decomp_up_to<15> never even compares against 16. Lengths of 256 bytes and up fall through to std::memcpy, where the library's bulk-copy paths are the right tool.

Bucket Map

1 byte
A single byte store. No overlap needed.
2 – 63 bytes
Buckets 2, 4, 8, 16, 32. Two overlapping copies of the bucket size: [0, b) and [n − b, n).
64 – 127 bytes
Bucket 64, split into register-width chunks: 4 copies on AVX2, 8 on SSE/NEON, 2 on AVX-512.
128 – 255 bytes
Bucket 128, split into register-width chunks: 8 copies on AVX2, 16 on SSE/NEON, 4 on AVX-512.
256+ bytes
Falls through to std::memcpy. Pruned entirely when the call site's compile-time bound is below 256.

Features

  • Header-only, single C++20 header
  • Every copy is a compile-time power of two
  • Branch-only dispatch: a compile-time compare tree, no jump table
  • Register width detected per target (SSE/NEON, AVX, AVX-512)
  • Bounded variant prunes unreachable buckets at compile time
  • Falls back to std::memcpy for bulk copies
  • Cross-platform CI: MSVC, GCC, Clang on Windows / Linux / macOS
  • Exhaustive length × alignment sweep with guard bytes on every run
  • Benchmarked at 4,096 copies per timed iteration
  • Statistical tie detection via Welch's t-test
  • MIT licensed
  • No dependencies beyond <bit> and <cstring>

Quick Start

Drop in the header
#include "memcpy-decomp.hpp" int main() { char src[64]{}, dst[64]{}; uint64_t n = 37; memcpy_decomp::memcpy_decomp(dst, src, n); memcpy_decomp::memcpy_decomp_up_to<64>(dst, src, n); }
Build the benchmark suite
git clone https://github.com/nihilai-collective/memcpy-decomp.git cd memcpy-decomp cmake -B build -DCMAKE_BUILD_TYPE=Release cmake --build build --config Release ./build/memcpy-decomp

CMake pulls BenchmarkSuite and rt-ut automatically via FetchContent. Requires C++20.

Correctness

Every run opens with an exhaustive sweep: every length from 0 to 300, at every source and destination misalignment from 0 to 31, copied into a buffer surrounded by guard bytes. Each byte inside the copy is checked against the source, and each byte outside it is checked to be untouched. The bounded variant is swept the same way at every bucket boundary (0, 1, 2, 3, 4, 7, 8, … 255, 256, 257).

Before each benchmark, every contender then replays 16 iterations of that test's exact workload into its own buffer, and the final buffers are compared byte-for-byte against std::memcpy's. Nothing is timed until it has been proven correct on that machine, with that compiler, on that exact input. Validation runs across the full Windows MSVC, Ubuntu GCC, Ubuntu Clang, macOS Clang, and macOS GCC matrix on every push.

Benchmark Methodology

Throughput is measured in megabytes copied per second (MB/s), benchmarked against std::memcpy with a runtime length on identical hardware. Every timed iteration performs 4,096 copies between two 64 KiB buffers. Copy i starts at offset (i × 192) & 0xFF00, plus a small jitter ((i × 5) & 63 on the source, (i × 11 + 3) & 63 on the destination), so copies land at varied alignments while the address stream stays regular and cache-resident.

Lengths come from a fixed 65,536-entry table generated by a seeded LCG over each test's range. Every iteration starts 12,347 entries further into the table, so consecutive iterations replay different length sequences. Every length also passes through a volatile read, so the compiler can never treat a size as a constant. There are two stages, each a head-to-head against std::memcpy on the identical workload, and both run the same six ranges: 1–15, 1–16, 1–17, 1–31, 1–32, 1–33, 1–63, 1–64, 1–65, 1–127, 1–128 and 1–129 bytes.

memcpy_decomp
Unbounded variant, carrying every bucket plus the std::memcpy fallback on every call.
memcpy_decomp_up_to<N>
Bounded variant, with N set to the range's maximum, so only the buckets that range can reach are compiled in.

The ranges come in triplets around each power of two from 16 to 128. A range ending at 2k − 1 exactly fills its top bucket. Ending at 2k adds a single length that falls into the next bucket up, and ending at 2k + 1 adds two. For memcpy_decomp that is a rare extra branch; for memcpy_decomp_up_to<N> it is one more bucket compiled in. Each triplet shows what crossing that boundary costs, and the 127–129 triplet reaches into the 128-byte bucket.

The harness uses adaptive epoch sampling. The exact iteration counts, thresholds, and timing bounds for each run are read live from that run's own results CSV, since they can vary per run. Each epoch runs a fresh batch of iterations, starting at the configured measured-iteration count and doubling every epoch, and is scored on a trailing window of k = clamp(iterations / 10, MinK, MaxK) samples. An epoch's score is its relative standard error plus its mean shift from the previous epoch.

Sampling does not stop at the first good epoch. It keeps running until the time limit or the total iteration budget is exhausted, and the lowest-scoring epoch is kept as the canonical result, whether or not it met the convergence thresholds. That result counts as converged only if its RSE and mean shift are both at or under their configured thresholds. Non-converged results are still included in the rankings and in win/tie/loss tallying, so a result that missed those thresholds should be read with its reduced statistical confidence in mind.

All results are ranked by throughput. Neighbouring results are checked for a statistical tie with Welch's t-test on per-iteration timings, using Bessel-corrected variance and Welch–Satterthwaite degrees of freedom at roughly 95% confidence, so a small delta inside the noise floor doesn't get dressed up as a victory. CPU caches are evicted before each contender's run.

Every range stays at or below 129 bytes — the regime that small-copy-heavy workloads like serialization actually live in. The CPU, OS, and compiler for each run are detected at build time and recorded in that platform's result file.

Platforms: Windows, Linux, macOS — Compilers: MSVC, GCC, Clang — Harness: BenchmarkSuite

View the full benchmark repository →

Results

Platform / Compiler
Loading benchmarks
Contender

Acknowledgments