Nihilai Collective Logo

void-numerics

Nihilai Collective

SWAR-optimized integer conversion · C++23 · Header-only

What It Is

vn::to_chars and vn::from_chars are drop-in replacements for their standard library equivalents, engineered for performance on hot integer-conversion paths. Header-only, zero-allocation, no exceptions, no RTTI.

Every conversion is exhaustively unit-tested against std::to_chars and std::from_chars — including the full LLVM libc++ test suite — and benchmarked against std, jeaiii , and strtoll/strtoull across 8-, 16-, 32-, and 64-bit signed and unsigned integer types. Registered on the Microsoft vcpkg registry.

Why

The C++ standard library's <charconv> is already fast. void-numerics is faster — substantially so on hot integer-conversion paths — without sacrificing correctness, portability, or API compatibility.

The gains come from a combination of SWAR-width arithmetic, compile-time specialization per integer type, multiply-and-shift division replacement, and force-inlined ladder dispatch sized to digit count. The result is measurable throughput improvement across all major platforms and compilers. The biggest optimizations come from asking the right questions, not from better answers to the wrong questions.

Features

  • API-compatible with std::to_chars / std::from_chars — returns standard result types
  • All integer types: int8_t through uint64_t
  • Header-only — single include, no build step required
  • Zero allocation, zero exceptions, zero RTTI
  • C++23 — concepts for type-correct dispatch
  • Compile-time tables for digit conversion and overflow bounds
  • Multiply-and-shift division replacement on hot paths
  • Force-inlined helpers with manual ladder dispatch per digit count
  • Cross-platform CI: Ubuntu Clang, Ubuntu GCC, macOS Clang, macOS GCC, Windows MSVC
  • AddressSanitizer / UndefinedBehaviorSanitizer support out of the box
  • Non-base-10 conversions transparently dispatch to std for full compatibility

Quick Start

Integer → String
#include <void-numerics> int main() { char buffer[32]; int64_t value = -9223372036854775807LL; auto result = vn::to_chars(buffer, buffer + sizeof(buffer), value); if (result.ec == std::errc{}) { // result.ptr points one past the last written character std::string_view text(buffer, result.ptr - buffer); // text == "-9223372036854775807" } }
String → Integer
#include <void-numerics> int main() { const char* input = "42abc"; uint32_t value{}; auto result = vn::from_chars(input, input + 5, value); // result.ptr points to 'a' (first non-digit) // result.ec == std::errc{} // value == 42 }
Base Support
char buf[32]; auto r = vn::to_chars(buf, buf + 32, 0xDEADBEEF, 16); // r.ptr - buf == 8, buf == "deadbeef"

Non-base-10 conversions transparently dispatch to std::to_chars / std::from_chars for full standard-library compatibility.

API Reference

vn::to_chars

template<integer_types v_type> std::to_chars_result to_chars( char* first, char* last, v_type value, int32_t base = 10 ) noexcept;

Writes the textual representation of value to [first, last). Returns {ptr, std::errc{}} on success, where ptr is one past the last character written. Returns {last, std::errc::value_too_large} if the buffer is too small.

vn::from_chars

template<integer_types v_type> std::from_chars_result from_chars( const char* first, const char* last, v_type& value, int32_t base = 10 ) noexcept;

Parses an integer from [first, last) into value. Returns {ptr, std::errc{}} on success, where ptr points to the first character not consumed. Returns {first, std::errc::invalid_argument} if no characters could be parsed.

Building

void-numerics is header-only — copy include/vn-incl/ into your project, or consume via CMake.

CMake

add_subdirectory(void-numerics) target_link_libraries(your_target PRIVATE void-numerics::void-numerics)

Options

Option Default Description
VN_TESTSOFFBuild the unit test suite
VN_BENCHMARKSOFFBuild the benchmark harness
VN_ASANOFFEnable AddressSanitizer
VN_UBSANOFFEnable UndefinedBehaviorSanitizer

Requirements

  • C++23-capable compiler — Clang ≥ 20, GCC ≥ 14, MSVC latest, AppleClang
  • CMake ≥ 3.28

Testing

The test suite provides exhaustive coverage across all integer types and edge cases:

  • Exhaustive value enumeration for int8 / uint8 / int16 / uint16
  • Digit-length sweeps (1 through max digits) for all types
  • Powers of 2, powers of 10, all-1s, all-9s patterns
  • Limits: min, min+1, min/2, max, max-1, max/2, max/3, max/7
  • Round-trip verification (to_chars → from_chars → equality)
  • Leading-zero handling, stop-at-non-digit semantics, lone-minus and empty-input edge cases
  • Full LLVM libc++ <charconv> test suite
cmake -S . -B build -DVN_TESTS=TRUE -DCMAKE_BUILD_TYPE=Debug cmake --build build ./build/bin/vn_unit_tests

Memory Footprint

vn::to_chars uses a precomputed lookup table of approximately 40KB for branch-free digit pair extraction. This is well-suited to any CPU with an L2 cache of 256KB or larger — all modern desktop, server, and laptop CPUs. The table resides comfortably in L2 while streaming integer data flows through L1, with no contention between the two. Benchmarks confirm this behavior holds under explicit cache-eviction pressure between runs.

vn::from_chars carries no significant lookup tables and is suitable for any target.

⚠ Not Recommended For
Microcontrollers without L2 cache (Cortex-M0/M3/M4/M7, AVR, MSP430) — the table cannot fit in available cache or SRAM. Embedded targets where total flash/SRAM is smaller than 40KB. Any environment where the constant data footprint is unacceptable for binary size. For these targets, std::to_chars is more appropriate.

Benchmark Methodology

Throughput is measured in megabytes of source data processed per second (MB/s), computed as total megabytes divided by total elapsed time across the measured sample window. For each test, a randomized dataset of n integers (n ∈ {100, 1,000, 10,000, 100,000}) is generated for every standard integer width (int8 through int64, signed and unsigned), under positive, negative, and mixed-sign variants where applicable. Two input distributions are benchmarked, in both directions:

  • uniform-value — values are drawn uniformly across the type's entire range. Because most of any range is made of large numbers, nearly every value has the maximum or near-maximum digit count (for example, about 89% of int64 values have 19 digits). This is the worst-case, long-number workload typical of random IDs and hashes.

The int → str and str → int benchmarks use identical generators, so both directions are measured on matching inputs. For signed types, the sign is drawn independently of the value: 50/50 in the mixed-sign variants, always negative in the negative variants.

The int → str rows labelled vn (unchecked core) time a harness-local copy of the vn::to_chars core with its bounds checks stripped out, to match jeaiii's unchecked implementation; they are not the shipped, bounds-checked entry point. std::to_chars is omitted from that stage because it performs bounds checking, and is used only as the correctness reference. The str → int rows time the real vn::from_chars.

Benchmarks use adaptive epoch sampling — the exact iteration counts, convergence thresholds, and timing bounds for each run are read live from that run's own results CSV (below the platform/compiler/benchmark tabs), 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 any 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. When the run's config enables it, CPU caches are evicted before each contender's run; see the config values printed with each result set below.

Every benchmark binary is built with full LTO and Profile-Guided Optimization via a three-stage pipeline: an instrumented GENERATE build, a training run that exercises the same input distributions used in measurement, and a final USE build that consumes the resulting profile data. On Clang, raw .profraw files are merged via llvm-profdata into a single default.profdata before the USE configure. On GCC, .gcda files are written directly into the build tree and consumed in-place with -fprofile-correction. On MSVC, the linker accumulates .pgc files written by the instrumented binary and merges them automatically at link time. All three compilers receive matched optimization flags: -O3 -march=native -flto on Clang and GCC, /O2 /GL /arch:AVX2 /LTCG on MSVC, with dead-code elimination (--gc-sections on Linux, -dead_strip on macOS, /OPT:REF /OPT:ICF on Windows) applied at link time. The standard library and all competing implementations receive the same LTO and PGO treatment, trained on the same input distribution — a PGO-optimized void-numerics is never compared against a non-PGO competitor.

Platforms: Linux, macOS, Windows — Compilers: Clang, GCC, MSVC — Benchmark library: benchmarksuite

View all benchmark files on GitHub →

Results

Platform
Compiler
Loading benchmarks