Можно ли кодировать UTF-8 без ветвлений?

Да.

Вопрос


Натан Голдбаум задал в чате Recurse вопрос:

Я знаю, как декодировать UTF-8 с помощью битовой математики и таблиц поиска (см. https://github.com/skeeto/branchless-utf8), но если я хочу преобразовать кодовую точку UTF-8, то можно ли сделать ли это без ветвлений?

Для начала, можно ли как-то написать эту функцию на C, которая возвращает количество байтов, необходимых для хранения байтов UTF-8 кодовой точки, без использования ветвления? Или для этого потребуется огромная таблица поиска?

Функция на C
int
num_utf8_bytes_for_codepoint(uint32_t code)
{
    if (code <= 0x7F) {
        return 1;
    }
    else if (code <= 0x07FF) {
        return 2;
    }
    else if (code <= 0xFFFF) {
        if ((code >= 0xD800) && (code <= 0xDFFF)) {
            // суррогаты - это недопустимые кодовые точки UCS4
            return -1;
        }
        return 3;
        }
    else if (code <= 0x10FFFF) {
        return 4;
    }
    else {
        // кодовая точка вне пределов интервала Unicode
        return -1;
    }
}

Я поразмыслил на этим вопросом, но не смог сходу придумать, как это сделать без огромной таблицы поиска (2^32).

Почти полный ответ


Но потом Lorenz оставил такой комментарий:

Пока очень смутная идея: закодировать 32-битную кодовую точку в utf8, но хранить результат снова в 32-битном слове. Подсчитать количество начальных/конечных нулей, чтобы понять, сколько байтов необходимо. Записать четыре байта в буфер вывода, но смещать позицию в выводе только на действительно необходимое количество байтов.

Ага!

В начале будет от 12 до 32 нулей1 — вполне разумный размер для таблицы поиска. Далее мы сможем искать другие параметры по длине (не больше 4).

Я закинул в чат черновик, а вечером вернулся к нему для тестирования (и исправлений). Когда тесты начали проходить, код выглядел так:

/// Возвращает количество байтов, необходимых для кодирования кодовой точки в UTF-8.
/// Возвращает 0 для суррогатов и значений вне интервала.
const fn utf8_bytes_for_codepoint(codepoint: u32) -> usize {
    let len = LEN[codepoint.leading_zeros() as usize] as usize;

    // Обрабатывает суррогаты манипуляциями с битами.
    // Rust гарантирует true == 1 и false == 0:
    let surrogate_bit = ((codepoint >= 0xD800) && (codepoint <= 0xDFFF)) as usize;
    // Расширяем этот один бит до трёх и используем обратное ему значение в качестве маски для длины
    let surrogate_mask = surrogate_bit << 2 | surrogate_bit << 1 | surrogate_bit;

    // Обрабатываем выходящие за пределы значения при помощи манипуляций с битами.
    // К сожалению, они не точно выровнены с границей начальных нулей;
    // самая большая кодовая точка - это U+10FFFF.
    let exceeded_bit = (codepoint > 0x10_FFFF) as usize;
    let exceeded_mask = exceeded_bit << 2 | exceeded_bit << 1 | exceeded_bit;

    len & !surrogate_mask & !exceeded_mask
}

/// Длина на основании количества начальных нулей.
const LEN: [u8; 33] = [
    // 0-10 начальных нулей: значение невалидно
    0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
    // 11-15 начальных нулей: 4 байта
    4, 4, 4, 4, 4,
    //16-20 начальных нулей: 3 байта
    3, 3, 3, 3, 3,
    // 21-24 начальных нулей: 2 байта
    2, 2, 2, 2,
    // 25-32 начальных нулей: 1 байт
    1, 1, 1, 1, 1, 1, 1, 1,
];

/// Кодируем кодовую точку UTF-8.
/// Возвращаем буфер и количество валидных байтов в буфере.
///
/// Чтобы добавить эту кодовую точку к строке, добавляем все четыре байта в правильном порядке
/// и записываем, что к строке было добавлено (usize) байтов.
///
/// Возвращаем нулевую длину для недопустимых кодовых точек (суррогатов и значений вне интервала).
pub fn branchless_utf8(codepoint: u32) -> ([u8; 4], usize) {
    let len = utf8_bytes_for_codepoint(codepoint);
    let buf = [
        PREFIX[len][0] | ((codepoint >> SHIFT[len][0]) & MASK[len][0] as u32) as u8,
        PREFIX[len][1] | ((codepoint >> SHIFT[len][1]) & MASK[len][1] as u32) as u8,
        PREFIX[len][2] | ((codepoint >> SHIFT[len][2]) & MASK[len][2] as u32) as u8,
        PREFIX[len][3] | ((codepoint >> SHIFT[len][3]) & MASK[len][3] as u32) as u8,
    ];

    (buf, len)
}

type Table = [[u8; 4]; 5];

// Байтовый префикс для continuation byte.
const CONTINUE: u8 = 0b1000_0000;
const PREFIX: Table = [
    [0u8; 4],
    [0, 0, 0, 0],
    [0b1100_0000, CONTINUE, 0, 0],
    [0b1110_0000, CONTINUE, CONTINUE, 0],
    [0b1111_0000, CONTINUE, CONTINUE, CONTINUE],
];

// Мы должны сделать так, чтобы самые старшие байты всегда находились в байте 0.
const SHIFT: Table = [
    [0u8; 4],
    [0, 0, 0, 0],
    [6, 0, 0, 0],
    [12, 6, 0, 0],
    [18, 12, 6, 0],
];

const MASK: Table = [
    [0u8; 4],
    [0x7f, 0, 0, 0],
    [0x1f, 0x3f, 0, 0],
    [0x0f, 0x3f, 0x3f, 0],
    [0x07, 0x3f, 0x3f, 0x3f],
];

Ветвления


Никаких операторов if, циклов и других условных конструкций. То есть, вроде бы, ветвления нет?

… ну, на самом деле, это не так.

Если заглянуть в код (оптимизированный) в Compiler Explorer, то мы увидим, что ассемблерный код x86_64 имеет два разных вида ветвлений.

Подсчёт начальных нулей


Прямо в начале функции есть ветвление:

            test    esi, esi
            je      .LBB0_1
            bsr     eax, esi
            xor     eax, 31
            jmp     .LBB0_3
    .LBB0_1:
            mov     eax, 32
    .LBB0_3:
            mov     eax, eax

Я не понимал, что это такое, пока пошагово не выполнил код. Похоже, «особый» случай — это нулевой ввод (esi); в этом случае код возвращает 32.

Зачем нужен этот особый случай? В подсказке Compiler Explorer по команде bsr написано следующее:

Если содержимое операнда источника равно 0, то содержимое конечного операнда undefined.

То есть в процессорах x86_64 существует ветвление, сообщающее «32-битное нулевое значение имеет 32 начальных нулей». Иными словами, встроенный объект (intrinsic) «подсчёта начальных нулей» необязательно оказывается командой без ветвления. Возможно, в другой архитектуре это выглядит красивее!

Проверки границ


Другой переход, похоже, является слиянием множества проверок границ массива.

        cmp     eax, 4
        ja      .LBB0_5
        ...
LBB0_5:
        lea     rdx, [rip + .L__unnamed_5]
        mov     esi, 5
        mov     rdi, rax
        call    qword ptr [rip + core::panicking::panic_bounds_check::h8307ccead484a122@GOTPCREL]

Все массивы переходов имеют одинаковую границу (4), поэтому компилятор может решить выполнить проверку только один раз, и при этом всё равно обеспечить гарантии безопасности Rust.

В принципе, если компилятор выполнял оптимизацию через таблицу LEN, то мог бы устранить и эту проверку; значение LEN никогда не бывает больше 4, а это валидный для всех таблиц индекс.

Но, похоже, константы настолько далеко не распространяются.

Устраняем ветвление


Изменив код и перейдя к небезопасному доступу к массивам, можно избавиться от проверки границ массивов. Но у нас по-прежнему остаётся ветвление подсчёта начальных нулей. Как от него избавиться?

Давайте ещё раз взглянем на код обработки значений вне интервала:

let exceeded_bit = (codepoint > 0x10_FFFF) as usize;

Здесь я сделал трюк с преобразованием типов из boolean (true или false) в integer
(1 или 0). Семантика Rust гарантирует безопасность такого преобразования, и с таким представлением может работать оборудование; оно не создаёт дополнительных условных конструкций после компиляции2.

Я использовал эти преобразования boolean в integer для выполнения маскирования. А знаете, что ещё можно делать с integer?

Складывать их.

Ответ


Мы можем избавиться от всех ветвлений, изменив функцию вычисления длины:

const fn utf8_bytes_for_codepoint(codepoint: u32) -> usize {
    let mut len = 1;
    // В Rust true преобразуется в 1, а false - в 0, поэтому мы можем "просто" суммировать длины.
    len += (codepoint > 0x7f) as usize;
    len += (codepoint > 0x7ff) as usize;
    len += (codepoint > 0xffff) as usize;

    // Как и раньше:
    let surrogate_bit = ((codepoint >= 0xD800) && (codepoint <= 0xDFFF)) as usize;
    let surrogate_mask = surrogate_bit << 2 | surrogate_bit << 1 | surrogate_bit;
    let exceeded_bit = (codepoint > 0x10_FFFF) as usize;
    let exceeded_mask = exceeded_bit << 2 | exceeded_bit << 1 | exceeded_bit;

    len & !surrogate_mask & !exceeded_mask
}

И это ответ на вопрос Натана про определение количества байтов. Compiler explorer подтверждает, что при включенных оптимизациях эта функция не содержит ветвлений.

К счастью, это преобразование также позволило компилятору осознать, что len <= 4 на всех путях выполнения, и статически устранить проверку границ массивов. Благодаря этому весь код тоже избавился от ветвления. Победа!

Тонкости


Хоть в коде и нет ветвления, я не утверждаю, что он оптимизирован — моя единственная цель заключалась в доказательстве концепции отсутствия ветвлений. Я даже не проверял код бенчмарками!

Крис Уэллонс отмечает в своём посте о декодировании без ветвления, что декодер на основе DFA может иметь схожую производительность; SIMD и другие техники «используем то, что нам даёт оборудование», вероятно, будут даже лучше. Я бы не стал использовать свой кодировщик вместо того, который есть в вашей любимой стандартной библиотеке.

Также я не утверждаю, что он полезен. Но вы можете делать с кодом почти что угодно: я выпустил его под лицензией MIT. Полный код и тесты, которые я использовал для его сравнения с реализацией на Rust, находятся здесь.

Примечания


  1. Я хотел сделать эту функцию определённой всюду (total function), поэтому поиск длины включает все значения от 0 до 32.
  2. Разумеется, как мы видели выше, то, что делает компилятор, может быть непрозрачным. Я говорю лишь о том, что rustc +1.83 -O делает сегодня, у вас может быть другая картина.

Комментарии (2)


  1. AndreyDmitriev
    30.01.2025 16:07

    Непонятно, зачем автор упирался в раст, если уж захотел избавиться от джампов в машинном коде, так и писал бы прямо на ассемблере, там же и SIMD можно вкорячить. Если же цель была добиться этого именно на расте, то конкретно этот кейс нельзя экстраполировать на любые вычисления, надо будет постоянно лазить в ассемблерный листинг и контролировать выхлоп. Я не очень знаю зачем это всё с практической точки зрения, но как упражение - норм.


  1. vadimr
    30.01.2025 16:07

    Вроде бы кодирование символов в UTF-8 в некоторых языках зависит от контекста, поэтому непонятно, зачем вообще упарываться в отдельную кодовую точку.