all posts

ENGINEERING NOTES

От быстрой формулы к честному выбору владельца: как в VRH появилась N3

Иногда хеш-функцию проверяют на коллизии, смотрят на 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, их было шесть:

  1. v1. Дешёвая обратимая shift/add-формула. Контрпример 25/25/50 показал, что её нельзя оставлять как универсальный HRW score.
  2. v2 на SplitMix64. Первую плохую тройку она исправила, но позднее на {0, 2^29, 2^59} дала примерно 35,2% / 32,4% / 32,4%. Это около 1,9 процентного пункта сверх идеальной трети у первого узла, гораздо больше обычного шума миллиона ключей.
  3. Опубликованный Murmur fmix64 как контроль. Он проходил обычные проверки на 279 наборах, но на новой структурной тройке {0, 2^30, 2^63} первый узел получил около 47,5%.
  4. Собственное двухмультипликативное семейство. Я просмотрел сетку из 500 формул; кандидаты C и D сначала прошли большие holdout-проверки. Позже для них тоже нашлись тройки с сильным перекосом. Хорошая avalanche-матрица здесь оказалась недостаточной.
  5. Подготовка ключа и новые линейные слои. Проверил, среди прочего, 36 вариантов key mixer и отдельную сетку из 48 новых score-формул. R2 и R3 выглядели убедительно на привычных битовых направлениях, пока смена базиса не дала им прямые контрпримеры: около 42,7% у одного узла для R2 и 39,3% для R3.
  6. Переход к проверке самого порядка 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 Ringgo-rendezvous + XXH64NSPCC scan¹XXH64 pair¹go / VRH
16,4916,5014,6814,202,54×
214,1827,2827,1031,981,92×
315,9331,4131,3346,321,97×
729,4146,3647,84104,831,58×
1548,6868,0273,77176,971,40×
1655,9772,5778,85197,631,30×
1776,3780,9589,17221,531,06×
3191,42101,61106,35322,021,11×
3289,62102,06115,36359,451,14×
63137,07148,18174,82598,821,08×
64140,34145,68164,65598,901,04×
65133,98144,63157,57551,541,08×
256372,24412,43493,122 291,401,11×
10241 279,401 428,861 765,218 716,561,12×
40965 009,746 000,417 372,8035 528,301,20×
1638418 972,4921 013,2027 358,90139 821,361,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.

УзловAutoScalarOwnerScalar
314,3313,8712,53
1652,1348,7744,59
1760,8160,5847,32
64128,80129,60184,00
10241 293,001 420,003 690,00
1638422 322,0019 231,0043 917,00

На 17 узлах OwnerScalar показал 47,32 нс против 60,81 нс у Auto. На 64, 1024 и 16 384 узлах подготовленный путь оказался быстрее. BackendAVX2 для N3 возвращает ошибку.

Создание кольца и память

Время создания адаптера из []uint64, мкс. Включены преобразования ID, обёртка стенда и проверки конструктора. VRH проверяет дубликаты; проверки остальных конструкторов оставлены штатными.

УзловVRH Ringgo-rendezvous + XXH64NSPCC scan¹XXH64 pair¹
30,2720,6390,2520,131
160,9421,9050,5820,152
171,1491,9720,5960,172
643,2775,3091,6250,227
2569,51919,9206,0360,476
102435,24784,32424,5001,931
16384744,1711 886,829376,19517,892

Выделенные байты / число выделений на создание, включая временные структуры:

УзловVRH Ringgo-rendezvous + XXH64NSPCC scan¹XXH64 pair¹
3161 / 6504 / 11136 / 580 / 3
16881 / 91 856 / 26344 / 5184 / 3
171 041 / 101 944 / 27376 / 5200 / 3
643 505 / 106 976 / 741 112 / 5568 / 3
25613 745 / 1027 584 / 2664 184 / 52 104 / 3
102453 465 / 12108 008 / 1 03616 472 / 58 248 / 3
16384853 389 / 721 660 312 / 16 456262 232 / 5131 128 / 3

Прямой вызов vrh.New без обёртки стенда:

УзловNew, мксБ/созданиеВыделенийДанные массивов ID, Б²
30,204121424
160,7478417128
170,9361 0018272
642,5813 46581 024
2568,54213 70584 096
102432,75853 4251016 384
16384734,582853 34970262 144

² Массивы ID занимают 8×N байт до 16 узлов и 16×N после 16. Размер указан без заголовков slice, структур и округления аллокатора. Б/создание включает временные выделения и отличается от памяти, занятой готовым кольцом.

Пакетная обработка

Время в нс/ключ, буфер результата используется повторно. У VRH вызывается OwnerBatch, у остальных — цикл вызовов Owner. В каждом повторе обрабатывается один и тот же пакет.

УзловКлючей в пакетеVRH Ringgo-rendezvous + XXH64NSPCC scan¹XXH64 pair¹
31612,7328,3930,5739,24
36411,4034,5629,7545,55
3102411,9232,3433,3544,25
161658,0450,1954,08175,62
166445,2083,9288,47167,59
16102443,9767,3269,11171,72
171644,0955,9063,06204,75
176443,8965,1165,83211,33
17102459,9480,1972,06187,36
25616346,62523,75516,502 326,38
25664367,62455,95524,702 770,95
2561024370,77492,64622,982 720,45
163841619 328,1221 600,6929 552,31153 232,75
163846420 571,5821 601,1227 281,03160 816,52
16384102420 371,8923 031,5830 098,39146 350,58

Также проверены пустой пакет, один ключ, dst=nil, короткий dst и запись на месте. В замерах Owner — 0 Б/op и 0 allocs/op. Для VRH это дополнительно проверено через AllocsPerRun на 260 размерах кольца.

Выделения на один OwnerBatch при 17 узлах:

КлючейReuse, БallocsNil, БallocsShort, БallocsIn-place, Бallocs
000000000
100818100
16001281128100
64005121512100
1024008 19218 192100

При повторном использовании буфера и записи на месте выделений нет. При nil или коротком буфере непустой пакет требует одного выделения под результат.

Перестроение кольца

Один ID заменяется, кольцо создаётся заново, затем выполняются Q запросов. Время в мкс на весь цикл. Все реализации измеряются с полным перестроением.

УзловQVRH Ringgo-rendezvous + XXH64NSPCC scan¹XXH64 pair¹
310,2570,6250,2240,143
3641,3502,8142,2493,098
3409664,997136,237130,694194,680
1711,1622,0750,6430,326
17644,5659,2175,76913,925
174096277,633309,932338,940875,063
256111,57524,1927,1623,223
2566437,15451,72341,476157,732
25640961 520,9351 930,4202 406,69010 916,345
163841772,4631 808,359414,047173,530
16384641 983,6523 221,6322 124,2809 879,798
16384409679 793,941101 870,417127 075,473567 831,580

Параллельное чтение

Суммарная пропускная способность, млн Owner/с. Одно неизменяемое кольцо, CPU 0–5 — шесть физических ядер. Для каждого значения GOMAXPROCS выполнено пять замеров по 200 мс или дольше.

УзловGOMAXPROCSVRH Ringgo-rendezvous + XXH64NSPCC scan¹XXH64 pair¹
16119,27214,82413,4595,311
16236,51028,09025,32310,135
16473,36856,02252,21920,367
166104,95481,90075,30128,686
25612,6072,1251,9190,398
25625,1764,6843,5450,795
256410,2199,0507,1021,409
256614,62612,7009,8911,811
1638410,0490,0410,0350,007
1638420,0960,0880,0680,013
1638440,1900,1650,1340,026
1638460,2870,2300,1880,037

Распределение и корректность

Независимая проверка — по 1 048 576 новых случайных ключей на набор. В ячейке максимальный Z и результат проверки порогов maxZ ≤ 7, χ² ≤ (N−1)+8√(2(N−1)). Метки v1, v2, Murmur, R2 и R3 обозначают наборы узлов из прежних контрпримеров. В столбце VRH во всех случаях используется N3; large — 16 384 разреженных узла.

НаборVRH Ringgo-rendezvous + XXH64NSPCC scan¹XXH64 pair¹
v11,19 / OK0,43 / OK0,48 / OK0,97 / OK
v21,15 / OK0,53 / OK1,61 / OK0,23 / OK
murmur1,88 / OK1,30 / OK0,69 / OK0,91 / OK
r21,65 / OK0,37 / OK0,32 / OK0,88 / OK
r32,25 / OK1,94 / OK1,68 / OK2,02 / OK
adaptive0,27 / OK1,12 / OK0,39 / OK1,50 / OK
large4,38 / OK4,25 / OK4,50 / OK4,63 / OK

Доли владельцев на прежнем контрпримере v2 {0, 2^29, 2^59}:

Реализация02^292^59
VRH Ring33,3134%33,3005%33,3861%
go-rendezvous + XXH6433,3089%33,3349%33,3562%
NSPCC scan¹33,4074%33,2727%33,3199%
XXH64 pair¹33,3347%33,3426%33,3226%

Число превышений статистических порогов:

РеализацияОсновная матрица, из 114Независимая проверка, из 7
VRH Ring00
go-rendezvous + XXH6400
NSPCC scan¹20
XXH64 pair¹00

Оба превышения 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 пока нет доказанной оценки максимального перекоса на произвольных наборах узлов. На это нужно больше времени и знаний в математике. Подожду, когда товарищ освободится и поможет разобраться

all posts