When Compilers Disagree About UTF‑8
Programming at the right level
SubscribeSign in
When Compilers Disagree About UTF‑8<br>Why a Simple ASCII Fast‑Path Tripled Clang’s Performance but Left GCC Unchanged
Nemanja Trifunovic<br>Jul 26, 2026
Share
In the summer of 2006 I started an open-source C++ library for handling UTF-8 strings. I wanted it to be portable and to work well with STL but did not go crazy with optimizations at the time.<br>Recently, as I started writing about UTF-8 decoding, I spent more time revisiting the internals of the library. The function I decided to optimize decodes a UTF-8 encoded code point:<br>template<br>utf_error validate_next(octet_iterator& it, octet_iterator end, utfchar32_t& code_point)<br>if (it == end)<br>return NOT_ENOUGH_ROOM;
// Save the original value of it so we can go back in case of failure<br>// Of course, it does not make much sense with i.e. stream iterators<br>octet_iterator original_it = it;
utfchar32_t cp = 0;<br>// Determine the sequence length based on the lead octet<br>const int length = utf8::internal::sequence_length(it);
// Get trail octets and calculate the code point<br>utf_error err = UTF8_OK;<br>switch (length) {<br>case 0:<br>return INVALID_LEAD;<br>case 1:<br>err = utf8::internal::get_sequence_1(it, end, cp);<br>break;<br>case 2:<br>err = utf8::internal::get_sequence_2(it, end, cp);<br>break;<br>case 3:<br>err = utf8::internal::get_sequence_3(it, end, cp);<br>break;<br>case 4:<br>err = utf8::internal::get_sequence_4(it, end, cp);<br>break;
if (err == UTF8_OK) {<br>// Decoding succeeded. Now, security checks...<br>if (utf8::internal::is_code_point_valid(cp)) {<br>if (!utf8::internal::is_overlong_sequence(cp, length)){<br>// Passed! Return here.<br>code_point = cp;<br>++it;<br>return UTF8_OK;<br>else<br>err = OVERLONG_SEQUENCE;<br>else<br>err = INVALID_CODE_POINT;
// Failure branch - restore the original value of the iterator<br>it = original_it;<br>return err;
Pretty straightforward: based on the value of the lead byte, determine the length of the sequence, and then depending on the length, construct the value of the code point by extracting appropriate bit fields from the bytes. After the code point is successfully decoded, perform two more checks: one for validity of the code point, and another for an overlong UTF-8 sequence. If both checks pass, return the success status, and the iterator is moved to the next sequence.<br>An opportunity to optimization was the fact that ASCII trivially satisfies all UTF‑8 validity requirements. If it turns out the high bit of the lead byte is zero, we have an “ASCII character” - a value in the range of [U+0000, U+007F] which is always valid. All we need to do is zero-extend it and we have our code point.<br>Something like this:<br>template<br>utf_error validate_next(octet_iterator& it, octet_iterator end, utfchar32_t& code_point)<br>if (it == end)<br>return NOT_ENOUGH_ROOM;
// Save the original value of it so we can go back in case of failure<br>// Of course, it does not make much sense with i.e. stream iterators<br>octet_iterator original_it = it;
utfchar32_t cp = 0;<br>// Determine the sequence length based on the lead octet<br>const int length = utf8::internal::sequence_length(it);
// Get trail octets and calculate the code point<br>utf_error err = UTF8_OK;<br>switch (length) {<br>case 0:<br>return INVALID_LEAD;<br>case 1:<br>err = utf8::internal::get_sequence_1(it, end, cp);<br>// No need for further validations<br>if (err == UTF8_OK) {<br>code_point = cp;<br>++it;<br>return UTF8_OK;<br>} else {<br>it = original_it;<br>return err;<br>break; // just to make sure there are no warnings<br>case 2:<br>err = utf8::internal::get_sequence_2(it, end, cp);<br>break;<br>case 3:<br>err = utf8::internal::get_sequence_3(it, end, cp);<br>break;<br>case 4:<br>err = utf8::internal::get_sequence_4(it, end, cp);<br>break;
if (err == UTF8_OK) {<br>// Decoding succeeded. Now, security checks...<br>if (utf8::internal::is_code_point_valid(cp)) {<br>if (!utf8::internal::is_overlong_sequence(cp, length)){<br>// Passed! Return here.<br>code_point = cp;<br>++it;<br>return UTF8_OK;<br>else<br>err = OVERLONG_SEQUENCE;<br>else<br>err = INVALID_CODE_POINT;
// Failure branch - restore the original value of the iterator<br>it = original_it;<br>return err;
My expectation was to see a visible improvement in handling of sequences of ASCII-only strings, with a minor impact on mixed ASCII/non-ASCII strings. Testing with clang 18.1.3 beat my expectations: For pure ASCII text, UTF-8 decoding throughput tripled, and even for mixed text, the improvement was substantial: around 34%.
I actually submitted the change to GitHub, but then at some point decided to test with gcc. Compilers just never stop surprising me: this time there was no difference for the ASCII text at all! Zero!
For heavily mixed text the throughput was consistently worse by 3-4%, which was not that surprising.<br>Time to get our hands dirty and look at the generated assembly code. Here is what happens for an ASCII code point with original version compiled with g++:<br>15e0: ldrb w0, [x22] ; load byte<br>15e4: tbz w0, #7, 1700 ; if bit 7 == 0 (ASCII) return immediately<br>...<br>1700: add x22, x22, #1 ; consume 1 byte<br>1704: cmp x2, x22...