Рассмотрим следующую задачу:
"Дан художественный текст на английском языке. Требуется составить множество слов текста, то есть структуру, хранящую все его слова и позволяющую как можно быстрее определять принадлежность некоторого слова к нему."
Задача может быть решена с использованием хеш-таблицы c закрытой адресацией (см. описание алгоритма на сайте AlgoList).
Введём следующие определения:
- ключ (элемент) - идентификатор элемента, хранящегося в структуре. В случае рассматриваемой задачи это слово из текста.
- хеш-функция - функция, сопоставляющая ключам некоторые натуральные числа. Числа, сопоставляемые одинаковым ключам должны быть равны.
- хеш - значение хеш-функции.
- список (bucket) - множество элементов, в которое можно добавлять ключи и проверять, находятся ли они в нём. Соответствует структуре данных "связный список".
- хеш-таблица (таблица) - множество списков, в каждом из которых всем ключам соответствуют одинаковые (по модулю количества списков в таблице) хеши.
Проверка принадлежности ключей к хеш-таблице происходит по следующему алгоритму:
- Пусть требуется проверить ключ
$x$ на принадлежность к таблице. Посчитаем хеш элемента$h(x)$ , где$h$ - хеш-функция. - Рассмотрим
$h(x) \text{mod} N$ - й список таблицы, где$N$ - количество списков в таблице. Если$x$ принадлежит списку, то$x$ принадлежит таблице. Иначе$x$ не принадлежит таблице.
Добавление ключа в таблицу происходит по следующему алгоритму:
- Если ключ
$x$ уже принадлежит таблице, ничего не делать. - Иначе добавить
$x$ в$h(x) \text{mod} N$ - й список таблицы, где$N$ - количество списков в таблице,$h$ - хеш-функция.
![]() |
|---|
| Рисунок 2. Иллюстрация к добавлению ключа к таблице. Обозначения аналогичны вводимым в рисунке 1. |
Из алгоритма распределения ключей по спискам следует, что распределение зависит от выбора хеш-функции, используемой для расчёта хешей элементов. К примеру, хеш-функция, возвращающая на все ключи число 1 (тождественно равная 1) даст распределение, отличное от того, что даёт хеш-функция, тождественно равная числу 2.
Целью данной части работы было исследование распределений, даваемые различными хеш-функциям на массиве ключей, взятых из литературного текста.
Исследуемые хеш-функции:
-
constant_hashсопоставляет любому ключу хеш 1,$h(x)=1$ , -
first_char_hashсопоставляет каждому ключу хеш его первого байта,$h(x)=x_0$ , -
length_hashсопоставляет каждому ключу его длину как нуль-терминированного слова,$h(x)=\text{len}(x)$ , -
sum_hashсопоставляет каждому ключу сумму значений его байт,$h(x)=\sum_{i}x_i$ , -
left_shift_hashсопоставляет каждому ключу такой последовательный XOR его байт, что после каждой операции побитового исключения выполняется циклический сдвиг хеша на 1 бит влево,
right_shift_hash - аналогично left_shift_hash, но сдвиг происходит вправо,
murmur_hash - функция хеширования MurmurHash64
Использованные обозначения:
-
$h$ - хеш-функция, -
$x$ - ключ (строка символов$x_1,x_2,\dots,x_{\text{len}(x)-1}$ ), -
$x_i$ -$i$ -й символ строки$x$ (в нумерации с 0) -
$\text{len}(x)$ - длина строки$x$ , -
$x_{[0, \dots ,\text{len}(x)-1)}$ - строка$x$ кроме последнего символа, -
$\text{rol}(t)$ - циклический побитовый сдвиг числа$t$ влево, -
$\text{ror}(t)$ - циклический побитовый сдвиг числа$t$ вправо, -
$\otimes$ - операция побитового исключающего "или".
Реализации хеш-функций приведены в файле src/hash/hash_functions.cpp на странице Processing.
Была составлена программа, реализующая хеш-таблицу на 2027 списков. Данной программе на вход передавался массив слов произведения Вильяма Шекспира "The Comedy of Errors" в кодировке utf-8 (без учёта регистра). Массив слов мог включать повторы, которые отбрасывались на этапе вставки слов в таблицу. В тексте было 1784 уникальных слова.
После распределения слов по спискам хеш-таблицы в соответствии с хешами, выданными тестируемой хеш-функцией, программа измеряла длины получившихся списков и записывала измерения в файл в виде таблицы в формате csv.
Результаты измерений представлены на рисунках 3 - 8.
![]() |
|---|
Рисунок 1. Гистограмма распределения, даваемого const_hash. Слева - полное распределение, справа - его увеличенный фрагмент. |
![]() |
|---|
Рисунок 2. Гистограмма распределения, даваемого first_char_hash. Слева - полное распределение, справа - его увеличенный фрагмент. |
![]() |
|---|
Рисунок 3. Гистограмма распределения, даваемого length_hash. Слева - полное распределение, справа - его увеличенный фрагмент. |
![]() |
|---|
Рисунок 4. Гистограмма распределения, даваемого sum_hash. |
![]() |
|---|
Рисунок 5. Гистограмма распределения, даваемого left_shift_hash. |
![]() |
|---|
Рисунок 6. Гистограмма распределения, даваемого right_shift_hash. |
![]() |
|---|
Рисунок 7. Гистограмма распределения, даваемого murmur_hash. |
![]() |
|---|
| Рисунок 8. Сравнительная гистограмма всех исследованных хеш-функций. Слева - по стандартному отклонению, справа - по максимальной длине списка. |
Как можно заметить, распределения, даваемые sum_hash, left_shift_hash, right_shift_hash и murmur_hash обладают наименьшими максимальными длинами списков и стандартными отклонениями. Среди них функцией, дающей наименьшее по максимальной длине и стандартному отклонению распределение является murmur_hash.
const_hash по определению определила все ключи в один список, что увеличило оба измеряемых параметра его распределения.
Значение first_char_hash не могла превышать 255 по определению, так как все ключи были в кодировке utf-8, и, следовательно, код первой буквы слова не мог превышать 255.
length_hash так же по определению не превышала 17, так как длины всех слов в тексте не превышали 17.
sum_hash хоть и обладает распределением, гораздо более распространённым по спискам таблицы, чем у ранее рассмотренных функций, но в его распределении визуально становится заметна закономерность, которой не наблюдается у left_shift_hash, right_shift_hash и murmur_hash (череда "пиков", начинающаяся у списка с индексом 100 и заканчивающаяся у списка с индексом 1400). Распределение ограничено списком с индексом 1800.
О распределениях left_shift_hash, right_shift_hash и murmur_hash можно судить по их распределениям. left_shift_hash даёт менее равномерное распределение ключей по спискам, чем right_shift_hash, который, в свою очередь, даёт распределение менее равномерное, чем у murmur_hash.
murmur_hash даёт наиболее "равномерное" распределение на данном множестве ключей, что показывает пригодность функции murmur_hash к использованию в хеш-таблицах, по тем или иным причинам требующих равномерного распределения ключей в них.
Целью данной части работы является измерение коэффициента ускорения при использовании различных методов оптимизации проверки принадлежности элементов к таблице.
Исследуемые оптимизации:
- оптимизация при помощи SIMD-intrinsics,
- оптимизация ассемблерными вставками,
- оптимизация переписыванием части кода на assembly и его линковки к основной программе, написанной на C.
В качестве объекта оптимизации была взята программа, выполняющая следующие действия:
- заполнить хеш-таблицу на 2027 списков с хеш-функцией MurmurHash64 ключами, являющимися словами произведения Вильяма Шекспира "The Comedy of Errors" в нижнем регистре в кодировке utf-8,
- 2000 раз выполнить поиск всех слов произведения в таблице (т.е. 2000 раз запустить цикл по всем словам произведения, для каждого из которых выполняется проверка на принадлежность к таблице).
- Повторить предыдущее действие 30 раз, записывая время исполнения каждого повтора в таблицу.
- Записать таблицу измерений в файл в формате
csv.
Далее на программе были выполнены исследуемые оптимизации. Перед применением каждой оптимизации программа анализировалась valgrind с модулем callgrind для определения функции программы, оптимизация которой даст наибольший прирост производительности.
Текст подавался программе в виде бинарного файла, разбитого на области по 32 байта, каждая из которых хранила по одному слову текста в кодировке utf-8. Пустые байты были заполнены нулями, слова длинны более 32 игнорировались (в используемом тексте таких слов не было).
Флаги компиляции: -ggdb3 -std=c++2a -O2 -pie -march=corei7 -mavx2 (из списка были исключены флаги -I и -W, полный список флагов см. в файле makefile).
Условия запуска:
- Ноутбук Lenovo Legion 15ARH05H, подключенный к снабжающей его сети электропитания,
Processor AMD Ryzen 5 4600H with Radeon Graphics 3.00 GHz
Installed RAM 16.0 GB (15.9 GB usable)
Product ID 00325-81942-83222-AAOEM
System type 64-bit operating system, x64-based processor
- ОС: Windows 10 Home 22H2 (Build 19045.2846),
- WSL: Ubuntu 20.04.5 LTS,
- Температура процессора в момент тестирования не превышала 67 градусов Цельсия.
Результат работы не оптимизированной программы можно найти в файле results/bmark_0.csv.
Среднее время обработки 2-го шага алгоритма (поиска слов в тексте 2048 раз) базовой версией программы составило
Стоит обратить внимание, что так как valgrind значительно замедляет программу, количество повторов поиска было снижено с 2000 до 1 в версиях, предназначенных для профилирования, и сделана соответствующая поправка при выборе следующей оптимизируемой функции. В листингах представлен НЕ модифицированный вывод анотатора вывода профилизатора.
Фрагмент результата профилирования программы на данном этапе приведён в листинге 1.
--------------------------------------------------------------------------------
Ir
--------------------------------------------------------------------------------
78,398,333 PROGRAM TOTALS
--------------------------------------------------------------------------------
Ir file:function
--------------------------------------------------------------------------------
12,438,032 /build/glibc-SzIz7B/glibc-2.31/string/../sysdeps/x86_64/multiarch/memset-vec-unaligned-erms.S:__memset_avx2_unaligned_erms [/usr/lib/x86_64-linux-gnu/libc-2.31.so]
9,931,680 ./src/hash/hash_functions.cpp:murmur_hash(void const*, void const*) [/root/projects/hash_functions/build/hash_testcase_v0.1_dev_linux.out]
9,064,277 src/hash/hash_table.hpp:HashTable_find_value(HashTable const*, unsigned long long, char const*, int (*)(char const*, char const*)) [/root/projects/hash_functions/build/hash_testcase_v0.1_dev_linux.out]
5,862,256 /build/glibc-SzIz7B/glibc-2.31/stdio-common/vfprintf-internal.c:__vfprintf_internal [/usr/lib/x86_64-linux-gnu/libc-2.31.so]
5,525,198 /build/glibc-SzIz7B/glibc-2.31/libio/genops.c:_IO_default_xsputn [/usr/lib/x86_64-linux-gnu/libc-2.31.so]
4,746,791 /build/glibc-SzIz7B/glibc-2.31/string/../sysdeps/x86_64/multiarch/strcmp-avx2.S:__strcmp_avx2 [/usr/lib/x86_64-linux-gnu/libc-2.31.so]
Листинг 1. Фрагмент вывода valgrind, обработанного callgrind_annotate, полученного при анализе базовой версии программы.
Как можно заметить, дольше всего программа обрабатывает функции murmur_hash и HashTable_find_value (функцию поиска ключа в хеш-таблице). murmur_hash довольно сложно оптимизировать SIMD-intrinsic-ами, так что оптимизируем функцию HashTable_find_value, которая тратит незначительно больше ресурсов, чем murmur_hash, но при этом поддаётся оптимизации векторными регистрами.
Результат профилирования на данном этапе приведён в листинге 2.
--------------------------------------------------------------------------------
Ir
--------------------------------------------------------------------------------
59,569,618 PROGRAM TOTALS
--------------------------------------------------------------------------------
Ir file:function
--------------------------------------------------------------------------------
9,931,680 ./src/hash/hash_functions.cpp:murmur_hash(void const*, void const*) [/root/projects/hash_functions/build/hash_testcase_v0.1_dev_linux.out]
6,162,303 src/hash/hash_table.hpp:HashTable_find_value(HashTable const*, unsigned long long, long long __vector(4), int (*)(long long __vector(4), long long __vector(4))) [/root/projects/hash_functions/build/hash_testcase_v0.1_dev_linux.out]
5,862,240 /build/glibc-SzIz7B/glibc-2.31/stdio-common/vfprintf-internal.c:__vfprintf_internal [/usr/lib/x86_64-linux-gnu/libc-2.31.so]
5,525,198 /build/glibc-SzIz7B/glibc-2.31/libio/genops.c:_IO_default_xsputn [/usr/lib/x86_64-linux-gnu/libc-2.31.so]
Листинг 2. Фрагмент вывода valgrind, обработанного callgrind_annotate, полученного при анализе версии программы, ускоренной SIMD-intrinsic-ами.
Результат работы программы на данном этапе оптимизации - results/bmark_1.csv.
Время обработки 2-го шага алгоритма программой, оптимизированной SIMD-intrinsic-ами, составляет
Ускорение относительно предыдущей версии программы составило
Попытаемся ускорить функцию murmur_hash, переписав её с использованием ассемблерных вставок. Стоит заметить, что так как наличие ассемблерных вставок в функции запрещает компилятору применять большинство оптимизаций, имеет смысл лишь переписывание всей функции за исключением разве что заголовка, и то для данной функции он тривиален (т.е. включает только label функции без фиксации stack frame-а). За основу переписанной версии можно взять код, сгенерированный компилятором CLang, генерирующий для данной функции более эффективный код, чем используемый для компиляции проекта gcc. Далее для оптимизации можно убрать из кода несколько проверок, не требующихся в контексте данной задачи (к примеру проверки на длину ключа, так как мы знаем, что это значение - константа).
С данной оптимизацией среднее среднее время обработки стало
Так как относительное ускорение мало и не превосходит погрешности эксперимента, логичном шагом на данной этапе было бы прекращение попыток оптимизации хотя бы данной функции.
Но мы продолжим "оптимизировать" программу, чтобы показать, что мы умеем линковать C-шные программы с asm-овскими.
--------------------------------------------------------------------------------
Ir
--------------------------------------------------------------------------------
59,538,352 PROGRAM TOTALS
--------------------------------------------------------------------------------
Ir file:function
--------------------------------------------------------------------------------
9,931,680 ./src/hash/hash_functions.cpp:murmur_hash(void const*, void const*) [/root/projects/hash_functions/build/hash_testcase_v0.1_dev_linux.out]
6,162,303 src/hash/hash_table.hpp:HashTable_find_value(HashTable const*, unsigned long long, long long __vector(4), int (*)(long long __vector(4), long long __vector(4))) [/root/projects/hash_functions/build/hash_testcase_v0.1_dev_linux.out]
5,862,240 /build/glibc-SzIz7B/glibc-2.31/stdio-common/vfprintf-internal.c:__vfprintf_internal [/usr/lib/x86_64-linux-gnu/libc-2.31.so]
Листинг 3. Фрагмент вывода valgrind, обработанного callgrind_annotate, полученного при анализе версии программы, ускоренной SIMD-intrinsic-ами и ассемблерными вставками.
Выделим переписанную на asm функцию murmur_hash в отдельный файл и прилинкуем его к проекту, надеясь на улучшение.
Среднее время обработки с описанной "оптимизацией" составило
Обработка исходных результатов экспериментов была проведена в файле results/bmark_combined.xlsx на странице Processing.
Как было показано, наиболее эффективной оптимизацией была оптимизация SIMD-intrinsic-ами. Обе оптимизации ассемблером дали незначительное ускорение.
Результаты показывают, что современные компиляторы хорошо оптимизируют код, написанный на языке программирования C, в связи с чем имеют смысл лишь оптимизации с изменением логики работы программы. К примеру, первая из приведённых оптимизаций дала наилучший результат, так как предполагала изменение логики работы программы под использование векторных регистров, которое было возможно только с дополнительными предположениями о данных, обрабатываемых программой. Очевидно, компилятор не может выполнить данные оптимизации, так как не обладает необходимой информацией.
Для сборки проекта используется make. Команда компиляции:
$ make CASE_FLAGS="[flags]"
Где [flags] - список используемых флагов. Доступные флаги:
-D DISTRIBUTION_TEST- провести исследование распределения (см. часть 1),-D PERFORMANCE_TEST- провести исследование быстродействия (см. часть 2),-D TESTED_HASH=[hash_function_hame]- использовать указанную хеш-функцию. Список доступных хеш-функций - src/hash/hash_functions.h,-D OPTIMIZATION_LEVEL=[0 ... 3]- выполнить сборку с указанной стадией оптимизации (номер стадии соответствует порядку применения оптимизации в главе "Результаты" 2-й части эксперимента),-D BUCKET_COUNT=[int]- использовать хеш-таблицу с указанным числом списков (по умолчанию 2027),-D TEST_COUNT=[int]- повторить эксперимент указанное число раз (по умолчанию 30),-D TEST_REPETITION=[int]- выполнить указанное число повторений в каждом эксперименте (по умолчанию 2000).
Команда запуска собранной программы:
$ make run
Команда восстановления проекта в изначальное положение:
$ make rm
Кодекс поведения расположен в файле CODE_OF_CONDUCT.md.
Проект выложен на сайте GitHub в открытом доступе под лицензией MIT. Полный текст лицензии представлен в файле LICENSE.
Проект был создан исключительно в образовательных целях и не предполагает изменения со стороны. Но если читатель всё равно хочет дополнить проект своим кодом, то информацию о дополнению проекта можно найти в файле CONTRIBUTING.md.
(автор) Кудряшов Илья - kudriashov.it@phystech.edu













