Вход на сайт

Просмотр новости

Найдите то, что Вас интересует

HashMap в Rust: SwissTable, SIMD по 16 байт за раз и RawTable, который от вас спрятали

Дата публикации: 27-07-2026 07:05:56

Привет, Хабр!Признавайтесь: вы пользуетесь std::collections::HashMap примерно каждый день и ни разу не задумывались, что под ним. А под ним, если коротко, сидит алгоритм от Google. С Rust 1.36 (это лето 2019-го) стандартный HashMap это порт SwissTable, той самой структуры из абсейловского flat_hash_map. До этого там был Robin Hood hashing, и если вы где-то ещё видите описание std-мапы как «linear probing and Robin Hood bucket stealing», знайте: оно протухло, актуальная документация уже пишет «quadratic probing and SIMD lookup».И вот «SIMD lookup» это самое интересное. Весь фокус скорости SwissTable держится на одном байте служебных данных на элемент, который сканируется по 16 штук за одну инструкцию процессора. В статье глянем, как это устроено внутри, почему ваша мапа по умолчанию устойчива к hash DoS и платит за это скоростью, когда в проде стоит переходить на FxHash, и почему низкоуровневый RawTable существует, но в публичном HashMap его спрятали. Будет много кода и немного ассемблерной романтики. Читать далее

Основное содержимое страницы с новостью.

2f0c5d74d453639491e4085e0f2a4943.png

Привет, Хабр!

Признавайтесь: вы пользуетесь std::collections::HashMap примерно каждый день и ни разу не задумывались, что под ним. А под ним, если коротко, сидит алгоритм от Google. С Rust 1.36 (это лето 2019-го) стандартный HashMap это порт SwissTable, той самой структуры из абсейловского flat_hash_map. До этого там был Robin Hood hashing, и если вы где-то ещё видите описание std-мапы как «linear probing and Robin Hood bucket stealing», знайте: оно протухло, актуальная документация уже пишет «quadratic probing and SIMD lookup».

И вот «SIMD lookup» это самое интересное. Весь фокус скорости SwissTable держится на одном байте служебных данных на элемент, который сканируется по 16 штук за одну инструкцию процессора.

В статье глянем, как это устроено внутри, почему ваша мапа по умолчанию устойчива к hash DoS и платит за это скоростью, когда в проде стоит переходить на FxHash, и почему низкоуровневый RawTable существует, но в публичном HashMap его спрятали.

Будет много кода и немного ассемблерной романтики.

Раскладка SwissTable: метадата отдельно, данные отдельно

Первое, что надо уложить в голове: SwissTable хранит метаданные и сами пары ключ-значение в двух разных массивах.

Есть массив control-байт: ровно один байт на каждый слот. И есть массив слотов с настоящими данными. Когда вы ищете ключ, вы сначала шерстите дешёвый массив байт, и только когда он говорит «вот тут, возможно, твой ключ», лезете в дорогую память за реальным сравнением. Большинство промахов вообще не доходит до ключей.

Что лежит в control-байте? Одно из трёх состояний, и закодированы они так, что по старшему биту мгновенно понятно, занят слот или нет:

0b1111_1111   // EMPTY     (0xFF) — слот пуст
0b1000_0000   // DELETED   (0x80) — надгробие, тут что-то удалили
0b0xxx_xxxx   // FULL      — слот занят; младшие 7 бит это h2, отпечаток хеша

У пустого и удалённого старший бит равен единице, у занятого нулю. Эта мелочь позволяет одной битовой операцией находить, например, все свободные слоты в группе. А у занятого слота в байте лежит h2, семь бит хеша.

Хеш ключа (64 бита) разрезается на две части. Одна выбирает, с какой группы слотов начинать поиск, вторая становится коротким отпечатком в control-байте. В hashbrown это выглядит примерно так:

// hashbrown, упрощённо
fn h1(hash: u64) -> usize {
    hash as usize                 // младшие биты → индекс стартовой группы (& bucket_mask)
}

fn h2(hash: u64) -> u8 {
    (hash >> (64 - 7)) as u8 & 0x7f   // старшие 7 бит → отпечаток в control-байте
}

Старшие 7 бит идут в отпечаток, младшие выбирают группу. Биты берутся из разных концов хеша специально, чтобы выбор группы и отпечаток не коррелировали. Семь бит дают 128 возможных отпечатков, то есть вероятность ложного совпадения по h2 это примерно 1 к 128. Маленькая, но не нулевая, поэтому после совпадения по отпечатку всё равно надо сравнить настоящий ключ.

И зачем такая возня с отдельным байтом-отпечатком? Чтобы не трогать ключи зря. Сравнение байт это копейки, сравнение ключей (особенно строк) это поход в холодную память и кеш-промахи. SwissTable сначала фильтрует кандидатов по дешёвым отпечаткам и лезет в ключи только для уцелевших. Обычно уцелевает один-два слота из группы.

SIMD: 16 слотов за одну инструкцию

А теперь то, ради чего всё затевалось. Раз отпечатки лежат подряд байтами, их можно сравнивать пачкой через SIMD (Single Instruction Multiple Data, «одна инструкция, много данных»).

Группа в hashbrown на x86 с SSE2 это 16 control-байт, ровно один 128-битный регистр. Поиск отпечатка в группе выглядит так:

// найти в группе все слоты, чей control-байт равен h2, одной инструкцией
let group  = _mm_loadu_si128(ctrl_ptr);   // 16 control-байт в 128-битный регистр
let needle = _mm_set1_epi8(h2 as i8);      // размножаем h2 по всем 16 байтам
let eq     = _mm_cmpeq_epi8(group, needle);// побайтовое сравнение всех 16 разом
let mask   = _mm_movemask_epi8(eq) as u16; // собираем результат в 16-битную маску
// в mask единичка там, где отпечаток совпал. обычно 0, 1 или 2 бита.

Три инструкции, и вы за раз проверили 16 слотов. mmset1_epi8 размазывает искомый отпечаток по всем шестнадцати байтам регистра, mmcmpeq_epi8 сравнивает их с группой параллельно (там, где совпало, ставит 0xFF, иначе 0x00), а mmmovemask_epi8 выжимает из этого 16-битную маску, где каждый бит это «совпало или нет» для своего слота. Дальше вы просто итерируетесь по выставленным битам маски, и для каждого лезете в ключ за подтверждением. Эффективно вы прошли 16 шагов пробинга за один такт.

На ARM то же самое делается через NEON, другими инструкциями, идея та же. А если SIMD недоступен вообще, есть портативная ветка на трюке SWAR (SIMD within a register, «SIMD внутри обычного регистра»): восемь control-байт упаковываются в один u64, отпечаток размножается умножением на 0x0101010101010101, дальше пара xor и битовых масок, и вы сравниваете 8 байт за раз обычной арифметикой, без всякого SIMD. Медленнее, чем настоящие векторные инструкции, но всё равно пачкой, а не по одному.

Кстати про память. Тот самый «один байт оверхеда на элемент» это и есть control-байт. Старая, дореформенная мапа тратила около 8 байт служебных данных на запись, hashbrown тратит 1. Отсюда и заявленные «в 2 раза быстрее и заметно компактнее». А еще конце массива control-байт продублирован кусочек начала, чтобы групповой скан у самого края таблицы спокойно читал 16 байт и не вылезал за границу аллокации.

Пробинг: треугольными числами по группам

Коллизии SwissTable разрешает открытой адресацией, но пробит не по одному слоту, а по группам. И последовательность не линейная, а триангулярная.

В hashbrown за это отвечает ProbeSeq: позиция и шаг, где шаг каждый раз растёт на ширину группы:

// старт: pos = младшие биты хеша, маскированные под размер таблицы
let mut pos = h1(hash) & bucket_mask;
let mut stride = 0;

loop {
    // ... просканировать группу в pos через SIMD выше ...
    // нашли пустой слот в группе? ключа в таблице нет, выходим.

    stride += GROUP_WIDTH;                 // 16
    pos = (pos + stride) & bucket_mask;    // смещения 16, 48, 96, ... треугольные числа
}

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

Загрузку SwissTable держит высокой, до 7/8, то есть до 87.5 процента заполнения, и при этом не разваливается по скорости. Обычная открытая адресация на такой загрузке уже захлёбывается в длинных цепочках пробинга, а тут групповой SIMD-скан остаётся дешёвым почти до конца.

hash DoS: почему дефолтная мапа намеренно медленнее, чем могла бы

Теперь про безопасность.

У любой хеш-таблицы есть страшный сон: все ключи попадают в одну корзину. Тогда O(1) превращается в O(n), и каждая вставка или поиск деградируют до линейного перебора. Если хешер детерминированный и публично известный, атакующий может специально подобрать набор ключей, которые все коллизируют, скормить их вашему серверу через какой-нибудь HTTP-параметр или заголовок, и положить сервис под нагрузкой.

Защита Rust простая: рандомизация. Дефолтный хешер у HashMap это RandomState, и он засеивается случайным ключом, причём seed добывается из качественного источника случайности операционной системы, по возможности не блокируя программу. Сам хешер это SipHash 1-3 (раньше был SipHash 2-4, переключили ради скорости, и std специально не фиксирует алгоритм в документации, чтобы иметь право менять его дальше).

Логика такая: раз ключ хешера случаен и атакующему неизвестен, он не может предсказать, куда лягут ключи, а значит не может подобрать гарантированно коллизирующий набор. SipHash тут не просто быстрый хеш, а криптографический псевдослучайный, его как раз и проектировали стойким к подбору коллизий.

use std::collections::HashMap;

let mut m = HashMap::new();   // на самом деле HashMap<_, _, RandomState>
m.insert("user-supplied-key", 1);
// SipHash 1-3 со случайным ключом: куда ляжет ключ, заранее не угадать

Если вы создаёте мапу с детерминированным хешером, защита испаряется. Самый проблемный случай это HashMap в const- или static-инициализаторе: рандомного seed там взять негде, и такая мапа к hash DoS не устойчива. То же самое с любым фиксированным хешером. В документации std про это есть предупреждение, и про with_hasher тоже: ставя хешер руками, вы можете своими руками открыть вектор для DoS-атаки.

Мораль: дефолт намеренно жертвует частью скорости ради безопасности по умолчанию, и для всего, что хоть как-то касается внешнего ввода, это правильный размен. А вот когда ввод доверенный, можно разменять обратно.

FxHash: машете там, где DoS не грозит

Раз уж зашла речь про «доверенный ввод», вот вам канонический пример: сам компилятор Rust. rustc обрабатывает исходники, которые он полностью контролирует, никакой злоумышленник не подсунет ему идентификаторы, специально подобранные под коллизии. Значит, можно выкинуть криптостойкость и взять что-нибудь брутально быстрое. Так появился FxHash.

Классический FxHash (его таскали из Firefox, отсюда буква Fx) это буквально три операции на слово:

// классический FxHash: старый rustc-hash и крейт fxhash
const K: u64 = 0x51_7c_c1_b7_27_22_0a_95;   // забавный факт: 2^64 / K ≈ π

fn add_word(hash: &mut u64, word: u64) {
    *hash = (hash.rotate_left(5) ^ word).wrapping_mul(K);
}
// инициализация: hash = 0
// финализация: ничего, возвращаем как есть

Поворот на 5, xor со словом, умножение на константу. И всё. Никакого перемешивания 64-битными блоками, как у SipHash: FxHash просто кастует каждое целое в слово и прогоняет add_word. Качество, мягко говоря, среднее. Прогоните его через тесты качества хеша, и он завалит половину: например, для любой последовательности нулей он выдаёт ноль, а старшие биты входа умножение во многом выкидывает. Но для нужд компилятора это неважно, и обогнать его трудно. Большинство ключей в rustc это маленькие целые (старшие биты нули) и указатели (мало энтропии в старших битах), строки редки.

В rustc-hash 2.0 алгоритм заменили. Имя FxHasher оставили для совместимости, но по сути это теперь полиномиальный хеш с финализацией одним битовым поворотом плюс wyhash-подобная компрессия для строк. Поворот-финализатор как раз чинит старую болячку: перетаскивает высокоэнтропийные старшие биты вниз, туда, где их ждут хеш-таблицы. Так что «FxHash» сегодня это уже не тот FxHash, что в большинстве туториалов.

Пользоваться им просто:

use rustc_hash::FxHashMap;

let mut map: FxHashMap<u32, u32> = FxHashMap::default();
map.insert(22, 44);

Когда переключаться на FxHash? Когда профайлер показал, что хеширование это горячая точка, и вы уверены, что ключи доверенные. Внутренние мапы по целочисленным идентификаторам, интернинг, кеши, индексы по своим же данным, любые структуры, куда внешний пользователь не дотягивается напрямую.

И когда не переключаться: всё, что хешит внешний ввод. Ключи из HTTP-заголовков, имён файлов, пользовательских строк, тел запросов. Там FxHash уже как открытая дверь для hash DoS, и экономия пары наносекунд не стоит положенного сервиса. Если хочется и побыстрее, и с защитой, посмотрите в сторону aHash (умеет в аппаратный AES и засеивается случайно) или foldhash. Кстати, отдельный крейт hashbrown давно ушёл с SipHash: сначала на aHash, теперь на foldhash, который заметно быстрее, но и слабее по стойкости к hash DoS. А вот std продолжает держать SipHash 1-3 со своим RandomState.

RawTable: он есть, но вам его не дают

Внутри hashbrown живёт RawTable, низкоуровневый движок, который и реализует всю описанную выше механику: память, раскладку, вставку, поиск, удаление. HashMap, HashSet и весь Entry API это тонкие обёртки поверх него. И вот RawTable в публичном std-HashMap вам не отдают. Почему?

Во-первых, это unsafe-API в чистом виде. В документации hashbrown он так и подписан: «A raw hash table with an unsafe API». Он ничего не знает про ваши ключи и хеши, вы передаёте хеши руками и сами отвечаете за инварианты.

Во-вторых, и это главная причина, экспонировать RawTable в std значит заморозить внутреннее устройство HashMap. Стандартная библиотека хочет иметь право поменять реализацию: вчера Robin Hood, сегодня SwissTable, завтра что-то ещё. Если бы публичный API раскрывал кишки таблицы, любой такой переход стал бы ломающим изменением для всей экосистемы. Поэтому std держит HashMap тонкой безопасной обёрткой: пары ключ-значение, Entry, и больше ничего наружу.

Санкционированный компромисс в std существует и называется raw_entry. Он позволяет искать и вставлять по уже посчитанному хешу, не требуя K: Hash и не хешируя дважды. Но он так и остался нестабильным, за фиче-гейтом hash_raw_entry, потому что API вышел корявый.

А что в самом hashbrown?

В hashbrown 0.15.1 публичные RawTable и raw_entry убрали из публичного API. Вместо них теперь HashTable: тоже низкоуровневая таблица с явным хешированием, но уже не такая зубастая. Вы по-прежнему передаёте хеш сами, но без сырого unsafe на каждом шагу:

use hashbrown::{HashTable, DefaultHashBuilder};
use std::hash::BuildHasher;

let mut table: HashTable<i32> = HashTable::new();
let bh = DefaultHashBuilder::default();
let hash = |v: &i32| bh.hash_one(v);

table.insert_unique(hash(&1), 1, hash);   // хеш считаешь и передаёшь сам
table.insert_unique(hash(&2), 2, hash);
table.insert_unique(hash(&3), 3, hash);

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

Но у низкоуровневости есть цена. API разрешает положить в таблицу два элемента с одинаковым ключом, таблица не сломается, но лукап начнёт возвращать произвольный из дубликатов, а сложность операций уедет с O(1) на O(k), где k это число дублей. То есть гарантии уникальности теперь на вас.

Вот почему RawTable есть, но почти никто его не использует. Прямого доступа из std нет по дизайну, в hashbrown сырой RawTable спрятали в пользу более безопасного HashTable, а сам HashTable нужен лишь в узких случаях, где вы готовы вручную следить за инвариантами ради последней капли производительности. Подавляющему большинству кода хватает обычного HashMap.

В завершение

Самое забавное тут в том, что в обычном коде вам ничего из этого не понадобится. Как писали HashMap::new(), так и будете писать, и правильно сделаете. Вся эта махина внутри крутится молча и ровно затем, чтобы вы про неё не думали.

Хорошая абстракция это та, которую не замечаешь, пока сам не полезешь внутрь от любопытства. .

Так что менять у себя ничего не надо. Но в следующий раз, когда наберёте map.insert(...), вы хотя бы будете представлять, какая дичь прячется за этой одной строчкой.


Размещайте облачную инфраструктуру и масштабируйте сервисы с надежным облачным провайдером Beget.
Эксклюзивно для читателей Хабра мы даем бонус 10% при первом пополнении.

Воспользоваться

Воспользоваться

Схожие новости

#Наименование новостиТональностьИнформативностьДата публикации
1Четыре f64 за одну инструкцию не делают вас быстрыми: как я векторизовал торговый движок на Rust и словил CI на лжи07.5129-07-2026
2sizeof(Mutex&lt;()&gt;) упал с 40 байт до 5: что внутри std::sync::Mutex после Rust 1.62012.3809-07-2026
3const fn в 2026: ваш компилятор втихаря исполняет Rust09.5820-07-2026
4Как понять, что делает Rust-компилятор: визуализация AST, MIR и LLVM IR010.815-07-2026
5Полностью нечестное сравнение std::expected в C++23 и core::result::Result в Rust07.7609-07-2026
6История о том, как я написал компилятор Svelte на Rust011.8302-08-2026
7Что внутри #[derive(Serialize)]: TokenStream, syn, quote и почему этот serde так долго компилируется07.6216-07-2026
8«lock xadd, и это весь Arc::clone? Не совсем06.0630-07-2026
9Если ссылки схлопываются, значит это кому‑то нужно08.2630-07-2026
10[Перевод] Rust 1.97.0: манглинг символов, вывод линкера и поддержка запрета предупреждений в Cargo011.4314-07-2026

Классификация: . Схожих патентов: 0. Схожих новостей: 10. Тональность: 0. Информативность: 9.2. Источник: habr.com.