Skip to content

[Feature Request / Optimization] Super-fast AVX2 SIMD alternative for full_formatter timestamps #3635

Description

@leagem

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!

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions