diff --git a/Docs/set9_ru.md b/Docs/set9_ru.md new file mode 100644 index 0000000..10d07b9 --- /dev/null +++ b/Docs/set9_ru.md @@ -0,0 +1,586 @@ +# Реализация set:version в `set9.c` + +Документ описывает экспериментальную реализацию [`reimplement/set9.c`](../reimplement/set9.c). +Общий смысл механизма set:version и устройство исходного [`set.c`](../set.c) разобраны в +[`set:version_ru.md`](set:version_ru.md). Здесь основное внимание уделено отличиям `set9.c`: +потоковому кодированию и декодированию, сортировке, двум LRU-кэшам и +новому алгоритму сравнения отсортированных множеств. + +## Назначение + +ALT RPM записывает наборы ELF-символов в версии зависимостей вида: + +```text +libfoo.so.X()(64bit) = set: +libfoo.so.X()(64bit) >= set: +``` + +`Provides` содержит хэши предоставляемых символов, а `Requires` — хэши символов, требуемых +от конкретной библиотеки. `rpmsetcmp()` сравнивает декодированные множества по включению, +а не как обычные RPM-версии. + +`set9.c` сохраняет формат строк и Jenkins OAAT из исходной реализации. Поэтому его задача — +изменить внутреннее представление и горячие пути, не меняя смысл корректных set-строк. +Совпадение формата не устраняет фундаментальную вероятность коллизий: сравниваются усечённые +32-битные хэши, а не исходные имена символов. + +## Основные отличия от исходного `set.c` + +| Область | Исходный `set.c` | `set9.c` | +| ------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------- | +| Хранение имён | отдельный `xstrdup()` для каждого имени | общая растущая строковая арена и массив смещений | +| Сортировка | всегда `qsort()` | `qsort()` для `< 128` элементов, LSD radix sort для остальных | +| Кодирование | отдельные массивы delta, битов и Base62 | один поток `hash -> delta -> Golomb-Rice -> Base62` | +| Декодирование | сложный табличный декодер пар символов | простой потоковый декодер с 64-битным аккумулятором | +| Кэш | один кэш первого операнда на 256 записей | два независимых кэша по 512 записей, по одному на каждый операнд | +| Поиск в кэше | линейный просмотр и `memmove()` | хэш-бакеты и двусвязный LRU со сменой позиции за `O(1)` | +| Нормализация `bpp` | выполняется для каждого сравнения | выполняется при промахе кэша и сохраняется в записи кэша | +| Сравнение | флаги `ge`/`le`, sentinel-элементы и макросы прыжков | проверка мощности, `memcmp()` и `sorted_subset()` | +| Освобождение `struct set` | сама структура не освобождается | освобождаются арена, массив символов и сама структура | + +## Публичный API + +Как и исходная реализация, файл предоставляет пять функций: + +```c +int rpmsetcmp(const char *set1, const char *set2); + +struct set *set_new(void); +void set_add(struct set *set, const char *sym); +const char *set_fini(struct set *set, int bpp); +struct set *set_free(struct set *set); +``` + +### `set_new()` + +Создаёт пустой `struct set`. Все счётчики и ёмкости устанавливаются в ноль, указатели — в +`NULL`. + +### `set_add()` + +Добавляет копию строки символа во внутреннюю строковую арену: + +1. массив `symbols_v` увеличивается блоками по 1024 элемента; +2. строковая арена при первом выделении получает 4096 байт; +3. при нехватке места ёмкость арены удваивается, пока не вместит новую строку; +4. в `symbols_v` сохраняются смещение строки в арене и нулевое начальное значение хэша. + +В отличие от хранения отдельных указателей, перемещение арены через `xrealloc()` не делает +записи массива недействительными: в них находятся смещения, а не адреса строк. + +### `set_fini()` + +Хэширует, сортирует и кодирует добавленные имена. Возвращаемая строка выделена через +`xstrdup()` и **не содержит** префикс `set:`. Префикс добавляет вызывающий код, например +`mkset`. + +В `set9.c` контракт проверяется через `assert()`: + +```text +set != NULL +set->cnt > 0 +10 <= bpp <= 32 +``` + +Это отличается от исходного `set.c`, где пустое множество или недопустимый `bpp` приводят к +`NULL`. При сборке с `NDEBUG` проверки `assert()` исчезают, поэтому передавать некорректные +аргументы нельзя. + +### `set_free()` + +Освобождает строковую арену, массив `symbols_v` и сам `struct set`, затем возвращает `NULL`. +Типичный вызов: + +```c +set = set_free(set); +``` + +### `rpmsetcmp()` + +Сравнивает две set-строки. Префикс `set:` у каждого операнда необязателен. + +Результаты совместимы с исходным API: + +| Код | Значение | +| ---: | ---------------------------------------------------- | +| `1` | первое множество строго содержит второе | +| `0` | множества равны | +| `-1` | первое множество строго содержится во втором | +| `-2` | множества несравнимы по включению | +| `-3` | ошибка метаданных или декодирования первого операнда | +| `-4` | ошибка метаданных или декодирования второго операнда | + +В типичном RPM-вызове первым операндом остаётся `Provides`, вторым — `Requires`. + +## Формат set-строки + +После необязательного `set:` строка имеет структуру: + +```text + +``` + +Первые два символа кодируют числа формулой: + +```c +value = character + 7 - 'a'; +character = value - 7 + 'a'; +``` + +Ограничения: + +```text +10 <= bpp <= 32 +7 <= Mshift <= 31 +Mshift < bpp +payload не пуст +``` + +`payload` использует алфавит: + +```text +0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ +``` + +Его числовые значения расположены как `0..9`, `a..z`, `A..Z`. Символ `Z` имеет значение +61 и одновременно служит escape-маркером для исходных шестибитных значений 61, 62 и 63. + +## Внутреннее представление создаваемого множества + +```c +struct set { + size_t cnt; + size_t symbols_cap; + size_t strings_len; + size_t strings_cap; + char *strings; + struct symbols { + size_t offset; + unsigned hash; + } *symbols_v; +}; +``` + +Здесь: + +- `cnt` — число добавленных имён; +- `symbols_cap` — ёмкость массива метаданных; +- `strings_len` и `strings_cap` — занятая и выделенная части строковой арены; +- `strings` — последовательность NUL-терминированных имён; +- `offset` — начало конкретного имени относительно `strings`; +- `hash` — хэш, вычисляемый только при `set_fini()`. + +Такое представление сокращает число отдельных выделений памяти при построении больших наборов. + +## Работа `set_fini()` + +Полный путь выглядит так: + +```text +строковая арена и offsets + | Jenkins OAAT + mask(bpp) + v +массив struct symbols с хэшами + | qsort или LSD radix sort + v +отсортированные хэши + | предупреждения о коллизиях + удаление повторов + v +массив уникальных хэшей + | потоковый delta + Golomb-Rice + Base62 + v +строка +``` + +### Jenkins OAAT и усечение + +Для каждого имени вычисляется тот же Jenkins one-at-a-time hash, что и в исходном файле. +Начальное состояние равно `0x9e3779b9`. Результат ограничивается младшими `bpp` битами: + +```c +unsigned mask = (bpp < 32) ? (1u << bpp) - 1 : ~0u; +hash_value = hash(symbol) & mask; +``` + +### `sort_symbols()` + +Для малых наборов (`count < 128`) используется `qsort()`. Для больших наборов применяется +стабильная LSD radix sort по байтам хэша: + +1. число проходов равно `ceil(bpp / 8)`; +2. на каждом проходе строятся 256 счётчиков/смещений; +3. элементы стабильно распределяются по текущему байту; +4. источник и приёмник меняются местами; +5. если итог оказался во временном массиве, он копируется назад. + +Поскольку перед сортировкой хэш уже ограничен `bpp` битами, обработки +`ceil(bpp / 8)` байтов достаточно. Временный массив размером `count` размещается на стеке. + +### Коллизии и повторяющиеся символы + +После сортировки соседние равные хэши проверяются попарно. Для разных строк печатается: + +```text +warning: hash collision: +``` + +Две одинаковые строки предупреждения не создают. Затем все одинаковые **хэш-значения** +схлопываются в одно. Поэтому настоящая хэш-коллизия после предупреждения всё равно становится +одним элементом кодируемого множества — это свойство исходного формата. + +### Выбор `Mshift` + +Параметр Golomb-Rice вычисляется как: + +```text +Mshift = bpp - floor(log2(count)) - 1 +``` + +После этого значение ограничивается диапазоном `7..31` и проверяется условие +`Mshift < bpp`. Идея та же, что в исходной реализации: при примерно равномерном распределении +хэшей средняя дельта близка к `2^bpp / count`. + +### Потоковый `encode_set()` + +Исходный `set.c` сначала создаёт массив дельт, затем массив по одному байту на бит и лишь потом +Base62-строку. В `set9.c` эти стадии объединены. + +Для каждого отсортированного хэша вычисляется: + +```c +unsigned delta = current - previous; +unsigned q = delta >> Mshift; +unsigned r = delta & ((1u << Mshift) - 1); +``` + +В `encode_writer` последовательно добавляются: + +```text +q нулевых битов +1 — разделитель +Mshift младших битов r +``` + +Все биты идут младшими вперёд (LSB-first), как и в исходном формате. + +### `struct encode_writer` + +"Писатель" содержит: + +- `uint64_t bits` — накопленные ещё не выведенные биты; +- `filled` — число занятых битов; +- `escaped` — ожидание второй половины escape-пары; +- `pending_high` — старшие два бита значения 61, 62 или 63; +- `output` — текущую позицию в выходной строке. + +`encode_writer_put()` добавляет обычное поле битов. `encode_writer_zeros()` добавляет длинную +unary-последовательность нулей блоками не более 56 бит, чтобы аккумулятор оставался в пределах +`uint64_t`. `encode_writer_flush()` выводит накопленные Base62-цифры. + +В обычном состоянии потребляется шесть потоковых битов. Если значение равно 61, 62 или 63: + +1. выводится `Z`; +2. различие между 61/62/63 сохраняется как два старших бита следующего символа; +3. из потока для следующего символа берутся только четыре бита. + +При завершении неполный символ дополняется нулями. После payload записывается `\0`. + +## Метаданные декодируемой строки + +Для декодирования используется `struct set_meta`: + +```c +struct set_meta { + const char *str; + const char *payload; + size_t len; + size_t payload_len; + int bpp; + int Mshift; + int bit_capacity; + int value_capacity; +}; +``` + +Подготовка разделена на две части. + +### `set_meta_init()` — быстрая проверка + +Функция читает только начало строки: + +1. проверяет наличие двух метасимволов и хотя бы одного символа payload; +2. декодирует и проверяет `bpp`; +3. декодирует и проверяет `Mshift`; +4. сохраняет `str`, `payload`, `bpp` и `Mshift`. + +На этой стадии `strlen()` не вызывается. Это позволяет сначала выполнить дешёвую проверку и +поискать уже готовую запись в кэше. + +### `set_meta_fini()` — вычисление размеров + +Функция вызывается только при промахе кэша: + +```text +len = strlen(str) +payload_len = len - 2 +bit_capacity = payload_len * 6 +value_capacity = bit_capacity / (Mshift + 1) +``` + +`bit_capacity` является верхней границей: обычный символ даёт шесть битов, а два символа +escape-пары дают десять битов, то есть не больше двенадцати. Каждое Golomb-Rice-значение +требует как минимум `Mshift + 1` бит. Если места нет даже для одного значения, +строка отклоняется. + +## Потоковое декодирование + +Путь декодирования объединяет три логических стадии: + +```text +Base62 payload + | decode_chunk() + v +6- или 10-битные блоки + | q-state + r-state + v +Golomb-Rice delta + | previous += delta + v +отсортированные хэши +``` + +Отдельные массивы битов и дельт не создаются. + +### Таблица `char_to_num[]` + +Таблица из 256 элементов классифицирует любой байт: + +- `0..60` — обычная Base62-цифра; +- `61` — `Z`; +- `0xff` — конец строки; +- `0xee` — недопустимый символ. + +Индекс берётся как `unsigned char`, поэтому байты с установленным старшим битом тоже безопасно +попадают в диапазон таблицы и отклоняются как недопустимые. + +### `decode_chunk()` + +Обычный символ со значением меньше 61 возвращает шесть битов. При `Z` функция читает следующий +символ и: + +1. отклоняет конец строки сразу после `Z`; +2. отклоняет не-Base62 символ; +3. отклоняет комбинацию старших битов `11`, которая создала бы новый `Z`; +4. собирает один 10-битный блок из значения 61/62/63 и четырёх младших битов второго символа. + +Таким образом escape-пара сразу преобразуется в исходные десять потоковых битов. + +### `decode_set()` + +Декодер хранит ещё не обработанные биты в `uint64_t bits`, а их количество — в `filled`. +Для каждого значения он проходит два состояния. + +#### Unary-часть `q` + +Если аккумулятор пуст, загружается следующий блок. Полностью нулевой блок целиком прибавляется +к `q`. Иначе `__builtin_ctzll(bits)` находит число нулей до разделительной единицы. Нули и сама +единица удаляются из аккумулятора. + +#### Остаток `r` + +Пока доступно меньше `Mshift` битов, загружаются следующие блоки. Затем младшие `Mshift` битов +образуют остаток, а дельта восстанавливается как: + +```c +unsigned delta = (q << Mshift) | r; +``` + +Дельта сразу превращается в исходный хэш: + +```c +previous += delta; +hash_arr[count++] = previous; +``` + +#### Завершение строки + +Конец payload допустим в состоянии чтения `q`, если осталось не более пяти нулевых битов +Base62-дополнения. Более длинный нулевой хвост даёт внутреннюю ошибку `-10`. + +Конец строки во время набора `Mshift` битов остатка означает незавершённое значение и даёт +внутреннюю ошибку `-11`. Ошибки `decode_chunk()` также возвращаются вверх. Публичный +`rpmsetcmp()` скрывает конкретный внутренний код и преобразует его в `-3` или `-4` в зависимости +от операнда. + +## Нормализация точности: `downsample_set()` + +Строки с разным `bpp` нельзя сравнивать напрямую. Обе стороны приводятся к: + +```text +target_bpp = min(bpp1, bpp2) +``` + +Один вызов `downsample_set()` уменьшает точность с `bpp + 1` до `bpp`: + +1. маска равна `(1u << bpp) - 1`; +2. бинарным поиском находится первый элемент с удаляемым старшим битом; +3. исходный отсортированный массив делится на две отсортированные половины; +4. у второй половины удаляется старший бит; +5. половины сливаются как два отсортированных массива; +6. появившиеся после усечения дубликаты удаляются. + +Если требуется убрать несколько битов, операция повторяется по одному биту. В `set9.c` результат +нормализации сохраняется в кэше, поэтому та же строка при том же `target_bpp` не проходит этот +цикл повторно. + +## Два кэша декодированных множеств + +`cache_decode_set()` обслуживает два независимых кэша: + +```text +cache_id = 0 — первый операнд +cache_id = 1 — второй операнд +``` + +Каждый кэш содержит до `CACHE_SIZE = 512` записей и 1024 бакета. Разделение не даёт потоку +часто повторяющихся `Requires` вытеснять кэш `Provides` и наоборот, хотя реальный эффект зависит +от порядка вызовов решателя зависимостей. + +### Ключ записи + +Для быстрого предварительного отбора вычисляется fingerprint: + +```c +str[0] | (str[2] << 8) | (str[3] << 16) +``` + +Он смешивается с `target_bpp`, после чего выбирается бакет. Совпадения fingerprint недостаточно: +успешный hit требует также равного `target_bpp` и полного `strcmp()` строки. Поэтому коллизия +fingerprint влияет только на длину цепочки, но не подменяет декодированное множество. + +`target_bpp` входит в ключ, поскольку одна и та же исходная строка может участвовать в +сравнениях с операндами разной точности и, следовательно, иметь разные нормализованные массивы. + +### Устройство записи + +Одним `xmalloc()` выделяются: + +```text +struct cache_ent +массив unsigned для хэшей +копия исходной строки +``` + +Запись хранит fingerprint, исходную строку, число элементов и `target_bpp`. + +### Промах + +При промахе происходят следующие шаги: + +1. `set_meta_fini()` вычисляет длины и верхнюю границу числа значений; +2. выделяется новая запись; +3. `decode_set()` создаёт отсортированный массив хэшей; +4. при необходимости массив поэтапно уменьшается до `target_bpp`; +5. строка и метаданные копируются в запись; +6. запись добавляется в хэш-бакет и в начало логического LRU. + +### LRU + +Все записи одного кэша образуют двусвязный список от `oldest` к `newest`. При hit запись +отцепляется от текущего места и становится `newest`; это не требует сдвига массива записей. +При заполненном кэше удаляется `oldest`, включая поиск ссылки на него в соответствующей цепочке +бакета. + +## Сравнение в `rpmsetcmp()` + +Последовательность работы: + +1. снять `set:`, если он присутствует; +2. выполнить `set_meta_init()` для обеих строк; +3. выбрать минимальный `target_bpp`; +4. получить первый операнд из кэша `0` или декодировать его; +5. если строки полностью одинаковы, вернуть `0`; +6. получить второй операнд из кэша `1` или декодировать его; +7. сравнить мощности и содержимое нормализованных массивов. + +Быстрый возврат для одинаковых строк выполняется после полноценного декодирования первого +операнда. Поэтому равенство строк не позволяет принять некорректный payload без проверки. +Метаданные второго операнда к этому моменту также уже прошли начальную проверку. + +### Равная мощность + +Нормализованные массивы отсортированы и не содержат дубликатов. Если `cnt1 == cnt2`, два +множества могут быть либо равны, либо несравнимы. Поэтому достаточно: + +```c +memcmp(hash_arr1, hash_arr2, cnt1 * sizeof(unsigned)) +``` + +Совпадение даёт `0`, различие — `-2`. + +### Разная мощность + +Множество с большим числом уникальных хэшей не может быть строгим подмножеством меньшего. +Поэтому проверяется только одно возможное направление: + +```text +cnt1 > cnt2: set2 ⊆ set1 ? 1 : -2 +cnt1 < cnt2: set1 ⊆ set2 ? -1 : -2 +``` + +Саму проверку выполняет `sorted_subset(small, large)`. + +### `sorted_subset()` + +Алгоритм выбирается по отношению размеров: + +```c +jump = large_count / small_count; +``` + +Если `jump < 4`, используется обычное линейное слияние: указатель большого массива двигается +до текущего элемента малого. Для близких по размеру множеств это последовательный проход с +хорошей локальностью. + +Если `jump >= 4`, вызывается `step_lower_bound()`. Функция сначала делает шаги примерно на +среднее расстояние между искомыми элементами, затем делит шаг пополам, пока не найдёт первый +элемент, который не меньше искомого. Это позволяет пропускать части большого массива при +разреженном малом множестве без sentinel-значений и выхода за границы. + +При первом отсутствующем элементе `sorted_subset()` возвращает ложь. + +## `SELF_TEST` + +При `SELF_TEST` компилируется `main()`, который проверяет только публичный API на коротких +наборах: + +1. строгое надмножество возвращает `1`; +2. равные множества возвращают `0`; +3. строгое подмножество возвращает `-1`; +4. пересекающиеся несравнимые множества возвращают `-2`; +5. выделенные строки и оба объекта освобождаются. + +В отличие от исходного `set.c`, встроенный тест является smoke-тестом, а не полной проверкой совместимости. + +## "Карта функций" + +| Функция | Роль | +| ------------------------ | -------------------------------------------- | +| `set_new()` | создание контейнера символов | +| `set_add()` | добавление имени в строковую арену | +| `set_free()` | освобождение контейнера | +| `hash()` | Jenkins OAAT | +| `sort_symbols()` | выбор `qsort()` или radix sort | +| `encode_golomb_Mshift()` | вычисление параметра Golomb-Rice | +| `encode_writer_*()` | потоковая упаковка битов в Base62 | +| `encode_set()` | объединённые delta, Golomb-Rice и Base62 | +| `set_fini()` | полный путь от имён до set-строки | +| `set_meta_init()` | быстрая проверка заголовка строки | +| `set_meta_fini()` | длины и верхние границы буферов | +| `decode_chunk()` | чтение обычного или escape Base62-блока | +| `decode_set()` | объединённые Base62, Golomb-Rice и delta | +| `cache_decode_set()` | два бакетных LRU-кэша | +| `downsample_set()` | уменьшение точности на один бит | +| `step_lower_bound()` | прыжок и уточнение позиции в большом массиве | +| `sorted_subset()` | проверка включения отсортированных множеств | +| `rpmsetcmp()` | публичное сравнение set-строк | diff --git a/README.md b/README.md index 6720136..6a87106 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ Research on ALT Linux RPM set.c and dependency comparison using encoded symbol sets. - [Docs about current set:version implementation](Docs/set:version.md) +- [Docs about rewrite implementation (ru)](Docs/set9_ru.md) # Compare results