Иногда хеш-функцию проверяют на коллизии, смотрят на avalanche, запускают benchmark и считают задачу закрытой. Собственно я так и сделал с первой версией VRH. Формула была короткой, обратимой и хорошо подходила для SIMD. Но стоило поставить перед ней три специально выбранных узла, и один получил примерно половину ключей вместо трети.(и к сожалению один прод пострадал)
Задача и ошибка v1
В Rendezvous Hashing для каждого ключа вычисляется score(key, node) по всем узлам, а владельцем становится узел с максимальным score. Узлы могут иметь произвольные uint64 ID, поэтому такой подход удобен для разреженных и меняющихся наборов. Цена — O(N) вычислений на один ключ.
Исходная VRH v1 пыталась сделать каждое вычисление дешёвым: после одного SplitMix64(key) она применяла к node XOR, сложения, сдвиги и повороты без умножения в узловой части. Все преобразования по отдельности обратимы, значит при фиксированном ключе разные ID не сталкиваются по score. Это и было главным достоинством v1.
k = SplitMix64(key)
x = node XOR k
x += ROTL(k, 23)
x ^= x >> 29
x += ROTL(k, 41)
x ^= x << 17
score = x XOR (x >> 32)Но отсутствие коллизий не означает равные шансы стать максимумом. На тройке {0, 1, 2^63} v1 распределяла ключи примерно как 25% / 25% / 50%. В первоначальном наборе из 83 проверочных наборов узлов она провалила 82. Для выбора владельца это критично: перегруженный узел получает больше данных и запросов, хотя отдельные score никогда не совпадают.
Сколько итераций было до N3
Одного честного числа попыток у меня нет, в моменте я просто устал создавал файлы с одним и тем же названием, я проверял целые сетки параметров, менял архитектуру score, отдельно искали быстрый key mixer и многократно усиливали тесты. Если считать крупные этапы пути от v1 до появления N3, их было шесть:
- v1. Дешёвая обратимая shift/add-формула. Контрпример 25/25/50 показал, что её нельзя оставлять как универсальный HRW score.
- v2 на SplitMix64. Первую плохую тройку она исправила, но позднее на
{0, 2^29, 2^59}дала примерно 35,2% / 32,4% / 32,4%. Это около 1,9 процентного пункта сверх идеальной трети у первого узла, гораздо больше обычного шума миллиона ключей. - Опубликованный Murmur fmix64 как контроль. Он проходил обычные проверки на 279 наборах, но на новой структурной тройке
{0, 2^30, 2^63}первый узел получил около 47,5%. - Собственное двухмультипликативное семейство. Я просмотрел сетку из 500 формул; кандидаты C и D сначала прошли большие holdout-проверки. Позже для них тоже нашлись тройки с сильным перекосом. Хорошая avalanche-матрица здесь оказалась недостаточной.
- Подготовка ключа и новые линейные слои. Проверил, среди прочего, 36 вариантов key mixer и отдельную сетку из 48 новых score-формул. R2 и R3 выглядели убедительно на привычных битовых направлениях, пока смена базиса не дала им прямые контрпримеры: около 42,7% у одного узла для R2 и 39,3% для R3.
- Переход к проверке самого порядка score. Вместо того чтобы смотреть только на равномерность битов, я начал изучать, как распределяется разница между двумя наибольшими score на почти полных XOR-кубах. Эта проверка вскрыла дефекты прежних кандидатов и подсказала новую архитектуру с дополнительным нелинейным слоем — N3.
Сетки в 500, 36 и 48 вариантов — отдельные эксперименты; их нельзя складывать в число полноценно проверенных формул. Короткий поисковый экран и независимое подтверждение проходили разные числа кандидатов. После появления N3 работа тоже не остановилась: я пытался искать более дешёвые варианты, например проверил отдельные сетки из 80 и 60 двухмультипликативных формул, но лучшие из них не выдержали независимых структурных проверок. История заняла гораздо больше одного удачного подбора констант.
Ключевой урок оказался простым. Для score вида P(node XOR K(key)) смена обратимого K переставляет полный набор ключей и не может исправить систематический перекос, заложенный в P. Проверки по двум узлам тоже мало что говорят: для этого семейства они дают точные 50/50 по всему пространству ключей. А вот неполные трёхузловые XOR-наборы уже показывают, кто чаще оказывается первым.
Что делает N3
N3 состоит из одноразовой подготовки ключа K, обратимого линейного слоя T и более длинного нелинейного хвоста:
k = key + 0x9e3779b97f4a7c15
k ^= k >> 32
k *= 0xbf58476d1ce4e5b9
k ^= k >> 27
x = k XOR node
x = x XOR ROTL(x, 17) XOR ROTL(x, 43)
x *= 0x2cb0f69f4abea221
x ^= x >> 27
x = ROTL(x, 23)
x *= 0xb19e04cb9dce3147
x ^= x >> 31
x *= 0xbf58476d1ce4e5b9
score = x XOR (x >> 33)Все операции выполняются над uint64 по модулю 2^64. Нечётные множители, XOR со сдвигом, поворот и T(x) = x XOR ROTL(x,17) XOR ROTL(x,43) обратимы. Следовательно, разные узлы не получают одинаковый score при одном ключе. Это строгая гарантия отсутствия ничьих, не доказательство идеального баланса.
У T есть полезное для производительности свойство: T(k XOR node) = T(k) XOR T(node). Поэтому для большого кольца можно подготовить T(node) при создании кольца, вычислить T(k) один раз на запрос, а после полного скана обратить T только для победителя. Для 1–16 узлов используется короткий Go-путь; для больших колец на amd64 — нативный скан по подготовленным ID. В нынешней библиотеке исходные ID также хранятся ради Nodes(), так что после 16 узлов подготовка добавляет 8 байт на узел. В прототипе подготовленный массив заменял исходный, поэтому его оценка памяти отличается.
Сознательно не переносил старый AVX2-код v2 под прежним названием: он вычислял бы другие scores. Сейчас BackendAVX2 для N3 возвращает ошибку. Скалярный amd64-путь и переносимый Go-путь дают одинаковых владельцев. Тут еще работа идет..
Итоги тестирования
Проверял текущую реализацию N3 через vrh.New, Ring.Owner и Ring.OwnerBatch. Сравнивал с go-rendezvous + XXH64, адаптером NSPCC и прямым сканом XXH64(key || node). Адаптер NSPCC сверял с первым элементом публичного Sort.
Условия: AMD Ryzen 5 2600, Linux amd64, Go 1.27.1. Однопоточные замеры — CPU 4, GOMAXPROCS=1, без фиксации частоты. В таблицах медианы пяти повторов: полные проходы по ключам для Owner, не менее 100 мс на повтор для остальных API-бенчмарков.
Проверено 114 сценариев: 16 размеров кольца от 1 до 16 384 узлов; последовательные и разреженные ID; случайные, последовательные и меняющиеся в старших 32 битах ключи; шесть наборов из прежних контрпримеров. На каждую реализацию — 23 396 352 наблюдения для распределения и ещё 7 340 032 в независимой проверке. Отдельно измерены 411 вариантов API-вызовов и 48 вариантов параллельного чтения.
Выбор владельца
Разреженные ID, случайные uint64 ключи. Время в нс/Owner. Создание кольца и преобразование ключей в строки/байты вынесены из замера; хеширование запроса и поиск владельца включены. go / VRH — отношение времени go-rendezvous к VRH.
| Узлов | VRH Ring | go-rendezvous + XXH64 | NSPCC scan¹ | XXH64 pair¹ | go / VRH |
|---|---|---|---|---|---|
| 1 | 6,49 | 16,50 | 14,68 | 14,20 | 2,54× |
| 2 | 14,18 | 27,28 | 27,10 | 31,98 | 1,92× |
| 3 | 15,93 | 31,41 | 31,33 | 46,32 | 1,97× |
| 7 | 29,41 | 46,36 | 47,84 | 104,83 | 1,58× |
| 15 | 48,68 | 68,02 | 73,77 | 176,97 | 1,40× |
| 16 | 55,97 | 72,57 | 78,85 | 197,63 | 1,30× |
| 17 | 76,37 | 80,95 | 89,17 | 221,53 | 1,06× |
| 31 | 91,42 | 101,61 | 106,35 | 322,02 | 1,11× |
| 32 | 89,62 | 102,06 | 115,36 | 359,45 | 1,14× |
| 63 | 137,07 | 148,18 | 174,82 | 598,82 | 1,08× |
| 64 | 140,34 | 145,68 | 164,65 | 598,90 | 1,04× |
| 65 | 133,98 | 144,63 | 157,57 | 551,54 | 1,08× |
| 256 | 372,24 | 412,43 | 493,12 | 2 291,40 | 1,11× |
| 1024 | 1 279,40 | 1 428,86 | 1 765,21 | 8 716,56 | 1,12× |
| 4096 | 5 009,74 | 6 000,41 | 7 372,80 | 35 528,30 | 1,20× |
| 16384 | 18 972,49 | 21 013,20 | 27 358,90 | 139 821,36 | 1,11× |
У VRH минимальная медиана в 113 из 114 сценариев. Исключение — 64 разреженных узла с последовательными ключами: 156,13 нс у VRH и 152,99 нс у go-rendezvous. Диапазоны пяти повторов в этом случае пересекаются.
¹ NSPCC scan и XXH64 pair — локальные адаптеры. Для go-rendezvous вызывается публичный Lookup. Все реализации возвращают исходный uint64 ID.
Отдельный замер путей VRH, нс/Owner. Auto и Scalar выбирают одну реализацию; различия между их замерами связаны с условиями выполнения. OwnerScalar — прямой Go-скан без подготовленных ID.
| Узлов | Auto | Scalar | OwnerScalar |
|---|---|---|---|
| 3 | 14,33 | 13,87 | 12,53 |
| 16 | 52,13 | 48,77 | 44,59 |
| 17 | 60,81 | 60,58 | 47,32 |
| 64 | 128,80 | 129,60 | 184,00 |
| 1024 | 1 293,00 | 1 420,00 | 3 690,00 |
| 16384 | 22 322,00 | 19 231,00 | 43 917,00 |
На 17 узлах OwnerScalar показал 47,32 нс против 60,81 нс у Auto. На 64, 1024 и 16 384 узлах подготовленный путь оказался быстрее. BackendAVX2 для N3 возвращает ошибку.
Создание кольца и память
Время создания адаптера из []uint64, мкс. Включены преобразования ID, обёртка стенда и проверки конструктора. VRH проверяет дубликаты; проверки остальных конструкторов оставлены штатными.
| Узлов | VRH Ring | go-rendezvous + XXH64 | NSPCC scan¹ | XXH64 pair¹ |
|---|---|---|---|---|
| 3 | 0,272 | 0,639 | 0,252 | 0,131 |
| 16 | 0,942 | 1,905 | 0,582 | 0,152 |
| 17 | 1,149 | 1,972 | 0,596 | 0,172 |
| 64 | 3,277 | 5,309 | 1,625 | 0,227 |
| 256 | 9,519 | 19,920 | 6,036 | 0,476 |
| 1024 | 35,247 | 84,324 | 24,500 | 1,931 |
| 16384 | 744,171 | 1 886,829 | 376,195 | 17,892 |
Выделенные байты / число выделений на создание, включая временные структуры:
| Узлов | VRH Ring | go-rendezvous + XXH64 | NSPCC scan¹ | XXH64 pair¹ |
|---|---|---|---|---|
| 3 | 161 / 6 | 504 / 11 | 136 / 5 | 80 / 3 |
| 16 | 881 / 9 | 1 856 / 26 | 344 / 5 | 184 / 3 |
| 17 | 1 041 / 10 | 1 944 / 27 | 376 / 5 | 200 / 3 |
| 64 | 3 505 / 10 | 6 976 / 74 | 1 112 / 5 | 568 / 3 |
| 256 | 13 745 / 10 | 27 584 / 266 | 4 184 / 5 | 2 104 / 3 |
| 1024 | 53 465 / 12 | 108 008 / 1 036 | 16 472 / 5 | 8 248 / 3 |
| 16384 | 853 389 / 72 | 1 660 312 / 16 456 | 262 232 / 5 | 131 128 / 3 |
Прямой вызов vrh.New без обёртки стенда:
| Узлов | New, мкс | Б/создание | Выделений | Данные массивов ID, Б² |
|---|---|---|---|---|
| 3 | 0,204 | 121 | 4 | 24 |
| 16 | 0,747 | 841 | 7 | 128 |
| 17 | 0,936 | 1 001 | 8 | 272 |
| 64 | 2,581 | 3 465 | 8 | 1 024 |
| 256 | 8,542 | 13 705 | 8 | 4 096 |
| 1024 | 32,758 | 53 425 | 10 | 16 384 |
| 16384 | 734,582 | 853 349 | 70 | 262 144 |
² Массивы ID занимают 8×N байт до 16 узлов и 16×N после 16. Размер указан без заголовков slice, структур и округления аллокатора. Б/создание включает временные выделения и отличается от памяти, занятой готовым кольцом.
Пакетная обработка
Время в нс/ключ, буфер результата используется повторно. У VRH вызывается OwnerBatch, у остальных — цикл вызовов Owner. В каждом повторе обрабатывается один и тот же пакет.
| Узлов | Ключей в пакете | VRH Ring | go-rendezvous + XXH64 | NSPCC scan¹ | XXH64 pair¹ |
|---|---|---|---|---|---|
| 3 | 16 | 12,73 | 28,39 | 30,57 | 39,24 |
| 3 | 64 | 11,40 | 34,56 | 29,75 | 45,55 |
| 3 | 1024 | 11,92 | 32,34 | 33,35 | 44,25 |
| 16 | 16 | 58,04 | 50,19 | 54,08 | 175,62 |
| 16 | 64 | 45,20 | 83,92 | 88,47 | 167,59 |
| 16 | 1024 | 43,97 | 67,32 | 69,11 | 171,72 |
| 17 | 16 | 44,09 | 55,90 | 63,06 | 204,75 |
| 17 | 64 | 43,89 | 65,11 | 65,83 | 211,33 |
| 17 | 1024 | 59,94 | 80,19 | 72,06 | 187,36 |
| 256 | 16 | 346,62 | 523,75 | 516,50 | 2 326,38 |
| 256 | 64 | 367,62 | 455,95 | 524,70 | 2 770,95 |
| 256 | 1024 | 370,77 | 492,64 | 622,98 | 2 720,45 |
| 16384 | 16 | 19 328,12 | 21 600,69 | 29 552,31 | 153 232,75 |
| 16384 | 64 | 20 571,58 | 21 601,12 | 27 281,03 | 160 816,52 |
| 16384 | 1024 | 20 371,89 | 23 031,58 | 30 098,39 | 146 350,58 |
Также проверены пустой пакет, один ключ, dst=nil, короткий dst и запись на месте. В замерах Owner — 0 Б/op и 0 allocs/op. Для VRH это дополнительно проверено через AllocsPerRun на 260 размерах кольца.
Выделения на один OwnerBatch при 17 узлах:
| Ключей | Reuse, Б | allocs | Nil, Б | allocs | Short, Б | allocs | In-place, Б | allocs |
|---|---|---|---|---|---|---|---|---|
| 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
| 1 | 0 | 0 | 8 | 1 | 8 | 1 | 0 | 0 |
| 16 | 0 | 0 | 128 | 1 | 128 | 1 | 0 | 0 |
| 64 | 0 | 0 | 512 | 1 | 512 | 1 | 0 | 0 |
| 1024 | 0 | 0 | 8 192 | 1 | 8 192 | 1 | 0 | 0 |
При повторном использовании буфера и записи на месте выделений нет. При nil или коротком буфере непустой пакет требует одного выделения под результат.
Перестроение кольца
Один ID заменяется, кольцо создаётся заново, затем выполняются Q запросов. Время в мкс на весь цикл. Все реализации измеряются с полным перестроением.
| Узлов | Q | VRH Ring | go-rendezvous + XXH64 | NSPCC scan¹ | XXH64 pair¹ |
|---|---|---|---|---|---|
| 3 | 1 | 0,257 | 0,625 | 0,224 | 0,143 |
| 3 | 64 | 1,350 | 2,814 | 2,249 | 3,098 |
| 3 | 4096 | 64,997 | 136,237 | 130,694 | 194,680 |
| 17 | 1 | 1,162 | 2,075 | 0,643 | 0,326 |
| 17 | 64 | 4,565 | 9,217 | 5,769 | 13,925 |
| 17 | 4096 | 277,633 | 309,932 | 338,940 | 875,063 |
| 256 | 1 | 11,575 | 24,192 | 7,162 | 3,223 |
| 256 | 64 | 37,154 | 51,723 | 41,476 | 157,732 |
| 256 | 4096 | 1 520,935 | 1 930,420 | 2 406,690 | 10 916,345 |
| 16384 | 1 | 772,463 | 1 808,359 | 414,047 | 173,530 |
| 16384 | 64 | 1 983,652 | 3 221,632 | 2 124,280 | 9 879,798 |
| 16384 | 4096 | 79 793,941 | 101 870,417 | 127 075,473 | 567 831,580 |
Параллельное чтение
Суммарная пропускная способность, млн Owner/с. Одно неизменяемое кольцо, CPU 0–5 — шесть физических ядер. Для каждого значения GOMAXPROCS выполнено пять замеров по 200 мс или дольше.
| Узлов | GOMAXPROCS | VRH Ring | go-rendezvous + XXH64 | NSPCC scan¹ | XXH64 pair¹ |
|---|---|---|---|---|---|
| 16 | 1 | 19,272 | 14,824 | 13,459 | 5,311 |
| 16 | 2 | 36,510 | 28,090 | 25,323 | 10,135 |
| 16 | 4 | 73,368 | 56,022 | 52,219 | 20,367 |
| 16 | 6 | 104,954 | 81,900 | 75,301 | 28,686 |
| 256 | 1 | 2,607 | 2,125 | 1,919 | 0,398 |
| 256 | 2 | 5,176 | 4,684 | 3,545 | 0,795 |
| 256 | 4 | 10,219 | 9,050 | 7,102 | 1,409 |
| 256 | 6 | 14,626 | 12,700 | 9,891 | 1,811 |
| 16384 | 1 | 0,049 | 0,041 | 0,035 | 0,007 |
| 16384 | 2 | 0,096 | 0,088 | 0,068 | 0,013 |
| 16384 | 4 | 0,190 | 0,165 | 0,134 | 0,026 |
| 16384 | 6 | 0,287 | 0,230 | 0,188 | 0,037 |
Распределение и корректность
Независимая проверка — по 1 048 576 новых случайных ключей на набор. В ячейке максимальный Z и результат проверки порогов maxZ ≤ 7, χ² ≤ (N−1)+8√(2(N−1)). Метки v1, v2, Murmur, R2 и R3 обозначают наборы узлов из прежних контрпримеров. В столбце VRH во всех случаях используется N3; large — 16 384 разреженных узла.
| Набор | VRH Ring | go-rendezvous + XXH64 | NSPCC scan¹ | XXH64 pair¹ |
|---|---|---|---|---|
| v1 | 1,19 / OK | 0,43 / OK | 0,48 / OK | 0,97 / OK |
| v2 | 1,15 / OK | 0,53 / OK | 1,61 / OK | 0,23 / OK |
| murmur | 1,88 / OK | 1,30 / OK | 0,69 / OK | 0,91 / OK |
| r2 | 1,65 / OK | 0,37 / OK | 0,32 / OK | 0,88 / OK |
| r3 | 2,25 / OK | 1,94 / OK | 1,68 / OK | 2,02 / OK |
| adaptive | 0,27 / OK | 1,12 / OK | 0,39 / OK | 1,50 / OK |
| large | 4,38 / OK | 4,25 / OK | 4,50 / OK | 4,63 / OK |
Доли владельцев на прежнем контрпримере v2 {0, 2^29, 2^59}:
| Реализация | 0 | 2^29 | 2^59 |
|---|---|---|---|
| VRH Ring | 33,3134% | 33,3005% | 33,3861% |
| go-rendezvous + XXH64 | 33,3089% | 33,3349% | 33,3562% |
| NSPCC scan¹ | 33,4074% | 33,2727% | 33,3199% |
| XXH64 pair¹ | 33,3347% | 33,3426% | 33,3226% |
Число превышений статистических порогов:
| Реализация | Основная матрица, из 114 | Независимая проверка, из 7 |
|---|---|---|
| VRH Ring | 0 | 0 |
| go-rendezvous + XXH64 | 0 | 0 |
| NSPCC scan¹ | 2 | 0 |
| XXH64 pair¹ | 0 | 0 |
Оба превышения NSPCC пришлись на кольца из 16 384 узлов с 16 384 ключами: maxZ = 7,000214 при среднем одном ключе на узел. Нормальная аппроксимация для такого малого числа наблюдений неточна. В независимой выборке с 64 ключами на узел в среднем все реализации прошли пороги.
У N3 превышений порогов не было. Проверки совпадения Auto, Scalar, OwnerScalar и batch, перестановки ID, добавления и удаления узлов, изоляции входных данных и конкурентного чтения прошли. Проверены размеры 1–257, 1024, 4096 и 16 384. Тесты, -race и go vet прошли для библиотеки и стенда; переносимый путь выполнен при GOARCH=386, arm64 проверен сборкой.
Для N3 пока нет доказанной оценки максимального перекоса на произвольных наборах узлов. На это нужно больше времени и знаний в математике. Подожду, когда товарищ освободится и поможет разобраться