[Feature Request / Optimization] Super-fast AVX2 SIMD alternative for `full_formatter` timestamps
Hi @gabime and spdlog maintainers,
I have designed and implemented a highly optimized AVX2 SIMD alternative for spdlog's full_formatter time-formatting logic (which produces patterns like [2026-07-14 12:34:56.789] ).
By utilizing vector addition and 64-bit integer packaging for subsecond volatility, it completely eliminates runtime branching, modulo/division calculations, and character-by-character memory copying during high-frequency microsecond/millisecond log formatting.
The Performance Pain Point in Current full_formatter
Currently, full_formatter relies on multiple append_int, pad2, and pad3 calls, which process each date/time digit sequentially. While the seconds part is cached, formatting the subseconds (milliseconds) and joining brackets/spaces requires multiple branches and memory stores.
In low-latency applications (e.g., high-frequency trading), these tiny operations at the log call-site easily degrade the CPU's Instruction-Level Parallelism (ILP).
Our SIMD (AVX2) Optimization Strategy
1. Branchless Single Vector Addition (_mm256_add_epi8)
Instead of formatting digits and selecting separators via branches or masking/blends, we pre-package a 32-byte SYMBOL_MASK containing ASCII base offsets '0' (48) for digit channels and raw ASCII values for symbols ([, -, :, ).
With our raw_digits aligned with zeroes at symbol channels, we do exactly one hardware instruction to simultaneously convert all digits to ASCII and format separators:
v_digits = raw_digits + SYMBOL_MASK// Symbols: '[' (91), '-' (45), ' ' (32), ':' (58), '.' (46)
alignas(32) static constexpr uint8_t SYMBOL_MASK[32] = {
91, 48, 48, 48, 48, 45, 48, 48, 45, 48, 48, 32, 48, 48, 58, 48, 48, 58, 48, 48, 46,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0
};
2. Subsecond & Bracket Packaging (Single 64-bit Store)
For the milliseconds and closing brackets ] , we don't call pad3. Instead, we package ., ms, ] and space into a single 64-bit integer on registers and execute one single uint64_t store into the tail buffer:
uint64_t tail_pack = ('0' + (msec / 100))
| (('0' + ((msec / 10) % 10)) << 8)
| (('0' + (msec % 10)) << 16)
| (static_cast<uint64_t>(']') << 24)
| (static_cast<uint64_t>(' ') << 32);
*reinterpret_cast<uint64_t*>(&time_buf[20]) = tail_pack;
Benchmark Results (Google Benchmark)
We ran the benchmark comparing the native formatting pipeline vs. our AVX2 optimization.
Note: In local benchmarking, MSVC's extremely heavy thread_local storage (TLS) lookup wrapper added ~4.3ns to the loops, but when testing the core conversion algorithm (No-TLS / localized pointer context), we saw true hardware-level performance.
Environment
- CPU: Intel i9-14900K
- Compiler (Linux): Clang -O3, -mavx2, -std=c++26
| Test Case | Native spdlog | AVX2 SIMD Optimized | Speedup |
|---|---|---|---|
| Cache Hit (Same Sec) | 0.683 ns | 0.513 ns | ~1.33x |
| Cache Miss (New Sec) | 6.360 ns | 3.140 ns | ~2.02x |
Here is the benchmark source code: https://github.com/leagem/Test/blob/master/spdlog/bench_spdlog_time.cc
Proposed Integration Code in full_formatter
We can optionally enable this using macro guards (#if defined(__AVX2__)) so it gracefully falls back to the native portable implementation on other architectures (ARM/SVE can have its own neon implementation later):
#include <immintrin.h>
void format(const details::log_msg &msg, const std::tm &tm_time, memory_buf_t &dest) override {
using std::chrono::duration_cast;
using std::chrono::milliseconds;
using std::chrono::seconds;
auto duration = msg.time.time_since_epoch();
auto secs = duration_cast<seconds>(duration);
#if defined(__AVX2__)
alignas(32) static thread_local char time_buf[32];
static thread_local seconds::rep last_s = -1;
if (last_s != secs.count()) [[unlikely]] {
last_s = secs.count();
auto year = static_cast<int32_t>(tm_time.tm_year + 1900);
auto month = static_cast<uint32_t>(tm_time.tm_mon + 1);
auto day = static_cast<uint32_t>(tm_time.tm_mday);
auto hour = static_cast<uint32_t>(tm_time.tm_hour);
auto min = static_cast<uint32_t>(tm_time.tm_min);
auto sec = static_cast<uint32_t>(tm_time.tm_sec);
int32_t y_hi = year / 100;
int32_t y_lo = year % 100;
uint8_t raw_digits[32] = {0, // '['
static_cast<uint8_t>(y_hi / 10),
static_cast<uint8_t>(y_hi % 10),
static_cast<uint8_t>(y_lo / 10),
static_cast<uint8_t>(y_lo % 10),
0, // '-'
static_cast<uint8_t>(month / 10),
static_cast<uint8_t>(month % 10),
0, // '-'
static_cast<uint8_t>(day / 10),
static_cast<uint8_t>(day % 10),
0, // ' '
static_cast<uint8_t>(hour / 10),
static_cast<uint8_t>(hour % 10),
0, // ':'
static_cast<uint8_t>(min / 10),
static_cast<uint8_t>(min % 10),
0, // ':'
static_cast<uint8_t>(sec / 10),
static_cast<uint8_t>(sec % 10),
0, // '.'
0,
0,
0,
0,
0,
0,
0,
0,
0,
0,
0};
__m256i v_digits = _mm256_loadu_si256(reinterpret_cast<const __m256i*>(raw_digits));
alignas(32) static constexpr uint8_t SYMBOL_MASK[32] = {91, // '['
48, 48, 48, 48,
45, // '-'
48, 48,
45, // '-'
48, 48,
32, // ' '
48, 48,
58, // ':'
48, 48,
58, // ':'
48, 48,
46, // '.'
0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0};
__m256i v_symbols = _mm256_load_si256(reinterpret_cast<const __m256i*>(SYMBOL_MASK));
v_digits = _mm256_add_epi8(v_digits, v_symbols);
_mm256_storeu_si256(reinterpret_cast<__m256i*>(time_buf), v_digits);
}
auto millis = fmt_helper::time_fraction<milliseconds>(msg.time);
int msec = static_cast<int>(millis.count());
uint64_t tail_pack = ('0' + (msec / 100)) | (('0' + ((msec / 10) % 10)) << 8) |
(('0' + (msec % 10)) << 16) | (static_cast<uint64_t>(']') << 24) |
(static_cast<uint64_t>(' ') << 32);
*reinterpret_cast<uint64_t*>(&time_buf[21]) = tail_pack;
dest.append(time_buf, time_buf + 26);
#else
// Native fallback...
#endif
I would love to contribute this as a Pull Request. Please let me know what you think!
Source: gabime/spdlog