Decoding UTF-8. Part VIII: Validating Decoder

In part VII of the series, we saw how to validate a UTF-8 sequence, that is to ensure it can be safely decoded into a Unicode code point. In part VI, we set validation aside and saw how a non-validating decoder would look like. Now it is time to investigate a decoding validator - e.g. decode a UTF-8 sequence without assuming it is valid.

An obvious approach would be to perform the two steps sequentially: first validate the sequence, then decode it using the non‑validating decoder. That is a reasonable strategy, especially if the string will be decoded more than once or if subsequent operations assume valid UTF‑8. However, if all we want to do is decode a byte sequence once, we can do better by validating while decoding.

Intermixing decoding with validation is not hard, once we have figured out both steps. We can start from the validation algorithm and add composition of the code point value in the “valid sequence” branches. Here is how it is implemented in the utfcpp library:

    template 
    utf_error decode_next(octet_iterator& it, octet_iterator end, utfchar32_t& cp)
    {
        if (it == end)
            return NOT_ENOUGH_ROOM;

        // Save the original value of it so we can go back in case of failure
        // Of course, it does not make much sense with i.e. stream iterators
        octet_iterator original_it = it;

        cp = 0;
        // Determine the sequence length based on the lead octet
        const int length = utf8::internal::sequence_length(it);

        // Get trail octets and calculate the code point
        utf_error err = UTF8_OK;
        switch (length) {
            case 0:
                return INVALID_LEAD;
            case 1:
                err = utf8::internal::get_sequence_1(it, end, cp);
                break;
            case 2:
                err = utf8::internal::get_sequence_2(it, end, cp);
            break;
            case 3:
                err = utf8::internal::get_sequence_3(it, end, cp);
            break;
            case 4:
                err = utf8::internal::get_sequence_4(it, end, cp);
            break;
        }

        if (err != UTF8_OK) {
        // Failure branch - restore the original value of the iterator
            it = original_it;
            cp = 0;
            return err;
        }

        it++; // Successfully parsed the sequence, advance the iterator
        return UTF8_OK;
    }

The sequence_length() function is similar to what we saw in Part II: Determining Sequence Length - a Straightforward Approach: based on the lead byte value, it returns the expected length of the sequence or 0 in case of an invalid lead. More interesting are the get_sequence_n functions. Here is get_sequence_2():

    template 
    utf_error get_sequence_2(octet_iterator& it, octet_iterator end, utfchar32_t& code_point)
    {
        if (it == end)
            return NOT_ENOUGH_ROOM;

        const utfchar8_t lead = utf8::internal::mask8(*it);
        code_point = static_cast(lead);

        UTF8_CPP_INCREASE_AND_RETURN_ON_ERROR(it, end)

        const utfchar8_t trail1 = utf8::internal::mask8(*it);
        code_point = ((code_point << 6) & 0x7ff) + (trail1 & 0x3f);

        if (lead == 0xC0 || lead == 0xC1) {
            return OVERLONG_SEQUENCE;
        }

        return UTF8_OK;
    }

Note how we scan for errors as we decode: after we set the initial value of the code point based on the lead byte, there is a checked advancement to the trail byte that ensures a valid trail byte exists. Then, after getting the trail byte and calculating the code point, we check for an overlong sequence based on the lead byte. If get_sequence_2 returns successfully, there is no need for additional checks for validity of the code point.

Let’s look at the generated machine code (aarch64 on Linux). I built the apitests executable with debug symbols and then searched for decode_next:

$ objdump -t --demangle build/tests/apitests | grep -F 'decode_next'
000000000000a2f8  w    F .text  0000000000000208     utf8::internal::utf_error utf8::internal::decode_next(char const*&, char const*, char32_t&)
000000000000a964  w    F .text  0000000000000208     uf8::internal::utf_error utf8::internal::decode_next(char*&, char*, char32_t&)

We got two specializations: the char const* specialization at 0xa2f8 and the char* specialization at 0xa964. They are identical: only their addresses and template types are different.

With that information, I disassembled one of the specializations:

$  objdump --demangle --source --line-numbers --disassemble   --start-address=0xa2f8 --stop-address=0xa500 build/tests/apitests

build/tests/apitests:     file format elf64-littleaarch64


Disassembly of section .text:

000000000000a2f8 (char const*&, char const*, char32_t&)>:
utf8::internal::utf_error utf8::internal::decode_next(char const*&, char const*, char32_t&):
/home/ntrif/utfcpp/tests/../source/utf8/core.h:268
    #undef UTF8_CPP_INCREASE_AND_RETURN_ON_ERROR

    template 
    utf_error decode_next(octet_iterator& it, octet_iterator end, utfchar32_t& cp)
    {
        if (it == end)
    a2f8:       f9400009        ldr     x9, [x0]
    a2fc:       eb01013f        cmp     x9, x1
    a300:       54000160        b.eq    a32c (char const*&, char const*, char32_t&)+0x34>  // b.none
...

The listing is pretty long; as expected for header-only C++ code, the function calls are fully inlined. Instead of showing the entire dump here, let’s focus on a few interesting points:

As expected, (see When Compilers Disagree About UTF-8), there is a shortcut for the ASCII range:

a308: 39c0012a        ldrsb   w10, [x9]        // load lead byte
a30c: 12001d48        and     w8, w10, #0xff   // mask8
a310: 37f8012a        tbnz    w10, #31, a334   // if top bit is set, not ASCII - bail out
a314: aa0903ea        mov     x10, x9          // ASCII case
a318: b9000048        str     w8, [x2]         // cp = lead
a31c: 91000549        add     x9, x10, #1      // it++
a320: f9000009        str     x9, [x0]         // store iterator
a324: 2a1f03e0        mov     w0, wzr          // return UTF8_OK
a328: d65f03c0        ret

It is the TBNZ instruction that “recognizes” an ASCII code point and sends the code to the no-validation path.

The C++ function that is used to check whether a byte is a valid UTF-8 trail:

    template
    inline bool is_trail(octet_type oc)
    {
        return ((utf8::internal::mask8(oc) >> 6) == 0x2);
    }

after inlining ends up as:

a354: 3940014b        ldrb    w11, [x10]       // load trail byte
a358: 121a056c        and     w12, w11, #0xc0  // mask top two bits
a35c: 7102019f        cmp     w12, #0x80       // must be 10xxxxxx
a360: 54000c21        b.ne    a4e4             // INVALID_TRAIL

Pretty much nothing exciting - straightforward C++ code being well optimized. We did not use any tricks to avoid branching or use non-portable techniques like SIMD. The combination of inlining, predictable branches, and the ASCII shortcut gives good throughput without sacrificing portability.

添加评论
点赞收藏
点踩分享查看原文
评论
?
参与讨论