Compare commits

...
14 Commits
Author SHA1 Message Date
krosh 4ebf8fe4a8 article.tex 2026-09-10 19:09:50 +03:00
krosh fe2edcea5c article.tex 2026-09-10 19:01:37 +03:00
krosh 1cca8e5c88 article.tex 2026-09-10 16:25:12 +03:00
krosh e38d1365cd add desc for test_result 2026-08-27 13:47:49 +03:00
krosh 9e9c99fc2b add refs 2026-08-27 07:13:23 +03:00
krosh 87b588f63e last chapters 2026-08-27 06:50:15 +03:00
krosh 8a53f2186b WIP 2026-08-27 06:30:20 +03:00
krosh 1fac5b277d chapter 5 2026-08-27 05:46:21 +03:00
krosh 7106d0c9ea new chapter 2026-08-25 06:10:34 +03:00
krosh f86537626a new chapter 2026-08-25 05:45:43 +03:00
krosh 548159e5ea new chapter in article 2026-08-24 14:03:46 +03:00
krosh b1806cda5e update test result 2026-08-24 05:53:12 +03:00
krosh e7836fdf31 article for asvk 2026-08-21 04:44:49 +03:00
krosh 45033e7f6d perf: measure set benchmarks with user and system time 2026-08-20 13:31:12 +03:00
7 changed files with 1184 additions and 188 deletions
+31 -28
View File
@@ -1,3 +1,10 @@
# Результаты APT-RPM тестов
Коэффициент считается как отношение времени базовой реализации к времени проверяемой реализации. Значение больше `1×` означает ускорение, меньше `1×` — замедление.
- `original set.c` - код текущего `lib/set.c` в `rpm`/`rpm-build`
- `set9.c` - drop-in замена оригинального `lib/set.c`.
## original set.c w/out optimizations
функция `decode_set` идёт по пути функций:
@@ -6,15 +13,13 @@
2. `decode_golomb`
3. `decode_delta`
| command | average_seconds | run1_seconds | run2_seconds | run3_seconds | exit_status |
| :-------------------------- | :-------------: | :----------: | :----------: | :----------: | ----------: |
| -s check | 4.217343 | 4.310882 | 4.179343 | 4.161804 | 0 |
| -s autoremove | 4.884413 | 4.876135 | 4.882347 | 4.894757 | 0 |
| -s install rpm-build | 4.634789 | 4.652499 | 4.623077 | 4.628792 | 0 |
| -s install openuds-server | 12.704538 | 12.640266 | 12.975482 | 12.497867 | 0 |
| -s install password-store | 8.560463 | 8.561515 | 8.557596 | 8.562277 | 0 |
| --enable-upgrade -s upgrade | 36.271838 | 35.511443 | 35.908641 | 37.395430 | 100 |
| -s dist-upgrade | 90.550086 | 94.702198 | 92.174160 | 84.773901 | 100 |
| command | user, с | system, с | user+system, с |
| :-------------------------- | -------: | --------: | -------------: |
| `-s check` | 1.320000 | 0.050000 | 1.370000 |
| `-s autoremove` | 1.626667 | 0.050000 | 1.676667 |
| `-s install rpm-build` | 2.913333 | 0.070000 | 2.983333 |
| `-s install openuds-server` | 5.526667 | 0.100000 | 5.626667 |
| `-s install password-store` | 2.940000 | 0.070000 | 3.010000 |
## original set.c
@@ -23,26 +28,24 @@
1. `decode_base62_golomb`
2. `decode_delta`
| command | average_seconds | run1_seconds | run2_seconds | run3_seconds | exit_status |
| :-------------------------- | :-------------: | :----------: | :----------: | :----------: | ----------: |
| -s check | 1.570747 | 1.621496 | 1.545001 | 1.545744 | 0 |
| -s autoremove | 2.294502 | 2.312574 | 2.287133 | 2.283798 | 0 |
| -s install rpm-build | 2.108121 | 2.142784 | 2.093474 | 2.088105 | 0 |
| -s install openuds-server | 8.028098 | 8.147214 | 8.001140 | 7.935940 | 0 |
| -s install password-store | 3.373377 | 3.396943 | 3.361290 | 3.361899 | 0 |
| --enable-upgrade -s upgrade | 33.915979 | 33.895262 | 33.770192 | 34.082484 | 100 |
| -s dist-upgrade | 88.722136 | 95.484250 | 85.808085 | 84.874073 | 100 |
Коэффициенты посчитаны относительно `original set.c w/out optimizations`.
| command | user, с | system, с | user+system, с | коэфф. user | коэфф. user+system |
| :-------------------------- | -------: | --------: | -------------: | ----------: | -----------------: |
| `-s check` | 0.453333 | 0.033333 | 0.486666 | 2.912× | 2.815× |
| `-s autoremove` | 0.763333 | 0.040000 | 0.803333 | 2.131× | 2.087× |
| `-s install rpm-build` | 1.210000 | 0.050000 | 1.260000 | 2.408× | 2.368× |
| `-s install openuds-server` | 3.746667 | 0.080000 | 3.826667 | 1.475× | 1.470× |
| `-s install password-store` | 1.233333 | 0.050000 | 1.283333 | 2.384× | 2.345× |
## set9.c
Переписанная реализация `set.c`
Переписанная реализация `set.c`. Коэффициенты посчитаны относительно `original set.c`.
| command | average_seconds | run1_seconds | run2_seconds | run3_seconds | exit_status |
| :-------------------------- | :-------------: | :----------: | :----------: | :----------: | ----------: |
| -s check | 1.550342 | 1.596307 | 1.532768 | 1.521950 | 0 |
| -s autoremove | 2.200188 | 2.237645 | 2.185378 | 2.177540 | 0 |
| -s install rpm-build | 2.031055 | 2.045757 | 2.023173 | 2.024234 | 0 |
| -s install openuds-server | 7.580058 | 7.572994 | 7.582557 | 7.584622 | 0 |
| -s install password-store | 3.337012 | 3.367696 | 3.309826 | 3.333514 | 0 |
| --enable-upgrade -s upgrade | 31.494775 | 31.535714 | 31.585522 | 31.363090 | 100 |
| -s dist-upgrade | 85.326580 | 85.389775 | 85.604566 | 84.985398 | 100 |
| command | user, с | system, с | user+system, с | коэфф. user | коэфф. user+system |
| :-------------------------- | -------: | --------: | -------------: | ----------: | -----------------: |
| `-s check` | 0.503333 | 0.040000 | 0.543333 | 0.901× | 0.896× |
| `-s autoremove` | 0.810000 | 0.040000 | 0.850000 | 0.942× | 0.945× |
| `-s install rpm-build` | 1.283333 | 0.050000 | 1.333333 | 0.943× | 0.945× |
| `-s install openuds-server` | 3.896667 | 0.083333 | 3.980000 | 0.962× | 0.961× |
| `-s install password-store` | 1.310000 | 0.050000 | 1.360000 | 0.941× | 0.944× |
+14
View File
@@ -152,6 +152,20 @@ set-строка
Оставляя изначальную идею слитого декодера, была написана его реимлементация, использующая более простую логику, но использующая 64-битные блоки, а также упрощённую precompiled таблицу. (???) если есть результаты ускорения, сюда надо
### 4.6. Тесты производительности
Для проверки производительности сравнивались две локальные сборки `librpm`: исходная реализация и версия с описанными изменениями. Замеры выполнялись на одинаковых симуляционных командах `apt-get`.
Коэффициент считается как отношение времени исходной реализации к времени изменённой. Значение больше `1` - ускорение, меньше `1` - замедление.
| Команда | `set.c` user, с | `set.c` system, с | `set9.c` user, с | `set9.c` system, с | Ускорение по user | Ускорение по user+system |
| --------------------------- | --------------: | ----------------: | ---------------: | -----------------: | ----------------: | -----------------------: |
| `-s check` | 0.453 | 0.033 | 0.503 | 0.040 | 0.901× | 0.896× |
| `-s autoremove` | 0.763 | 0.040 | 0.810 | 0.040 | 0.942× | 0.945× |
| `-s install rpm-build` | 1.210 | 0.050 | 1.283 | 0.050 | 0.943× | 0.945× |
| `-s install openuds-server` | 3.747 | 0.080 | 3.897 | 0.083 | 0.962× | 0.961× |
| `-s install password-store` | 1.233 | 0.050 | 1.310 | 0.050 | 0.941× | 0.944× |
## 5. Прочие исследования
В данной главе собраны исследования, которые не вошли непосредственно в реализацию новой версии, однако важны своими идеями и результатами.
+210
View File
@@ -0,0 +1,210 @@
\author{Дмитрий Гудов}
% Город
\city{Москва}
% Организация
\affiliation{ALT Linux}
% Название проекта, которому посвящён доклад (необязательно,
% но желательно).
% \projecttitle{set-строки в rpm}
% Сайт(ы) проекта
\projecturl{\url{https://github.com/kr0sh512/alt-rpm-set-version}}
\title{Исследование и улучшение механизма работы \texttt{set}-строк в ALT RPM}
\maketitle
\begin{abstract}
В ALT RPM версии зависимостей \texttt{set:} кодируют множества ELF-символов и
позволяют проверять совместимость библиотек точнее, чем по одному SONAME.
В работе разобраны формат строк и оптимизации исходного \texttt{lib/set.c},
описана совместимая реимплементация \texttt{set9.c} и приведены результаты её
проверки на метаданных Sisyphus и в симуляциях APT. Отдельно рассмотрены
несовместимые альтернативные форматы и замена хэш-функции.
\end{abstract}
\keywords{ALT RPM, ELF-символы, set:version, Golomb--Rice}
\begingroup
\setlength{\emergencystretch}{3em}
\section*{1. Зачем нужны set-строки}
Обычная зависимость от версии библиотеки не гарантирует, что в ней остались все нужные программе символы: символ можно удалить, не сменив SONAME, а одинаковые SONAME могут скрывать разные наборы экспортов. Поэтому ALT RPM использует версии зависимостей вида \texttt{set:<encoded-set>}. Для \texttt{Provides} такая строка описывает символы, предоставляемые библиотекой, а для \texttt{Requires} --- символы, которые конкретный потребитель требует от неё. В такой конфигурации сравниваются не номера версий, а включение множеств: все хэши символов из \texttt{Requires} должны присутствовать в \texttt{Provides}.
Гарантия вероятностная, поскольку вместо полных имён символов хранятся усечённые хэши. Ложное отрицание невозможно: совпадающее имя всегда даёт то же хэш-значение. Возможны ложные положительные результаты, если разные имена имеют одинаковый усечённый хэш.
\section*{2. Структура set-строки}
Set-строка формируется следующим образом:
\begin{enumerate}
\item Список символов формируется автодепами \texttt{rpm-build}. Для \texttt{Provides} это отфильтрованные экспортируемые базовые имена ELF-символов; для \texttt{Requires} \texttt{ldd --bindings} связывает сильные неопределённые символы с конкретной библиотекой.
\item Для каждого имени вычисляется Jenkins OAAT; при формировании строки оставляются младшие \texttt{bpp} бит хэша. Допустимый диапазон \texttt{bpp} --- от 10 до 32.
\item Массив хэшей сортируется, повторы удаляются, а абсолютные значения заменяются дельтами между соседними значениями.
\item Для кодирования Golomb--Rice вычисляется параметр \texttt{Mshift = bpp - log2(cnt) - 1}; затем он ограничивается диапазоном 7--31 так, чтобы \texttt{Mshift < bpp}.
\item Массив дельт сжимается кодировкой Golomb--Rice, а битовый поток преобразуется в Base62-строку.
\end{enumerate}
При формировании строки \texttt{Requires} точность \texttt{bpp} выбирается по числу символов соответствующей предоставляющей библиотеки, а не по числу требуемых символов. Это сохраняет одинаковую точность представления для обеих сторон зависимости и уменьшает риск коллизий в малом множестве \texttt{Requires}.
Итоговая строка имеет вид:
\begin{verbatim}
set:<bpp_char><Mshift_char><base62-полезная нагрузка>
\end{verbatim}
\begin{verbatim}
bpp_char = bpp - 7 + 'a'
Mshift_char = Mshift - 7 + 'a'
\end{verbatim}
Краткая схема:
\begin{verbatim}
массив строк
| (Jenkins OAAT)
v
массив усечённых хэшей -- сортировка и удаление повторов
| (разности соседних значений)
v
массив дельт
| (преобразование Golomb--Rice)
v
битовый поток
| (Base62)
v
set-строка
\end{verbatim}
\subsection*{Механизм сравнения set-строк}
Для проверки включения множества символов \texttt{Requires} в множество \texttt{Provides} выполняется обратное декодирование:
\begin{verbatim}
set-строка
| (обратное Base62-преобразование)
v
битовый поток
| (обратное преобразование Golomb--Rice)
v
массив дельт
| (накопление дельт)
v
отсортированный массив усечённых хэшей
\end{verbatim}
Если точности строк различаются, значения приводятся к минимальному \texttt{bpp} из двух строк. После проекции сохраняются сортировка и уникальность: части массива по разные стороны удаляемого старшего бита сливаются с удалением повторов. Затем включение одного отсортированного множества усечённых хэш-значений в другое проверяется линейным проходом с пропусками.
\section*{3. Практическая реализация}
Несмотря на описанную структуру set-строки, в текущем \texttt{lib/set.c} применяется множество оптимизаций, направленных на ускорение \texttt{rpmsetcmp(const char *set1, const char *set2)}. В рабочем вызове первым аргументом передаётся \texttt{Provides}, вторым --- \texttt{Requires}; результат \texttt{1} означает строгое включение первого множества во второе, \texttt{0} --- равенство, а \texttt{-1} и \texttt{-2} --- обратное включение и несравнимость соответственно.
\subsection*{3.1. Слитый декодер}
Вместо последовательного декодирования Base62 и Golomb--Rice применяется функция \texttt{decode\_base62}\allowbreak\texttt{\_golomb()}, объединяющая оба этапа. Она считывает Base62-полезную нагрузку парами символов, с помощью заранее вычисленной таблицы получает биты, накапливает блоки до 24 бит и сразу декодирует значения Golomb--Rice. После этого восстанавливаются абсолютные значения из дельт.
Роль этой оптимизации подтверждается отдельной контрольной серией: вариант без оптимизированного декодера расходовал в APT-симуляциях в 1,47--2,82 раза больше суммарного процессорного времени, чем исходный \texttt{set.c}.
\subsection*{3.2. Кэширование \texttt{Provides} set-строк}
При передаче первого параметра в \texttt{rpmsetcmp()} его строка кэшируется. Исходная реализация использует массив из 256 записей, содержащих короткий отпечаток, исходную строку и декодированный массив хэшей; совпадение отпечатка подтверждается полным сравнением строки. При попадании запись перемещается в начало двумя вызовами \texttt{memmove()}; при заполнении кэша последняя запись освобождается, а новая помещается в позицию 243. Поэтому этот кэш близок к LRU, но не является строгим LRU.
Кэшируется только первый операнд, а второй декодируется при каждом сравнении. Следовательно, попадание в кэш устраняет не всё сравнение, а только повторное декодирование \texttt{Provides}; нормализация \texttt{bpp} также выполняется заново.
\subsection*{3.3. Быстрое сравнение включения множеств символов}
После получения отсортированных и приведённых к одинаковому \texttt{bpp} массивов хэшей необходимо проверить включение множеств. Как правило, множество \texttt{Requires} разрежено относительно \texttt{Provides}. Для этого \texttt{lib/set.c} использует макросы \texttt{IFLT4} и \texttt{IFLT8}: они делают прыжки на 4 или 8 элементов и уточняют позицию меньшими шагами. Вариант \texttt{IFLT8} выбирается при \texttt{len(Provides) >= 16 * len(Requires)}, в остальных случаях используется \texttt{IFLT4}.
Фиксированные шаги являются компромиссом между простотой и числом сравнений. Они хорошо работают для разреженных требований, но не учитывают точное отношение размеров множеств.
\section*{4. Совместимая реимплементация}
Для упрощения сопровождения была создана совместимая с форматом и публичным API реализация \texttt{set9.c}. В \texttt{set9.c} были реализованы следующие изменения.
\subsection*{4.1. Потоковое кодирование и хранение символов}
Энкодер \texttt{set9.c} передаёт значения по цепочке «хэш --- дельта --- Golomb--Rice --- Base62» без промежуточных массивов битов и дельт. Вместо отдельных выделений памяти под каждое имя используется растущая строковая арена, а в массиве символов хранятся смещения. Это позволет существенно уменьшить число вызовов \texttt{malloc()}.
\subsection*{4.2. Сортировка}
Для наборов меньше 128 значений сохраняется \texttt{qsort()}, а для больших используется стабильная побайтовая LSD radix sort. Число проходов равно \texttt{ceil(bpp/8)} и не превышает четырёх; дополнительная память имеет размер \texttt{O(n + 256)}.
\subsection*{4.3. Изменённый кэш и декодер}
В новой реализации используются два независимых кэша по 512 записей --- по одному для каждого операнда. Поиск выполняется через хэш-бакеты, а двусвязный LRU-список перемещает запись за \texttt{O(1)}. Ключ включает исходную строку и целевой \texttt{bpp}, поэтому результат нормализации можно повторно использовать.
Потоковый декодер использует 64-битный аккумулятор и более простую таблицу Base62. Для сравнения равных по размеру множеств применяется \texttt{memcmp()}, а для неравных проверяется только потенциальное включение меньшего в большее; разреженный случай использует монотонный поиск с шагом, зависящим от отношения размеров.
\subsection*{4.4. Проверка корректности и производительности}
\texttt{set9.c} был собран с \texttt{-Wall -Wextra -Werror}; встроенный \texttt{SELF\_TEST} успешно проверил хэширование, сортировку, кодирование и декодирование, метаданные, понижение \texttt{bpp}, включение, кэш, построитель и API. На снимке Sisyphus для \texttt{x86\_64/noarch} от 21 августа 2026 года проверены 68\,494 пары \texttt{set:} с одинаковым capability: 232 пары равны, в 68\,260 случаях \texttt{Provides} строго содержит \texttt{Requires}, а две несравнимые пары \texttt{python3(ast)} дополнительно имеют обычный unversioned \texttt{Provides}. С учётом 661 такого unversioned \texttt{Provides} все 69\,153 set-требования снимка удовлетворяются.
Для производительности сравнивались две локальные сборки \texttt{librpm}: исходная реализация и версия с описанными изменениями. Замеры выполнялись на одинаковых симуляционных командах \texttt{apt-get}. В таблице приведены средние процессорные времена. Коэффициент равен отношению времени исходной реализации к времени изменённой: значение больше единицы означает ускорение, меньше единицы --- замедление.
\begin{center}
\begingroup
\scriptsize
\setlength{\tabcolsep}{2pt}
\begin{tabular}{@{}lrrrrrr@{}}
\hline
Команда & \shortstack{\texttt{set.c}\\user, с} & \shortstack{\texttt{set.c}\\system, с} & \shortstack{\texttt{set9.c}\\user, с} & \shortstack{\texttt{set9.c}\\system, с} & \shortstack{Коэффициент\\по user} & \shortstack{Коэффициент\\по user+system} \\
\hline
\texttt{-s check} & 0.453 & 0.033 & 0.503 & 0.040 & 0.901$\times$ & 0.896$\times$ \\
\texttt{-s autoremove} & 0.763 & 0.040 & 0.810 & 0.040 & 0.942$\times$ & 0.945$\times$ \\
\texttt{-s install rpm-build} & 1.210 & 0.050 & 1.283 & 0.050 & 0.943$\times$ & 0.945$\times$ \\
\texttt{-s install openuds-server} & 3.747 & 0.080 & 3.897 & 0.083 & 0.962$\times$ & 0.961$\times$ \\
\texttt{-s install password-store} & 1.233 & 0.050 & 1.310 & 0.050 & 0.941$\times$ & 0.944$\times$ \\
\hline
\end{tabular}
\endgroup
\end{center}
Во всех пяти завершённых сценариях \texttt{set9.c} медленнее исходной реализации: суммарное процессорное время увеличилось на 4,0--11,6\%. Таким образом, упрощённый декодер с улучшениями сортировки и кэша не компенсировали стоимость более сложного декодера в данной APT-нагрузке. Однако, данная реализация остаётся значительно быстрее работы оригинального \texttt{set.c} с декодированием "в лоб".
\section*{5. Прочие исследования}
В данной главе собраны исследования, которые не вошли непосредственно в совместимую реализацию, однако важны своими идеями и результатами. Форматы этого раздела несовместимы с существующими \texttt{set:}-строками и требуют регенерации метаданных.
\subsection*{5.1. Хранение хэшей без Golomb--Rice}
Первое направление --- отказаться от дельт и Golomb--Rice и хранить отсортированные усечённые хэши почти напрямую в Base64. Это уменьшает стоимость декодирования, но увеличивает строку. В микротесте с 1000 предоставляемыми и 500 требуемыми символами при \texttt{bpp=32} длина строки выросла с 3994 до 5340 символов. Построение строки ускорилось: \texttt{set\_fini} занял 105,09 вместо 147,45 мкс. Холодное сравнение заняло 113,04 вместо 129,67 мкс (0,87$\times$ времени \texttt{set9}), а прогретое --- 1,04 вместо 2,99 мкс (0,35$\times$).
Однако результат зависит от нагрузки: при одном требуемом символе прогретое сравнение прямого формата заняло 1,02$\times$ времени \texttt{set9}.
Также был рассмотрен Roaring Bitmap. В проверенном прототипе на разреженных усечённых хэшах он дал более длинные строки и более медленное сравнение: строка выросла до 19\,924 символов, холодное сравнение стало в 4,50 раза, а прогретое --- в 76,14 раза медленнее \texttt{set9}. Сжатие zstd сократило строку до 14\,126 символов, но холодное и прогретое сравнения остались в 4,96 и 101,86 раза медленнее.
\subsection*{5.2. Тестирование хэш-функций}
Отдельно проверялась идея заменить Jenkins OAAT на CityHash32 или XXH32. На фиксированных случайных байтовых строках, медиана трёх запусков, Jenkins уступал: для 16 байт --- 22,330 нс/хэш; CityHash32 --- 10,402; XXH32 --- 7,792. Для 1024 байт значения составили 1796,485, 232,598 и 184,234 нс/хэш соответственно.
Однако скорость отдельной хэш-функции не описывает её качества на данных,
характерных для \texttt{set:}-строк. Поэтому для Jenkins OAAT, XXH64 и t1ha2
была измерена вероятность единицы в каждом из младших 32 битов хэша на корпусе
из 577\,509 уникальных C++ ABI-символов, извлечённых из 259 ELF-файлов пяти
пакетов ALT p11. Корпус намеренно содержит близкие по префиксу имена: 99,60\%
символов имеют с другим символом общий префикс длиной не менее 12 знаков, а
95,05\% --- не менее 24 знаков. Среднее отклонение вероятности единицы в
выходном бите от $0{,}5$ равно 0,0475 процентного пункта для Jenkins OAAT, 0,0431 для XXH64 и
0,0615 для t1ha2; максимальное отклонение одного бита --- 0,167, 0,122 и
0,188 процентного пункта соответственно. Следовательно, все три функции
достаточно равномерно смешивают проверенный корпус; только по этой метрике
замена Jenkins OAAT не получает убедительного обоснования.
Следовательно, в проверенной метрике устойчивого преимущества замены Jenkins не обнаружено. Кроме того, новая хэш-функция изменила бы все значения в wire-format и потребовала бы новой версии формата и пересоздания репозиторных метаданных.
\section*{6. Итоги}
Работа позволила восстановить назначение и инварианты \texttt{set:}-формата, описать пути исходного \texttt{lib/set.c} и подготовить совместимую реализацию \texttt{set9.c}. Проверка на Sisyphus и \texttt{SELF\_TEST} подтверждают корректную обработку исследованного корпуса и публичного API. Вместе с тем APT-измерения показали отрицательный результат по производительности: более читаемая и структурированная реализация не превзошла специализированный декодер исходного кода.
Эксперименты с прямым хранением хэшей, Roaring Bitmap и альтернативными хэш-функциями уточнили границы дальнейшей оптимизации.
\endgroup
%%% Local Variables:
%%% mode: latex
%%% TeX-master: "../main"
%%% End:
+588
View File
@@ -0,0 +1,588 @@
\Title{Гудов Д.О.}{Исследование алгоритма разрешения зависимостей в ALT RPM}
\section*{Введение}
Одной из задач пакетного менеджера является проверка совместимости устанавливаемого программного обеспечения с уже имеющимися или одновременно устанавливаемыми библиотеками. Традиционная зависимость от имени библиотеки и номера её версии не всегда достаточна: отдельный экспортируемый символ может быть удалён без изменения SONAME, а библиотеки с одинаковым SONAME могут предоставлять различные программные интерфейсы. В результате формально удовлетворённая зависимость от версии ещё не гарантирует, что динамический загрузчик найдёт все символы, необходимые программе.
В ALT RPM эта задача решается при помощи специальных версий зависимостей вида \texttt{set:<encoded-set>} [\Ref{SETC}]. Для отношения \texttt{Provides} такая строка описывает множество символов, экспортируемых библиотекой, а для отношения \texttt{Requires} --- символы, требуемые программой от конкретной библиотеки. При проверке зависимости обычное сравнение версий заменяется проверкой включения множеств. Полные имена символов при этом не сохраняются: они заменяются усечёнными хеш-значениями, сортируются и кодируются в компактную строку, допустимую как версия RPM.
Цель настоящей работы --- исследовать алгоритм построения и сравнения \texttt{set:}-строк, оценить вероятностные свойства хэша и вычислительную сложность алгоритма, а также определить направления оптимизации реализации.
%% В первых разделах формализуется решаемая задача, рассматриваются реальные данные репозитория Sisyphus, формат строки и путь её построения в исходной реализации \texttt{lib/set.c}.
\section{Постановка задачи и данные ALT Linux}
\label{PROBLEM}
Пусть $P$ --- множество символов, предоставляемых библиотекой, а $R$ --- множество символов, требуемых от неё программой. Обозначим $p=|P|$ и $r=|R|$. Зависимость выполнима тогда и только тогда, когда
\[
R\subseteq P.
\]
Списки символов формируются механизмом автоматического определения зависимостей \texttt{rpm-build}. Для \texttt{Provides} из динамической таблицы библиотеки выбираются доступные извне определённые символы. Для \texttt{Requires} утилита \texttt{ldd --bindings} устанавливает соответствие между требуемым символом и конкретной библиотекой-поставщиком; слабые неопределённые символы исключаются. Таким образом, одна строка \texttt{Requires} содержит только множество, связанное с данным поставщиком.
Явное хранение имён увеличивало бы RPM-метаданные пропорционально их суммарной длине. Поэтому используется усечённая хеш-функция c целым $b$, называемого в реализации \texttt{bpp},
\[
h_b(x)=h_{32}(x)\bmod 2^b,
\qquad
H_b(S)=\{h_b(x)\mid x\in S\},
\]
где $h_{32}$ --- 32-битная функция Jenkins one-at-a-time. Фактически \texttt{lib/set.c} проверяет условие
\[
H_b(R)\subseteq H_b(P).
\]
Такое представление сохраняет включение: из $R\subseteq P$ следует $H_b(R)\subseteq H_b(P)$. Следовательно, коллизия хешей не создаёт ложного отказа для корректной зависимости. Обратное утверждение неверно: отсутствующий символ из $R\setminus P$ может получить то же усечённое значение, что и один из символов $P$, и привести к ложному принятию зависимости. Тем самым предоставляемая гарантия имеет вероятностный характер.
Для оценки реальной нагрузки был исследован срез репозитория Sisyphus для архитектур \texttt{x86\_64} и \texttt{noarch}. Объём рассмотренных метаданных на момент 2026-08-20 приведён в табл.~\Ref{CORPUS}.
\begin{table}[H]
\begin{center}
\caption{\label{CORPUS}Объём исследованного среза Sisyphus}
\small
\begin{tabular}{|l|r|}
\hline
Объект & Количество \\
\hline
Пакеты & 47\,654 \\
\hline
Отношения \texttt{Provides} с \texttt{set:}-версией & 14\,859 \\
\hline
Отношения \texttt{Requires} с \texttt{set:}-версией & 69\,153 \\
\hline
Сопоставленные пары $P,R$ & 68\,492 \\
\hline
\end{tabular}
\end{center}
\end{table}
661 отношений удовлетворены обычным неверсионированным \texttt{Provides}.
Для каждой сопоставленной пары декодировались мощности множеств $p$ и $r$, а также вычислялось индивидуальное отношение $p/r$. Квантили этих величин показаны в табл.~\Ref{CARDINALITIES}. Квантили отношения вычислялись непосредственно по парам.
\begin{table}[H]
\begin{center}
\caption{\label{CARDINALITIES}Мощности множеств в парах \texttt{Provides}/\texttt{Requires}}
\small
\begin{tabular}{|c|r|r|r|}
\hline
Квантиль & $p$ & $r$ & $p/r$ \\
\hline
0,50 & 480 & 13 & 28,1 \\
\hline
0,75 & 1\,886 & 37 & 80 \\
\hline
0,90 & 6\,219 & 104 & 257 \\
\hline
\end{tabular}
\end{center}
\end{table}
В 92,4\% сопоставленных пар выполняется $p/r\geq 4$. Следовательно, типичный проверяемый набор требований существенно разрежен относительно множества предоставляемых символов. Это наблюдение важно для алгоритма сравнения: последовательный симметричный просмотр двух массивов не всегда использует характерное различие их мощностей.
\section{Устройство существующей \texttt{set:}-строки}
\label{FORMAT}
Построение \texttt{set:}-строки выполняется как последовательность преобразований
\[
\begin{aligned}
\text{имена символов}
&\longrightarrow \text{усечённые хеши}
\longrightarrow \text{сортировка},\\
&\longrightarrow \text{удаление повторов и вычисление дельт},\\
&\longrightarrow \text{код Голомба--Райса}
\longrightarrow \text{Base62-представление}.
\end{aligned}
\]
Итоговая версия имеет вид
\[
\texttt{set:}\langle b\rangle\langle m\rangle\langle payload\rangle.
\]
Префикс \texttt{set:} распознаётся RPM как признак специальной версии. Следующие два символа кодируют параметры $b=\texttt{bpp}$ и $m=\texttt{Mshift}$ по правилу $c=v-7+\texttt{'a'}$. Допустимы $10\leq b\leq32$, $7\leq m\leq31$ и $m<b$. Оставшаяся часть строки содержит закодированный в \texttt{Base62} битовый поток.
\subsection{Хеширование и вероятность коллизий}
Для каждого имени вычисляется 32-битный Jenkins one-at-a-time [\Ref{JENKINS}], после чего сохраняются только $b$ младших бит. Пусть $n$ различных имён независимо и равномерно отображаются в пространство из $N=2^b$ значений. Тогда математическое ожидание числа столкнувшихся пар равно
\[
\mathbb{E}C=\binom{n}{2}\frac{1}{2^b}
=\frac{n(n-1)}{2^{b+1}},
\]
а вероятность хотя бы одной коллизии имеет вид
\[
\P(C>0)=1-\prod_{i=0}^{n-1}\left(1-\frac{i}{2^b}\right)
\approx 1-\exp\left(-\frac{n(n-1)}{2^{b+1}}\right).
\]
Эти выражения являются модельными: Jenkins OAAT не является случайным оракулом, поэтому окончательная оценка должна дополняться измерением коллизий на реальном корпусе символов.
Эвристическую верхнюю оценку ложного принятия при $k=|R\setminus P|$ отсутствующих символов можно записать как
\[
\Pr\bigl(H_b(R)\subseteq H_b(P)\mid R\nsubseteq P\bigr)
\lesssim
\left(\frac{|H_b(P)|}{2^b}\right)^k.
\]
Увеличение $b$ снижает вероятность ошибки, но увеличивает кодируемые значения и длину строки. Поэтому выбор \texttt{bpp} представляет собой компромисс между компактностью метаданных и риском коллизий; при формировании \texttt{set:}-строки для \texttt{Requires} набора используется точность, определённая по числу символов соответствующего \texttt{Provides}, а не по обычно намного меньшему числу требований.
\subsection{Кодирование Голомба--Райса}
После сортировки уникальных значений
\[
0\leq x_1<x_2<\dots<x_n<2^b
\]
они заменяются дельтами
\[
\delta_1=x_1,
\qquad
\delta_i=x_i-x_{i-1},\quad i=2,\dots,n.
\]
Для равномерных хешей средний промежуток имеет порядок $2^b/n$, поэтому дельты значительно меньше абсолютных значений и хорошо подходят для кодирования Голомба--Райса [\Ref{GOLOMB-RICE}].
В реализации модуль $M$ равен степени двойки $M=2^m$. Для каждой дельты вычисляются
\[
q_i=\left\lfloor\frac{\delta_i}{M}\right\rfloor,
\qquad
r_i=\delta_i\bmod M.
\]
Частное $q_i$ записывается унарно как $q_i$ нулей и завершающая единица, после которой следуют $m$ младших бит остатка $r_i$. Длина кода одного значения равна
\[
\ell_i=q_i+1+m.
\]
Параметр выбирается приближённо как
\[
m=b-\lfloor\log_2 n\rfloor-1
\]
и ограничивается диапазоном формата $7\leq m\leq31$. При принятой модели суммарная длина битового потока оценивается как
\[
B=\sum_{i=1}^{n}\ell_i
=O\!\left(n\left(1+\log_2\frac{2^b}{n}\right)\right).
\]
\subsection{Base62-представление}
Битовый поток нельзя непосредственно поместить в поле версии RPM: необходима строка из допустимых символов. Исходная реализация использует алфавит \texttt{0--9}, \texttt{a--z}, \texttt{A--Z}. Значения от 0 до 60 записываются обычным символом, а \texttt{Z} служит escape-символом и позволяет представить также шестибитные значения 61, 62 и 63. Их различают два старших бита следующего символа: \texttt{00}, \texttt{01} или \texttt{10}. Комбинация \texttt{11} не используется, поэтому escape-последовательности не могут образовать неоднозначную цепочку. Вследствие этого один символ несёт от пяти до шести бит, и длина текстовой части приближённо пропорциональна $B/6$.
Рассмотрим небольшой пример с $b=10$. Для пяти имён Jenkins OAAT после усечения даёт значения, приведённые в табл.~\Ref{ENCODING_EXAMPLE}.
\begin{table}[H]
\begin{center}
\caption{\label{ENCODING_EXAMPLE}Пример хеширования символов при $b=10$}
\small
\begin{tabular}{|l|r|r|}
\hline
Символ & 32-битный хеш & $h_{10}$ \\
\hline
\texttt{fclose} & \texttt{0x6743def6} & 758 \\
\hline
\texttt{fopen} & \texttt{0x0e984918} & 280 \\
\hline
\texttt{free} & \texttt{0xa5bbbac7} & 711 \\
\hline
\texttt{malloc} & \texttt{0x07c1b8ff} & 255 \\
\hline
\texttt{printf} & \texttt{0xfd1ad5cc} & 460 \\
\hline
\end{tabular}
\end{center}
\end{table}
После сортировки получается массив $(255,280,460,711,758)$, а после вычисления разностей --- $(255,25,180,251,47)$. Для $n=5$ формула даёт $m=7$; полученный 43-битный поток преобразуется в payload \texttt{ZvpACXZy}. Параметрам $b=10$ и $m=7$ соответствуют заголовочные символы \texttt{d} и \texttt{a}, поэтому итоговая строка равна \texttt{set:daZvpACXZy}.
\section{Построение \texttt{set:}-строк в \texttt{set.c}}
\label{CONSTRUCTION}
Благодаря сценариям автозависимостей \texttt{rpm-build} утилита \texttt{mkset} получает символы из ELF-файлов и далее использует \texttt{lib/set.c}, вызывая интерфейс построения множества:
\[
\texttt{set\_new()}\ \longrightarrow\
\texttt{set\_add()}\ \longrightarrow\
\texttt{set\_fini()}.
\]
Функция \texttt{set\_new()} создаёт пустую структуру \texttt{struct set}. В исходной реализации она представляет собой растущий массив пар «указатель на строку --- хеш». Функция \texttt{set\_add()} увеличивает ёмкость массива блоками по 1024 элемента и копирует каждое имя отдельным вызовом \texttt{xstrdup()}. На этом этапе хеши ещё не вычисляются, поэтому один объект можно финализировать с заданным значением \texttt{bpp}.
Основную работу выполняет \texttt{set\_fini()}. Её действия следуют в фиксированном порядке:
\begin{enumerate}
\item проверяется непустота множества и условие $10\leq b\leq32$;
\item для каждого имени вычисляется Jenkins OAAT и применяется маска из $b$ младших бит;
\item массив пар сортируется стандартной функцией \texttt{qsort()} по хеш-значению;
\item для равных хешей различных имён выводится предупреждение о коллизии;
\item хеши копируются в числовой массив, а повторяющиеся значения удаляются;
\item массив кодируется последовательно как дельты, код Голомба--Райса и Base62;
\item сформированная строка копируется в динамическую память и возвращается вызывающей стороне.
\end{enumerate}
Удаление повторов необходимо в двух случаях. Во-первых, одно имя может несколько раз попасть во входной поток. Во-вторых, различные имена могут совпасть после усечения хеша. Для семантики $H_b(S)$ оба случая означают один элемент множества. При этом предупреждение о коллизии формируется до удаления повторов, пока реализации ещё доступны исходные строки и можно различить совпадение имён от совпадения их хешей.
Оценим вычислительную сложность. Пусть $L$ --- суммарная длина всех входных имён, $n$ --- их количество, а $B$ --- длина потока Голомба--Райса в битах. Хеширование требует $O(L)$ операций, сортировка --- $O(n\log n)$ сравнений, линейные проходы сортированного массива --- $O(n)$, кодирование --- $O(B)$. Поэтому для исходного варианта
\[
T_{\mathrm{build}}=O(L)+O(n\log n)+O(B).
\]
Хранимые копии строк, массив пар, массив уникальных хешей, битовый буфер и результирующая строка дают дополнительную память порядка
\[
M_{\mathrm{build}}=O(L+n+B).
\]
Главным асимптотическим слагаемым для больших наборов остаётся \texttt{qsort()}, тогда как большое число отдельных копирований строк и наличие промежуточного битового массива влияют на постоянные затраты. Это определяет два независимых направления дальнейшего улучшения: замена сортировки для крупных множеств и потоковое кодирование без промежуточных представлений. При этом любые изменения должны сохранять байтовую совместимость формата.
\section{Сравнение \texttt{set:}-строк}
\label{COMPARISON}
Функция \texttt{rpmsetcmp()} получает две строки и определяет отношение включения между закодированными множествами. В штатном пути RPM первым операндом передаётся \texttt{Provides}, а вторым --- \texttt{Requires}. Возвращаемые значения различают четыре результата: 1, если первое множество строго содержит второе; 0 при равенстве; $-1$, если первое множество строго содержится во втором; $-2$, если множества несравнимы по включению. Ошибки декодирования первого и второго операндов отображаются соответственно в коды $-3$ и $-4$.
\subsection{Обратное декодирование и нормализация точности}
После удаления необязательного префикса \texttt{set:} проверяются параметры $b$ и $m$, а затем выполняется обратная цепочка преобразований
\[
\begin{aligned}
\text{Base62-строка}
&\longrightarrow \text{битовый поток}
\longrightarrow \text{дельты Голомба--Райса},\\
&\longrightarrow \text{возрастающий массив хешей}.
\end{aligned}
\]
Логически это преобразование обратно рассмотренному в разд.~\Ref{FORMAT}. Однако исходный \texttt{lib/set.c} не создаёт отдельный битовый массив: функция \texttt{decode\_base62\_golomb()} объединяет первые две стадии. Она считывает обычные символы попарно, преобразует их предварительно вычисленной таблицей и обрабатывает блоки до 24 бит. Частное и остаток кода Голомба--Райса восстанавливаются непосредственно из этих блоков. После этого единственный линейный проход суммирует дельты и получает исходные усечённые хеши.
Две строки могут иметь разные значения \texttt{bpp}. Сравнение выполняется при общей точности
\[
b_* = \min(b_P,b_R).
\]
Для этого более точный массив последовательно проецируется на один бит вниз. На одном шаге применяется отображение
\[
\pi_t(x)=x\bmod 2^t.
\]
После удаления старшего бита исходный возрастающий массив распадается на две возрастающие части. Обе части сливаются с сохранением возрастания элементов, а появившиеся совпадения удаляются. Поэтому после понижения точности сохраняются одновременно сортировка и представление множества без повторов. При разности точностей $d=|b_P-b_R|$ слияние повторяется $d$ раз.
В результате сравниваются два возрастающих массива
\[
\widehat P=H_{b_*}(P),\qquad
\widehat R=H_{b_*}(R).
\]
Понижение \texttt{bpp} необходимо, так как сравнение множеств хэшей отличающейся точности невозможно.
\subsection{Кэш декодированных множеств}
Декодирование длинной строки существенно дороже проверки нескольких уже готовых целых значений. Кроме того, один поставщик обычно сопоставляется с требованиями нескольких пакетов. Поэтому исходная реализация кэширует декодированный первый операнд, то есть в обычном вызове множество \texttt{Provides}.
Кэш состоит из двух массивов по 256 элементов: коротких отпечатков и указателей на записи. Отпечаток формируется из трёх байтов строки и служит только предварительным фильтром. Корректность попадания подтверждается полным сравнением исходной строки. Запись одним выделением памяти хранит декодированный массив, его длину и копию строки.
Поиск выполняется линейно. При попадании запись перемещается в начало двумя вызовами \texttt{memmove()}. Если кэш заполнен, последняя запись освобождается, а новый элемент вставляется на позицию 243 (\texttt{PIVOT\_SIZE}), после чего хвост обоих массивов также сдвигается. Такая схема приближает LRU-политику, но не является строгим LRU: новая, ещё не подтвердившая полезность запись помещается около конца кэша, тогда как повторно использованная попадает в начало.
Второй операнд исходная реализация каждый раз декодирует во временный массив на стеке. Кроме того, кэш хранит результат при исходном \texttt{bpp}; если для конкретного сравнения его требуется понизить, проекция вычисляется заново. Следовательно, кэш особенно полезен для потока сравнений одного \texttt{Provides} с разными \texttt{Requires} одинаковой точности, но не устраняет стоимость декодирования требований и нормализации.
\subsection{Проверка включения возрастающих массивов}
Наивная проверка $\widehat R\subseteq\widehat P$ последовательно продвигает указатель по $\widehat P$ до очередного требуемого значения. Исходная реализация одновременно вычисляет оба отношения включения с помощью флагов \texttt{ge} и \texttt{le}. Это позволяет одним проходом получить все четыре значения публичного API, а не только ответ на типичный для RPM вопрос $\widehat R\subseteq\widehat P$.
Для ускорения поиска используются макросы \texttt{IFLT4} и \texttt{IFLT8}. Первый перемещает указатель по массиву первого операнда блоками по четыре элемента, после превышения искомого значения возвращается на два элемента и уточняет позицию единичными шагами. Второй выполняет ту же схему с начальным шагом восемь и уточнениями 4, 2 и 1. В конец массива добавляются восемь значений-сентинелов $\mathtt{UINT\_MAX}$, благодаря чему пробный прыжок за последний настоящий элемент остаётся допустимым обращением к памяти. Макрос \texttt{IFLT8} выбирается при $p\geq16r$, в остальных случаях используется \texttt{IFLT4}.
Такой выбор учитывает разреженность требований, установленную в разд.~\Ref{PROBLEM}, но лишь двумя фиксированными режимами. При медианном $p/r=28{,}1$ шаг восемь уже применим, тогда как при $p/r=257$ он всё ещё остаётся равным восьми и не отражает фактическое среднее расстояние между требуемыми значениями.
Пусть $s_P$ и $s_R$ --- длины payload двух строк, а $d=|b_P-b_R|$. Холодное сравнение имеет оценку
\[
T_{\mathrm{cold}}=
O(s_P+s_R)+O\bigl(d\max(p,r)\bigr)+O(p+r).
\]
Поскольку формат ограничивает $10\leq b\leq32$, величина $d$ ограничена константой, и итоговая асимптотика линейна по размеру входных строк и декодированных множеств. При попадании первого операнда в кэш исчезает его декодирование, но остаются линейный поиск по кэшу, проверка строки, декодирование второго операнда, возможная нормализация и проход по массивам:
\[
T_{\mathrm{hit}}=
O(C+s_P+s_R)+O\bigl(d\max(p,r)\bigr)+O(p+r),
\qquad C=256.
\]
Здесь член $s_P$ отражает окончательную проверку ключа; на практике она выполняется только для записей с совпавшим коротким отпечатком. Дополнительная память одного холодного вызова составляет $O(p+r)$, не считая сохраняемого в процессе кэша первого операнда.
\section{Оптимизации с сохранением текущего формата}
\label{COMPATIBLE_OPTIMIZATIONS}
Для проверки направлений оптимизации была создана экспериментальная реализация \texttt{set9.c} [\Ref{SET9}]. Она сохраняет Jenkins OAAT, формат заголовка, Golomb--Rice/Base62-представление и пять функций публичного API. Поэтому сформированные ею строки побайтово совместимы с исходной реализацией, а изменения относятся только к внутренним структурам и алгоритмам.
Помимо оптимизации сравнения, построение множества было переведено с отдельных \texttt{xstrdup()} для каждого имени на общую растущую строковую арену. В массиве элементов хранятся смещения, которые остаются корректными после \texttt{xrealloc()} арены. Кодирование и декодирование выполняются потоково: дельты, состояния Голомба--Райса и Base62 обрабатываются через 64-битный аккумулятор без отдельных массивов битов и дельт.
\subsection{Два bucketed LRU-кэша}
Вместо одного линейного кэша используются два независимых кэша --- для первого и второго операндов. Каждый содержит до 512 записей и таблицу из 1024 бакетов. Разделение предотвращает взаимное вытеснение часто повторяющихся \texttt{Provides} и \texttt{Requires}, а бакеты ограничивают область поиска записи.
Ключ включает короткий отпечаток исходной строки и целевую точность $b_*$. Совпадение, как и в исходном варианте, обязательно подтверждается полным \texttt{strcmp()}, поэтому ускоряющая структура не меняет семантику. Значение $b_*$ принципиально важно: одна строка может сравниваться с операндами разной точности и давать разные нормализованные массивы.
При промахе строка декодируется, сразу приводится к $b_*$ и в таком виде сохраняется. Повторное сравнение той же строки при той же точности не требует ни декодирования, ни цикла \texttt{downsample\_set()}. Порядок вытеснения поддерживается двусвязным списком: попадание переносит запись в начало за $O(1)$, а при заполнении удаляется самый старый элемент. В отличие от массивного кэша, полный сдвиг записей не требуется. При равномерном распределении отпечатков ожидаемая стоимость поиска близка к $O(1)$ плюс стоимость окончательной проверки строки.
\subsection{Radix sort для больших множеств}
При построении строки исходный \texttt{qsort()} требует $O(n\log n)$ вызовов функции сравнения. Для 32-битных целых ключей число разрядов заранее ограничено, поэтому в \texttt{set9.c} при $n\geq128$ применяется стабильная LSD radix sort по байтам хеша. Для меньших наборов сохраняется \texttt{qsort()}, поскольку подготовка таблиц и временного массива не окупается.
Число проходов определяется фактически используемой точностью:
\[
k=\left\lceil\frac b8\right\rceil,
\qquad
T_{\mathrm{radix}}=O(kn).
\]
На каждом проходе сначала подсчитываются 256 значений текущего байта, затем префиксные суммы преобразуют счётчики в позиции, после чего элементы стабильно распределяются во временный массив. Источник и приёмник меняются местами между проходами. Дополнительная память равна
\[
M_{\mathrm{radix}}=O(n+256).
\]
Так как $b\leq32$, выполняется не более четырёх линейных проходов.
\subsection{Адаптивная проверка включения}
Мощности массивов после нормализации позволяют заранее исключить часть отношений. Если $p=r$, строгого включения быть не может: равенство проверяется одним \texttt{memcmp()}, а несовпадение означает несравнимость. Если $p>r$, проверяется только $\widehat R\subseteq\widehat P$; обратное строгое включение невозможно. Случай $p<r$ обрабатывается симметрично. Тем самым устраняется одновременное ведение двух флагов и выбирается единственная содержательная проверка.
Функция \texttt{sorted\_subset()} использует отношение мощностей
\[
j=\left\lfloor\frac{n_{\mathrm{large}}}{n_{\mathrm{small}}}\right\rfloor.
\]
При $j<4$ выполняется обычное линейное слияние. Для разреженного случая поиск очередного элемента начинается от позиции предыдущего совпадения, делает шаг приблизительно $j$, а затем делит шаг пополам до нахождения нижней границы. Указатель по большому массиву движется только вперёд; при первом отсутствующем хеше функция немедленно возвращает отрицательный результат.
Для плотного случая сохраняется оценка $O(p+r)$. В разреженном потоке суммарное число крупных шагов имеет порядок $O(p/j)$, а уточнение границы требует до $O(\log j)$ проб на один элемент меньшего множества:
\[
T_{\mathrm{sparse}}=
O\left(\frac pj+r\log j\right).
\]
При характерном $j\approx p/r$ первое слагаемое имеет порядок $r$. В отличие от фиксированного выбора между шагами 4 и 8, величина перехода адаптируется к наблюдаемому отношению мощностей.
\section{Результаты экспериментальной проверки}
\label{RESULTS}
Проверка экспериментальной реализации проводилась в два этапа. Сначала проверялась корректность формата и операций над множествами, затем измерялась производительность в сценариях пакетного менеджера.
\subsection{Дифференциальное тестирование на синтетических множествах}
Для проверки совместимости был разработан дифференциальный тест, не использующий заранее подготовленные \texttt{set:}-строки. На каждой итерации он самостоятельно формировал исходное множество из случайных уникальных строк. Число символов выбиралось в диапазоне от 1 до 1000, длина каждого имени --- от 1 до 100 знаков, а параметр $bpp$ --- от 10 до 32. В алфавит входили латинские буквы, цифры и знаки, встречающиеся в именах экспортируемых символов: точка, знак \texttt{@} и подчёркивание.
Из исходного множества строилось второе множество с заранее известным отношением к первому. Тест охватывал четыре класса входов: равные множества, строгое включение, несравнимые множества и некорректные \texttt{set:}-строки. Для проверки строгого включения из первого множества удалялась случайная непустая часть элементов. В случае несравнимости после удаления добавлялись новые символы, отсутствующие в первом множестве. Некорректная строка генерировалась случайным набором символов, в таком случае был шанс получить корректную строку, но для нас остаётся важным одинаковый результат двух программ.
Один и тот же набор исходных символов независимо передавался построителю исходного \texttt{set.c} и построителю \texttt{set9.c}. Таким образом, каждая реализация сама выполняла хеширование, сортировку и кодирование, после чего её вариант \texttt{rpmsetcmp()} сравнивал полученную пару строк. Тест сопоставлял наблюдаемый результат публичного API: $1$ для строгого надмножества, $0$ для равенства, $-1$ для строгого подмножества, $-2$ для несравнимости и $-3$ или $-4$ для ошибки декодирования соответствующего операнда. Для случая включения операнды дополнительно менялись местами, что позволяло одновременно проверить результаты $1$ и $-1$.
Генерация и сравнение выполнялись циклически до ручной остановки теста. Такой подход проверял эквивалентность реализаций на широком диапазоне мощностей и точностей.
\subsection{Производительность в сценариях APT}
Для измерения времени использовались две локальные сборки \texttt{librpm}: с исходным \texttt{set.c} и с \texttt{set9.c}. На каждой сборке трижды выполнялись одинаковые успешно завершившиеся симуляции APT. В табл.~\Ref{APT_RESULTS} приведены средние значения процессорного времени пользователя и ядра. Время ожидания и ввода-вывода в эти величины не входит.
Коэффициент вычислялся по суммарному процессорному времени:
\[
K_{\mathrm{CPU}}=
\frac{U_{\mathrm{original}}+S_{\mathrm{original}}}
{U_{\mathrm{set9}}+S_{\mathrm{set9}}}.
\]
Значение $K_{\mathrm{CPU}}>1$ означает ускорение, а $K_{\mathrm{CPU}}<1$ --- замедление экспериментальной реализации.
\begin{table}[H]
\begin{center}
\caption{\label{APT_RESULTS}Сравнение исходного \texttt{set.c} и \texttt{set9.c} в симуляциях APT}
\scriptsize
\begin{tabular}{|l|r|r|r|r|r|}
\hline
Сценарий & \multicolumn{2}{c|}{Исходный, с} & \multicolumn{2}{c|}{\texttt{set9.c}, с} & $K_{\mathrm{CPU}}$ \\
\cline{2-5}
& user & system & user & system & \\
\hline
\texttt{-s check} & 0,453 & 0,033 & 0,503 & 0,040 & 0,896 \\
\hline
\texttt{-s autoremove} & 0,763 & 0,040 & 0,810 & 0,040 & 0,945 \\
\hline
\texttt{-s install rpm-build} & 1,210 & 0,050 & 1,283 & 0,050 & 0,945 \\
\hline
\texttt{-s install openuds-server} & 3,747 & 0,080 & 3,897 & 0,083 & 0,961 \\
\hline
\texttt{-s install password-store} & 1,233 & 0,050 & 1,310 & 0,050 & 0,944 \\
\hline
\end{tabular}
\end{center}
\end{table}
Во всех пяти измеренных сценариях \texttt{set9.c} не превзошёл исходный вариант: суммарное процессорное время увеличилось примерно на 4,0--11,6\%. Это отрицательный результат, вызванный более простым потоковым декодером, который не превзошёл специализированного табличного декодера исходной реализации даже при условии остальных оптимизаций.
Для проверки этого объяснения исходный слитый декодер отдельно сравнивался с вариантом, в котором Base62, код Голомба--Райса и восстановление дельт выполнялись последовательными стадиями. В зависимости от сценария слитый путь сокращал суммарное процессорное время в 1,47--2,82 раза. Следовательно, благодаря табличной обработке Base62/Golomb получается измеримый выигрыш. Практическое направление дальнейшей работы может состоять в разработке нового формата хранения и обработки \texttt{set:}-строк.
\section{Варианты со сменой формата или API}
\label{ALTERNATIVE_FORMATS}
Оптимизации разд.~\Ref{COMPATIBLE_OPTIMIZATIONS} ограничены требованием побайтовой совместимости. Параллельно исследовались представления, снимающие это ограничение. Их результаты нельзя напрямую переносить на существующие RPM-метаданные: новый формат требует повторного формирования зависимостей репозитория.
\subsection{Roaring Bitmap: отрицательный результат}
Roaring Bitmap [\Ref{ROARING}] предназначен прежде всего для множеств, содержащих плотные участки целочисленного пространства. Усечённые хеши, напротив, распределены по диапазону приблизительно равномерно. Поэтому контейнеры bitmap не получают длинных серий соседних значений, но сохраняют собственные заголовки и индексы.
В микротесте использовались 1000 предоставляемых и 500 требуемых символов при $b=32$. Результаты приведены в табл.~\Ref{ROARING_RESULTS}. Коэффициенты времени вычислены относительно \texttt{set9.c}; значение больше единицы означает замедление.
\begin{table}[H]
\begin{center}
\caption{\label{ROARING_RESULTS}Сравнение Golomb/Base62 и Roaring Bitmap}
\small
\begin{tabular}{|l|r|r|r|}
\hline
Представление & Длина строки & Cold compare & Warm compare \\
\hline
Golomb/Base62 \texttt{set9} & 3\,994 & 1,00 & 1,00 \\
\hline
Roaring/hex & 19\,924 & 4,50 & 76,14 \\
\hline
Roaring+zstd & 14\,126 & 4,96 & 101,86 \\
\hline
\end{tabular}
\end{center}
\end{table}
Без дополнительного сжатия строка оказалась длиннее в 4,99 раза. Холодное сравнение заняло 1321,17 мкс вместо 293,87 мкс, а прогретое --- 399,20 мкс вместо 5,24 мкс. При этом построение bitmap занимало лишь $0,76$ времени \texttt{set\_fini()} варианта \texttt{set9}; ускорение генерации не компенсировало стоимость хранения и сравнения.
Сжатие zstd [\Ref{ZSTD}] уменьшило строку до 14\,126 символов, то есть до 3,54 длины \texttt{set9}, но добавило декомпрессию в горячий путь. В отдельном прогоне холодное и прогретое сравнения были медленнее соответственно в 4,96 и 101,86 раза. Таким образом, Roaring Bitmap для равномерных хешей является подтверждённым отрицательным вариантом: структура данных не соответствует распределению кодируемых значений.
\subsection{Прямые хеши в Base64}
Другой вариант, обозначенный как D1, исключает дельты и код Голомба--Райса. $n$ отсортированных уникальных хешей записываются подряд по $b$ бит, после чего битовый массив преобразуется в Base64-строку [\Ref{BASE64}]. Если
\[
Q=\left\lceil\frac{nb}{8}\right\rceil
\]
--- число байтов упакованного массива, то доминирующая часть длины payload равна
\[
L_{\mathrm{payload}}=
\left\lceil\frac{4Q}{3}\right\rceil,
\]
а заголовок формата добавляет постоянное число символов. В отличие от Golomb--Rice, эта оценка почти не зависит от расстояний между соседними хешами: цена быстрого произвольного доступа --- увеличение метаданных.
Для тех же 1000/500 символов при $b=32$ длина строки выросла с 3994 до 5340 символов, то есть на 33,7\%. Результаты теста показаны в табл.~\Ref{DIRECT_RESULTS}; отношение меньше единицы означает, что D1 затратил меньшую долю времени \texttt{set9.c}.
\begin{table}[H]
\begin{center}
\caption{\label{DIRECT_RESULTS}Тест прямого представления D1}
\small
\begin{tabular}{|l|r|r|r|}
\hline
Операция & \texttt{set9}, мкс & D1, мкс & D1/\texttt{set9} \\
\hline
\texttt{set\_fini()} & 147,45 & 105,09 & 0,71 \\
\hline
\texttt{rpmsetcmp()}, cold & 129,67 & 113,04 & 0,87 \\
\hline
\texttt{rpmsetcmp()}, warm & 2,99 & 1,04 & 0,35 \\
\hline
\end{tabular}
\end{center}
\end{table}
Для крупного требования прямое представление ускорило все три измеряемые операции, особенно попадание в кэш. Однако при одном требуемом символе холодное сравнение занимало 83,22 мкс ($0,88$ времени \texttt{set9}), а прогретое --- 0,86 мкс ($1,02$), то есть преимущество исчезало. Это подчёркивает зависимость результата от формы нагрузки: выигрыш одного сравнения не доказывает ускорение полного потока зависимостей APT. D1 представляет интерес как компромисс между размером и стоимостью декодирования, но требует отдельной сквозной проверки на преобразованных метаданных репозитория.
\subsection{Альтернативные хеш-функции}
Отдельно сравнивались Jenkins OAAT, xxHash32 [\Ref{XXHASH}] и CityHash32 [\Ref{CITYHASH}]. Сравнение было необходимо, так как xxHash и CityHash --- хеш-функции, разработанные значительно позже последнего обновления \texttt{set.c}. В каждом замере использовались три запуска; в табл.~\Ref{HASH_SPEED} приведены медианы. Для коротких строк указано время одного хеширования, для длинных --- пропускная способность.
\begin{table}[H]
\begin{center}
\caption{\label{HASH_SPEED}Скорость 32-битных хеш-функций}
\small
\begin{tabular}{|l|r|r|}
\hline
Функция & 32 байта, нс/хеш & 1024 байта, ГиБ/с \\
\hline
Jenkins OAAT & 50,98 & 0,53 \\
\hline
xxHash32 & 11,56 & 5,18 \\
\hline
CityHash32 & 16,70 & 4,10 \\
\hline
\end{tabular}
\end{center}
\end{table}
На строках длиной 32 байта xxHash32 был быстрее Jenkins примерно в 4,4 раза, CityHash32 --- в 3,1 раза. При 1024 байтах различие по пропускной способности достигало соответственно 9,8 и 7,7 раза. Следовательно, Jenkins OAAT не является оптимальным по скорости, особенно на длинных входах.
Для оценки качества использовался другой тест, в нём сравнивались Jenkins OAAT, 64-битный xxHash и \texttt{t1ha2\_atonce} [\Ref{T1HA}]. Поскольку формат \texttt{set:version} сохраняет не более 32 бит хеша, при сопоставлении учитывались младшие 32 выходных бита всех трёх функций.
Тест измерял лавинный эффект на реальном корпусе экспортируемых C++-символов ALT Linux p11. Утилита \texttt{provided\_symbols} извлекла из 259 ELF-файлов пяти пакетов 577\,509 уникальных имён. Пакеты выбирались среди имеющих наиболее длинные \texttt{Provides set:}-строки. Корпус специально является сложным для хеширования похожих строк: 99,60\% символов имеют с другим символом общий префикс длиной не менее 12 знаков, 95,05\% --- не менее 24 знаков, а медиана максимального общего префикса равна 61 знаку.
Для каждого бита вычислялась доля $q_i$ корпусных хешей, в которых этот бит равен единице. Идеальному распределению соответствует $q_i=0{,}5$. Результаты обоих тестов представлены в табл.~\Ref{HASH_QUALITY_EXOTIC}. Значения приведены в процентах от полной шкалы вероятности; последний столбец содержит максимальное среднее абсолютное отклонение младших 32 бит от $0{,}5$.
Среднее абсолютное отклонение по младшим 32 битам вычислялось как:
\[
\overline{\Delta}=\frac{1}{32}\sum_{i=0}^{31}|p_i-0{,}5|.
\]
Меньшее значение $\overline{\Delta}$ соответствует более равномерному лавинному эффекту.
\begin{table}[H]
\begin{center}
\caption{\label{HASH_QUALITY_EXOTIC}Качество перемешивания младших 32 бит хеш-функций}
\scriptsize
\begin{tabular}{|l|r|r|r|r|}
\hline
Функция & \makecell{Корпус\\$\overline{\Delta}$, \%} & \makecell{Корпус\\$\Delta_{\max}$, \%} \\
\hline
Jenkins OAAT & 0,0475 & 0,167 \\
\hline
xxHash64 & 0,0431 & 0,122 \\
\hline
t1ha2 & 0,0615 & 0,188 \\
\hline
\end{tabular}
\end{center}
\end{table}
Все три функции продемонстрировали близкое к равномерному распределение. На реальном корпусе лучший результат получен для xxHash64, однако разность между функциями составляла сотые доли процента, а максимальное отклонение отдельного бита не превысило 0,188\%.
Таким образом, тестирование на похожих C++-символах не выявило недостаточного перемешивания Jenkins OAAT. xxHash64 и t1ha2 остаются кандидатами для отдельной оценки производительности, но качество распределения само по себе не обосновывает замену Jenkins.
\References
\begin{enumerate}
\item
\Label{SETC}
Tourbin A.
\emph{set.c --- base62, Golomb and set-string routines}.
ALT RPM source code, 2010--2012.
[Электронный ресурс] URL: \url{https://git.altlinux.org/gears/r/rpm.git?a=blob;f=lib/set.c}.
\item
\Label{JENKINS}
Jenkins B.
\emph{A Hash Function for Hash Table Lookup}.
1997, обновлено в 2013 г.
[Электронный ресурс] URL: \url{https://burtleburtle.net/bob/hash/doobs.html}.
\item
\Label{GOLOMB-RICE}
Wikipedia contributors.
\emph{Golomb coding: описание кодов Голомба и Райса}.
[Электронный ресурс] URL: \url{https://en.wikipedia.org/wiki/Golomb_coding}.
\item
\Label{SET9}
Гудов Д.О.
\emph{set9.c --- экспериментальная реализация алгоритмов set:version}.
Исходный код на GitHub.
[Электронный ресурс] URL: \url{https://github.com/kr0sh512/alt-rpm-set-version/blob/main/reimplement/set9.c}.
\item
\Label{ROARING}
RoaringBitmap.org.
\emph{Roaring Bitmaps --- A better compressed bitset}.
[Электронный ресурс] URL: \url{https://roaringbitmap.org/}.
\item
\Label{BASE64}
Josefsson S.
\emph{The Base16, Base32, and Base64 Data Encodings}. RFC~4648, 2006.
[Электронный ресурс] URL: \url{https://www.rfc-editor.org/info/rfc4648}.
\item
\Label{ZSTD}
Collet Y., Kucherawy M.
\emph{Zstandard Compression and the application/zstd Media Type}. RFC~8878, 2021.
[Электронный ресурс] URL: \url{https://www.rfc-editor.org/info/rfc8878}.
\item
\Label{XXHASH}
Collet Y.
\emph{xxHash --- extremely fast non-cryptographic hash algorithm}.
Исходный код и документация.
[Электронный ресурс] URL: \url{https://github.com/Cyan4973/xxHash}.
\item
\Label{CITYHASH}
Google.
\emph{CityHash --- a family of hash functions for strings}.
Исходный код и документация.
[Электронный ресурс] URL: \url{https://github.com/google/cityhash}.
\item
\Label{T1HA}
Erthink.
\emph{t1ha --- Fast Positive Hash}.
Исходный код и документация.
[Электронный ресурс] URL: \url{https://gitflic.ru/project/erthink/t1ha}.
\end{enumerate}
+141
View File
@@ -0,0 +1,141 @@
%% ---------------------------------------------------------------------------
\documentclass[openany, twoside, a4paper, 10pt]{extbook}
%% ---------------------------------------------------------------------------
\usepackage[utf8]{inputenc}
\usepackage[english, russian]{babel}
%% ---------------------------------------------------------------------------
\usepackage{indentfirst}
\frenchspacing
\raggedbottom
%% ---------------------------------------------------------------------------
\usepackage{url}
%% ---------------------------------------------------------------------------
\usepackage{ifpdf}
\ifpdf
\usepackage[pdftex]{graphicx}
\usepackage{cmap}
\pdfcompresslevel=9
\DeclareGraphicsExtensions{.pdf,.png,.jpg}
\else
\usepackage[dvips]{graphicx}
\DeclareGraphicsExtensions{.eps}
\fi
\graphicspath{ {./images/} }
%% ---------------------------------------------------------------------------
\usepackage{float}
\restylefloat{table}
\usepackage{multirow}
%% ---------------------------------------------------------------------------
\usepackage{amsmath}
\usepackage{amssymb}
\usepackage{amscd}
\usepackage{bm}
\usepackage{caption}
\usepackage{makecell}
\usepackage{nameref}
%% ---------------------------------------------------------------------------
\usepackage{titlesec}
\titleformat{\section}[block]{\rmfamily\Large\bfseries\filcenter}{\thesection.}{1em}{}
%% ---------------------------------------------------------------------------
\sloppy
%% ---------------------------------------------------------------------------
% a5paper: 148 x 210
% print area: 110 x 180
% top = bottom = (210 - 180) / 2 = 15
% left = right = (148 - 110) / 2 = 19
\usepackage[%
left=5cm,%
top=5.85cm,%
right=5cm,%
bottom=5.85cm,%
headsep=0.2cm,%
footskip=0.5cm,%
]{geometry}
%% ---------------------------------------------------------------------------
\usepackage{titlesec}
\newpagestyle{lvkpagestyle}{
\sethead{}{}{}
\setfoot{}{\usepage}{}
}
\pagestyle{lvkpagestyle}
%% ---------------------------------------------------------------------------
\makeatletter
\newcommand\Title[2]{%
%\par
\begin{centering}
\medskip
{
\textbf{#1} \\
}
{
\Large
\textbf{\textsc{#2}} \\
}
\bigskip
\end{centering}
\@afterheading
}
\makeatother
%% ---------------------------------------------------------------------------
\makeatletter
\newcommand{\References}{
\medskip
\section*{Литература}
\nopagebreak
\medskip
\@afterheading
}
\makeatother
\makeatletter
\newcommand{\ReferencesEng}{
\medskip
\section*{References}
\nopagebreak
\medskip
\@afterheading
}
\makeatother
%% ---------------------------------------------------------------------------
\makeatletter
\newcommand{\unchapter}[1]{%
\begingroup
\let\@makechapterhead\@gobble % make \@makechapterhead do nothing
\chapter{#1}
\endgroup
}
\makeatother
%% ---------------------------------------------------------------------------
\makeatletter
\newcommand{\startgroup}[1]{\addtocontents{toc}{\contentsline{chapter}{#1}{}}}
\makeatother
%% ---------------------------------------------------------------------------
\renewcommand*\thesection{\arabic{section}}
\newcommand{\AddRefPrefix}[1]{
\renewcommand{\Pageref}[1]{\pageref{#1##1}}
\renewcommand{\Ref}[1]{\ref{#1##1}}
\renewcommand{\Label}[1]{\label{#1##1}}
}
%%\newcommand{\Ref}[1]{\ref{#1}}
\newcommand{\Pageref}[1]{\pageref{#1}}
\newcommand{\Label}[1]{\label{#1}}
%% ---------------------------------------------------------------------------
\renewcommand{\thetable}{\arabic{table}}
\renewcommand{\thefigure}{\arabic{figure}}
%% ---------------------------------------------------------------------------
\usepackage{pdfpages}
%% ---------------------------------------------------------------------------
\begin{document}
%% \setcounter{page}{3}
\setcounter{tocdepth}{0}
%% \tableofcontents
%% ---------------------------------------------------------------------------
% \startgroup{Секция 1. Разные вещи}
% \unchapter{\textnormal{\textit{Иванов И.И.}} Первая статья в сборнике}
% \AddRefPrefix{th1}
\input{article_hbd}
%% ---------------------------------------------------------------------------
\end{document}
%% ---------------------------------------------------------------------------
+175 -142
View File
@@ -3,15 +3,19 @@ import argparse
import ctypes
import gc
import os
import shutil
import statistics
import subprocess
import time
import sys
import tempfile
from pathlib import Path
HERE = Path(__file__).resolve().parent
BUILD = HERE / "build"
LIBC = ctypes.CDLL(None)
LIBC.free.argtypes = [ctypes.c_void_p]
TIME_COMMAND = os.environ.get("TIME_COMMAND") or shutil.which("time") or "/usr/bin/time"
TIME_FORMAT = "%U\t%S"
class SetAPI:
@@ -49,108 +53,132 @@ class SetAPI:
return encoded
def median_fini(api, symbols, bpp, calls, rounds):
samples = []
for _ in range(rounds):
sets = [api.new_with_symbols(symbols) for _ in range(calls)]
results = []
start = time.perf_counter_ns()
for value in sets:
results.append(api.lib.set_fini(value, bpp))
samples.append((time.perf_counter_ns() - start) / calls)
if not all(results):
raise RuntimeError("set_fini returned NULL")
encoded = [ctypes.string_at(result) for result in results]
if len(set(encoded)) != 1:
raise RuntimeError("set_fini is not deterministic")
for value, result in zip(sets, results):
api.release(value, result)
return statistics.median(samples)
def make_symbols(args):
symbols = tuple(
f"symbol_{i:08d}_version_ALT_{i % 97}".encode() for i in range(args.symbols)
)
required = (
symbols[::2]
if args.required is None
else tuple(symbols[i * args.symbols // args.required] for i in range(args.required))
)
return symbols, required
def median_build(api, symbols, bpp, calls, rounds):
samples = []
for _ in range(rounds):
sets = []
results = []
start = time.perf_counter_ns()
for _ in range(calls):
def api_for(name):
if name == "set9":
return SetAPI(BUILD / "libset9.so")
if name == "direct":
return SetAPI(BUILD / "libdirect-hash.so")
raise ValueError(f"unknown implementation: {name}")
def child_main(args):
symbols, required = make_symbols(args)
api = api_for(args.time_child_impl)
checksum = 0
if args.time_child_operation == "fini":
# Measured by /usr/bin/time around this child process. The repeated work
# is set construction plus set_fini; keeping construction in the same
# child avoids Python-side subsection timers while still reporting CPU
# user/system time from the external time utility.
for _ in range(args.fini_calls):
value = api.new_with_symbols(symbols)
result = api.lib.set_fini(value, bpp)
sets.append(value)
results.append(result)
samples.append((time.perf_counter_ns() - start) / calls)
if not all(results):
raise RuntimeError("set_fini returned NULL")
for value, result in zip(sets, results):
result = api.lib.set_fini(value, args.bpp)
if not result:
raise RuntimeError("set_fini returned NULL")
checksum ^= len(ctypes.string_at(result))
api.release(value, result)
return statistics.median(samples)
def median_add(api, symbols, calls, rounds):
samples = []
for _ in range(rounds):
values = []
start = time.perf_counter_ns()
for _ in range(calls):
values.append(api.new_with_symbols(symbols))
samples.append((time.perf_counter_ns() - start) / calls)
for value in values:
elif args.time_child_operation == "add":
for _ in range(args.fini_calls):
value = api.new_with_symbols(symbols)
checksum ^= int(value)
api.lib.set_free(value)
return statistics.median(samples)
def median_cmp(api, provider, requirement, calls, rounds):
expected = api.lib.rpmsetcmp(provider, requirement)
if expected != 1 or api.lib.rpmsetcmp(provider, provider) != 0:
raise RuntimeError(f"unexpected rpmsetcmp result: {expected}")
for _ in range(100):
api.lib.rpmsetcmp(provider, requirement)
samples = []
for _ in range(rounds):
checksum = 0
start = time.perf_counter_ns()
for _ in range(calls):
elif args.time_child_operation == "build":
for _ in range(args.fini_calls):
value = api.new_with_symbols(symbols)
result = api.lib.set_fini(value, args.bpp)
if not result:
raise RuntimeError("set_fini returned NULL")
checksum ^= len(ctypes.string_at(result))
api.release(value, result)
elif args.time_child_operation == "cmp-cold":
provider = api.encode(symbols, args.bpp)
requirement = api.encode(required, args.bpp)
for _ in range(args.cold_calls):
pid = os.fork()
if pid == 0:
result = api.lib.rpmsetcmp(provider, requirement)
os._exit(0 if result == 1 else 1)
_, status = os.waitpid(pid, 0)
if status != 0:
raise RuntimeError(f"cold rpmsetcmp child failed: status={status}")
checksum += 1
elif args.time_child_operation == "cmp-warm":
provider = api.encode(symbols, args.bpp)
requirement = api.encode(required, args.bpp)
expected = api.lib.rpmsetcmp(provider, requirement)
if expected != 1 or api.lib.rpmsetcmp(provider, provider) != 0:
raise RuntimeError(f"unexpected rpmsetcmp result: {expected}")
for _ in range(100):
api.lib.rpmsetcmp(provider, requirement)
for _ in range(args.cmp_calls):
checksum += api.lib.rpmsetcmp(provider, requirement)
samples.append((time.perf_counter_ns() - start) / calls)
if checksum != calls:
if checksum != args.cmp_calls:
raise RuntimeError("rpmsetcmp result changed during benchmark")
return statistics.median(samples)
else:
raise RuntimeError(f"unknown timed operation: {args.time_child_operation}")
# Keep a small observable side effect so timed loops are not optimized away
# inside the C library or by future wrappers.
print(checksum, file=sys.stderr)
def cold_cmp_once(api, provider, requirement):
read_fd, write_fd = os.pipe()
pid = os.fork()
if pid == 0:
os.close(read_fd)
start = time.perf_counter_ns()
result = api.lib.rpmsetcmp(provider, requirement)
elapsed = time.perf_counter_ns() - start
os.write(write_fd, f"{elapsed} {result}".encode())
os.close(write_fd)
os._exit(0)
os.close(write_fd)
payload = b""
while chunk := os.read(read_fd, 128):
payload += chunk
os.close(read_fd)
_, status = os.waitpid(pid, 0)
if status != 0:
raise RuntimeError(f"cold rpmsetcmp child failed: status={status}")
elapsed, result = map(int, payload.split())
if result != 1:
raise RuntimeError(f"unexpected cold rpmsetcmp result: {result}")
return elapsed
def median_cmp_cold(api, provider, requirement, calls, rounds):
def measure_with_time(args, implementation, operation, calls):
samples = []
for _ in range(rounds):
total = sum(cold_cmp_once(api, provider, requirement) for _ in range(calls))
samples.append(total / calls)
return statistics.median(samples)
for _ in range(args.rounds):
with tempfile.NamedTemporaryFile(prefix="arsv-time-", delete=False) as handle:
time_path = Path(handle.name)
command = [
TIME_COMMAND,
"-f",
TIME_FORMAT,
"-o",
str(time_path),
"--",
sys.executable,
str(Path(__file__).resolve()),
"--skip-build",
"--time-child",
"--time-child-impl",
implementation,
"--time-child-operation",
operation,
"--symbols",
str(args.symbols),
"--bpp",
str(args.bpp),
"--rounds",
"1",
"--fini-calls",
str(args.fini_calls),
"--cmp-calls",
str(args.cmp_calls),
"--cold-calls",
str(args.cold_calls),
]
if args.required is not None:
command.extend(["--required", str(args.required)])
try:
subprocess.run(command, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
user_text, system_text = time_path.read_text().split()
finally:
time_path.unlink(missing_ok=True)
samples.append((float(user_text) / calls, float(system_text) / calls))
users = [sample[0] for sample in samples]
systems = [sample[1] for sample in samples]
return statistics.median(users), statistics.median(systems)
def verify_complete_decoding(api, provider, requirement):
@@ -167,11 +195,17 @@ def verify_complete_decoding(api, provider, requirement):
raise RuntimeError("second operand was not decoded and validated completely")
def format_time(ns):
return f"{ns / 1000:.2f} us"
def format_cpu_time(seconds):
return f"{seconds * 1_000_000:.2f} us"
def main():
def format_ratio(new, old):
if old == 0:
return "n/a"
return f"{new / old:.2f}x"
def build_parser():
parser = argparse.ArgumentParser(description="Compare set9 and direct-hash set APIs")
parser.add_argument("--symbols", type=int, default=1000)
parser.add_argument(
@@ -185,6 +219,18 @@ def main():
parser.add_argument("--cmp-calls", type=int, default=2000)
parser.add_argument("--cold-calls", type=int, default=20)
parser.add_argument("--skip-build", action="store_true")
parser.add_argument("--time-child", action="store_true", help=argparse.SUPPRESS)
parser.add_argument("--time-child-impl", choices=("set9", "direct"), help=argparse.SUPPRESS)
parser.add_argument(
"--time-child-operation",
choices=("fini", "add", "build", "cmp-cold", "cmp-warm"),
help=argparse.SUPPRESS,
)
return parser
def main():
parser = build_parser()
args = parser.parse_args()
if args.symbols < 2 or not 10 <= args.bpp <= 32:
parser.error("symbols must be >= 2 and bpp must be in 10..32")
@@ -192,26 +238,27 @@ def main():
parser.error("required must be in 1..symbols-1")
if min(args.rounds, args.fini_calls, args.cmp_calls, args.cold_calls) < 1:
parser.error("rounds and call counts must be positive")
if args.time_child and not (args.time_child_impl and args.time_child_operation):
parser.error("--time-child requires --time-child-impl and --time-child-operation")
if not args.skip_build:
subprocess.run([str(HERE / "build.sh")], check=True)
symbols = tuple(
f"symbol_{i:08d}_version_ALT_{i % 97}".encode() for i in range(args.symbols)
)
required = (
symbols[::2]
if args.required is None
else tuple(symbols[i * args.symbols // args.required] for i in range(args.required))
)
if args.time_child:
child_main(args)
return
if not Path(TIME_COMMAND).is_file():
raise RuntimeError(f"time executable not found: {TIME_COMMAND}")
symbols, required = make_symbols(args)
apis = {
"set9": SetAPI(BUILD / "libset9.so"),
"direct": SetAPI(BUILD / "libdirect-hash.so"),
"set9": api_for("set9"),
"direct": api_for("direct"),
}
gc.disable()
try:
timings = {name: [[] for _ in range(5)] for name in apis}
lengths = {}
encoded = {}
for name, api in apis.items():
@@ -221,31 +268,20 @@ def main():
wire_format = "D1/base64" if provider.startswith(b"D1") else "golomb/base62"
lengths[name] = (len(provider), wire_format)
operations = (
lambda name, api: median_fini(api, symbols, args.bpp, args.fini_calls, 1),
lambda name, api: median_add(api, symbols, args.fini_calls, 1),
lambda name, api: median_build(api, symbols, args.bpp, args.fini_calls, 1),
lambda name, api: median_cmp_cold(
api, encoded[name][0], encoded[name][1], args.cold_calls, 1
),
lambda name, api: median_cmp(
api, encoded[name][0], encoded[name][1], args.cmp_calls, 1
),
)
names = tuple(apis)
for operation_index, operation in enumerate(operations):
for round_index in range(args.rounds):
order = names if round_index % 2 == 0 else tuple(reversed(names))
for name in order:
timings[name][operation_index].append(operation(name, apis[name]))
timings = {
name: tuple(statistics.median(samples) for samples in operation_samples)
for name, operation_samples in timings.items()
}
# Run validation after timing: forked cold samples must inherit an empty
# decoded-set cache from the parent process.
for name, api in apis.items():
verify_complete_decoding(api, *encoded[name])
operations = (
("set_fini child", "fini", args.fini_calls),
("new+add child", "add", args.fini_calls),
("new+add+fini child", "build", args.fini_calls),
("rpmsetcmp cold", "cmp-cold", args.cold_calls),
("rpmsetcmp warm", "cmp-warm", args.cmp_calls),
)
timings = {name: [] for name in apis}
for label, operation, calls in operations:
for name in apis:
timings[name].append(measure_with_time(args, name, operation, calls))
finally:
gc.enable()
@@ -253,18 +289,15 @@ def main():
print("implementation set_chars format")
for name in apis:
print(f"{name:<14} {lengths[name][0]:>9} {lengths[name][1]}")
print("\noperation set9 direct direct/set9")
labels = (
"set_fini only",
"new+add (ctypes)",
"new+add+fini (ctypes)",
"rpmsetcmp cold",
"rpmsetcmp warm",
)
for index, label in enumerate(labels):
old = timings["set9"][index]
new = timings["direct"][index]
print(f"{label:<22} {format_time(old):>10} {format_time(new):>10} {new / old:>12.2f}x")
print("\noperation set9_user set9_sys direct_user direct_sys user_ratio sys_ratio")
for index, (label, _operation, _calls) in enumerate(operations):
old_user, old_sys = timings["set9"][index]
new_user, new_sys = timings["direct"][index]
print(
f"{label:<22} {format_cpu_time(old_user):>10} {format_cpu_time(old_sys):>9} "
f"{format_cpu_time(new_user):>11} {format_cpu_time(new_sys):>10} "
f"{format_ratio(new_user, old_user):>10} {format_ratio(new_sys, old_sys):>9}"
)
if __name__ == "__main__":
+25 -18
View File
@@ -16,6 +16,7 @@ PERF_EVENTS='task-clock,context-switches,cpu-migrations,page-faults,minor-faults
PACKAGER='krosh <gudovdo@my.msu.ru>'
APT_SOURCE=/etc/apt/sources.list.d/alt.list
APT_GET=/usr/lib/apt/apt-get
TIME_COMMAND=/usr/bin/time
RPM_BUILD_GIT=https://git.altlinux.org/gears/r/rpm-build.git
RPM_GIT=https://git.altlinux.org/gears/r/rpm.git
@@ -93,7 +94,7 @@ operation_label()
run_once()
{
local operation=$1 variant=$2 run=$3 start end status perf_stat
local operation=$1 variant=$2 run=$3 status perf_stat time_file
local libdir="$variant/lib/usr/lib64"
local root="$COMMON/root"
local raw="$variant/raw"
@@ -117,8 +118,9 @@ run_once()
)
fi
start=$(date +%s%N)
if env LC_ALL=C APT_CONFIG="$COMMON/apt.conf" \
time_file="$raw/$operation.$run.time.tsv"
if "$TIME_COMMAND" -f $'%U\t%S' -o "$time_file" -- \
env LC_ALL=C APT_CONFIG="$COMMON/apt.conf" \
LD_LIBRARY_PATH="$libdir" \
taskset -c "$CPU" "${command[@]}" \
>"$raw/$operation.$run.stdout" \
@@ -127,10 +129,7 @@ run_once()
else
status=$?
fi
end=$(date +%s%N)
RUN_TIME=$(awk -v start="$start" -v end="$end" \
'BEGIN { printf "%.6f", (end - start) / 1000000000 }')
read -r RUN_USER_TIME RUN_SYSTEM_TIME <"$time_file"
RUN_STATUS=$status
}
@@ -228,8 +227,8 @@ benchmark_variant()
{
local variant=$1 result=$2 debug_file=$3 debuginfo_rpm=$4
local runtime_file=$5 dso_name=$6
local operation run average label status_text perf_result perf_dir
local -a times statuses
local operation run average_user average_system label status_text perf_result perf_dir
local -a user_times system_times statuses
local -a operations=(
check
autoremove
@@ -241,7 +240,7 @@ benchmark_variant()
)
mkdir -p "$variant/raw/perf-stat"
printf 'command\taverage_seconds\trun1_seconds\trun2_seconds\trun3_seconds\texit_status\n' \
printf 'command\taverage_user_seconds\taverage_system_seconds\trun1_user_seconds\trun1_system_seconds\trun2_user_seconds\trun2_system_seconds\trun3_user_seconds\trun3_system_seconds\texit_status\n' \
>"$result"
perf_result=${result%.tsv}.perf-stat.tsv
perf_dir=${result%.tsv}.perf
@@ -257,22 +256,27 @@ benchmark_variant()
fi
for operation in "${operations[@]}"; do
times=()
user_times=()
system_times=()
statuses=()
for ((run = 1; run <= RUNS; ++run)); do
run_once "$operation" "$variant" "$run"
times+=("$RUN_TIME")
user_times+=("$RUN_USER_TIME")
system_times+=("$RUN_SYSTEM_TIME")
statuses+=("$RUN_STATUS")
if ((COLLECT_PERF)); then
append_perf_stat "$operation" "$run" \
"$variant/raw/perf-stat/$operation.$run.tsv" "$perf_result"
fi
printf '%s: run %d/%d: %ss, status=%s\n' \
"$operation" "$run" "$RUNS" "$RUN_TIME" "$RUN_STATUS"
printf '%s: run %d/%d: user=%ss system=%ss status=%s\n' \
"$operation" "$run" "$RUNS" \
"$RUN_USER_TIME" "$RUN_SYSTEM_TIME" "$RUN_STATUS"
done
average=$(printf '%s\n' "${times[@]}" | \
average_user=$(printf '%s\n' "${user_times[@]}" | \
awk '{ total += $1 } END { printf "%.6f", total / NR }')
average_system=$(printf '%s\n' "${system_times[@]}" | \
awk '{ total += $1 } END { printf "%.6f", total / NR }')
status_text=${statuses[0]}
@@ -284,9 +288,11 @@ benchmark_variant()
done
label=$(operation_label "$operation")
printf '%s\t%s\t%s\t%s\t%s\t%s\n' \
"$label" "$average" \
"${times[0]}" "${times[1]}" "${times[2]}" "$status_text" \
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$label" "$average_user" "$average_system" \
"${user_times[0]}" "${system_times[0]}" \
"${user_times[1]}" "${system_times[1]}" \
"${user_times[2]}" "${system_times[2]}" "$status_text" \
>>"$result"
if ((COLLECT_PERF && PERF_RECORD)); then
@@ -300,6 +306,7 @@ for command in git gear-hsh hsh rpm rpmquery rpm2cpio cpio apt-get apt-cache \
taskset awk sed date sha256sum ldd readelf readlink eu-unstrip; do
command -v "$command" >/dev/null || fail "required command not found: $command"
done
[[ -x $TIME_COMMAND ]] || fail "time executable not found: $TIME_COMMAND"
if ((COLLECT_PERF)); then
command -v perf >/dev/null || \
fail "perf is not installed; install the ALT package: apt-get install perf"