Skip to content

[libc++] Optimize to_chars for bases 2, 8 and 16 - #223222

Open
davebayer wants to merge 1 commit into
llvm:mainfrom
davebayer:optimize_to_chars_pow2
Open

[libc++] Optimize to_chars for bases 2, 8 and 16#223222
davebayer wants to merge 1 commit into
llvm:mainfrom
davebayer:optimize_to_chars_pow2

Conversation

@davebayer

@davebayer davebayer commented Sep 13, 2026

Copy link
Copy Markdown
Contributor

I've come up with an optimization for std::to_chars that instead of character lookup computes the characters using SIMD within a register. The idea is to use std::bit_decompress behaviour to distribute the selected bits to the start of each byte in a register and add the 0 or a character offset using a single addition.

However, this optimization is dependent on the std::bit_decompress implementation. For example, when BMI2 on x86 is available, it is compiled to a single pdep instruction.

I've ran some benchmarks on my Intel i9-14900K with -O3 -mbmi2 flags using 8 characters/register and the results are:

Type Base 2 old → new Base 8 old → new Base 16 old → new
u8 350 → 802 ns (0.44×) 326 → 475 ns (0.69×) 281 → 494 ns (0.57×)
u16 703 → 881 ns (0.80×) 423 → 388 ns (1.09×) 283 → 509 ns (0.56×)
u32 1320 → 1046 ns (1.26×) 897 → 517 ns (1.74×) 644 → 500 ns (1.29×)
u64 2024 → 1605 ns (1.26×) 1538 → 593 ns (2.59×) 841 → 563 ns (1.49×)
u128 4905 → 3747 ns (1.31×) 3140 → 1313 ns (2.39×) 1971 → 1141 ns (1.73×)

For small types, it's not worth it, but for 32-bit and wider types, we can get some substantial improvements.

Would you be interested in having this optimization when a pext-like instruction is available?

If yes, then I'll rework the implementation to change the chars/register based on the platform's word size as well.

Similar idea could be used to optimize from_chars, too.

I've used GPT-5.6 SOL to help me do the benchmarks & optimize the base 16 path.

@davebayer
davebayer requested a review from a team as a code owner September 13, 2026 09:34
@llvmorg-github-actions llvmorg-github-actions Bot added the libc++ libc++ C++ Standard Library. Not GNU libstdc++. Not libc++abi. label Sep 13, 2026
@llvmorg-github-actions

Copy link
Copy Markdown

@llvm/pr-subscribers-libcxx

Author: David Bayer (davebayer)

Changes

I've come up with an optimization for std::to_chars that instead of character lookup computes the characters using SIMD within register. The idea is to use std::bit_decompress behaviour to distribute the selected bits to the start of each byte in a register and add the 0 or a character offset using a single addition.

However, this optimization is dependent on the std::bit_decompress implementation. For example, when BMI2 on x86 is available, it is compiled to a single pext instruction.

I've ran some benchmarks on my Intel i9-14900K with -O3 -mbmi2 flags using 8 characters/register and the results are:

Type Base 2 old → new Base 8 old → new Base 16 old → new
u8 350 → 802 ns (0.44×) 326 → 475 ns (0.69×) 281 → 494 ns (0.57×)
u16 703 → 881 ns (0.80×) 423 → 388 ns (1.09×) 283 → 509 ns (0.56×)
u32 1320 → 1046 ns (1.26×) 897 → 517 ns (1.74×) 644 → 500 ns (1.29×)
u64 2024 → 1605 ns (1.26×) 1538 → 593 ns (2.59×) 841 → 563 ns (1.49×)
u128 4905 → 3747 ns (1.31×) 3140 → 1313 ns (2.39×) 1971 → 1141 ns (1.73×)

For small types, it's not worth it, but for 32-bit and wider types, we can get some substantial improvements.

Would you be interested in having this optimization when a pext-like instruction is available?

If yes, then I'll rework the implementation to change the chars/register based on the platform's word size as well.

I've used GPT-5.6 SOL to help me do the benchmarks & optimize the base 16 path.


Full diff: https://github.com/llvm/llvm-project/pull/223222.diff

1 Files Affected:

  • (modified) libcxx/include/__charconv/to_chars_integral.h (+78-36)
diff --git a/libcxx/include/__charconv/to_chars_integral.h b/libcxx/include/__charconv/to_chars_integral.h
index 6d425139260b6..3fbbd6a8f8c3c 100644
--- a/libcxx/include/__charconv/to_chars_integral.h
+++ b/libcxx/include/__charconv/to_chars_integral.h
@@ -110,6 +110,11 @@ namespace __itoa {
 template <unsigned _Base>
 struct _LIBCPP_HIDDEN __integral;
 
+template <size_t _Np>
+struct _LIBCPP_HIDDEN __chars_storage {
+  char __data[_Np];
+};
+
 template <>
 struct _LIBCPP_HIDDEN __integral<2> {
   template <typename _Tp>
@@ -126,21 +131,32 @@ struct _LIBCPP_HIDDEN __integral<2> {
     int __n         = __width(__value);
     if (__n > __cap)
       return {__last, errc::value_too_large};
-
     __last                   = __first + __n;
     char* __p                = __last;
-    const unsigned __divisor = 16;
-    while (__value > __divisor) {
-      unsigned __c = __value % __divisor;
+
+    constexpr auto __ncs_per_word   = 8u;
+    constexpr auto __divisor        = 1u << __ncs_per_word;
+    constexpr auto __dep_mask       = uint64_t{0x0101'0101'0101'0101};
+    constexpr auto __c0_offset_mask = uint64_t{0x3030'3030'3030'3030};
+    while (__value >= __divisor) {
+      const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
+      auto __cs            = __digits | __c0_offset_mask;
+#ifdef _LIBCPP_LITTLE_ENDIAN
+      __cs = __builtin_bswap64(__cs);
+#endif
       __value /= __divisor;
-      __p -= 4;
-      std::copy_n(&__base_2_lut[4 * __c], 4, __p);
+      __p -= __ncs_per_word;
+      const auto __chars = __builtin_bit_cast(__chars_storage<__ncs_per_word>, __cs);
+      __builtin_memcpy(__p, __chars.__data, __ncs_per_word);
     }
-    do {
-      unsigned __c = __value % 2;
-      __value /= 2;
-      *--__p = "01"[__c];
-    } while (__value != 0);
+    const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
+    auto __cs            = __digits | __c0_offset_mask;
+#ifdef _LIBCPP_LITTLE_ENDIAN
+    __cs = __builtin_bswap64(__cs);
+#endif
+    const auto __chars = __builtin_bit_cast(__chars_storage<__ncs_per_word>, __cs);
+    const auto __remaining = static_cast<size_t>(__p - __first);
+    __builtin_memcpy(__first, __chars.__data + __ncs_per_word - __remaining, __remaining);
     return {__last, errc(0)};
   }
 };
@@ -161,21 +177,32 @@ struct _LIBCPP_HIDDEN __integral<8> {
     int __n         = __width(__value);
     if (__n > __cap)
       return {__last, errc::value_too_large};
+    __last                   = __first + __n;
+    char* __p                = __last;
 
-    __last             = __first + __n;
-    char* __p          = __last;
-    unsigned __divisor = 64;
-    while (__value > __divisor) {
-      unsigned __c = __value % __divisor;
+    constexpr auto __ncs_per_word   = 8u;
+    constexpr auto __divisor        = 1u << (3 * __ncs_per_word);
+    constexpr auto __dep_mask       = uint64_t{0x0707'0707'0707'0707};
+    constexpr auto __c0_offset_mask = uint64_t{0x3030'3030'3030'3030};
+    while (__value >= __divisor) {
+      const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
+      auto __cs            = __digits | __c0_offset_mask;
+#ifdef _LIBCPP_LITTLE_ENDIAN
+      __cs = __builtin_bswap64(__cs);
+#endif
       __value /= __divisor;
-      __p -= 2;
-      std::copy_n(&__base_8_lut[2 * __c], 2, __p);
+      __p -= __ncs_per_word;
+      const auto __chars = __builtin_bit_cast(__chars_storage<__ncs_per_word>, __cs);
+      __builtin_memcpy(__p, __chars.__data, __ncs_per_word);
     }
-    do {
-      unsigned __c = __value % 8;
-      __value /= 8;
-      *--__p = "01234567"[__c];
-    } while (__value != 0);
+    const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
+    auto __cs            = __digits | __c0_offset_mask;
+#ifdef _LIBCPP_LITTLE_ENDIAN
+    __cs = __builtin_bswap64(__cs);
+#endif
+    const auto __chars = __builtin_bit_cast(__chars_storage<__ncs_per_word>, __cs);
+    const auto __remaining = static_cast<size_t>(__p - __first);
+    __builtin_memcpy(__first, __chars.__data + __ncs_per_word - __remaining, __remaining);
     return {__last, errc(0)};
   }
 };
@@ -197,21 +224,36 @@ struct _LIBCPP_HIDDEN __integral<16> {
     if (__n > __cap)
       return {__last, errc::value_too_large};
 
-    __last             = __first + __n;
-    char* __p          = __last;
-    unsigned __divisor = 256;
-    while (__value > __divisor) {
-      unsigned __c = __value % __divisor;
+    __last    = __first + __n;
+    char* __p = __last;
+
+    constexpr auto __ncs_per_word   = 8u;
+    constexpr auto __divisor        = uint64_t{1} << (4 * __ncs_per_word);
+    constexpr auto __dep_mask       = uint64_t{0x0f0f'0f0f'0f0f'0f0f};
+    constexpr auto __add_six_mask   = uint64_t{0x0606'0606'0606'0606};
+    constexpr auto __high_bit_mask  = uint64_t{0x1010'1010'1010'1010};
+    constexpr auto __c0_offset_mask = uint64_t{0x3030'3030'3030'3030};
+    while (__value >= __divisor) {
+      const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
+    const auto __alpha = ((__digits + __add_six_mask) & __high_bit_mask) >> 4;
+    auto __cs          = __digits + __c0_offset_mask + __alpha * 0x27;
+#ifdef _LIBCPP_LITTLE_ENDIAN
+      __cs = __builtin_bswap64(__cs);
+#endif
       __value /= __divisor;
-      __p -= 2;
-      std::copy_n(&__base_16_lut[2 * __c], 2, __p);
+      __p -= __ncs_per_word;
+      const auto __chars = __builtin_bit_cast(__chars_storage<__ncs_per_word>, __cs);
+      __builtin_memcpy(__p, __chars.__data, __ncs_per_word);
     }
-    if (__first != __last)
-      do {
-        unsigned __c = __value % 16;
-        __value /= 16;
-        *--__p = "0123456789abcdef"[__c];
-      } while (__value != 0);
+    const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
+    const auto __alpha = ((__digits + __add_six_mask) & __high_bit_mask) >> 4;
+    auto __cs          = __digits + __c0_offset_mask + __alpha * 0x27;
+#ifdef _LIBCPP_LITTLE_ENDIAN
+    __cs = __builtin_bswap64(__cs);
+#endif
+    const auto __chars     = __builtin_bit_cast(__chars_storage<__ncs_per_word>, __cs);
+    const auto __remaining = static_cast<size_t>(__p - __first);
+    __builtin_memcpy(__first, __chars.__data + __ncs_per_word - __remaining, __remaining);
     return {__last, errc(0)};
   }
 };

@github-actions

Copy link
Copy Markdown

⚠️ C/C++ code formatter, clang-format found issues in your code. ⚠️

You can test this locally with the following command:
git-clang-format --diff origin/main HEAD --extensions h -- libcxx/include/__charconv/to_chars_integral.h --diff_from_common_commit

⚠️
The reproduction instructions above might return results for more than one PR
in a stack if you are using a stacked PR workflow. You can limit the results by
changing origin/main to the base branch/commit you want to compare against.
⚠️

View the diff from clang-format here.
diff --git a/libcxx/include/__charconv/to_chars_integral.h b/libcxx/include/__charconv/to_chars_integral.h
index 3fbbd6a8f..104319abe 100644
--- a/libcxx/include/__charconv/to_chars_integral.h
+++ b/libcxx/include/__charconv/to_chars_integral.h
@@ -140,7 +140,7 @@ struct _LIBCPP_HIDDEN __integral<2> {
     constexpr auto __c0_offset_mask = uint64_t{0x3030'3030'3030'3030};
     while (__value >= __divisor) {
       const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
-      auto __cs            = __digits | __c0_offset_mask;
+      auto __cs           = __digits | __c0_offset_mask;
 #ifdef _LIBCPP_LITTLE_ENDIAN
       __cs = __builtin_bswap64(__cs);
 #endif
@@ -150,11 +150,11 @@ struct _LIBCPP_HIDDEN __integral<2> {
       __builtin_memcpy(__p, __chars.__data, __ncs_per_word);
     }
     const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
-    auto __cs            = __digits | __c0_offset_mask;
+    auto __cs           = __digits | __c0_offset_mask;
 #ifdef _LIBCPP_LITTLE_ENDIAN
     __cs = __builtin_bswap64(__cs);
 #endif
-    const auto __chars = __builtin_bit_cast(__chars_storage<__ncs_per_word>, __cs);
+    const auto __chars     = __builtin_bit_cast(__chars_storage<__ncs_per_word>, __cs);
     const auto __remaining = static_cast<size_t>(__p - __first);
     __builtin_memcpy(__first, __chars.__data + __ncs_per_word - __remaining, __remaining);
     return {__last, errc(0)};
@@ -177,8 +177,8 @@ struct _LIBCPP_HIDDEN __integral<8> {
     int __n         = __width(__value);
     if (__n > __cap)
       return {__last, errc::value_too_large};
-    __last                   = __first + __n;
-    char* __p                = __last;
+    __last    = __first + __n;
+    char* __p = __last;
 
     constexpr auto __ncs_per_word   = 8u;
     constexpr auto __divisor        = 1u << (3 * __ncs_per_word);
@@ -186,7 +186,7 @@ struct _LIBCPP_HIDDEN __integral<8> {
     constexpr auto __c0_offset_mask = uint64_t{0x3030'3030'3030'3030};
     while (__value >= __divisor) {
       const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
-      auto __cs            = __digits | __c0_offset_mask;
+      auto __cs           = __digits | __c0_offset_mask;
 #ifdef _LIBCPP_LITTLE_ENDIAN
       __cs = __builtin_bswap64(__cs);
 #endif
@@ -196,11 +196,11 @@ struct _LIBCPP_HIDDEN __integral<8> {
       __builtin_memcpy(__p, __chars.__data, __ncs_per_word);
     }
     const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
-    auto __cs            = __digits | __c0_offset_mask;
+    auto __cs           = __digits | __c0_offset_mask;
 #ifdef _LIBCPP_LITTLE_ENDIAN
     __cs = __builtin_bswap64(__cs);
 #endif
-    const auto __chars = __builtin_bit_cast(__chars_storage<__ncs_per_word>, __cs);
+    const auto __chars     = __builtin_bit_cast(__chars_storage<__ncs_per_word>, __cs);
     const auto __remaining = static_cast<size_t>(__p - __first);
     __builtin_memcpy(__first, __chars.__data + __ncs_per_word - __remaining, __remaining);
     return {__last, errc(0)};
@@ -235,8 +235,8 @@ struct _LIBCPP_HIDDEN __integral<16> {
     constexpr auto __c0_offset_mask = uint64_t{0x3030'3030'3030'3030};
     while (__value >= __divisor) {
       const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
-    const auto __alpha = ((__digits + __add_six_mask) & __high_bit_mask) >> 4;
-    auto __cs          = __digits + __c0_offset_mask + __alpha * 0x27;
+      const auto __alpha  = ((__digits + __add_six_mask) & __high_bit_mask) >> 4;
+      auto __cs           = __digits + __c0_offset_mask + __alpha * 0x27;
 #ifdef _LIBCPP_LITTLE_ENDIAN
       __cs = __builtin_bswap64(__cs);
 #endif
@@ -246,8 +246,8 @@ struct _LIBCPP_HIDDEN __integral<16> {
       __builtin_memcpy(__p, __chars.__data, __ncs_per_word);
     }
     const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
-    const auto __alpha = ((__digits + __add_six_mask) & __high_bit_mask) >> 4;
-    auto __cs          = __digits + __c0_offset_mask + __alpha * 0x27;
+    const auto __alpha  = ((__digits + __add_six_mask) & __high_bit_mask) >> 4;
+    auto __cs           = __digits + __c0_offset_mask + __alpha * 0x27;
 #ifdef _LIBCPP_LITTLE_ENDIAN
     __cs = __builtin_bswap64(__cs);
 #endif

Comment on lines +142 to +150
const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
auto __cs = __digits | __c0_offset_mask;
#ifdef _LIBCPP_LITTLE_ENDIAN
__cs = __builtin_bswap64(__cs);
#endif
__value /= __divisor;
__p -= 4;
std::copy_n(&__base_2_lut[4 * __c], 4, __p);
__p -= __ncs_per_word;
const auto __chars = __builtin_bit_cast(__chars_storage<__ncs_per_word>, __cs);
__builtin_memcpy(__p, __chars.__data, __ncs_per_word);

@davebayer davebayer Sep 13, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The idea is to take bottom __ncs_per_word bits

0b0101'1010'0110'0110 -> 0b0110'0110

distribute each bit to start of a byte within a register

0b0110'0110 -> 0x0001'0100'0001'0100

add 0x30 ('0' character offset) to every byte (can be done using bit or)

0x0001'0100'0001'0100 + 0x3030'3030'3030'3030 -> 0x3031'3130'3031'3130

byteswap on little endian platforms

0x3031'3130'3031'3130 -> 0x3031'3130'3031'3130

and copy the result to memory

Comment on lines +237 to +239
const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);
const auto __alpha = ((__digits + __add_six_mask) & __high_bit_mask) >> 4;
auto __cs = __digits + __c0_offset_mask + __alpha * 0x27;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For base 16, '0' and 'a' have different offsets, so we need to account for that

@Andarwinux

Copy link
Copy Markdown
Member

cc @eisenwave

constexpr auto __dep_mask = uint64_t{0x0101'0101'0101'0101};
constexpr auto __c0_offset_mask = uint64_t{0x3030'3030'3030'3030};
while (__value >= __divisor) {
const auto __digits = __builtin_elementwise_pdep(static_cast<uint64_t>(__value), __dep_mask);

@Zingam Zingam Sep 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If __builtin_elementwise_pdep was introduced in Clang 23 as I think https://releases.llvm.org/23.1.0/tools/clang/docs/ReleaseNotes.html#non-comprehensive-list-of-changes-in-this-release we still need to support Clang 22 (officially) and some compiler like OpenXL which are on Clang 21 still.

For current support see: https://libcxx.llvm.org/#platform-and-compiler-support

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Absolutely, I just wanted to discuss first whether you want this thing in before spending the time on portability and more performance analysis

@eisenwave eisenwave left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like a reasonable optimization, but

  • As Zingam mentioned, some of those builtins might need feature-tests. __builtin_bit_cast is also more recent than std::to_chars.
  • Any performance analysis should be cautious of the notoriously expensive early Zen BMI2 implementation, which is done in microcode. I imagine that attempting to use a pdep instruction on that architecture is strictly worse. https://godbolt.org/z/bvcjTo5sP https://godbolt.org/z/xE885Pzzc From what I can tell, we don't software-emulate pdep on Zen 2, so you're really getting that dramatic performance regression too. Anyway, that makes me think we want to keep the whole original implementation and only conditionally use the new one if it's not on Zen 2.

@eisenwave

Copy link
Copy Markdown
Contributor

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

Labels

libc++ libc++ C++ Standard Library. Not GNU libstdc++. Not libc++abi.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants