Benchmarking C++: std::tolower (Part 2) — Explaining the Clang vs GCC Performance Anomaly
Table of Contents
Benchmarking C++: std::tolower(Part 2) — Explaining the Clang vs GCC Performance Anomaly
Table of Contents
- A quick word on Part-1
- Goal of Part-2
- Problem Statement
- Lookup-Table vs Arithmetic Approach
- Assembly for std::tolower
- Overload #1: in <cctype>
- Clang with libC++
- GCC + libStdC++
- Overload #2: in <locale>
- Performance of <cctype> overload
- LibC++ version
- LibStdC++ version
- References
- Appendix
Note: This article can be read independently of Part-1 if so desired[1]. Throughout the article, Clang refers to (Clang + libc++) and GCC refers to (GCC + libstdc++)
Last Updated: 21-May-2026
1. A quick word on Part-1
Part-1 drew significant interest in this topic along with a lot of valuable feedback. Special thanks to readers who sent me their code samples [Appendix A], specially the lookup-table and SWAR (SIMD Within A Register) variants. These were very important for me to develop further insights. Also one of the readers pointed me to Tony Finch’s blog post [2], a Unix system developer working on BIND9, has a couple of great blogs on the similar topic of ASCII lowercasing using SIMD.
But I wrote Part-1 with following questions on my mind:
- Does it make a difference in performance if the loop expression for lowercasing was written with one or the other:
std::for_each,std::transform, raw-loops etc.? - Does the call to
std::tolowerget optimised to SIMD instructions by the compiler? - Why is there difference in libstdc++ (GCC) vs libc++ (Clang)?
The answer to #1 is now clear. The style of loop expressions can silently suppress auto-vectorisation at -O2 or -O3 , but if you can optimise successfully, all logically equivalent loops should get optimised to similar vector Assembly (if the target supports it). I covered this topic separately in another blog [4]. See results from [4] in the Appendix. std::transform most reliably vectorises for this problem (please read [4] if you haven’t, to see why). It’s enough to just test implementations with that.
2. Goal of this blog
Purpose of this blog is to answer the remaining two questions. Will calls to std::tolower get optimised to SIMD instructions? Why was there a difference in performance when comparing Clang to GCC? For this, we will have to look into the Assembly.
3. Problem Statement — again
Given a source std::string of (printable) ASCII characters, convert each character to lowercase and write the result into a destination string.

ASCII characters are 7-bit wide, range from 0–127, see [3]. (We’re not talking about wide characters or Unicode here to limit the scope of the discussion)
4. Lookup-Table vs Arithmetic Approach
Before I discuss std::tolower, I want to quickly give you an idea of performance difference one observes when comparing the following two manual approaches:
// ASCII lowercasing with Arithmetic expression
auto arithmetic = [](char c){ return c + 0x20 * (c >= 'A' && c <= 'Z');};
// ASCII lowercasing with Lookup-Table
auto lookup = [](unsigned char c){ return table[c];};
In the second version, table is a [256] byte lookup-table, initialised with a lowercase ASCII map. Let’s test the above operations with astd::transform
std::transform( s.begin(), s.end(), d.begin(), arithmetic);
//vs
std::transform( s.begin(), s.end(), d.begin(), lookup);
For both GCC/Clang with -O3 flags, the Arithmetic version gets auto-vectroised. However, the lookup-table version get’s only scalar Assembly with some optimisations like loop-unrolling. On the quick-bench platform, this simple lookup-table version is 8 times slower (on a 1Kb string, using Random data) in comparison to arithmetic version, on the Quick Bench’s platform.
https://quick-bench.com/q/nncoReqQvSd6h8mUDcJtKbq9Udk

Arithmetic version (Blue) vs a simple Lookup-Table (Yellow) approach.
NoTE: Lookup tables aren’t inherently slow, just this unvectorised one is. This is the speed of a lookup table which only gets scalar Assembly generated. Now I have written a hand-coded NEON version on Apple-M2 platform and it’s pretty close in performance to the vectorised Arithmetic version (for a future blog). The point is, you don’t get it automatically from Clang/GCC (atleast, i didn’t get it!) from the auto-vectoriser.
Now, armed with this knowledge, let’s have a look at whats going on under the hood in std::tolower
5. std::tolower
C++ has two overloads for std::tolower
Overload #1: in <cctype>
According to Cpp-Reference, the header <cctype> was originally in the C standard library as <ctype.h>. **std::tolower** is defined here as [5]:
// Defined in header <cctype>
int tolower( int ch );
where ch, should be representable as unsigned char or EOF. Recommended safe use of this method for a single character is:
char my_tolower(char ch)
{
return static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
}
And with **std::transform **[5]:
std::string str_tolower(std::string s)
{
std::transform( s.begin(), s.end(), s.begin(),
[](unsigned char c){ return std::tolower(c); }
);
return s;
}
Clang with libc++
If you paste the above **std::transform**based call in Compiler-Explorer and choose the flags below:
Compiler: x86–64 Clang 22.1.0
Flags: *-std=c++23 -O3 -stdlib=libc++ -march=haswell *Link: https://godbolt.org/z/zWfM86d71
You will see that the call to **std::tolower* is completely inlined and replaced with a call to `__ctype_tolower_loc`*:

And what is *__ctype_tolower_loc? It returns a (.. drum rolls…) a *pointer to a lookup-table. You can see this function defined in the libc sources [8]:
const __int32_t **__ctype_tolower_loc (void);
This lookup table is locale dependent and is specifically a 384-entry int32_t array (covering indices -128 to 255, offset by 128 to handle both signed char and unsigned char ranges), compared to the hand-coded char[256] table from section 4.
Additionally, you find in the Assembly code that Clang optimises the table-lookup with loop-unrolling, see [Appendix D].
But NO vector instructions were generated. This is similar to the scalar Lookup Table Assembly
GCC with libStdC++
Lets now see GCC, choose:
Compiler: x86–64 GCC 16.1
Flags: *-std=c++23 -O3 -march=haswell *Link: https://godbolt.org/z/cfb5dPPG7

You see that tolower has not been inlined to __ctype_tolower_loc() call like before. There is no loop-unrolling anywhere in the Assembly code and it’s essentially doing a per character function call to inlower() without inlining. If you want to jump into the rabbit-hole of why with libstdc++, GCC fails to inline, See [Appendix E].
Overload #2: in <locale>
// Defined in header <locale>
template< class CharT >
CharT tolower( CharT ch, const locale& loc );
This overload has virtual function dispatch call. But GCC/Clang do a comparable job to optimise it and you dont see any difference between the libstcd++ or libc++ versions.
6. Performance of std::tolower
Since we now know that overload#1 is a lookup table, let’s compare it to the baseline hand-coded ASCII lookup table from section 4 above.
Please note the difference, that std::tolower is a int[384] lookup table while i’m compairing it to a char[256] lookup, so i’ts not a equal comparison and that is intentional. The idea is to use the [256] byte table as a baseline. By compairing the Clang and GCC versions relative performance to this baseline, we can know how much does GCC slows down due to no inlining.
Flags: *-std=c++23 -O3*
https://quick-bench.com/q/i7bLK4ku9yHI9VrSkFejY9U3-ew
Clang 17.0 (libc++) — overload 1
![The libC++ (Clang) version is only 1.4 times slower to a char[256] table version](https://miro.medium.com/v2/resize:fit:809/1*sgNGgihFh6raMR9Q3NSqqw.png)
The libC++ (Clang) version is only 1.4 times slower to a char[256] table version
GCC 13.2 (libStdc++) — overload 1

The libStdC++ (GCC) is 3.6 times slower to the baseline
If you now compare the two graphs above you see the slowdown. With Clang (& libc++) your 1.5 times slower in comparison to the baseline while with GCC(& libStdC++) your 3.6 times slower. So GCC slows down twice as much, this is the function call overhead which it was not able to inline.
overload 2
For the function std::tolower( char, locale&); both versions with stdlibc++ and libc++ perform equally in comparison, but this version is much slwoer than overload 1 because every time the lambda runs, std::tolower(c, loc) must perform a virtual function call internally to fetch the std::ctype<char>facet. Although, the facet maps to the same lookup-table as overload #1.

For Both Clang/GCC the overload #2 of std::tolower is much slower than the overload #1
7. Conclusions
- std::tolower get’s a maximum optimisation of a lookup-table, which is loop-unrolled and is based on a scalar assembly.
- Calls to std::tolower do not get optimised to vector Assembly by either Clang 22 or GCC 16.
- Failure to do function call inlining slows down GCC to twice in comparison to Clang, when dealing with overload #1 of std::tolower.
References
- Benchmarking C++ std::tolower performance (part 1)
- tolower() in bulk at speed
- ASCII Tables
- C++ Auto-Vectorization: that Divine intervention in your loops
- std::tolower: cpp-reference
“Converts the given character to lowercase according to the character conversion rules defined by the currently installed C locale. In the default
"C"locale, the following uppercase lettersABCDEFGHIJKLMNOPQRSTUVWXYZare replaced with respective lowercase lettersabcdefghijklmnopqrstuvwxyz.” - std::tolower: <locale>
- std::setlocale: cpp-reference
*__ctype_tolower_loclink*
Appendix
A. Feedback from readers on Part-1
- Thanks to all the readers of the previous blog [1] who submitted some form of code. Their input on [1], was very valuable for me to see the full picture: — Greg Thain: SWAR based lowercasing — Max Hinkley: Lookup Table based approach — Tommaso Bonvicini: For-loop that was optimal, and arithmetic version of ASCII lowercase conversion. — Alexander Sopov: for pointing out the std::transform() implementation in Clang and a better std::for_each() version.\
B. Results from Benchmarking different loops
Results of Benchmarking different loop expressions[4]
![Results of benchmarking hand-coded lowercasing from [4]. All optimised loops ~34 nano seconds](https://miro.medium.com/v2/resize:fit:1268/1*zbFL3gSTjGdIvqlZMJH_aw.png)
Results of benchmarking hand-coded lowercasing from [4]. All optimised loops ~34 nano seconds
C. Loop Unrolling in std::tolower
//Loop Unrolling
.LBB0_8:
movzx esi, byte ptr [rcx + rdx]
mov rdi, qword ptr [rax]
movzx esi, byte ptr [rdi + 4*rsi]
mov byte ptr [rbx + rdx], sil
movzx esi, byte ptr [rcx + rdx + 1]
mov rdi, qword ptr [rax]
movzx esi, byte ptr [rdi + 4*rsi]
mov byte ptr [rbx + rdx + 1], sil
movzx esi, byte ptr [rcx + rdx + 2]
mov rdi, qword ptr [rax]
movzx esi, byte ptr [rdi + 4*rsi]
mov byte ptr [rbx + rdx + 2], sil
movzx esi, byte ptr [rcx + rdx + 3]
mov rdi, qword ptr [rax]
movzx esi, byte ptr [rdi + 4*rsi]
mov byte ptr [rbx + rdx + 3], sil
lea rsi, [rcx + rdx]
add rsi, 4
add rdx, 4
cmp rsi, r14
jne .LBB0_8
D. Why GCC doesn’t inline std::tolower?
The answer lies in the <ctype.h> headers in glibc.
#if !defined __NO_CTYPE
.....
# ifdef __USE_EXTERN_INLINES
__extern_inline int
__NTH (tolower (int __c))
{
return __c >= -128 && __c < 256 ? (*__ctype_tolower_loc ())[__c] : __c;
}
__extern_inline int
__NTH (toupper (int __c))
{
return __c >= -128 && __c < 256 ? (*__ctype_toupper_loc ())[__c] : __c;
}
# endif
When using libStdC++, if you include <algorithm> its header include chains, the define __NO_CTYPE to 1.
#include <algorithm>
└─► bits/c++config.h
└─► bits/os_defines.h → #define __NO_CTYPE 1
This does not happen when using libC++, and __NO_CTYPE is “not” set. Thus tolower deifinition above is inlined.
E. Benchmark Source
The Benchmark’s source code is available on GitHub: Link
메타데이터
- post_id
- e7f564f3e18b
- slug
- benchmarking-c-std-tolower-part-2-explaining-the-clang-vs-gcc-performance-anomaly-e7f564f3e18b
- url
- https://medium.com/@tali-1984/benchmarking-c-std-tolower-part-2-explaining-the-clang-vs-gcc-performance-anomaly-e7f564f3e18b
- canonical_url
- https://medium.com/@tali-1984/benchmarking-c-std-tolower-part-2-explaining-the-clang-vs-gcc-performance-anomaly-e7f564f3e18b
- author_url
- https://medium.com/@tali-1984
- status
- ok
- fetched_at
- 2026-06-22 12:55:45