Realisation v1

This commit is contained in:
Efim Beshmenev
2026-08-11 22:19:34 +03:00
commit aea6e330e3
194 changed files with 62580 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
# Архитектура
## Представления
`AdaptiveSequence<T>` хранит `variant<vector<T>, TieredStorage<T>>`. Векторный
режим предназначен для random/sequential read и append. Tiered-режим состоит из
независимо выделенных циклических leaf-блоков (`RingBlock`) и многоуровневого
каталога весов. Каталог строит только необходимое число уровней до заданного
максимума; поиск спускается по уровням, а отдельный Fenwick index восстанавливает
логическую позицию stable ID.
Текущий `TieredStorage` — сегментированный исследовательский вариант, а не
полная реализация implicit tiered vector с offsets на каждом внутреннем узле.
Offsets реально используются внутри leaf, но split/merge меняет массив
дескрипторов leaf и перестраивает каталог. Это важное ограничение: некоторые
uniform-edit результаты отражают O(number_of_leaves) обслуживание split, а не
теоретическую границу полноценного tiered vector.
## Адаптивное решение
Storage и policy разделены. Контейнер передаёт `OperationSample`, policy
возвращает:
```cpp
struct AdaptationDecision {
StorageMode target;
TieredConfig tiered_config;
double expected_saving;
};
```
Для каждого окна оцениваются vector и все tiered-кандидаты. Главная форма
модели edit-cost:
```text
vector: fixed + move_unit * sizeof(T) * (N - position)
tiered: fixed
+ move_unit * sizeof(T) * distance_inside_leaf
+ directory_unit * locality_multiplier * N / leaf
+ lookup(levels)
```
Первая переменная часть tiered растёт с leaf, вторая — с `N/leaf`. Поэтому
минимум сдвигается к большим блокам при росте N. Для близких последовательных
edit-позиций directory multiplier уменьшается: горячая область не создаёт
split во множестве разных leaf, и policy выбирает меньший блок.
Для каждого leaf вычисляется минимально достаточная глубина при текущих `N` и
fanout. Сравниваются три альтернативы: остаться, перейти в другое
представление, либо перестроить tiered с другой геометрией. Решение требует:
```text
(current_cost - candidate_cost) * forecast
> rebuild_cost * safety_factor
```
Для `vector→tiered`, `tiered→vector` и `tiered→tiered` используются разные
safety factor. Дополнительно действуют minimum residency, минимальное улучшение
shape, EWMA и два подтверждающих окна. После перехода статистическое окно
начинается заново. Все коэффициенты находятся в `AdaptationConfig` и могут
заменяться другой policy.
## Почему чтение по умолчанию не перестраивает storage
`ReadAdaptationMode::deferred` лишь записывает статистику. Pending-решение
выполняется на следующей мутации или при явном `adapt_now()`. Это сохраняет
важный контракт: обычное чтение не инвалидирует уже выданную ссылку.
`eager_nonconst` оставлен только как benchmark-эксперимент. Он может выполнить
переход вокруг non-const `operator[]`, добавляет проверку в горячий read path и
имеет более слабую семантику. Первые измерения не оправдали его как default.
Для read-only фазы, после которой мутаций нет, вызывающий код может поставить
явную maintenance point:
```cpp
values.adapt_now();
```
## Инвалидация
- const-чтение и сбор статистики в deferred mode ничего не инвалидируют;
- `set()` не меняет логические позиции, но ссылка на заменённое значение не
должна использоваться как ссылка на старый объект;
- любая структурная операция в auto mode может также выполнить rebuild, поэтому
инвалидирует все references, pointers и iterators;
- conversion, shape rebuild, `force_*`, `reserve`, `clear` и
`make_contiguous()` инвалидируют всё позиционное;
- stable ID переживает shifts, split и смену представления; он перестаёт быть
живым только после удаления элемента или `clear()`.
Debug-проверка iterator хранит structural generation и бросает `logic_error`
после инвалидирования.
## Hash index
`AdaptiveSequence<T, true>` использует open-addressed flat table
`value -> {head_id,count}`. Дубликаты связаны через компактные intrusive links в
ID metadata. `find_one` означает любой экземпляр. `find_all` возвращает
логически отсортированные позиции и потому может стоить O(k log k), тогда как
`find_all_ids` возвращает unordered stable IDs за O(k).
+78
View File
@@ -0,0 +1,78 @@
# Сборка и benchmark-методика
## Профили кода
Все сравниваемые бинарники — Release x64 `/O2`, `/MT`, без IPO/LTO. Отличается
только ось SIMD:
| Профиль | Ключи проекта | Назначение |
|---|---|---|
| `scalar` | `/O2 /d2Qvec- /Qvec-report:1 /Oi-` | MSVC-specific `project-novec` |
| `baseline` | `/O2`, без `/arch:AVX*` | обычный MSVC x64 |
| `avx2` | `/O2 /arch:AVX2` | разрешён AVX2 |
`/d2Qvec-` — внутренний, недокументированный ключ MSVC, а не переносимая
гарантия. Командная строка проверена автоматическим аудитом для MSVC 19.51;
вывод `/Qvec-report` пока не разбирается как отдельное доказательство. `scalar`
не означает «ни одной SIMD-инструкции в процессе»: x64 ABI имеет SSE2-baseline,
а CRT `memmove` может runtime-dispatch-ить SIMD. Дополнительно задаётся
`_USE_STD_VECTOR_ALGORITHMS=0`, чтобы STL не включала свои явно
векторизованные алгоритмы. Это честнее называть `project-novec`.
## Матрица
Benchmark сравнивает:
- reserved `std::vector`;
- `std::deque`;
- `std::list` с index semantics;
- forced tiered для каждого leaf 64/128/256/512/1024;
- adaptive deferred;
- experimental adaptive eager-read.
`std::deque` и `std::list` — только внешние фиксированные baselines;
`AdaptiveSequence` никогда в них не переключается. Для `std::list` runner
эмулирует index semantics линейным проходом, поэтому доступ по индексу имеет
O(N).
Типы: `uint32_t`, `uint64_t`, trivially-copyable 16/32/64-byte значения и
non-trivial movable string wrapper. Workloads включают random access, traversal,
append, 99.99/99/80/50% reads, uniform/localized edits, bursty edits и отдельный
phase trace:
```text
uniform edits -> maintenance
localized edits -> maintenance
reads -> maintenance
```
Последний trace нужен именно для проверки смены leaf, а не только
`vector↔tiered`. Время rebuild остаётся внутри измеряемого end-to-end интервала.
Tuning перебирает leaf 64…1024 и maximum directory levels 2/3/4. Каталог сам
останавливает построение на минимально достаточной глубине.
## CSV
Raw CSV содержит profile/type/workload/N/seed, initial и final tier config,
total/ns-per-op, p50/p95/p99/max edit latency, checksum, allocated bytes,
storage switches, shape rebuilds и final mode. Checksum должен совпасть у всех
контейнеров одной трассы.
`tools/analyze_results.ps1` сначала берёт median повторов, затем сравнивает с
лучшим фиксированным baseline в каждой ячейке. Итоговый geometric mean считается
иерархически по sizes → workloads → types → profiles, чтобы семейство с большим
числом строк не получило лишний вес.
## Масштабы запуска
```bat
benchmark.bat smoke rem проверка всего конвейера
benchmark.bat quick rem рабочее исследование
benchmark.bat full rem финальные повторы, длительный запуск
```
Для публикации финального числа нужны одинаковый power plan, отсутствие тяжёлой
фоновой нагрузки, закрепление на физическом ядре, randomized paired order и
holdout workloads/seeds. Текущие quick-данные являются calibration set, а не
финальным доказательством.
+171
View File
@@ -0,0 +1,171 @@
# Наблюдения и ограничения
## Что уже установлено
На Ryzen 9 5900X / MSVC 19.51 quick-tuning дал разные оптимумы, поэтому
фиксированный `leaf=512` отвергнут как итоговая стратегия. Для baseline
`uint32_t` наблюдалась следующая картина:
| Workload | N=10k | N=100k | N=1M |
|---|---:|---:|---:|
| uniform edit | 64 | 256 | 1024 |
| localized edit | 64 | 128 | 256 |
| mixed 80% read | 128 | 512 | 1024 |
Это calibration-наблюдение, а не универсальная таблица. Оно привело к
динамической модели `leaf` и отдельному `tiered→tiered` переходу. Кандидатная
геометрия учитывает `sizeof(T)`: дорогие перемещения естественно уменьшают
выбираемый leaf.
Эта таблица относится к исходному bulk layout, где свежие листья заполнялись
до 100%. Checkpoint `20260810-bulk-slack-smoke-v13` оставляет 1/8 leaf свободной
после bulk-конверсии: иначе первый случайный insert почти гарантированно платил
за split и rebuild каталога. В baseline-only smoke после изменения ranking стал
таким: forced leaf 64 — 0.39608, leaf 128 — 0.36662, adaptive deferred —
0.34673, reserved vector — 0.29797. Это не межзапусковой speedup и не финальное
доказательство, а сигнал, что старую таблицу leaf нужно заново проверить на
одинаковом quick holdout с тремя профилями.
## Hysteresis и итоговый quick-checkpoint
После v13 выполнены три контрольные серии:
- `20260811-hysteresis-smoke-v14` — baseline smoke завершённого hysteresis.
После согласования правила двухфазной трассы validator проходит на 58 compare
и 14 adapt ячейках.
- `20260811-policy-quick-v15-hysteresis` — baseline quick, adapt-only. Среди
адаптивных policy лучший aggregate score показал `moderate deferred`
(`0.224689`), но лучший fixed leaf128 остался значительно выше (`0.819729`).
Серия снабжена source/binary manifests.
- [`20260811-213422-quick`](../results/benchmarks/20260811-213422-quick/README.md)
— итоговый engineering quick-checkpoint итерации: tune, adapt, compare и index
для `scalar`, `baseline`, `avx2`.
Профили финальной серии запускались последовательно в фиксированном порядке
`scalar -> baseline -> avx2`; внутри каждого профиля порядок suites был
`tune -> adapt -> compare -> index`. Каждый кандидат внутри повтора получал один
и тот же материализованный trace, а стартовый кандидат циклически сдвигался между
повторами. Это снижает простое преимущество первого кандидата, но не заменяет
полную рандомизацию запусков.
Aggregate score нормирован по лучшему фиксированному baseline в каждой ячейке;
выше — лучше. Для compare по всем трём профилям получена такая картина:
| Кандидат | Aggregate score |
|---|---:|
| forced tiered, leaf64 | 0.395052 |
| forced tiered, leaf128 | 0.391598 |
| adaptive deferred | 0.347315 |
| adaptive eager | 0.326839 |
| reserved vector | 0.146329 |
| external deque | 0.080267 |
| external list-index | 0.012763 |
`adaptive deferred` выше reserved vector в этой конкретной quick-матрице, но
уступает лучшим fixed leaf-конфигурациям. Следовательно, данные не доказывают
победу над лучшим oracle или универсальное превосходство.
По парному агрегированию времени adaptive быстрее vector примерно в 2.37 раза,
но медленнее leaf64 на 13.74% и leaf128 на 12.75%; leaf256 он опережает на 1.07%.
Основной regret теперь создаёт не churn, а поздний первый переход. Для blob64 и
non-trivial при N=100k adaptive делает один switch без rebuild, но до него платит
за дорогие vector edits; относительно leaf64 проигрыш составляет примерно 2.45x
и 4.03x. В `read_99`, N=1M наблюдается другая ошибка: доля edits ниже 5% entry
threshold, хотя их абсолютная стоимость уже оправдывает tiered. Это аргумент за
накопленный cost/regret в следующей policy, а не только порог доли операций.
В adapt-наборе сильнейшими также остались fixed leaf128 (`0.817859`) и leaf256
(`0.788496`). Лучший адаптивный policy, `moderate deferred`, получил `0.216999`.
В tuning leaf128 оказался сильнейшим quick-кандидатом во всех трёх профилях:
scalar `0.878968`, baseline `0.873461`, avx2 `0.887906`. Это локальный результат
данной матрицы, а не универсальная константа.
Лучший единый межпрофильный компромисс — `128/64/3`; `128/64/4` отстаёт от него
всего на 0.276%, поэтому depth пока находится в пределах измерительного шума.
Выбор leaf устойчивее: профили совпали в 14 из 15 логических tuning-ячеек.
Random access выбирал 1024, большинство edit/mixed-ячеек — 64, localized edit
при N=1M — 128.
Итоговый validator pass охватывает 282 compare и 48 adapt ячеек. Первоначальный
лимит одной leaf-rebuild для phase trace был ошибкой спецификации валидатора:
трасса содержит две разные edit-фазы, и по одному устойчивому выбору формы на
каждую фазу не является churn. Только для явно двухфазного сценария лимит был
исправлен до двух; измерительные CSV не менялись. После исправления полный
quick-набор прошёл проверку.
Validator работает по медианам. В raw compare один repeat двухфазной трассы при
N=1M имеет три leaf-rebuilds, тогда как медиана равна двум; будущая проверка
должна применять ограничения также к каждому repeat, не только к median-cell.
`std::deque` и `std::list` в этих таблицах — только внешние фиксированные
baselines, не режимы `AdaptiveSequence`. Для дорогих индексных ячеек harness
применяет `skipped_cost_guard`; поэтому у них меньше eligible cells, и пропуски
нельзя интерпретировать как нулевое время или победу кандидата.
Index-suite подтверждает полезность специализированного flat hash index для
случайного lookup относительно линейного поиска vector, но результат относится
только к lookup-подзадаче и сопровождается существенной памятью под индекс. Он
не является доказательством общей победы контейнера. Геометрическое среднее
потребление памяти составило 9.26x от payload vector, с диапазоном 7.00x17.49x.
При N=1M suite выполнял лишь 50 запросов, построение индекса исключалось из
таймера, а свежепостроенный hash был cache-hot; поэтому экстремальные speedup
нельзя считать устойчивой production-оценкой.
Offline oracle, который в каждой calibration-ячейке задним числом выбирает
лучший leaf, был геометрически примерно в **1.318 раза** быстрее фиксированного
leaf 512 по срезу `levels=3` quick tuning трёх профилей. По семействам потенциал
составил около 1.54x для uniform edit, 1.67x для localized edit и 1.37x для
mixed-80. Это показывает ценность смены shape, но завышает достижимый online
результат: oracle знает будущее и не платит за переходы. Новый phase benchmark
как раз включает эту цену.
Deferred read adaptation оставлена default. В раннем smoke-тесте eager-вариант
иногда выигрывал несколько процентов на одном bursty trace, но проигрывал в
общем из-за проверки на каждом non-const read и создавал неожиданную
инвалидацию. Фазовые тесты теперь используют явный `adapt_now()` как безопасную
maintenance point.
Компактный flat hash после перехода на 32-bit internal IDs занимал примерно
28 bytes/element при низкой cardinality и около 54 bytes/element для уникальных
`uint32_t` при N=100k. Это существенно больше 4-byte payload, поэтому индекс
остаётся compile-time optional и не входит в storage score. `contains` был
порядка 4–18 ns в зависимости от cardinality; логически отсортированный
`find_all` на множестве дубликатов ожидаемо дороже `find_all_ids`.
AVX2 дал заметный выигрыш главным образом reserved vector; для tiered/adaptive
различия малы и смешаны с фиксированным порядком профилей. У
adaptive-deferred sequential `uint32` найден устойчивый AVX2 codegen outlier:
1.85–1.98x относительно baseline во всех пяти повторах при верных checksums.
Он требует отдельного анализа generated code, а не постфактум объяснения SIMD.
## Чего пока нельзя утверждать
- Не доказано, что adaptive implementation уже имеет geometric mean > 1 против
лучшего фиксированного контейнера на независимом full holdout.
- Текущий tiered backend имеет ring offsets на leaf и многоуровневый каталог
весов, но не реализует offsets/carry на каждом внутреннем узле полноценного
implicit tiered vector.
- Split/merge может перестраивать каталог и сдвигать дескрипторы leaf за
O(number_of_leaves). Именно поэтому uniform edit с маленьким leaf резко
ухудшался при N=1M. Policy умеет избегать такой shape, но это не заменяет
дальнейшую оптимизацию backend.
- Independent blocks реализованы; contiguous/virtual-memory backing пока не
реализован и не сравнен.
- Delta overlay пока не реализован. Его следует оценивать как отдельную третью
структуру, а не добавлять до появления честного выигрыша.
- Exception safety bulk conversion для throwing move типов требует отдельной
production-доработки.
- Финальная серия — quick с пятью повторами, фиксированным последовательным
порядком профилей, без process affinity/pinning и с планом питания Balanced.
- Scalar использует внутренний для MSVC `/d2Qvec-`: это проверенный на
зафиксированной версии компилятора project-novec, но не публичный контракт
MSVC и не доказательство отсутствия SIMD внутри CRT или x64 runtime.
## Следующая исследовательская граница
Phase-aware hysteresis завершён и прошёл трёхпрофильный quick-validator.
Следующий этап — независимый full holdout с несколькими seeds, affinity/pinning
и перемешанным порядком профилей/кандидатов, а также сравнение regret policy с
лучшим fixed leaf oracle. Следующая архитектурная граница — заменить descriptor
rebuild на настоящий multi-level offset/carry backend. Старые CSV сохраняются,
чтобы изменение архитектуры нельзя было выдать за улучшение без измерений.