Вы находитесь на странице: 1из 126

САНКТ-ПЕТЕРБУРГСКИЙ ГОСУДАРСТВЕННЫЙ

УНИВЕРСИТЕТ

На правах рукописи

Шапоренков
Дмитрий Александрович

ЭФФЕКТИВНЫЕ МЕТОДЫ ИНДЕКСИРОВАНИЯ ДАННЫХ И

ВЫПОЛНЕНИЯ ЗАПРОСОВ В СИСТЕМАХ УПРАВЛЕНИЯ

БАЗАМИ ДАННЫХ В ОСНОВНОЙ ПАМЯТИ

05.13.11 — математическое и программное обеспечение


вычислительных машин, комплексов и компьютерных сетей

Диссертация на соискание ученой степени кандидата


физико-математических наук

Научный руководитель —
доктор физико-математических наук, профессор Б.А.Новиков

Санкт-Петербург
2006
Оглавление

1. Введение 6
2. Предпосылки СУБД-ОП 10
2.1. Особенности архитектур современных компьютеров . . . . . 10
2.1.1. Процессоры . . . . . . . . . . . . . . . . . . . . . . . . 10
2.1.1.1. Внутренний параллелизм . . . . . . . . . . . 11
2.1.1.2. Предсказание переходов . . . . . . . . . . . . 12
2.1.2. Система памяти . . . . . . . . . . . . . . . . . . . . . 15
2.1.2.1. Кэш-память . . . . . . . . . . . . . . . . . . . 15
2.1.2.2. Характеристики кэш-памяти . . . . . . . . . 17
2.1.2.3. Виды кэш-промахов . . . . . . . . . . . . . . 18
2.1.2.4. Явное управление кэш-памятью . . . . . . . 19
2.1.2.5. Устройство трансляции адресов . . . . . . . 20
2.1.2.6. Стоимость доступа к памяти . . . . . . . . . 21
2.1.2.7. Методы измерения эффективности доступа к
памяти . . . . . . . . . . . . . . . . . . . . . . 23
2.1.2.8. Пример . . . . . . . . . . . . . . . . . . . . . 25
2.1.3. Особенности многопроцессорных систем . . . . . . . . 27
2.2. Архитектура традиционных СУБД и СУБД-ОП . . . . . . . 29
2.2.1. Компоненты архитектуры традиционной СУБД . . . 29
2.2.1.1. Приложение . . . . . . . . . . . . . . . . . . . 31
2.2.1.2. Менеджер соединений . . . . . . . . . . . . . 31
2.2.1.3. Оптимизатор запросов . . . . . . . . . . . . . 31
2.2.1.4. Менеджер ввода-вывода . . . . . . . . . . . . 33
2.2.1.5. Менеджер транзакций . . . . . . . . . . . . . 35

2
2.2.1.6. Менеджер восстановления . . . . . . . . . . . 35
2.2.1.7. Индексы . . . . . . . . . . . . . . . . . . . . . 37
2.2.1.8. Операционная система . . . . . . . . . . . . . 37
2.2.1.9. Аппаратное обеспечение . . . . . . . . . . . . 39
2.2.2. Отличия архитектуры СУБД-ОП . . . . . . . . . . . . 39
2.2.2.1. Отличия оптимизатора запросов . . . . . . . 41
2.2.2.2. Отличия менеджера транзакций . . . . . . . 42
2.2.2.3. Отличия менеджера восстановления . . . . . 43
2.2.2.4. Отличия индексных структур . . . . . . . . . 44
2.2.2.5. Выводы . . . . . . . . . . . . . . . . . . . . . 44

3. Общие методы оптимизации алгоритмов и структур данных


для улучшения использования кэш-памяти 46
3.1. Оптимизация структур данных . . . . . . . . . . . . . . . . . 47
3.1.1. Структура узлов . . . . . . . . . . . . . . . . . . . . . 48
3.1.1.1. Удаление ключевых полей . . . . . . . . . . . 48
3.1.1.2. Перегруппировка полей . . . . . . . . . . . . 49
3.1.1.3. Компрессия ключевых полей. . . . . . . . . . 50
3.1.1.4. Удаление указателей . . . . . . . . . . . . . . 51
3.1.2. Взаимное расположение узлов . . . . . . . . . . . . . 53
3.2. Оптимизация алгоритмов . . . . . . . . . . . . . . . . . . . . 54
3.2.1. Модель локальности ссылок . . . . . . . . . . . . . . . 54
3.2.2. Различные методы доступа к памяти . . . . . . . . . 57
3.2.3. Методы оптимизации для уменьшения пространствен-
ного интервала . . . . . . . . . . . . . . . . . . . . . . 59
3.2.3.1. Введение временных структур данных . . . . 59
3.2.3.2. Изменение порядка обхода структуры данных 60
3.2.4. Методы оптимизации для уменьшения интервала пе-
реиспользования . . . . . . . . . . . . . . . . . . . . . 61
3.2.4.1. Разбиение структуры данных на блоки . . . 61
3.2.4.2. Распределение структуры данных . . . . . . 62
3.2.4.3. Логическое распределение . . . . . . . . . . . 65
3.2.5. Использование явной предвыборки . . . . . . . . . . . 67

3
4. Алгоритмы выполнения операции соединения для СУБД-
ОП 70
4.1. Модели хранения данных . . . . . . . . . . . . . . . . . . . . 70
4.2. Операция естественного соединения . . . . . . . . . . . . . . 73
4.2.1. Естественное соединение в СУБД-ОП . . . . . . . . . 73
4.2.1.1. Близкие работы . . . . . . . . . . . . . . . . 74
4.2.1.2. Мульти-индексы — структура для эффектив-
ного естественного соединения в СУБД-ОП . 77
4.2.1.2.1. Организация мульти-индекса. . . . . 79
4.2.1.2.2. Экспериментальная проверка эф-
фективности мульти-индекса. . . . . 81
4.2.1.2.3. Запросы, включающие естественное
соединение и выборку. . . . . . . . . 84
4.3. Операция соединения по предикату над множественнознач-
ными атрибутами . . . . . . . . . . . . . . . . . . . . . . . . . 85
4.3.1. Известные алгоритмы . . . . . . . . . . . . . . . . . . 88
4.3.1.1. Алгоритм вложенных циклов (SN L) . . . . . 88
4.3.1.2. Алгоритм распределения (P SJ ) . . . . . . . 91
4.3.1.3. Алгоритм вложенных циклов с использова-
нием инвертированных списков (IF N L) . . . 93
4.3.1.4. Алгоритм соединения инвертированных фай-
лов (IF J ) . . . . . . . . . . . . . . . . . . . . 95
4.3.1.5. Алгоритм, использующий индекс пересече-
ния (IX ) . . . . . . . . . . . . . . . . . . . . 98
4.3.1.6. Обработка случая пустого множества в ал-
горитмах, основанных на инвертированных
списках . . . . . . . . . . . . . . . . . . . . . 100
4.3.2. Предварительные эксперименты с алгоритмами со-
единения SN L, P SJ и IX . . . . . . . . . . . . . . . 101
4.3.2.1. Набор данных Classes. . . . . . . . . . . . . . 103
4.3.2.2. Набор данных SD1: варьирующийся
|Domain(A)|. . . . . . . . . . . . . . . . . . . 103
4.3.2.3. Выводы из предварительных экспериментов 104

4
4.3.3. Модификация алгоритмов IF N L и IF J для лучшего
использования кэш-памяти . . . . . . . . . . . . . . . 105
4.3.3.1. Алгоритм IF N L . . . . . . . . . . . . . . . . 105
4.3.3.2. Алгоритм IF J . . . . . . . . . . . . . . . . . 108
4.3.4. Экспериментальная проверка алгоритмов IF N L(k) и
IF J(l) . . . . . . . . . . . . . . . . . . . . . . . . . . . 110
4.3.4.1. Эксперимент 1: оптимальное количество эта-
пов для IF J(n) . . . . . . . . . . . . . . . . . 110
4.3.4.2. Эксперимент 2: сравнение IF N L и IF J(l) . 112
4.3.4.3. Эксперимент 3: эффект сжатия списков . . . 113
4.3.4.4. Эксперимент 4: сравнение IF J(l) с другими
известными алгоритмами . . . . . . . . . . . 114
4.3.5. Выводы . . . . . . . . . . . . . . . . . . . . . . . . . . 116

5. Заключение 118

5
Глава 1.

Введение

В течение нескольких последних десятилетий системы управления базами


данных (СУБД) стали одним из основных приложений вычислительной
техники. Сегодня успешная деятельность многих организаций, таких как
государственные учреждения, предприятия, банки, магазины напрямую за-
висит от СУБД. Объемы данных, хранимые в таких системах, огромны и
часто достигают многих терабайт [3]. Более того, требования прикладных
областей к размерам хранимых данных постоянно растут [3, 1].
Организация таких объемов данных является нетривиальной задачей,
различными аспектами которой исследователи в области СУБД занима-
ются начиная примерно с 70х гг. 20 века [4, 1]. База данных размером в
несколько терабайт малополезна, если нет эффективного способа доступа
к хранимым данным. Запросы пользователей к базе данных могут иметь
различный характер - в одних случаях для получения ответа требуется
просмотреть все содержимое базы данных, но не менее часто запросы за-
трагивают лишь небольшую часть хранимых данных. Типичным примером
второго рода является проведение операции снятия денег со счета через
банкомат. Для такой операции обычно интерес представляет лишь счет
данного пользователя, идентифицируемый номером его кредитной карточ-
ки. Просмотр счетов всех пользователей банка в таком случае был бы оче-
видно неэффективным решением.
Традиционно СУБД хранят данные во вторичной памяти, такой как
жесткие диски [3, 1]. Основной причиной этого является большой объем

6
данных, который не может быть размещен в основной (оперативной) па-
мяти компьютера. С другой стороны, архитектуры современных вычис-
лительных систем ориентированы исключительно на обработку данных в
основной памяти. В частности, процессор предоставляет обычно лишь ин-
струкции для работы с данными, находящимися в памяти и во встроенных
регистрах [43, 26]. В результате традиционные СУБД вынуждены значи-
тельное время тратить на пересылку данных из вторичной памяти в основ-
ную и обратно [1].
В контексте вышесказанного естественной является идея разместить ба-
зу данных не во вторичной, а в основной памяти, приводящая к появлению
СУБД в основной памяти (СУБД-ОП) [1, 34]. Первые исследования, отно-
сящиеся к таким системам, появились примерно в начале 80х гг. 20 века
[34, 3]. Наиболее привлекательная черта СУБД-ОП — отсутствие необходи-
мости обращаться к медленной вторичной памяти для выполнения пользо-
вательских запросов, что дает выигрыш на величину порядка по сравнению
с традиционными СУБД [1, 19, 62]. Разумеется, ограниченный объем основ-
ной памяти влияет на максимальный размер базы данных, которую можно
разместить в основной памяти. Однако не всегда объем БД исчисляется
терабайтами, для многих приложений достаточно и нескольких гигабайт.
Кроме того, на протяжении последних десятилетий наблюдается устойчи-
вая тенденция быстрого роста объема ОП в компьютерах [26, 58], частично
связанная с удешевлением микросхем памяти [58, 11, 21].
Например, рассмотрим гипотетическую реляционную БД, содержащую
информацию о жителях крупного города с населением 4 млн человек. Одно
из отношений, Citizens, может иметь вид:

CREATE TABLE Citizens (ID int NOT NULL,


FirstName char (10) NOT NULL,
LastName char (25) NOT NULL,
Address char (30) NOT NULL,
Phone char (7))

7
Размер записи этого отношения составит примерно 80 байт, и для хра-
нения информации о всех жителях города потребуется, таким образом,
около 400 мегабайт. С учетом того, что в 2005 году объем ОП в 2 гигабай-
та не является редкостью для серверной системы, вся БД (состоящая из
нескольких отношений) может быть целиком размещена в памяти сервера
СУБД.
Проблемы оптимизация выполнения запросов в традиционных реляци-
онных СУБД хорошо формализованы и исследованы [1]. На протяжении
последних десятилетий были разработаны критерии эффективности мето-
дов выполнения запросов, и современные реляционные СУБД, как прави-
ло, способны выбирать наиболее эффективные пути выполнения запросов
без помощи пользователя или администратора [1, 6]. Однако с переносом
данных в ОП традиционные критерии эффективности перестают иметь
смысл [11, 21]: в традиционных СУБД оптимизация запросов обычно на-
правлена на минимизацию числа обращений к медленной вторичной памя-
ти, а в СУБД-ОП такие обращения не происходят вообще. Таким образом,
необходимы новые критерии эффективности и, возможно, иные методы
выполнения запросов.
Эффективное выполнение запросов в СУБД-ОП и является основным
предметом данной работы. Наши исследования касаются эффективных ин-
дексных структур и методов выполнения запросов, которые позволяют пол-
ностью использовать возможности современных компьютеров. Мы систе-
матизируем методы оптимизации работы с данными в ОП, а также пред-
лагаем ряд новых индексных структур и алгоритмов выполнения операций
и оцениваем их эффективность [51, 52, 54, 53].
Данная работа имеет следующую структуру. В главе 2 рассматривают-
ся особенности современных компьютерных архитектур и обосновывается,
почему традиционные СУБД, рассчитанные на хранение данных на диске,
не позволяют получить максимальную производительность при использо-
вании их в качестве СУБД-ОП. Там же показано, какие компоненты СУБД
требуют пересмотра для СУБД-ОП. Глава 3 описывает общие идеи, кото-

8
рые применяются для повышения эффективности алгоритмов и структур
данных в предположении, что данные располагаются в основной памяти.
Эти идеи проиллюстрированы небольшими примерами, поясняющими их
применение для простых задач, которые являются частями более сложных
проблем, возникающих при реализации СУБД. В главе 4 мы применяем
эти общие идеи к задачам оптимизации операции естественного соедине-
ния и соединения по предикату над множественнозначными атрибутами.
Мы предлагаем мульти-индексы — структуру, которая в компактном виде
хранит результат естественного соединения и допускает быстрое обновле-
ние при изменении индексируемых отношений. Мы также исследуем про-
изводительность нескольких алгоритмов для выполнения операции соеди-
нения по предикату над множественнозначными атрибутами в СУБД-ОП
и выясняем, что наиболее эффективными являются методы, основанные
на использовании инвертированных списков. Мы предлагаем модифика-
цию этих методов, учитывающую специфические для СУБД-ОП критерии
эффективности, и экспериментально демонстрируем, что эта модификация
сокращает время выполнения соединения.

9
Глава 2.

Предпосылки СУБД-ОП

2.1. Особенности архитектур современных

компьютеров

В данном разделе мы кратко описываем особенности архитектуры совре-


менных компьютеров, которые влияют на производительность СУБД-ОП.
До сих пор при анализе различных алгоритмов часто предполагается, что
доступ к ОП имеет настолько малую стоимость, что им можно пренебречь.
Мы демонстрируем, что такое предположение слишком упрощает общую
картину производительности и скрывает многие источники задержек при
выполнении программы.
Современные компьютеры состоят из одного или нескольких централь-
ных процессоров, соединенных с остальными компонентами системы, та-
кими как ОП, через системную шину. На производительность СУБД-ОП
наибольшее влияние оказывают процессор и ОП.

2.1.1. Процессоры

Разработчики процессоров пытаются достичь максимальной производи-


тельности, используя различные средства. Основным из них до последнего
времени оставалась постоянно увеличивающаяся тактовая частота [58, 26].
Согласно известному “закону Мура” [58], тактовая частота процессоров
удваивается каждые 18 месяцев. Увеличение тактовой частоты процессора

10
является “бесплатным” для программистов средством повышения произ-
водительности вычислительных систем, в том смысле, что программисты
(и в т.ч. разработчики СУБД) получают прирост производительности, не
прилагая никаких усилий по оптимизации своих программ.

2.1.1.1. Внутренний параллелизм


Увеличение тактовой частоты процессора является не единственным сред-
ством повышения производительности. Увеличение тактовой частоты свя-
зано с технологическими проблемами при производстве процессоров, в
частности, с необходимостью справляться с сильным перегревом процес-
сора, работающего на слишком высокой тактовой частоте [61]. Поэтому
другим важными средством повышения производительности процессоров
является использование внутреннего параллелизма, который появился в
архитектурах персональных компьютеров примерно с начала 90х гг. 20 ве-
ка, а в мэйнфреймах — на 10-15 лет раньше [58]. В скалярных архитектурах
внутренний параллелизм достигается за счет использования конвейериза-
ции, т.е. разбиения процесса выполнения инструкции на стадии. Устрой-
ство (предварительной) выборки и декодирования инструкций помещает
инструкции в конвейер параллельно с работой вычислительных устройств
процессора. Разбиение на стадии позволяет совмещать по времени различ-
ные этапы выполнения последовательных инструкций. Количество стадий
зависит от реализации процессора, например, в современных процессорах
Intel оно достигает нескольких десятков [29]. Стадии выполнения включают
в себя декодирование инструкции, выборку операндов, собственно выпол-
нение инструкции (которое может включать несколько стадий) и т.д.
Для повышения возможностей внутреннего параллелизма современ-
ные процессоры могут также иметь несколько вычислительных устройств
для выполнения целочисленных операций и операций с плавающей точкой
[58, 29]. Это позволяет одновременно вычислять несколько последователь-
ных инструкций в том случае, если между ними нет зависимости по дан-
ным. Кроме того, во многих современных архитектурах возможность од-
новременного использования нескольких вычислительных устройств сде-

11
лана явно доступной для программиста путем добавления специальных
SIMD-инструкций [29, 60] (SIMD — Single Instruction Multiple Data), ко-
торые применяют одну и ту же арифметическую операцию или операцию
сравнения к нескольким (обычно 2 или 4) операндам. Такие инструкции
дают возможность более эффективно кодировать многие используемые в
БД алгоритмы [67]. К сожалению, как отмечается в [43, 67], возможно-
сти современных компиляторов по генерации кода, использующего SIMD-
инструкции, существенно ограничены. Причиной этих ограничений являет-
ся сложность автоматического распознавания участков кода, допускающих
подобное распараллеливание, и доказательства корректности преобразова-
ния программы, что особенно проблематично в языках C/C++ (которые
часто применяются для реализации СУБД) из-за наличия в них конструк-
ций, допускающих произвольные действий с указателями (в частности,
арифметические, а также преобразования указателей в числа и обратно).
Так что эффективное использование SIMD-инструкций (как и других по-
добных “сложных” инструкций) сегодня во многом является ответственно-
стью программиста.

2.1.1.2. Предсказание переходов


Проблему для внутреннего параллелизма, однако, представляют инструк-
ции перехода. Поскольку значение, которое получает регистр указатель
команд (и, следовательно, адрес следующей выполняемой инструкции), во-
обще говоря, достоверно неизвестно до выполнения инструкции условного
перехода или перехода с выборкой целевого адреса из таблицы, устрой-
ство выборки и декодирования вынуждено делать предположения об этом
значении, чтобы продолжать пополнять конвейер инструкциями и избе-
жать простоя, связанного с необходимостью ожидания, пока адрес пере-
хода будет известен, и последующего заполнения конвейера. Этот процесс
называется предсказанием переходов. Помещение в конвейер инструкций,
которые, возможно, будут выполнены (при справедливости предположения
об адресе перехода) приводит к их спекулятивному выполнению. Если при
выполнении самой инструкции перехода оказывается, что предположение

12
об адресе перехода было неверным, то процессор, во-первых, вынужден ан-
нулировать результаты спекулятивного выполнения инструкций по непра-
вильно предсказанному адресу, а во-вторых, заполнять конвейер новыми
инструкциями с правильного адреса. Все это приводит к существенным
задержкам.
Данная проблема с неправильным предсказанием переходов имеет непо-
средственное отношение к программисту. Алгоритмы предсказания пере-
ходов в процессоре основаны на эвристических (‘условный переход назад,
скорее всего, выполнится, поскольку это, вероятно, переход на очередную
итерацию цикла’) и статистических (процессор поддерживает специаль-
ные таблицы со статистикой о том, как выполнялись инструкции перехода
в прошлом) соображениях. Такие алгоритмы хорошо работают в простых
ситуациях, но, разумеется, процессор не может правильно предсказать пе-
реходы в тех случаях, когда факт выполнения условного перехода нетри-
виальным образом зависит от данных, с которыми работает алгоритм.
Проиллюстрируем это на примере. Предположим, нам необходимо най-
ти первую из записей отношения Students(ID, FirstName, LastName, Age),
имеющую данное значение атрибута LastName. Предположим также, что
отношение не имеет индекса по атрибуту LastName и хранится в полностью
декомпозированной форме [3], т.е. каждый атрибут хранится в отдельной
(неупорядоченной) таблице / массиве. Подобная схема, например, исполь-
зуется в СУБД-ОП Monet [12]. Псевдокод последовательного поиска, ре-
шающий эту задачу, мог бы тогда выглядеть следующим образом:
int FindRecNoByLastName (string [] lastNames, string name)
{
for (int i = 0; i < lastNames.Length; i++)
if(lastNames [i] == name) return i;
return -1;
}
Заметим, что условие внутри цикла оказывается вплоть до заключи-
тельной итерации ложным. Поэтому процессор легко может определить
что ветка then условного оператора не выполняется, и количество ошибоч-
ных предсказаний минимально (они возможны только на первых итераци-
ях цикла, пока не накоплена статистика). Допустим теперь, что в тех же

13
условиях нам необходимо найти всех студентов моложе заданного возраста:
void FindStudentsYoungerThanAge (int [] ages, int age, int[] result,
out int totalRecords)
{
int pos = 0;
for (int i = 0; i < ages.Length; i++)
if (ages [i]<age) result[pos++] = i;
totalRecords = pos;
}
(здесь для упрощения мы опускаем код, занимающийся расширением мас-
сива result по мере добавления туда записей). Теперь условие внутри цикла
оказывается истинным, предположительно, намного чаще. Более того, если
записи в отношении не упорядочены по возрасту, нет никакой зависимости
значения условия от номера итерации. Алгоритм предсказания переходов,
основанный на статистике, не работает, и в худшем случае ошибка пред-
сказания перехода может происходить на каждой итерации цикла. Более
эффективный код, решающий ту же задачу, выглядит так:
void FindStudentsYoungerThanAge (int [] ages, int age, int [] result,
out int totalRecords)
{
int pos = 0;
for (int i = 0; i < ages.Length; i++)
{ result [pos] = i;
pos += (int)(ages [i] < age); }
totalRecords = pos;
}
Здесь выражение (int)(ages [i] < age) принимает значения 0 или 1. Мо-
дифицированный вариант выполняет лишние присваивания, зато не содер-
жит условных переходов внутри цикла. Как показано в [49], такой вари-
ант, как правило, выполняется быстрее на современных компьютерах. Мы
провели небольшой эксперимент, заключающийся в выполнении обоих ва-
риантов алгоритма на компьютере с процессором Intel Pentium 4 2.8 GHz
и 1 гигабайтом оперативной памяти при условиях |array| = 9 ∗ 107 и селек-
тивности (т.е. отношения количества записей, удовлетворяющих критерию
отбора, к общему их числу) ≈ 0.8. Среднее время выполнения первого ва-
рианта составило 0.78 c, второго — 0.67 c. При уменьшении селективности

14
выигрывал, как правило, первый вариант.

2.1.2. Система памяти

Основным свойством систем памяти, влияющим на производительность


программ, является их иерархичность [58, 26]. Причина использования
иерархических систем памяти заключается в несоответствии между ско-
ростью работы процессора и оперативной памяти (ОП или RAM, Random
Access Memory) [58]. Процессор работает намного быстрее, чем ОП, и если
текущая выполняемая инструкция должна прочитать данные из памяти
(или записать в память), то процессор вынужден простаивать, ожидая
пока данные из памяти станут доступны, чтобы продолжить вычисления.
Эти простои процессора могут нивелировать весь положительный эффект
от скорости его работы в приложениях, интенсивно работающих с памятью,
в частности, в СУБД.

2.1.2.1. Кэш-память
Для преодоления разрыва между скоростью работы процессора и оператив-
ной памяти современные компьютеры используют кэш-память или просто
кэш [58, 26, 29]. Применение кэш-памяти основано на принципе локально-
сти обращений к памяти в программе. Неформально, этот принцип состо-
ит в том, что инструкция обычно обращается к участку памяти, близкому
к тому, к которому обращались недавно выполнявшиеся инструкции. Это
позволяет вместо обращения к памяти использовать данные из кэша, за-
груженные туда предыдущими инструкциями. В случае отсутствия данных
в кэше данного уровня, процессор пытается найти их в кэше следующего
уровня. В худшем случае он вынужден обращаться за данными в опера-
тивную память. В современных архитектурах используется обычно до 3
уровней кэш-памяти, обозначаемых L1, L2 и L3.
Обычная оперативная память состоит из микросхем DRAM (Dynamic
RAM), в которых на хранение одного бита информации используется один
транзистор и один конденсатор [58]. Эти микросхемы достаточно дешевы,
их стоимость постоянно уменьшается, поэтому на системной плате в насто-

15
Таблица 2.1. Стоимость доступа для уровней иерархии памяти
Уровень Время доступа, нс
L1 0.7
L2 11
RAM 170

ящее время можно разместить много таких микросхем. Их недостатком, од-


нако, является необходимость постоянной перезарядки конденсатора, что
в совокупности с их удаленным расположением от процессора значительно
замедляет доступ к хранимым данным.
Кэш-память построена на основе микросхем SRAM (Static RAM), где
для хранения одного бита информации используется обычно 6 транзисто-
ров. Это делает такие микросхемы значительно дороже, чем DRAM, кроме
того, на хранение одного бита информации тратится намного больше ме-
ста, что означает, что микросхемы SRAM имеют гораздо меньшую емкость.
Микросхемы кэш-памяти первого уровня размещаются прямо внутри про-
цессора [58, 26] — доступ к данным в них осуществляется практически со
скоростью работы процессора. Кэш-память второго уровня находится либо
внутри процессора, либо на системной плате [58, 26]. Кэш-память третье-
го уровня, если она присутствует, находится обычно на системной плате.
С увеличением уровня уменьшается стоимость памяти, увеличивается ее
емкость и время доступа к ней. Оперативную память можно тем самым
рассматривать как один из верхних уровней в иерархии систем памяти. В
таблице 2.1 приведена стоимость доступа для различных уровней иерархии
памяти для типичной в 2004 году системы на базе процессора Intel Pentium
4 Mobile.
Заметим, что в современных архитектурах в кэш помещаются не толь-
ко данные, но и сами инструкции. Таким образом, устройство выборки
и декодирования извлекает инструкции из кэш-памяти первого уровня, и
лишь при отсутствии там требуемого адреса обращается к более высоким
уровням иерархии. Разница между инструкциями и данными, однако, за-
ключается в том, что код программы, как правило, не меняется во время ее
исполнения. Данные же могут модифицироваться во время нахождения в

16
кэше и эти изменения должны быть отражены в оперативной памяти. Это
отражение изменений может выполняться как немедленно после измене-
ния данных в кэше (write-through, или сквозная запись), так и отложенно,
при замещении данных (write-back, обратная запись) [58, 56]. В современ-
ных системах, как правило, имеются раздельные кэши первого уровня для
инструкций и данных и совмещенный кэш L2 [58].

2.1.2.2. Характеристики кэш-памяти


Каждый уровень из иерархии кэш-памяти может быть охарактеризован 3
параметрами: объемом (Capacity, C), размером блока (Line Size, S) и ассо-
циативностью (Associativity, A):

• Объем (C). Это общий размер кэш-памяти в байтах. В зависимо-


сти от уровня (L1, L2, L3) и архитектуры объем кэш-памяти может
составлять от 4 килобайт до 16 мегабайт.

• Размер блока (S). Это размер минимального блока, который пере-


дается между смежными иерархиями памяти. Если требуемый адрес
не найден в кэш-памяти данного уровня, то блок данных размером S
передается из более верхнего уровня иерархии. При этом использует-
ся возможность системной шины передавать широкие блоки данных,
состоящие из нескольких десятков байт. Кэш-память данного уровня,
тем самым, состоит из NCL = C
S кэш-блоков. Мы также будем исполь-
зовать термин кэш-блок памяти, обозначающий блок в оперативной
памяти такого же размера, как и кэш-блок данного уровня иерар-
хии. Кэш-блоки памяти всегда являются выровненными по границе
2k байт, где k = 4, 8, ..., т.е. адрес блока памяти кратен 2k .
Заметим, что передача блока данных за одно обращение к памяти бо-
лее высокого уровня иерархии использует уже упоминавшийся “прин-
цип локальности”. Предполагается, что если программе понадобилась
какая-то часть запрашиваемого блока, то остальное его содержимое,
скорее всего, также понадобится. При этом для загрузки следующих
требуемых данных не придется вновь обращаться к более медленным

17
уровням иерархии и передавать данные через медленную системную
шину.

• Ассоциативность (A). При загрузке данных из памяти более вы-


сокого уровня иерархии они помещаются в один из кэш-блоков. Для
определения номера этого блока с помощью младших разрядов ад-
реса запрашиваемых данных формируется номер множества, состо-
ящего из A блоков. Затем из этих A блоков выбирается один путем
применения политики замещения данных (replacement policy) в кэ-
ше. Используются политики, состоящие в выборе блока случайным
образом или на основе алгоритма с наиболее давним использовани-
ем (LRU — Least Recently Used). Как отмечено в [58], исследования
показывают, что существенной разницы в эффективности между раз-
личными политиками замещения нет. Содержимое выбранного блока
замещается запрашиваемым блоком, при этом возможна предвари-
тельная запись содержимого выбранного блока в память более вы-
сокого уровня иерархии, если за время пребывания в кэше оно было
модифицировано.
В случае A = NCL кэш является полностью ассоциативным, это
означает, что любой адрес памяти может быть загружен в любой из
блоков кэша. Случай A = 1 соответствует кэшу прямого отображе-
ния, при этом кэш-блок, куда будет загружено содержимое памяти,
однозначно определяется адресом памяти. Обычно A = 2, 4, 8.

2.1.2.3. Виды кэш-промахов


В случае, когда требуемые данные не найдены в кэш-памяти уровня i,
происходит кэш-промах и процессор вынужден обращаться к уровню i +
1. В зависимости от причин, вызывающих кэш-промахи, можно выделить
следующие 3 вида кэш-промахов [39]:

• Промахи первого обращения. Такие промахи возникают, когда к


данному блоку памяти процессор обращается впервые, и этот блок
памяти не мог быть загружен в кэш ранее. Следует заметить, что

18
слово “впервые” в данном контексте употребляется неформально, и
при этом всегда имеются в виду естественные временные рамки рас-
сматриваемого процесса (например, “впервые за время выполнения
программы” или “впервые за время выполнения запроса”).

• Промахи переполнения. Промахи переполнения происходят, если


блок памяти был загружен в кэш ранее, однако был вытеснен отту-
да перед повторным обращением к нему. Причиной таких промахов
является ограниченный объем кэш-памяти.

• Промахи ассоциативности, или промахи конфликтов. Если


A < NCL , то возможен и третий вид промахов. Если обращение к
памяти не приводит к промаху в полностью ассоциативном кэше та-
кого же объема, как данный, но приводит к промаху в данном кэше,
то такой промах является промахом ассоциативности. Причиной про-
махов ассоциативности является алгоритм загрузки данных в кэш,
который выбирает кэш-блок для замещения, используя только млад-
шие разряды адреса. Тем самым, данные, находящиеся по адресам
с одинаковыми младшими разрядами, оказываются отображенными
на один и тот же блок кэша, и возникает конфликт, который и может
привести к промаху данного вида.

2.1.2.4. Явное управление кэш-памятью


Современные процессоры, как правило, предоставляют ограниченные воз-
можности явного управления кэш-памятью [29, 58, 26]. Для нас наиболее
важной является возможность явной предвыборки данных в кэш-память.
Тот факт, что процессор при обращении к памяти считывает несколько по-
следовательных байт памяти, составляющих кэш-блок, также в известном
смысле является реализацией предвыборки, поскольку процессор предпо-
лагает, что программа будет использовать не только требуемый байт, но
и байты, смежные с ним (как мы увидим ниже, это предположение экви-
валентно предположению о хорошей пространственной локальности про-
граммы 3.2.1). В дополнение к такой аппаратной предвыборке, процессо-

19
ры предоставляют специальные инструкции для явного указания данных
для предвыборки. Например, процессор Pentium Pro имеет инструкцию
PREFETCH(Addr,Mode) [29], указывающую, что процессор должен попы-
таться загрузить в кэш-память уровня, заданного параметром Mode (если
Mode = 0, данные загружается к кэш-память всех уровней), кэш-блок из
ОП, содержащий адрес Addr. Данная инструкция не влияет на коррект-
ность программы и фактически выполняется асинхронно, используя воз-
можность процессора поддерживать в данный момент времени несколько
ссылок, ожидающих загрузки из ОП. Более того, процессор может проигно-
рировать эту инструкцию, если, например, предел на количество ссылок,
ожидающих загрузки, уже достигнут. Эта инструкция может оказаться по-
лезной в ситуации, когда на некотором этапе программы становится извест-
но, какие данные из памяти понадобятся на одном из следующих этапов
[31, 27]. В разделе 3.2.5 мы покажем, как можно использовать инструк-
ции явной предвыборки для “сглаживания” негативного эффекта кэш-
промахов.

2.1.2.5. Устройство трансляции адресов


Все современные архитектуры и операционные системы используют вир-
туальную память, т.е. комбинируют ограниченный объем оперативной
памяти и файл подкачки на жестком диске для предоставления програм-
мам большего объема оперативной памяти, чем на самом деле установле-
но в компьютере [58, 26]. Виртуальная память необходима для поддерж-
ки многопроцессного выполнения, поскольку позволяет каждому процессу,
работающему в операционной системе, считать оперативную память на-
ходящейся полностью в своем распоряжении. Поскольку не все процессы
используют память одинаково интенсивно и сами процессы могут нахо-
диться в состоянии “спячки” или ожидания действий со стороны пользо-
вателя, страницы памяти, неиспользуемые в данный момент, могут быть
вытеснены на диск, освобождая место в ОП для страниц, нужных актив-
ным процессам. В случае, если общий объем активных страниц превыша-
ет объем установленной на компьютере памяти, возникает “пробуксовка”

20
(trashing) — процессы испытывают “голод”, требуя страницы памяти, ко-
торые загружаются с диска на короткое время, чтобы вскоре вновь быть
вытесненными.
При использовании виртуальной памяти адреса памяти, по которым
производятся обращения в программах, являются виртуальными, и зада-
ют смещение данных от (нулевого) начала адресного пространства процес-
са. Процессор перед обращением по такому адресу должен произвести его
трансляцию в физический адрес в ОП. Эта трансляция производится путем
нахождения физического адреса, соответствующего виртуальному адресу
данной страницы, в специальных таблицах, поддерживаемых процессором
и операционной системой [29, 58]. Такая операция оказывается весьма до-
рогостоящей [11], поэтому современные процессоры имеют специальный
буфер трансляции адресов (TLB — Translation Lookaside Buffer), в кото-
ром хранится соответствие между виртуальными и физическими адресами
нескольких последних использовавшихся страниц. Объем TLB обычно со-
ставляет 64 записи. Если физический адрес страницы не найден в TLB,
процессор вынужден выполнять алгоритм трансляции адреса - такая си-
туация называется TLB-промахом [29, 58]. Обработка TLB-промаха, т.е.
трансляция адреса, занимает длительное время и снижает производитель-
ность программы [11].
Для наших целей удобно рассматривать TLB как отдельный полностью
ассоциативный кэш внутри процессора, в котором роль размера блока иг-
рает размер страницы (далее обозначаемый pagesize). Обращение по лю-
бому адресу внутри страницы, информация о которой находится в TLB,
означает отсутствие TLB-промаха — тот факт, что TLB не хранит само со-
держимое страницы, не имеет значения, поскольку при трансляции адресов
содержимое страницы не используется.

2.1.2.6. Стоимость доступа к памяти


Из вышесказанного становится вполне очевидно, что предположения о пре-
небрежимо малой стоимости доступа к данным в оперативной памяти,
справедливые несколько десятилетий назад, оказываются неприменимыми

21
в современных архитектурах. Стоимость доступа к памяти складывается
из следующих трех компонент:

• Время трансляции адреса, T Addr Это время, которое затрачивает-


ся на преобразование виртуального адреса в физический, как описано
выше.

• Время задержки (latency), T Late Это время, которое тратится на


неудачный поиск требуемого блока данных в памяти предыдущих
уровней иерархии. Если данные доступны в кэше L1, то TLate = 0.
Если возникает L1 кэш-промах, то TLate = TL1 miss , где TL1 miss —
время обработки L1 кэш-промаха, которое уходит на обращение к
кэшу L2. Если же требуемых данных нет и в кэш-памяти L2, то
TLate = TL1 miss + TL2 miss , где TL2 miss — время обработки промаха в
кэш L2.

• Время передачи (transfer) данных, T T rans Время передачи дан-


ных — это время, которое занимает передача требуемого блока дан-
ных. Каждый уровень иерархии памяти характеризуется своей про-
пускной способностью (bandwidth, β ) [58], показывающей, какой объ-
ем данных может быть передан между данным и предыдущим уров-
нем иерархии (или процессором, в случае L1 кэша) за единицу вре-
size(block)
мени (обычно 1 с). Таким образом, TT rans (block) = β .

Для дальнейшего изложения мы делаем следующие предположения


(каждое из них может быть опущено в конкретном случае, что оговари-
вается явно):

• Мы предполагаем наличие только одного уровня кэш-памяти, имею-


щего характеристики кэша L2. Это позволяет упростить многие вы-
числения и рассуждения, не лишая их в то же время общности, по-
скольку распространение на случай нескольких уровней кэш-памяти,
как правило, очевидно. Другим доводом в пользу этого предположе-
ния является то, что именно промахи в L2 кэш (при отсутствии кэша

22
L3) являются основным источником задержек при обращениях к па-
мяти (поскольку, как мы отмечали выше, TL2 miss превышает на TL1 miss
на величину порядка).

• Говоря о стоимости конкретного обращения к памяти, мы не рассмат-


риваем отдельно время задержки и время передачи данных. Вместо
этого мы оцениваем, сколько L2 кэш-промахов вызывает данной об-
ращение. В стоимость одного L2 кэш-промаха в такой интерпрета-
ции входит как время задержки TLate кэша L2, так и время передачи
TT rans L2 кэш-блока из ОП.

• Если это не оговорено специально, мы предполагаем, что в систе-


ме выполняется лишь один процесс, и кэш-память находится в его
полном распоряжении. Разумеется, в реальных серверных системах
это предположение редко оказывается справедливым — как правило,
процессорное время в такой системе разделено между несколькими
параллельными процессами (для нас в данный момент несущественна
разница между процессами и потоками в смысле операционной систе-
мы), обслуживающих запросы от нескольких клиентов. Тем не менее,
такое предположение позволяет значительно упростить анализ, так
как исключает из рассмотрения деятельность планировщика опера-
ционной системы.

2.1.2.7. Методы измерения эффективности доступа к памяти


Наряду с аналитической оценкой эффективности доступа к памяти алго-
ритма или программы, не менее важное значение имеет и ее измерение на
реальной программе, выполняющейся на процессоре, или на симуляторе
конкретной машинной архитектуры. Такое измерение является основным
способом проверки адекватности аналитической модели, а также возмож-
ным источником ее дополнения.
Мы используем несколько инструментальных средств, позволяющих из-
мерять характеристики системы памяти и доступа к ней в программе.
Calibrator [42] оценивает количество уровней кэш-памяти и параметры

23
каждого уровня i (Ci ,Si , TLi miss ), а также соответствующие характеристи-
ки устройства трансляции адресов (pagesize, Tmiss , NCL ). Принцип работы
этой программы основан на многократном доступе к большому массиву с
переменными шагом и сравнении времени выполнения в зависимости от
величины шага. Величина шага, как мы покажем далее, определяет коли-
чество кэш-промахов, поэтому сравнивая времена выполнения для различ-
ных величин шага, можно вычислить стоимость одного кэш-промаха.
Intel VTune Performance Analyzer является многофункциональным про-
филятором, измеряющим характеристики производительности программы
на различных уровнях — от количества обращений к диску (условно мож-
но сказать, что эта величина относится к уровню макроархитектуры или
операционной системы) до количества кэш-промахов и ошибок предсказа-
ния переходов (уровень микроархитектуры). Для этого используются раз-
личные средства, включающие счетчики, предоставляемые операционной
системой (Windows Performance Counters, [5]), инструментирование испол-
няемых модулей программы специальным кодом, а также встроенные в
процессор внутренние счетчики событий [29]. Именно последние позволяют
узнать количество кэш-промахов и TLB-промахов, а также ошибок пред-
сказания переходов. VTune периодически опрашивает значения этих счет-
чиков, этот процесс называется квантованием (sampling). Следует отме-
тить, что внутренние счетчики процессора глобальны в том смысле, что
они увеличиваются каждый раз при наступлении событий, вне зависимо-
сти от того, код какого процесса в данный момент выполняется. Поэтому
при квантовании VTune отмечает контекст события, т.е. информацию о
том, код какого код процесса выполнялся в данный момент на процессо-
ре и из какого модуля, загруженного в адресное пространство процесса,
происходит этот код.
В процессе проведения всех экспериментов мы старались минимизиро-
вать количество выполняемых одновременно процессов с целью уменьшить
взаимовлияние измерений от различных процессов. Для уменьшения “шу-
ма” при проведении экспериментов мы повторяли каждый запуск тестовой
программы несколько раз, и представленные величины (время выполне-
ния, количество кэш-промахов и т.д.) являются средними, полученными

24
от результатов нескольких измерений.

2.1.2.8. Пример
Приведем небольшой пример, демонстрирующий негативное влияние L2
кэш-промахов и TLB-промахов на время исполнения программы. Для этого
рассмотрим следующий код:
int result = 0;
void MemoryAccess (byte *pArray, int length, int stride)
{
for (int j = 0; j < stride; j++)
{
int total = length - stride + 1;
for (int i = 0; i < total; i += stride)
{
result += pArray [i + j];
}
}
}
Эта программа вычисляет сумму всех элементов массива pArray, рас-
сматривая их не подряд, а с некоторым шагом stride. На внешней итерации
цикла подмножество рассматриваемых элементов “сдвигается” вправо на
один элемент.
Мы запускали эту программу, написанную на языке С++ и скомпилиро-
ванную компилятором Microsoft Visual C++ 7.1 c максимальным уровнем
оптимизации, с различными значениями параметра stride (stride = 2i , i =
0, 1, ...) в ОС Windows XP. На рисунке 2.1 приведено время выполнения
программы в зависимости от величины stride при следующих параметрах
системы: CL2 = 512 килобайт, SL2 = 128 байт, |RAM | = 1 гигабайт,
NCLT LB
= 64 (где NCL — число кэш-блоков в данном кэше) и значении 1

length = 228 .
Оценим количество кэш-промахов, которые происходят в процессе вы-
полнения программы, в зависимости от значения величины stride. При этом
мы будем предполагать, что кэш-память является полностью ассоциатив-
1 Данная система в дальнейшем для краткости будет называться системой S.

25
ной, т.е. возможны только промахи первого обращения и промахи перепол-
нения, и что для управления кэш-памятью используется политика замеще-
ния дольше всего не использовавшихся данных (LRU). Если stride < 128,
то один прочитанный кэш-блок используется в 128
stride смежных итерациях
внутреннего цикла. За одну итерацию внешнего цикла при этом зачитыва-
length
ется 128 = 221 кэш-блоков; это означает, что кэш-блоки не переиспользу-
ются между итерациями внешнего цикла, поскольку емкость кэш-памяти
10
всего 512∗2
27 = 212 блоков. Таким образом, за время выполнения программы
происходит 221 ∗stride < length кэш-промахов, причем 221 из них являются
промахами первого обращения, которые происходят независимо от величи-
ны stride.
Если stride > 128, то последовательные итерации внутреннего цикла
используют разные кэш-блоки, при этом за одну итерацию внешнего цикла
используются length length
stride кэш-блоков. Если это количество stride ≥ 2NCL = 2
13

(т.е. выполнено 128 < stride ≤ length


2NCL = 2 ), то кэш-блоки не могут переис-
15

пользоваться между итерациями внешнего цикла, поскольку после оконча-


ния очередной итерации внешнего цикла кэш-память полностью загружена
блоками, которые не понадобятся на первых NCL итерациях внутреннего
цикла при следующей итерации внешнего цикла. Поэтому кэш-промахи
происходят при каждом обращении к массиву, и их количество равно
length. В случае length length
stride < 2NCL , или (поскольку stride = 2 ), stride ≤ NCL ,
i

т.е. stride ≥ length


NCL = 2 , за одну итерацию внешнего цикла загружает-
16

ся не более NCL кэш-блоков, и поэтому все загруженные кэш-блоки могут


полностью переиспользоваться на следующей итерации внешнего цикла,
промахов переполнения не происходит, и общее количество промахов сов-
падает с количеством промахов первого обращения: 221 .
Аналогичные рассуждения позволяют установить зависимость количе-
ства TLB-промахов от stride, анализ отличается лишь подстановкой других
значений для C и NCL . Таким образом, для кэш-промахов “переломными”
значениями stride являются 7 и 16; для TLB-промахов та же роль принад-
лежит значениям 12 и 22. Заметим, что на рисунке 2.1 данные значения
являются особыми точками графика.
Можно возразить, что способ доступа к памяти является не единствен-

26
ным параметром программы, оказывающим влияние на производитель-
ность, который зависит от величины stride. Действительно, вычислитель-
ная стоимость приведенного кода в терминах количества операций равна
length∗C1 +stride∗C2 , где C1 — стоимость тела итерации внутреннего цик-
ла и проверки его условия, а C2 — стоимость проверки условия внешнего
цикла. Если stride близко к length, то стоимость второй компоненты мо-
жет показаться значительной. Однако, как следует из таблицы, время вы-
полнения программы при максимальном значении stride мало отличается
от времени выполнения при минимальном значении stride (действительно,
при stride = 4 и stride = 27 получаются близкие значения времени выполне-
ния). Это показывает, что вторую компоненту вычислительной стоимости
можно не учитывать. Что касается других факторов, влияющих на время
выполнения, то в процессе проведения этого эксперимента мы удостовери-
лись, что при выполнении программы не происходят промахи обращения к
страницам (это достигается тем, что выделяемый массив целиком помеща-
ется в оперативную память, а убедиться в отсутствии промахов обращения
к страницам можно, контролируя параметр PageFaults стандартной про-
граммы Windows Task Manager). Таким образом, из вышесказанного мож-
но сделать вывод, что столь значительная разница во времени исполнения
вызвана различным количеством кэш- и TLB-промахов.

2.1.3. Особенности многопроцессорных систем

Использование внутренней кэш-памяти процессора вызывает специфиче-


ские сложности в многопроцессорных системах [58, 56, 29]. Проблема коге-
рентности кэш-памяти (cache coherency) возникает тогда, когда один из
процессоров в системе модифицирует данные, которые другие процессоры
могут считывать или модифицировать. Для обеспечения корректности ис-
полнения программы необходимо, чтобы другие процессоры работали с мо-
дифицированными данными, но соответствующие данные из оперативной
памяти могли быть уже загружены в их собственную кэш-память перед мо-
дификацией. Таким образом, необходим механизм, который позволит сде-
лать модифицированные данные видимыми для других процессоров.

27
Рис. 2.1. Зависимость времени выполнения программы от величины stride

28
Существует несколько решений проблемы когерентности кэш-памяти
[56]. Одним из решения является аппаратная поддержка распространения
изменений из кэш-памяти одного процессора в кэш-память других про-
цессоров (cache snooping) [58, 56]. Другое решение заключается в распро-
странении не самих измененных данных, а лишь их физического адреса
— все кэш-блоки в кэш-памяти других процессоров, соответствующие дан-
ному адресу, вытесняются из кэш-памяти, что при следующем обращении
к данному адресу приведет к кэш-промаху, в результате которого из опе-
ративной памяти будут загружены модифицированные данные. Очевидно,
оба этих решения в определенной степени снижают производительности
системы, откуда можно сделать вывод, что в многопроцессорной системе
модификация данных в памяти, разделяемых между процессорами, может
оказаться дорогостоящей операцией.

2.2. Архитектура традиционных СУБД и

СУБД-ОП

В данном разделе мы рассмотрим архитектуру традиционных, дисковых


СУБД и те отличия, которые хранение данных в оперативной памяти при-
вносит в нее. В частности, мы покажем, что хранение данных в памяти
упрощает архитектуру CУБД, делая ненужными некоторые ее компонен-
ты.

2.2.1. Компоненты архитектуры традиционной СУБД

Упрощенная схема архитектуры СУБД представлена на рисунке 2.2 [1].


В последующих разделах мы рассмотрим функциональность каждого
из компонентов схемы. При этом мы в основном будем концентрировать
внимание на аспектах, имеющих отношение к устройству хранения данных
(диску).

29
Рис. 2.2. Архитектура традиционных СУБД

30
2.2.1.1. Приложение
Приложение — это клиент CУБД, запрашивающий требуемые ему дан-
ные. Запрос данных обычно представлен в форме кода на некотором язы-
ке запросов, наиболее распространенным из которых является язык SQL
(Structured Query Language) [3]. Этот код пересылается на сервер СУБД
через соединение c СУБД

2.2.1.2. Менеджер соединений


Менеджер соединений является компонентом СУБД, управляющим соеди-
нениями с клиентами. Поскольку запрос каждого клиента выполняется,
как правило, в отдельном потоке или процессе ОС [1, 3] и требует опре-
деленного количества ресурсов сервера СУБД, избыточное количество ак-
тивных соединений с клиентами может привести к недопустимо низкой
производительности сервера или даже вызвать необходимость его переза-
пуска. Поэтому менеджер соединений обычно поддерживает ограниченный
пул соединений, выделяя соединения клиентам по мере их подключения,
или отказывая в соединении, если лимит соединений уже достигнут.

2.2.1.3. Оптимизатор запросов


Оптимизатор запросов в первую очередь транслирует запрос из кода на
языке запросов во внутреннее представление [1], пригодное для анали-
за и преобразований. Внутреннее представление обычно является деревом
операций, взятых из множества операций некоторой алгебры, например,
реляционной алгебры [3]. Это представление называется логическим пла-
ном запроса, поскольку указывает лишь операции алгебры, необходимые
для выполнения запроса, но не реализации этих операции. Например, в
логическом плане может присутствовать логическая операция соединения,
которая может быть реализована многими способами (вложенные циклы,
индексы, хэш-соединение, сортировка-слияние и т.д.)
При анализе и преобразовании логического плана оптимизатор исполь-
зует статистическую информацию, которая поддерживается при участии
других компонентов СУБД (отсюда на рисунке 2.2 стрелки от индексов

31
и менеджера ввода-вывода к оптимизатору запросов). Статистическая ин-
формация может содержать данные о количестве записей в отношениях,
количестве различных значений атрибута отношений, количестве записей
с данным значением атрибута [1, 32]. Задача оптимизатора состоит в выбо-
ре лучшего из множества эквивалентных планов, т.е. планов, выполнение
которых даст одно и то же множество записей. Критерием для сравне-
ния между собой двух логических планов является размер промежуточ-
ных отношений, которые получаются при выполнении операций плана: чем
меньше этот размер, тем лучше план. Основанием для этого является тот
факт, что на обработку отношений меньшего размера уходит, вообще го-
воря, меньше времени. При преобразовании логических планов использу-
ются алгебраические свойства операций, такие как ассоциативность, ком-
мутативность и дистрибутивность. Например, типичными методами пре-
образования логического плана запроса являются перестановка операций
соединения и сдвиг операций выборки вниз по дереву [1].
Один и тот же логический план можно выполнить разными способами,
и задачей оптимизатора является подбор такой реализации логического
плана, которая, предположительно, является наиболее эффективной (фи-
зический план запроса). Выбор конкретного алгоритма реализации логиче-
ской операции зависит как от размера входных отношений — аргументов
операции, так и текущих доступных ресурсов системы — загруженности
процессора и количества свободной оперативной памяти. Например, если
входные отношения не помещаются в оперативную память, то лучшим ал-
горитмом для операции соединения будет сортировка-слияние, а если оба
отношения помещаются в буфер, то больше подойдет хэш-соединение. На
решение оптимизатора о выборе физического плана влияет также нали-
чие индексов, которые могут использоваться для реализации операций вы-
борки или соединения. Если ожидаемая селективность выборки велика,
т.е. количество записей, отвечающих критерию выборки, мало, то индекс
может существенно ускорить выполнение. Если же в результат выборки
попадают почти все записи отношения, то выгоднее применить последова-
тельное сканирование записей.
Для сравнения различных физических планов оптимизатор использует

32
стоимость планов. Стоимость плана формулируется как суммарная сто-
имость различных операций, входящих в план, т.е.

X
T otalCost(P ) = T otalCost(o).
o∈P

Основными компонентами стоимости для традиционных СУБД являют-


ся стоимость ввода-вывода и вычислительная стоимость, т.е. количество
примитивных операций, выполняемых процессором для данной операции.
Другими словами,

T otalCost(o) = NoIO + NoCP U ,

где NoIO — количество операций ввода-вывода, а NoCP U — количество при-


митивных операций (сложение, умножение, присваивание). В зависимости
от реализации модели стоимости могут быть и более детальными, напри-
мер, различать типы примитивных операций. Например, для операции чте-
ния отношения в память Nread
CP U
= 0 и T otalCost(read) = Nb , где Nb — ко-
личество дисковых блоков, занимаемых отношением. Для операции сорти-
ровки отношения в памяти Nmemsort
IO
= 0 и T otalCost(memsort) = N log N ,
где N — количество записей в отношении.

2.2.1.4. Менеджер ввода-вывода


В традиционных СУБД база данных хранится на диске. Однако, обра-
щение к диску каждый раз, когда требуется чтение или запись данных,
было бы слишком неэффективно. При обращении к диску время выпол-
нения запроса ввода-вывода складывается из времени, необходимого для
позиционирования головок диска на нужное место, и времени, затрачива-
емого на передачу данных. Поскольку запросы ввода-вывода часто име-
ют непредсказуемый характер, позиционирование головок, вообще говоря,
необходимо для выполнения каждого запроса, другими словами, происхо-
дит произвольный доступ.
Для повышения эффективности обмена данными с медленным диско-
вым устройством менеджер ввода-вывода использует буферизацию. Мини-

33
мальной единицей обмена информацией с диском является страница, раз-
мер которой может совпадать, но может и отличаться от размера страницы
памяти операционной системы. Таким образом, диск или, точнее, файл на
диске, на котором находится сама база данным, можно считать состоящим
из страниц. Менеджер ввода-вывода поддерживает в оперативной памя-
ти набор буферов из страниц, содержимое которых недавно требовалось
СУБД [1, 3, 57]. Запросы на чтение удовлетворяются путем обращения к
страницам в памяти. Если требуется модификация содержимого страницы,
то она также осуществляется в памяти, и менеджер устанавливает флаг,
что данная страница является “грязной”. “Грязные” страницы периодиче-
ски записываются на диск, например, этим может заниматься специальный
фоновый поток менеджера ввода-вывода [1].
Менеджер ввода-вывода осуществляет также трансляцию адресов,
необходимую для разрешения ссылок из одних элементов данных на дру-
гие (например, ссылки между узлами в B-дереве индекса). В традиционной
СУБД могут использоваться ссылки двух типов — ссылки на адрес в БД
и ссылки на адрес памяти [1]. Разыменование ссылки на адрес памяти
заключается обычно лишь в разыменовании указателя (в смысле языка
программирования C) на данные в виртуальном адресном пространстве
процесса или обращении к таблице и выборке из нее по индексу, если ис-
пользуются косвенные ссылки. Использование косвенных ссылок делает
разыменование менее эффективным, но дает дополнительную гибкость,
поскольку позволяет перемещать элементы данных в памяти, не обновляя
ссылки. При разыменовании ссылки на адрес в БД необходимо определить
файл на диске и смещение в этом файле, после чего загрузить соответству-
ющие данные или найти их в буфере. Таким образом, при разыменовании
ссылки на адрес в БД требуется доступ к информации, поддерживаемой
менеджером ввода-вывода, независимо от того, загружена ли страница, со-
держащая требуемые данные, в память или нет. Поэтому использование
ссылок на адреса памяти, вообще говоря, более эффективно, чем ссылок
на адреса в БД. CУБД используют различные стратегии преобразования
ссылок одного типа в другой при переносе данных с диска в основную па-
мять и обратно [1].

34
2.2.1.5. Менеджер транзакций
Менеджер транзакций СУБД является компонентом, отвечающим за реа-
лизацию и корректность параллельного выполнения запросов и транзакций
в системе [1, 9]. Для реализации корректного параллельного исполнения
в СУБД используются различные протоколы управления параллельными
заданиями, большинство из которых предполагает применение замков для
предотвращения возникновения аномалий параллельного исполнения. Пе-
ред обращением к элементу данных транзакция пытается получить замок
на этот элемент. В зависимости от того, удерживается ли данный замок
в данный момент времени другими транзакциями и типа удерживаемого
замка (например, можно различать замки на чтение или на запись, первые
из которых должны брать транзакции, не меняющие данные, а вторые —
остальные транзакции), транзакция либо получает замок, либо вынуждена
ожидать, пока другая транзакция отпустит замок. Заметим, что в качестве
элемента данных в этом контексте могут выступать структуры различного
уровня — вся база данных, реляционная таблица или запись.
Деятельность менеджера транзакций тесно связана с другим компонен-
том системы — менеджером восстановления.

2.2.1.6. Менеджер восстановления


Менеджер восстановления необходим для обеспечения возможности отка-
та и воспроизведения результатов транзакций. Обычно это достигается с
помощью ведения журнала транзакций, в который записываются в хроно-
логическом порядке сведения об операциях транзакций (этот процесс на-
зывается протоколированием). Формат журнала и виды протоколируемых
операций зависят от реализации СУБД [1, 7], например, протоколирование
может происходить как в терминах низкоуровневых операций (содержимое
страницы P c адреса А длиной L заменено на новый блок данных B), так
и высокоуровневых, логических операций (над всеми значениями атрибу-
та A в отношении R выполнена операция инкремента). Журнал позволяет
считать транзакцию успешно завершенной, даже если ее результаты не
зафиксированы в копии базы данных на диске — достаточно лишь, что-

35
бы информация о завершении транзакции была записана в файл журнала
на диске [1, 7]. Это, в свою очередь, позволяет буферизовывать операции
записи, т.е. не выполнять запись “грязных” страниц на диск при любой
операции записи, повышая тем самым эффективность ввода-вывода [1, 7].
Другим применением менеджера восстановления является откат тран-
закции, что в соответствии с требованием атомарности означает отмену
всех ее результатов, как будто транзакция не происходила. Необходимость
отката может возникнуть как по требованию самой транзакции, обнару-
жившей, что дальнейшее выполнение невозможно, так и по другой при-
чине, например, из-за необходимости разрешения взаимоблокировки меж-
ду транзакциями [1, 9].
Менеджер восстановления отвечает также за восстановление системы
после сбоя. Сбой может возникнуть из-за программной ошибки или внеш-
ней причины, например, отключения питания. Во время сбоя в системе
могли выполняться транзакции, кроме того, результаты некоторых завер-
шенных транзакций могут быть не зафиксированы в копии БД на дис-
ке (но информация об их завершении присутствует в копии журнала на
диске), и они должны быть отражены в БД, в то время как результаты
остальных активных на момент сбоя транзакций должны быть удалены.
Существуют различные алгоритмы восстановления [1], но в большинстве
из них операции завершенных транзакций повторяются путем просмотра
журнала в хронологическом порядке (накат, redo, [1]) и отмены операций
незавершенных транзакций (откат, undo, [1]) просмотром журнала в обрат-
ном порядке. Специальные маркеры начала и конца транзакций в журнале
позволяют определить временные границы транзакций. Для оптимизации
процесса восстановления могут делаться периодические контрольные точ-
ки (checkpoints) [1], в процессе которых “грязные” страницы записываются
на диск. Контрольные точки позволяют “обрезать” журнал с начала, т.е.
не рассматривать записи журнала, сделанные раньше времени контроль-
ной точки.

36
2.2.1.7. Индексы
Индексы представляют собой метод ускорения доступа к необходимым дан-
ным, позволяя, например, эффективно выполнять запросы вида “выбрать
запись с данным идентификатором” (такие запросы называются точечны-
ми, поскольку им отвечает лишь одна точка в пространстве атрибутов)
или “найти все студентов старше 20 лет” (запрос по интервалу, которому
соответствует интервал в пространстве атрибутов). Индексы являются из-
быточными структурами в том смысле, что они всегда могут быть восста-
новлены на основе информации, содержащейся в базе данных. Во многих
случаях, однако, имеет смысл поддерживать индексы в соответствии с дан-
ными при обновлении последних, поскольку реконструкция индекса может
быть дорогостоящей информацией.
За последние 35 лет было предложено огромное количество разнооб-
разных индексных структур [1, 3, 34, 33, 47]. Наибольшее распространение
в традиционных СУБД получили индексы на основе различных вариаций
хэш-таблиц [35] и B-деревьев [8]. Помимо своей основной функции - уско-
рения доступа к данным, индексы могут также использоваться для полу-
чения статистики (например, распределения значений атрибута в отноше-
нии), необходимой для оптимизатора запросов.

2.2.1.8. Операционная система


Операционная система (OC) обычно не является частью СУБД, но пред-
ставляет собой интерфейс между СУБД и аппаратным обеспечением. В ее
функции входит управление внешними устройствами, такими как диск и
сетевой адаптер, управление памятью и планирование процессов / потоков.
В некоторых случаях сервером СУБД является специально разработанная
система, которая включает в себя и специализированную ОС, однако чаще
платформой для СУБД являются системы общего назначения, работающие
под управлением таких ОС как Windows или Linux [1]. ОС может также
предоставлять возможности для организации распределенных вычислений
с помощьюкластеров, объединяющих однотипные системы [59].
Важнейшей задачей ОС является управление виртуальной памятью,

37
т.е. предоставление выполняющимся процессам адресного пространства
большего, чем объем оперативной памяти, физически установленной в ком-
пьютере. Ранние системы не имели виртуальной памяти и программистам
приходилось вручную кодировать все пересылки данных из диска в ОП
и обратно. Это весьма трудоемкая задача, чреватая трудно находимыми
ошибками и неэффективными решениями [22]. При использовании вирту-
альной памяти программист может считать, что в его распоряжении на-
ходится достаточно большой объем памяти (например, в ОС Windows —
2 или 3 Гб), и писать код в соответствии с этим предположением. Опера-
ционная система поддерживает на диске страничный файл, и отображает
страницы этого файла на физическую оперативную память. При этом в
оперативной памяти находятся только страницы, требуемые процессам в
данный момент времени; остальные страницы не загружаются из странич-
ного файла. Если требуемая страница из файла не загружена в память,
возникает ошибка страницы, и страница загружается с диска, вытесняя
при этом одну из страниц в памяти. При выборе страницы для вытеснения
используется обычно алгоритм выбора дольше всего не используемых стра-
ниц (Least Recently Used, LRU), эта ситуация аналогична политикам управ-
ления кэш-памятью, рассмотренным в разделе 2.1.2.2. Здесь, как и в случае
кэш-памяти, используется принцип локальности [22], который утверждает,
что рабочий набор процесса, т.е. набор страниц страничного файла, исполь-
зуемых в данный момент времени, ограничен в каждый момент, и поэтому
рабочие наборы всех выполняющихся процессов могут поместиться в опе-
ративную память. Но принцип локальности, разумеется, не является фи-
зическим законом — это лишь эмпирический вывод, основанный на наблю-
дениях за выполнением программ и подкрепленный некоторыми общими
рассуждениями. Одним из таких рассуждений является то, что принцип
локальности в определенном смысле следует из общего принципа проекти-
рования алгоритмов и программ — разделения большой задачи на подза-
дачи, каждая из которых решается последовательно и имеет свой рабочий
набор, который меняется при переходе к решению следующей подзадачи
[22].

38
2.2.1.9. Аппаратное обеспечение
Аппаратное обеспечение предоставляет интерфейсы наиболее низкого
уровня, с которыми СУБД, как правило, непосредственно не взаимодей-
ствует. Современное аппаратное обеспечение, однако, имеет характерные
особенности, оказывающие значительное влияние на производительность,
и СУБД, как и любая другая программа, может использовать эти особен-
ности эффективно или неэффективно. Подробно особенности архитекту-
ры современных компьютерных систем были рассмотрены в разделе 2.1.
Здесь лишь отметим, что после повсеместного введения виртуальной па-
мяти в большинство моделей процессоров была заложена ее аппаратная
поддержка, заключающаяся в аппаратной трансляции адреса в виртуаль-
ной памяти в адрес в физической оперативной памяти. Для этого процес-
сор, как правило, имеет специальные регистры, в которых хранятся адреса
таблиц, отображающих виртуальные адреса страниц из страничного файла
на физические и хранящих признаки того, загружена ли данная страница
виртуальной памяти в физическую [29].
Заметим, что подсистема виртуальной памяти ОС во многом аналогич-
на менеджеру ввода-вывода в СУБД: в обоих случаях есть буфер страниц
(в случае ОС — это практически вся оперативная память), файл на диске
(файл БД и страничный файл) и политика замещения страниц в буфере.
Однако есть и некоторые отличия — в частности, в случае ОС и виртуаль-
ной памяти отсутствует преобразование ссылок, поскольку ОС не знает, где
в содержимом страницы находятся ссылки. Менеджер ввода-вывода СУБД
обладает информацией о структуре страниц и записей, поэтому он может
отличить ссылки от других элементов данных и знает формат ссылок. Од-
нако в обоих случаях трансляция адресов происходит централизованно -
через менеджер ввода-вывода в СУБД или через процессор и ОС в случае
виртуальной памяти.

2.2.2. Отличия архитектуры СУБД-ОП

Рассмотрим отличия архитектуры гипотетической СУБД-ОП от архитек-


туры традиционной СУБД. Прежде всего, заметим, что один из основ-

39
ных компонентов СУБД — менеджер ввода-вывода становится фактиче-
ски ненужным в СУБД-ОП. Действительно, мы предполагаем, что все
данные и индексы помещаются в оперативную память, поэтому необхо-
димости считывать и записывать данные на диск нет. Тем самым отпада-
ет и необходимость в буферизации, поддержка которой является важной
частью деятельности менеджера ввода-вывода. То же относится и к пре-
образованию ссылок — все ссылки в СУБД-ОП могут иметь общий фор-
мат, предполагающий наличие данных, на которые указывает ссылка, в
памяти. Как отмечалось выше, ссылка на адрес в памяти является либо
обычным указателем (в смысле языка программирования C), либо обра-
щается к требуемым данным через уровень косвенности — например, по-
средством указания смещения в таблице, в которой находится указатель
на реальные данные. Помимо очевидного преимущества первого вариан-
та, заключающегося в возможности реализации разыменования с помощью
одной процессорной инструкции, использование косвенных ссылок влечет,
вообще говоря, большее количество кэш- и TLB-промахов: разыменование
указателя может вызвать 1 кэш- и 1 TLB-промах, в то время как разыме-
нование косвенной ссылки требует, кроме разыменования указателя, еще
и обращение к таблице, что также может дополнительно вызвать 1 кэш-
и 1 TLB-промах. Тем не менее, использование косвенных ссылок может
быть оправдано возможностью более эффективной реорганизации струк-
тур данных, не требующей обновления внешних ссылок на их внутренние
элементы. Кроме того, приведенное выше рассуждение о количестве кэш-
промахов справедливо только в самом общем случае, если не учитывать
тот факт, что данные, адресуемые ссылкой, могли быть загружены в кэш
(и TLB-) предыдущими ссылками. Методы, рассматриваемые в главе 3,
позволяют во многих случаях избежать промахов переполнения и первого
обращения и при использовании косвенных ссылок.
Очевидно, нет никаких отличий в менеджере соединений — его функ-
циональность полностью аналогична функциональности соответствующего
компонента традиционной СУБД. Далее мы рассмотрим остальные компо-
ненты СУБД с целью выяснения необходимости изменений в них, связан-
ных с переносом основного хранилища данных в память.

40
2.2.2.1. Отличия оптимизатора запросов
Анализ и преобразование логического плана остается неизменным по срав-
нению с традиционной СУБД. Как и в традиционной СУБД, логический
план должен стремиться уменьшить размер промежуточных результатов,
поскольку гипотеза о том, что на обработку отношений меньшего размера
уходит меньше времени, справедлива и для СУБД-ОП. Сбор и использо-
вание статистической информации также практически не изменяются —
могут лишь исчезнуть некоторые специфические виды статистики, такие
как количество дисковых блоков, занимаемых отношением, и их взаимное
расположение друг относительно друга (другими словами, сколько опера-
ций позиционирования головки диска требуется для чтения всех записей
отношения).
Однако часть оптимизатора, занимающаяся генерацией физического
плана, претерпевает значительные изменения. Меняется как модель стои-
мости, так и реализации логических операций. Например, операция чтения
отношения в память исчезает, а модель стоимости, используемая в тради-
ционных СУБД, оказывается слишком неточной, поскольку теперь для лю-
бой операции o NoIO = 0. Как обсуждалось в главе 2.1, модель стоимости,
основанная только на вычислительной сложности операций CP U Cost, не
отражает адекватно особенности архитектуры современных компьютеров.
Помимо CP U Cost мы должны также принять во внимание время, которое
тратится на простои процессора:

T otalCost(o) = CP U Cost(o) + StallCost(o).

Предполагается, что CP U Cost и StallCost выражены в одинаковых


единицах измерения, например, в абстрактных единицах времени. Как от-
мечалось в главе 2.1, причин простоев процессора может быть несколько,
но основные из них — кэш-промахи и сбросы конвейера, вызванные ошиб-
ками предсказания переходов. Таким образом, мы можем считать, что

StallCost(o) = NL2CacheM isses (o)∗CCacheM iss +NBranchM ispred (o)∗CBranchM ispred ,

41
где NL2CacheM isses (o) и NBranchM ispred (o) — количество L2 кэш-промахов и
ошибок предсказания переходов за время выполнения операции o, соот-
вественно, и CCacheM iss и CBranchM ispred — стоимость одного кэш-промаха
и ошибки предсказания перехода в абстрактных единицах времени (т.е.
в таких единицах времени, которые по порядку совпадают с длительно-
стью рассматриваемых событий; например, в наносекундах), соответствен-
но. Для многих алгоритмов и наборов входных данных ошибки предска-
зания переходов могут не учитываться, поскольку встроенное в процессор
устройство предсказания переходов успешно справляется со своей задачей
— так происходит тогда, когда факт условного перехода мало зависит от
входных данных (например, все переходы на начало цикла относятся к этой
категории).
Изменение модели стоимости вызывает необходимость пересмотра ре-
ализаций логических операций с точки зрения их оптимальности в новой
модели стоимости. Многие реализации зависят от ряда параметров, таких
как размер буфера — эти параметры в новой модели стоимости нужно
выбирать другим образом. Более того, многие реализации нужно “исправ-
лять”, чтобы уменьшить количество кэш-промахов и ошибок предсказания
перехода. Подробно мы рассмотрим этот вопрос в следующих главах.

2.2.2.2. Отличия менеджера транзакций


Менеджер транзакций необходим для управления параллельным исполне-
нием в СУБД-ОП так же, как и в традиционной СУБД. Может показать-
ся, что менеджер транзакций не меняется в СУБД-ОП, однако это не со-
всем так. Как мы отмечали в разделе 2.1.3, в многопроцессорных системах
запись в оперативную память становится дорогостоящей операцией из-за
необходимости поддержки когерентности кэш-памяти. Следствием этого
является необходимость пересмотра стратегий управления параллелизмом,
в частности, протоколов блокирования.
Например, при обновлении данных, как правило, необходимо также об-
новление индексных структур. В традиционных СУБД необходимость об-
новления индексных структур, в частности, деревьев, обычно означает, что

42
доступ к этим структурам должен быть синхронизирован с помощью зам-
ков. Для обеспечения корректности замки должны брать и те транзакции,
которые не модифицируют данные. Однако получение замка означает за-
пись некоторой переменной состояния, т.е. запись в оперативную память,
что при большом числе транзакций в единицу времени может оказаться
слишком дорого в многопроцессорной системе [13]. Более эффективным
является другой протокол, использующий версионирование узлов: тран-
закции, которые меняют данные, получают замки и при модификации из-
меняют версию узла, а транзакции, только читающие данные, замки не
получают и проверяют номер версии узла во избежании считывания дан-
ных, модифицированных после начала транзакции [13].

2.2.2.3. Отличия менеджера восстановления


Менеджер восстановления в СУБД-ОП выполняет те же функции, что и
в традиционной СУБД. Несмотря на то, что все данные хранятся в па-
мяти, журнал транзакций поддерживается на диске, поскольку он должен
обеспечивать возможность восстановления данных при сбое системы. За-
метим, что доступ к файлу, в котором хранится журнал транзакций, явля-
ется строго последовательным — новые записи лишь добавляются в конец
файла. В простейшей реализации любая транзакция, изменяющая данные,
вызывает доступ к диску для обновления журнала. Для СУБД-ОП с боль-
шим количеством транзакций, модифицирующих данные, это может быть
неэффективно. Решением является буферизация записи журнала — записи
журнала накапливаются в буфере, а содержимое буфера периодически за-
писывается на диск [1, 19]. При этом транзакции, записи которых находят-
ся в буфере, не считаются завершившимися до тех пор, пока содержимое
журнала не запишется на диск. Негативным последствием использования
буферизации вывода в журнал может быть некоторая задержка заверше-
ния транзакции.

43
2.2.2.4. Отличия индексных структур
Индексные структуры, подобно алгоритмам реализации логических опера-
ций, требуют пересмотра по сравнению с традиционными СУБД. В тради-
ционных СУБД индексные структуры ориентированы на дисковый ввод-
вывод, и оптимизированы для уменьшения количества операций ввода-
вывода. Как и случае реализаций алгоритмов, индексные структуры могут
быть параметризованы, например, B-дерево имеет в качестве параметра
среднее количество дочерних узлов у узла. В традиционных СУБД этот
параметр часто выбирается так, чтобы узел дерева занимал одну страницу
— единицу обмена данными с диском в менеджере ввода-вывода. В резуль-
тате узел имеет большой размер и вмещает много ключей, что уменьшает
высоту дерева (где под высотой дерева понимается максимальное рассто-
яние от листа дерева до его корня). Разумеется, этот выбор параметра не
имеет никакого смысла в СУБД-ОП, поскольку там фактически отсутству-
ет обмен данными с диском.
Однако подбора параметров оказывается недостаточно для оптимиза-
ции индексных структур в СУБД-ОП. Пытаясь уменьшить количество
кэш-промахов, приходится менять внутреннее устройство структур, по-
скольку требуется поместить в кэш-блок как можно больше данных, ко-
торые будут использоваться во время поиска в структуре. Например, во
внутреннем узле B-дерева во время поиска всегда используется лишь один
из указателей на дочерние узлы — остальные указатели лишь напрасно за-
гружаются к кэш-память, занимая там место. Улучшить ситуацию можно
с помощью удаления указателей, оптимизации, рассматриваемой в главе
3.

2.2.2.5. Выводы
Основываясь на предыдущих разделах, можно утверждать, что многие
компоненты архитектуры СУБД требуют пересмотра для СУБД-ОП. В
связи с этим становится очевидным ответ на вопрос, можно ли полу-
чить СУБД-ОП из традиционной СУБД путем простого увеличения бу-
фера ввода-вывода так, чтобы все данные поместились в память [3]. Такой

44
подход действительно позволяет избежать большей части операций ввода-
вывода, которые происходят в традиционных СУБД, однако имеет ряд су-
щественных недостатков:

• Трансляция адресов происходит с помощью менеджера ввода-вывода,


хотя все данные находятся в памяти. Для этого менеджер ввода-
вывода поддерживает вспомогательные таблицы, которые фактиче-
ски становятся ненужными.

• Оптимизатор запросов не учитывает кэш-промахов и других источ-


ников простоев процессора и использует для выполнения логических
операций неоптимизированные для основной памяти алгоритмы.

• Индексные структуры ориентированы на минимизацию операций


ввода-вывода во время поиска, поэтому поиск с их помошью вызыва-
ет большое количество простоев процессора из-за кэш-промахов.

Таким образом, данный подход не позволяет получить СУБД-ОП с вы-


сокой производительностью. Представляет интерес также вопрос, можно
ли скомбинировать традиционную СУБД и СУБД-ОП так, что результи-
рующая архитектура имела бы высокую производительность как в случае
расположения данных на диске, так и в случае, когда они помещаются в
память. Этот вопрос, однако выходит за рамки данной работы. Мы рас-
сматриваем данную работу как необходимый шаг на пути к его решению.

45
Глава 3.

Общие методы

оптимизации алгоритмов и

структур данных для

улучшения использования

кэш-памяти

В данной главе мы рассмотрим некоторые общие методы оптимизации ал-


горитмов и структур данных для улучшения использования кэш-памяти.
Эти методы уменьшают время простоев процессора, минимизируя количе-
ство кэш-промахов. Рассматриваемые методы не являются специфически-
ми для СУБД, они применимы к любым программам, однако алгоритмы
СУБД-ОП, включающие в себя интенсивный обмен данными между про-
цессором и памятью, являются хорошими кандидатами для применения
этих методов. В последующих главах мы покажем, как эти методы приме-
няются для оптимизации индексных структур и алгоритмов СУБД-ОП.
За последние 10-15 лет было разработано множество методов оптимиза-
ции использования кэш-памяти в различных областях программного обес-
печения (ПО): СУБД [48, 47, 27], пакетов линейной алгебры [15, 44, 65], вир-
туальных машин и сборщиков мусора [17], ПО поддержки сетевых марш-

46
/* Сканирование структуры данных */
void Scan (Node startNode)
{
Node currentNode = startNode;
while (currentNode != null)
{
currentNode = ExamineNode(currentNode);
}
}

Рис. 3.1. Обобщенный алгоритм сканирования структуры данных

рутизаторов [46] и т.д. Здесь мы предлагаем классификацию этих мето-


дов, выделяющую общие приемы оптимизации использования кэш-памяти.
Прежде всего, мы будем различать методы оптимизации по цели их приме-
нения: алгоритмы или структуры данных. Методы первого типа реструк-
турируют алгоритмы таким образом, что результирующие алгоритмы вы-
зывают меньшее количество кэш-промахов за счет уменьшения рабочего
набора алгоритма. Методы второго типа улучшают использование кэш-
памяти за счет изменения структуры данных, при этом алгоритмы работы
с этой структурой данных (например, алгоритмы поиска) остаются неиз-
менными.

3.1. Оптимизация структур данных

Методы, рассматриваемые в данном разделе, подразумевают, что структу-


ра данных состоит из узлов. Связи между узлами могут поддерживаться с
помощью указателей (в смысле языка программирования C). Целью всех
рассматриваемых методов является уменьшение количества кэш-промахов,
возникающих при выполнении алгоритма сканирования, обобщенный псев-
докод которого приведен на рисунке 3.1.
Основной в данном алгоритме является функция ExamineN ode, кото-
рая анализирует текущий узел и возвращает либо null, если поиск необ-
ходимо завершить, либо следующий узел. Заметим, что приведенный код
обобщает как поиск ключа в иерархических структурах данных, напри-
мер, B- и B+-деревьях, так и поиск в списке коллизий хэш-таблиц, а так-
же просмотр массивов. Кроме того, поскольку currentN ode на очередной

47
итерации меняется, то при условии, что размер N ode больше кэш-блока,
обращение к currentN ode всегда вызывает обращение к новому кэш-блоку.
Для всех методов оптимизации структур данных характерна локальность
их применения, заключающаяся в том, что эти методы уменьшают коли-
чество промахов первого обращения в предположении, что в кэш-памяти
отсутствуют результаты предыдущих операций поиска.

3.1.1. Структура узлов

Методы оптимизации узлов достигают улучшения за счет оптимизации ис-


пользования узлом N ode кэш-блока. Очевидно, что чем меньше размер
N ode, тем это выгоднее с точки зрения использования кэш-блока.
Рассмотрим возможные методы оптимизации использования кэш-блока
узлом N ode. Будем считать, что N ode = (f1 , ..., fNf ields ), где Nf ields — коли-
чество полей узла N ode. fi , i = 1, ..., Nf ields является либо ключевым полем,
либо указателем на другой узел N ode0 . Мы предполагаем, что ключевые
поля составляют информационную часть узла, а указатели необходимы
для поддержки структуры данных.

3.1.1.1. Удаление ключевых полей


Удаление ключевых полей (fluff extraction) предлагается в работе [16].
Ключевые поля, которые не используются в функции ExamineN ode, лишь
занимают место в кэш-блоке и могут быть удалены из узла путем, на-
пример, вынесения их в отдельную структуру данных. В зависимости от
конкретной ситуации, эта оптимизация может быть применима или нет,
поскольку она приводит к появлению второй структуры данных, связи в
которой полностью повторяют связи в оригинальной. Очевидно, что две
структуры занимают, вообще говоря, больше места в памяти, чем одна, но
в некоторых случаях такое преобразования выгодно.
Пример. Рассмотрим отношение Students (Age int, F irstN ame char
(20), LastN ame char (20)). Предположим, что отношение хранится в виде
массива записей и указатели отсутствуют, т.е N ode = (Age, F irstN ame,
LastN ame). ExamineN ode используется для вычисления среднего возрас-

48
та студентов, т.е. единственным полем в N ode, нужным для ExamineN ode,
является Age. Если SL2 = 128 байт, то в один кэш-блок помещаются почти
3 узла. Таким образом, при сканировании отношения происходит пример-
|Students|
но 3 промахов первого обращения (эта формула не вполне точна,
поскольку не учитывает накапливающегося сдвига записей из-за не поме-
щающегося остатка третьей записи, если записи не выровнены в памяти).
Выделением полей F irstN ame и LastN ame в отдельный массив можно
добиться того, что N ode = (Age), и в один кэш-блок помещаются 32 узла.
Результирующее количество промахов первого обращения таким образом
уменьшается более чем в 10 раз.

3.1.1.2. Перегруппировка полей


Перегруппировка полей (field reordering, [16]) применима в более широком
круге случаев, чем удаление ключевых полей. Не всегда возможно выне-
сти неиспользуемые в ExamineN ode поля в отдельную структуру. В этом
случае можно попытаться перегруппировать поля таким образом, что ис-
пользуемые в близкие моменты времени поля находятся рядом в узле. Это
преобразование касается не только ключевых полей, но и указателей.
Пример 1. Рассмотрим другой вариант отношения Students
(P assportN umber char (10), Y ear int, F irstN ame char (20), LastN ame
char (20), Address char (50), P honeN umber char (8), RecordBookN umber
char (6), AvgGrade int), где Y ear и AvgGrade — курс, на котором
учится студент, и средний балл за текущий учебный год, соответственно.
Очевидно, размер записи равен 122 байта. Если SL2 = 64, то запись
занимает почти два кэш-блока. Предположим, что выполняется скани-
рование отношения, в процессе которого вычисляются средние баллы
студентов, которые учатся на различных курсах (ExamineN ode в этом
случае использует поля Y ear и AvgGrade). Поскольку Y ear и AvgGrade
находятся в разных кэш-блоках, функция ExamineN ode вызывает, во-
обще говоря, два кэш-промаха. Поэтому выгодно реорганизовать запись
так, чтобы Y ear и AvgGrade находились рядом, например, Students =
(P assportN umber, Y ear, AvgGrade, F irstN ame, LastN ame, Address,

49
P honeN umber, RecordBookN umber). При этом в процессе рассматривае-
мой операции сканирования будет использоваться только первый кэш-блок
записи, и ExamineN ode будет приводить к одному кэш-промаху.
Пример 2. Рассмотрим узел k-арного дерева. В этом случае N ode =
(ptr0 , key1 , ptr1 , ..., keyk , ptrk ), где keyi — ключевые поля, а ptri — указа-
тели на дочерние узлы, причем key0 < key1 < ... < keyk . Предположим,
что выполняется поиск ключа key и ExamineN ode находит l = max{i :
keyi <= key}. В этом случае выгодно сгруппировать все ключи вместе:
N ode = (key0 , ..., keyk , ptr1 , ..., ptrk ). При этом обращение к указателю для
продолжения поиска будет требовать не более 1 кэш-промаха в дополнение
к тем кэш-промахам, которые произошли во время (бинарного) поиска в
массиве (key0 , ..., keyk ).

3.1.1.3. Компрессия ключевых полей.


Компрессия ключевых полей позволяет уменьшить размер ключевых по-
лей и, следовательно, количество кэш-блоков, занимаемых узлом (или по-
местить больше узлов в один кэш-блок). Часто ключевые поля являются
неотрицательными целыми числами, причем располагаются в узле в по-
рядке возрастания — например, так было в рассмотренном в предыдущем
примере k -арном дереве. В этом случае может быть применим один из
методов компрессии возрастающей последовательности неотрицательных
целых чисел, рассмотренных в [66]. Такие методы основаны на том, что
вместо самого числа хранится разность между данным числом и предыду-
щим, которая является (предположительно небольшим) положительным
числом, и это число кодируется с помошью одного из методов кодирова-
ния, например, γ - или δ -кодирования [66]. Недостатком данного метода яв-
ляется то, что после его применения ключевые поля могут быть доступны
только для последовательного просмотра, поскольку для декодирования
очередного значения требуется предыдущее. Кроме того, закодированные
числа могут занимать не целое число байт, и для доступа к ним требуются
сравнительно медленные на современных процессорах инструкции сдвига
битов и побитового AND [29]. В качестве средства исправления последнего

50
недостатка можно выравнивать закодированные числа по границам байта,
но это уменьшает эффект от компрессии [20].
Другим методом компрессии, который применяется в ситуации, когда
ключевые поля имеют большой размер и переменную длину (в частности,
являются строковыми значениями), является отделение от каждого клю-
чевого значения префикса фиксированной длины [10]. Длинное ключевое
значение в узле при этом заменяется на префикс и указатель на само клю-
чевое значение: keyi 7→ (pref ixi , key _pointeri ) (заметим, что key _pointeri
является информационным полем в смысле используемой терминологии,
хотя и содержит указатель на строку). Основанием для этого является тот
факт, что во многих случаях вместо ключа можно использовать префикс -
например, при сравнении строк можно сравнить префиксы, и обращаться
к самим строкам только в случае совпадения префиксов.
Пример 1. Вновь рассмотрим узел 5-арного дерева N ode = (1980, 1998,
2002, 2004, 2005, ptr0 , ptr1 , ptr2 , ptr3 , ptr4 , ptr5 ). Используя компрессию
целых чисел, мы преобразуем последовательность ключевых значений в
(1980, 18, 4, 2, 1). При использовании кодирования целых чисел с вырав-
ниванием по границе байта каждое ключевое значение, начиная со второго,
занимает 1 байт, таким образом, всего мы сэкономили 4(sizeof(int) - 1) =
12 байт.
Пример 2. Пусть ключевые значения являются строками. Рассмотрим
узел 3-арного дерева:
N ode = (0 Ivanovsky 0 , 0 Lebedev 0 , 0 P avlovsky 0 , 0 P etrovsky 0 , ptr0 , ptr1 , ptr2 ,
ptr3 , ptr4 ). Вместо строк будем использовать 2-х символьные префиксы:
keyi 7→ (pref ix2i , key _pointeri ), получим: N ode = (0 Iv 0 , 0 Le0 , 0 P a0 , 0 P e0 ,
key _ptr1 , key _ptr2 , key _ptr3 , key _ptr4 , ptr0 , ptr1 , ptr2 , ptr3 , ptr4 ). В ре-
зультате мы сэкономим 3 ∗ 9 + 7 − (3 ∗ 2 + 3 ∗ 4) = 16 байт в узле, кроме
того, сравнение коротких префиксов быстрее, чем исходных строк.

3.1.1.4. Удаление указателей


Обычно поля, являющиеся указателями, не используются в функции
ExamineN ode — на очередной итерации цикла в алгоритме 3.1 происхо-

51
дит обращение лишь к одному из указателей узла, который выбирается в
результате вызова ExamineN ode. Уменьшив количество указателей, мож-
но уменьшить количество кэш-блоков, занимаемых узлом. Заметим, что
указатели нужны лишь для поддержания связей в структуре, поэтому их
можно заменить на другой способ поддержания этих связей — в частности,
на их вычисление. Как правило, вычисление связей основано на том, что,
при наличии в структуре определенной регулярности, адрес дочернего уз-
ла можно рассчитать исходя из адреса родительского узла и порядкового
номера дочернего узла среди дочерних узлов родительского узла.
Пример 1. Рассмотрим k -арное дерево, N ode =
(key0 ,...,keyk ,ptr0 ,...,ptrk ). Предположим, что все дочерние узлы располага-
ются в памяти последовательности, так что ptri = ptr0 + i ∗ sizeof (N ode).
В этом случае можно удалить из узла все указатели, кроме ptr0 , сэкономив
тем самым (k − 1) ∗ sizeof (ptr) = 4(k − 1) байт в кэш-блоке. Разумеется,
это требует специальной поддержки при модификации дерева, в частно-
сти, при добавлении новых узлов и увеличивает общий объем памяти,
занимаемой деревом [48].
Пример 2. В условиях предыдущего примера предположим, что дере-
во является полным и хранится в памяти так, что все уровни и все узлы
на одном уровне располагаются последовательно. Смещение i-го узла на
уровне l (l ≥ 1) относительно начала дерева addr0 в памяти в этом случае
равно (1 + k + ... + k l−1 + i) ∗ sizeof (N ode). Это дает возможность, зная ад-
рес currentN ode в алгоритме 3.1, определить уровень l узла currentN ode,
что позволяет найти адрес дочернего узла, зная его порядковый номер сре-
ди дочерних узлов currentN ode. Чтобы избежать вычисления уровня l на
каждой итерации алгоритма 3.1, можно поддерживать l в качестве допол-
нительной переменной цикла, увеличивая ее на каждой итерации. Таким
образом, в данной ситуации хранение указателей на дочерние узлы вообще
не требуется, однако такая структура данных непригодна, если в дерево
могут добавляться новые ключи [47].

52
3.1.2. Взаимное расположение узлов

Помимо оптимизации структуры узлов, может также использоваться опти-


мизация взаимного расположения узлов. Как правило, выполнять эту оп-
тимизацию стоит после улучшения структуры узла, поскольку чем меньше
размер узла, тем более вероятна успешная оптимизация взаимного распо-
ложения узлов. Цель такой оптимизации заключается в том, чтобы кэш-
промах происходил не на каждой итерации цикла в алгоритме 3.1. Очевид-
но, способом добиться этого является расположить узлы так, чтобы теку-
щий и новый узлы currentN ode располагались внутри одного кэш-блока.
Конечно, такая оптимизация возможна только при малых размерах узла:
sizeof (N ode) ≤ S2 . Кроме того, ограничения на возможность такой опти-
мизации накладывает функция ExamineN ode — если от текущего узла
возможен переход ко многим следующим узлам (как например, в B-дереве
достаточно большой степени, где у каждого узла могут быть десятки до-
черних узлов), то расположить текущий и все возможные следующие узлы
в одном кэш-блоке не удастся.
Пример. Рассмотрим 3-арное дерево, узел которого имеет два цело-
численных ключевых значения: N ode = (key0 , key1 , ptr0 , ptr1 , ptr2 ),
sizeof (N ode) = 2 ∗ 4 + 3 ∗ 4 = 20. Если S = 128, то мы можем сгруп-
пировать узлы четных уровней с их дочерними узлами, так что узел и все
его дочерние узлы располагаются в памяти последовательно. На рисун-
ке 3.2 группы узлов показаны с помощью пунктирных линий. Количество
кэш-промахов при поиске ключа в таком дереве уменьшается в среднем
в 2 раза, поскольку кэш-промахи не происходят при переходе с четно-
го уровня на нечетный. Однако, для аналогичного 4-арного дерева, где
sizeof (N ode) = 3 ∗ 4 + 4 ∗ 4 = 28, узел и его дочерние узлы уже не по-
мещаются в один кэш-блок. Тем не менее описанная оптимизация все же
оказывается эффективной и в этом случае, поскольку вероятность исполь-
зования в ExamineN ode той части последнего узла, которая не помещается
в кэш-блок, мала. Более того, с учетом тенденции роста размера кэш-блока
[58, 56], в будущих процессорах данная оптимизация может быть примени-
ма к деревьям большей арности.

53
Рис. 3.2. Группировка узлов 3-арного дерева

3.2. Оптимизация алгоритмов

В данном разделе мы рассматриваем приемы реструктуризации алгорит-


мов, позволяющие уменьшить количество кэш-промахов. Как правило, эти
приемы основаны на простых преобразованиях, затрагивающих наиболее
вложенные циклы алгоритма.

3.2.1. Модель локальности ссылок

Мы считаем, что программа P состоит из инструкций и в коде программы


имеются ссылки (обозначаются ссылки ref ), при выполнении которых про-
исходит обращение к оперативной памяти и, следовательно, к кэш-памяти
и устройству трансляции адресов. Набор ссылок определяется статически
просмотром кода программы (является ли данная инструкция ссылкой или
нет — это свойство инструкции). Будем считать, что полный набор входных
данных фиксирован и программа запущена на выполнение с этим набором
входных данных. Мы будем говорить об исполнении программы P : E(P ),
представляющим собой трассу, т.е. последовательность инструкций, запи-
санных в порядке их выполнения (вообще говоря, E(P ) зависит не только
от P , но и от входных данных, но во многих случаях мы будем сокращать
обозначения, полагая, что набор входных данных фиксирован). При выпол-
нении каждой инструкции программы увеличивается значение счетчика,
который будем называть моментом временем выполнения инструкции и
обозначать t. Для наших целей — минимизации количества кэш-промахов
- имеет значение только количество выполнившихся инструкций, обращав-

54
шихся к памяти, поэтому мы считаем, что при выполнении инструкций,
не являющихся ссылками, время не увеличивается. За время выполнения
программы одна и та же ссылка может выполняться многократно, поэто-
му мы будем говорить об исполнениях ссылки ref : reft0 , reft1 ,... . Адрес
исполнения ссылки — это адрес, на который ссылается данное исполнение
ссылки (обозначается Addr(reft )). Для простоты мы не будем различать
адреса в физической и виртуальной памяти. Двумя основными критерия-
ми эффективности использования кэш-памяти ссылкой, характеризующи-
ми локальность ссылки в программе, являются:

• Пространственный интервал ссылки — это средняя величина раз-


ности (по абсолютной величине) между адресами следующего по
времени и предыдущего исполнения ссылки: SpaceLoc(ref ) =
Ei (|Addr(refti+1 ) − Addr(refti )|) (здесь и далее E — математическое
ожидание величины). Эта величина характеризует пространственную
локальность ссылки.

• Интервал переиспользования ссылки — это средний интервал


времени между исполнениями ссылки, которые находятся в од-
ном и том же кэш-блоке памяти: ReuseSpan(ref ) = Ei ((ti+1 −
ti )|CacheAddr(Addr(refti+1 )) = CacheAddr(Addr(refti ))). Данная
величина характеризует временную локальность ссылки. Функция
CacheAddr : Addr → Addr, возвращает начальный адрес кэш-блока,
в котором находится данный адрес основной памяти. Во многих слу-
чаях мы будем для простоты считать, что

CacheAddr(Addr1 ) = CacheAddr(Addr2 ) ⇔ |Addr1 − Addr2 | < SL2 ,

т.е. что адреса, отличающиеся менее, чем на размер кэш-блока, попа-


дают в один кэш-блок, пренебрегая при этом выравниванием.

Заметим, что оба этих понятия относятся к фиксированному исполне-


нию программы. Неформально, чем больше интервал переиспользования,
тем меньше шансов, что данные, загруженные в кэш-память предыдущим

55
исполнением ссылки, останутся там до следующего исполнения ссылки.
Чем больше пространственный интервал, тем меньше шансов, что очеред-
ное исполнение ссылки сможет использовать тот же кэш-блок, который
был загружен во время предыдущего исполнения ссылки. Те же рассужде-
ния применимы и к адресу ссылки, загружаемому в устройство трансляции
адресов.
Пространственная и временная локальность являются независимыми
понятиями. Как показывает рассмотренный выше пример 2.1.2.8, при
stride = 2 наблюдается малый пространственный интервал ссылки на эле-
мент массива (этот интервал равен stride, т.е. SpaceLoc = 2), но интервал
переиспользования ReuseSpan равен length
stride = 2 , поэтому переиспользова-
27

ние данных, загруженных в кэш-память на предыдущей итерации внеш-


него цикла, не достигается. Несложно привести и пример, обладающий
малым интервалом переиспользования и большем пространственном ин-
тервалом локальности — для этого достаточно рассмотреть цикл, который
многократно обращается к небольшому подмножеству элементов большого
массива, далеко отстоящих друг от друга:
void GoodTemporalLocalityButBadSpatial ()
{
int length = 28;
byte array [2length ];
for (int j = 0; j <10000; j++)
for (int i = 0; i < 4; i++)
{
Print (array [2i+length−4 ]);
}
}
Этот код обращается лишь к 4 элементам, поэтому интервал переис-
пользования ReuseSpan ссылки pArray[i] равен 4. C другой стороны, рас-
стояние между последовательными обращениями, т.е. пространственный
интервал, равно SpaceLoc = 2length−4 = 224 .
Пространственный интервал и интервал переиспользования ссылки да-
ют нам способ количественного сравнения различных способов доступа к
памяти, однако являются ограниченными в том смысле, что связаны с кон-

56
кретной ссылкой. Можно определить сходные понятия, распространяющие
эти определения на всю программу или на ее фрагмент:

• Пространственный интервал программы — это средняя величина


разности (по абсолютной величине) между адресами следующего по
времени и предыдущего исполнения ссылок в программе: SpaceLoc =
Eref,i (|Addr(refti+1 ) − Addr(refti )|).

• Интервал переиспользования программы — это средний интервал


времени между исполнениями ссылок, которые имеют один и тот же
адрес: ReuseSpan = Eref,i ((ti+1 − ti )|Addr(refti+1 ) = Addr(refti )).

Различие между этими определениями для ссылки и программы состо-


ит в том, что достижение хорошей локальности для конкретной ссылки
(т.е. малых значений SpaceLoc и ReuseSpan), вообще говоря, не является
необходимым для достижения хорошей локальности программы. Причина
состоит в том, что данные, загруженные в кэш исполнением одной ссылки,
могут переиспользоваться исполнением другой ссылки. Наше предположе-
ние состоит в том, что исполнение программы состоит из стационарных
и нестационарных участков времени. Стационарные участки соответству-
ют различным этапам алгоритма программы, а нестационарные — перехо-
дам между этими этапами [22, 39]. Мы предполагаем, что взаимовлиянием
исполнений различных ссылок на нестационарных участках можно прене-
бречь и будем стремиться минимизировать количество кэш-промахов на
стационарных участках.

3.2.2. Различные методы доступа к памяти

Пусть имеется программа и ссылка ref в ней. В процессе выполнения про-


граммы эта ссылка порождает последовательность адресов ее исполнений:
Addr(reft0 ), Addr(reft1 ),..., Addr(reftk ),... Во многих случаях, однако, до-
ступ к памяти носит предсказуемый характер и можно сказать, какие адре-
са может иметь очередное исполнение ссылки, если известен адрес текуще-
го исполнения. Для формализации этого наблюдения мы определим мно-

57
жество возможных адресов исполнения ссылки AddrSet(ti+1) — это мно-
жество адресов, которые может принимать исполнение ссылки в момент
времени ti+1 , при условии, что адрес текущего исполнения Addrti , а также
функцию перехода адреса AddrT ransf er : Addr 7→ {Addr}, которая выра-
жает зависимость между адресом текущего исполнения и возможными ад-
ресами следующего исполнения: AddrSet(ti+1 ) = AddrT ransf er(Addrti ).
В зависимости от вида функции AddrT ransf er, мы будем различать
несколько способов доступа к памяти для ссылки ref :

• Последовательный, если ∀E(P ) ∀i : Addr(refti+1 ) − Addr(refti ) =


shif t, где shif t — некоторая константа, другими словами, при
любом исполнении программы адрес между последовательными
исполнениями ссылки отличается на константу. В этом случае
∀E(P ) ∀i |AddrSet(ti )| = 1, и AddrT ransf er(Addrti+1 ) = {Addrti +
shif t}.

• Псевдо-произвольный, если ∃N : ∀E(P ) ∀i |AddrSet(ti )| ≤ N , т.е.


независимо от исполнения программы множество AddrSet(ti ) может
включать один из нескольких элементов.

• Произвольный в остальных случаях. При этом простой зависимо-


сти множества возможных значений адреса следующего исполнения
ссылки от адреса текущего не наблюдается.

Пример 1. Рассмотрим вычисление среднего значения ключевого по-


ля Age в отношении Students (Age int, F irstN ame char (20), LastN ame
char (20)). Такое вычисление может выполняться алгоритмом 3.1, при этом
ExamineN ode использует только поле Age и демонстрирует последова-
тельный доступ с константой shif t = 44.
Пример 2. Поиск в бинарном дереве с N ode = (key0, ptr0, ptr1) по клю-
чевому полю key0 показывает псевдо-произвольный способ доступа к па-
мяти, поскольку AddrSet(ti ) = {ptr0i , ptr1i }, где N odei — это узел дерева,
посещаемой на i-ой итерации алгоритма 3.1.
Пример 3. Пусть дана достаточно длинная последовательность чисел
от 0 до 215 , и требуется подсчитать для каждого числа количество его

58
вхождений в последовательность. Очевидный алгоритм решения исполь-
зует массив размером [0...215 ] и увеличивает связанный с каждым числом
счетчик вхождений. Доступ к этому массиву имеет произвольный харак-
тер, поскольку адрес очередного исполнения ссылки зависит только от оче-
редного числа последовательности.

3.2.3. Методы оптимизации для уменьшения простран-

ственного интервала

Здесь мы рассматриваем методы, направленные на уменьшение простран-


ственного интервала ссылок в программе. Эти методы позволяют предот-
вратить загрузку в кэш-память ненужных в текущий момент данных, что
освобождает место для данных, необходимых для вычислений. Тем самым
уменьшается количество промахов переполнения, поскольку активно ис-
пользуемые данные размещаются в памяти более компактно. Будем пред-
полагать, как и ранее (3.1), что программа использует структуры данных,
состоящие из узлов.

3.2.3.1. Введение временных структур данных


Данный прием аналогичен методу оптимизации структуры узла 3.1.1.1,
однако меняет алгоритм, а не структуру данных, вводя дополнительную
временную структуру данных, состоящую из полей исходной структуры
данных, которые многократно используются в вычислениях. Этот метод
оптимизации может использоваться, если изменение состава узла 3.1.1.1
в силу каких-либо обстоятельств неприменимо, например, со структурой
данных работают также другие алгоритмы, и состав узла является ком-
промиссом между различными алгоритмами, использующими структуру
данных.
Пример. Рассмотрим код, который вычисляет декартово произведение
двух отношений Cities(N ame char (20), P opulation int, Country char (20))
и Banks(N ame char (20), Headquarters char (20)), причем нас интересу-
ют только поля Cities.N ame и Banks.N ame. Прямолинейная реализация

59
выглядит так (считаем, что отношения хранятся в виде массивов):
for (int i = 0; i < Banks.Length; i++)
{
char[] bankName = Banks[i].Name;
for (int j = 0; j < Cities.Length; j++)
{
Print (bankName + Cities[j].Name);
}
}
Заметим, что для ссылки Cities[j].N ame пространственный интервал
равен сумме длин всех полей, т.е. 44. Мы можем уменьшить его, если пред-
варительно выделим поле N ame в отдельную структуру:
char [][] cityNames = new char[Cities.Length][20];
for (int k = 0; k < Cities.Length; k++)
{
copy (cityNames [k], Cities [k].Name);
}
for (int i = 0; i < Banks.Length; i++)
{
char[] bankName = Banks[i].Name;
for (int j = 0; j < Cities.Length; j++)
{
Print (bankName + cityNames [j]);
}
}
В модифицированном алгоритме присутствует один лишний цикл,
копирующий часто используемое поле Name во временную структу-
ру данных cityN ames. Однако, пространственный интервал ссылки
SpaceLoc(cityN ames[j]) = 20, т.е. достигнуто улучшение локальности.

3.2.3.2. Изменение порядка обхода структуры данных


В некоторых случаях можно изменить порядок обхода структуры данных
при вычислениях так, что пространственный интервал при новом порядке
обхода будет меньше. Например, такая ситуация может возникнуть при
использовании многомерных массивов [15].

60
Пример. Дана двумерная матрица A[M, N ] и необходимо вычислить
сумму всех ее элементов. Приведенный ниже код решает эту задачу, но име-
ет плохой пространственный интервал SpaceLoc(A[j, i]) = N ∗ sizeof (A).
Простой перестановкой циклов можно добиться пространственного интер-
вала, равного sizeof (A).
for (int i = 0; i < N; i++)
{
for (int j = 0; j < M; j++)
{
sum += A[j,i];
}
}

3.2.4. Методы оптимизации для уменьшения интервала

переиспользования

Уменьшение интервала переиспользования ReuseSpan ссылки необходимо


для предотвращения вытеснения данных из кэш-памяти перед исполнени-
ем ссылки, которое вновь использует эти данные, другими словами, для
уменьшения числа промахов переполнения. Как правило, этого эффекта
можно достигнуть, уменьшив рабочий набор, т.е. набор узлов, который ис-
пользуется в наиболее вложенном цикле алгоритма.

3.2.4.1. Разбиение структуры данных на блоки (blocking)


Этот метод может применяться только при последовательном характере
доступа. Суть метода заключается в разбиении кода так, чтобы в наиболее
вложенном цикле использовался только блок структуры данных, т.е. на-
бор последовательно расположенных в памяти узлов. Для этого в код вво-
дится внешний цикл, проходящий все блоки структуры данных. Важным
параметром является размер блока, который должен выбираться из сооб-
ражений минимизации интервала переиспользования ссылки так, чтобы
данные не успевали вытесняться из кэш-памяти до очередного исполнения
ссылки, которое их использует. С другой стороны, размер интервала пере-
использования должен быть максимальным, обеспечивающим предыдущее

61
свойство, поскольку введение дополнительного внешнего цикла означает
дополнительные инструкции проверки условия во внешнем и во вложен-
ных циклах, стоимость которых увеличивается с увеличением числа бло-
ков. Необходимым условием применимости данной оптимизации является
отсутствие ограничения на порядок обработки узлов структуры данных,
так как разбиение на блоки меняет этот порядок.
Пример. Вернемся к примеру с отношениями Cities и Banks. Вы-
ше мы улучшили пространственный интервал исходного кода, однако
интервал переиспользования ссылки ReuseSpan(cityN ames[j]) = 1 +
cityN ames.Length (слагаемое + 1 возникает из-за наличия ссылки
Banks[i].N ame), поэтому если cityN ames не помещается в кэш-память,
могут возникать промахи переполнения. Разбиение на блоки преобразует
код следующим образом (начало фрагмента остается неизменным, поэтому
здесь опущено):
int itemsPerBlock = C / 20; // число 20 из Cities(Name char (20), ...)
int totalBlocks; // общее число блоков
totalBlocks = cityNames.Length / itemsPerBlock;
for (int blockNo = 0; blockNo < totalBlocks; blockNo++)
for (int i = 0; i < Banks.Length; i++)
for (int j = blockNo*itemsPerBlock;
j < (blockNo + 1)*itemsPerBlock;
j++)
{
Print (Banks[i].Name + cityNames [j]);
}
В модифицированном коде интервал переиспользования
ReuseSpan(cityN ames[j]) = itemsP erBlock = C/20, где C - размер
кэш-памяти. Таким образом, весь текущий блок cityNames помещается в
кэш-память, и промахов переполнения не происходит.

3.2.4.2. Распределение структуры данных (partitioning)


Если ссылка использует произвольный метод доступа, то разбиение на бло-
ки оказывается неприменимо. В этом случае полезным может оказаться
распределение структуры данных, которое так же, как и разбиение на

62
блоки, позволяет уменьшить размер рабочего набора алгоритма. Преоб-
разование действует следующим образом. Перед основной обработкой про-
изводится распределение узлов структуры данных на разделы P1, ..., PN в
соответствии с некоторой функцией Distr : N ode 7→ Pi , i = 1, ..., N . После
чего наиболее вложенный цикл, проходящий всю структуру данных, заме-
няется на цикл, проходящий один раздел, и вводится еще один внешний
цикл, проходящий все разделы. Как и в случае разбиения на блоки, необ-
ходимым условием применимости оптимизации является отсутствие огра-
ничений на порядок обработки структуры данных. Аналогично размеру
блока при разбиении на блоки, параметром при распределении структуры
является средний размер одного раздела |Pi |.
Пример. Рассмотрим следующую задачу. Даны две длинных последо-
вательности целых чисел (a)i и (b)i и требуется подсчитать количество тех
элементов последовательности (a)i , которые встречаются в последователь-
ности (b)i (каждое число считается столько раз, сколько оно встречает-
ся в последовательности (a)i ; предполагается, что обе последовательности
неупорядочены и значения элементов находятся в интервале 0...M ax − 1).
Задачу можно решить, вычислив для каждого числа 0...M ax − 1, встреча-
ется ли оно в последовательности (b)i и затем проверив каждый элемент
последовательности (a)i :
bool [] present = new bool[M ax];
int total = 0;
foreach (int b in (b)i ) present [b] = true;
foreach (int a in (a)i )
if (present[a]) total++;
Очевидно, ссылки present[a] и present[b] используют произвольный ме-
тод доступа, поэтому их интервал переиспользования может быть доста-
точно большим, чтобы данные успевали быть вытесненными из кэша пе-
ред очередным исполнением ссылки, имеющим тот же адрес. Уменьшить
интервал переиспользования можно, применив распределение ссылок на
массив present:
bool [] present = new bool[M ax];
int total = 0;
List [] aParts = new List[numPartitions],

63
Таблица 3.1. Зависимость времени выполнения и количества кэш-промахов
от числа разделов
N CacheMisses, *106 Время, с
1 19 5.4
50 18.5 9
100 18.3 7.3
250 14.5 6.3
500 6 4.6
750 2 4.5
1000 2 5.5

bParts = new List[numPartitions];


foreach (int b in (b)i )
{
bParts [b % numPartitions].Add (b);
}
foreach (int a in (a)i )
{
aParts [a % numPartitions].Add (a);
}
for (int i = 0; i < numPartitions; i++)
{
foreach (int b in bP arts[i]) present[b] = true;
foreach (int a in aP arts[i])
if (present [a]) total++;
}
Заметим, что мы используем функцию Distr(a) = a mod N , где N
— число разделов. Таким образом, после модификации исходный алгоритм
фактически применяется к каждому разделу. Мы провели эксперимент, ре-
ализовав этот алгоритм на языке С (компилятор MS VC++ 7.1 с включен-
ной оптимизацией — ключом /O2) и запустив на выполнение на системе S.
Результаты эксперимента приведены в таблице 3.1 (остальные параметры
эксперимента: |(a)i )| = |(b)i )| = 2 ∗ 107 , M ax = 2 ∗ 106 , элементы последова-
тельностей (a)i и (b)i равномерно распределены в интервале [0...M ax − 1])
Количество разделов N = 1 в таблице 3.1 соответствует исходному ва-
рианту алгоритма. Очевидно, при количестве разделов, равном N = 750,
достигается минимальное время выполнения.

64
3.2.4.3. Логическое распределение (logical partitioning)
При распределении структуры данных разделы заполняются и хранятся в
явном виде. Это означает, что распределение узлов (т.е. вычисление функ-
ции Distr) должно выполняться лишь один раз, однако для хранения раз-
делов требуется дополнительная память, что во многих случаях ограничи-
вает возможность использования оптимизации. Альтернативой является
вычисление функции Distr в процессе обработки структуры данных, что
приводит к методу логического распределения. Логическое распределение
заменяет явное хранение разделов на их вычисление, что может быть более
выгодно, если функция Distr достаточно дешевая, чтобы компенсировать
ее многократное вычисление.
Пример. Рассмотрим задачу индексирования множественнозначных
атрибутов, т.е. атрибутов, значениями которых являются не атомарные
элементы (как в традиционной реляционной модели),а множества элемен-
тов. Если R — отношение, А — множественнозначный атрибут R над доме-
ном элементов Domain(A), то инвертированный файл IFRA является отоб-
ражением из Domain(A) в множество идентификаторов записей (RID —
record identifier), сопоставляющим каждому элементу Domain(A) множе-
ство RID записей, содержащих данный элемент (инвертированный список
элемента Domain(A)). Реализация инвертированного файла представляет
индекc в виде B-дерева или хэш-таблицы, отображающий элементы в ука-
затели на инвертированные списки, которые хранятся упорядоченными по
RID.
Предположим, что дан инвертированный файл IFRA и требуется вос-
становить по нему мощности значений атрибута A для всех записей R.
Прямолинейный алгоритм, решающий данную задачу, выглядит так:
void ComputeCards (InvertedFile invFile, int [] result)
{
foreach(element,invList) in invFile)
foreach (RID rid in invList)
result [rid]++
}
Очевидно, что ссылка result[rid] (с произвольным способом доступа)

65
имеет большой пространственный интервал, поскольку между последова-
тельными ее исполнениями с одинаковыми значениями rid успевает прой-
ти много времени. Для уменьшения пространственного интервала мы ис-
пользуем логическое разбиение — все RID разбиваются на интервалы, и в
очередной раздел попадают только RID из соответствующего интервала.
Заметим, что тот факт, что инвертированные списки хранятся упорядочен-
ными, позволяет находить RID из данного интервала эффективно, с помо-
шью бинарного поиска. В результате получаем следующий алгоритм (part
в алгоритме является номером раздела, т.е. определяет интервал RID):
void ComputeCards (InvertedFile invFile, int [] result, int nParts)
{
for (int part = 0; part < nParts; part++)
foreach ((element,invList) in invFile)
{
(firstIndex, lastIndex) = binarySearch (invList, part);
for (int currIndex = firstIndex;
currIndex < lastIndex;
currIndex++)
{
int rid = invList[currIndex];
result [rid]++
}
}
}
Реализация этого алгоритма на языке C (компилятор MS VC++ 7.1 c
уровнем оптимизации /O2), была запущена на выполнение на системе S
со входным отношением R, состоящим из 107 записей, средняя мощность
значений множественнозначного атрибута равна 5.5. Результаты приведе-
ны в таблице 3.2. Как и в предыдущем эксперименте, количество разделов
N = 1 соответствует исходному варианту алгоритма, до применения логи-
ческого распределения.
В данном случае минимальное время выполнение достигается при N =
128 разделах. При дальнейшем увеличении количества разделов наклад-
ные расходы на многократное вычисление функции Distr перевешивают
выигрыш от уменьшения количества кэш-промахов. Демонстрируемая в

66
Таблица 3.2. Зависимость времени выполнения и количества кэш-промахов
от числа разделов
N CacheMisses, *106 Время, с
1 56 11.8
32 37 7.72
64 26 5.8
128 20 3.8
200 20 4.5

этом примере идея будет использована в главе 4 для улучшения другого


алгоритма, работающего с инвертированными файлами.

3.2.5. Использование явной предвыборки

Рассмотренные в предыдущих разделах методы позволяют уменьшить ко-


личество кэш-промахов первого обращения (за счет уменьшения простран-
ственного интервала) и переполнения (за счет уменьшения интервала пере-
использования). Альтернативным способом оптимизации является умень-
шение не количества кэш-промахов, а их стоимости. Этого можно добить-
ся, используя инструкции для явной предвыборки данных в кэш-память
(2.1.2.4) для загрузки данных в память перед тем, как они действитель-
но понадобятся. Заметим, что для этого необходимо знать адрес данных
перед их использованием. Это возможно, в частности, если ссылка исполь-
зует псевдо-произвольный метод доступа, поскольку тогда на очередной
итерации цикла становится известен адрес данных, которые потребуются
на следующей итерации.
Пример. Рассмотрим стандартный алгоритм бинарного поиска в упоря-
доченном массиве из N элементов — целых чисел.
int BinarySearch (int [] array, int key)
{
int left = 0, right = array.Length - 1;
while (left <= right)
{
int mid = (left + right) / 2;
int value = array[mid];
if (value < key) left = mid + 1;

67
else if (value > key) right = mid -1;
else return mid;
}
return -1;
}
Здесь ссылка array[mid] использует псевдо-произвольный способ досту-
па, поэтому можно попытаться применить предвыборку. Для этого име-
ются две возможности — либо вставить инструкцию PREFETCH в каж-
дую из веток условного оператора, после определения значений left и
right, либо вставить две инструкции PREFETCH сразу после определения
mid и value (соответствующие модификации алгоритма обозначим Pref1
и Pref2, а исходный Orig; модификация Pref2 представлена в процедуре
BinarySearchPref2). В обоих случаях инструкции PREFETCH выбирают
адрес &array[mid] для следующей итерации цикла. Заметим, что инструк-
ции PREFETCH могут не только улучшить производительность, но и ухуд-
шить ее [30, 28]. Мы провели эксперимент, запустив все три варианта алго-
ритма на выполнение на системе S. Размер массива был равен 107 , массив
содержал числа в интервале [1, 5 ∗ 109 ]. Выполнялся поиск 106 ключей,
равномерно распределенных в интервале [1, M ax]. Зависимость времени
выполнения и количества кэш-промахов от величины M ax дана в таблице
3.3.
int BinarySearchPref2 (int [] array, int key)
{
int left = 0, right = array.Length - 1;
while (left <= right)
{
int mid = (left + right) / 2;
int value = array [mid];
if (value == key) return mid;
PREFETCH (mid + 1 + right) / 2;
PREFETCH (mid - 1 + left) / 2;
if (value < key) left = mid + 1;
else right = mid - 1;
}
return -1;
}

68
Таблица 3.3. Зависимость времени выполнения и количества кэш-промахов
от Max
Max T imeOrig , sec T imeP ref 1 , sec T imeP ref 2 , sec CacheMisses, *106
9 ∗ 109 13 11.7 11.2 50
9 ∗ 10 15.6
8
14.5 12.9 68
9 ∗ 10 4.8
7
5.8 8.5 16
9 ∗ 10 2.3
5
2.3 3.8 0.01

Из таблицы 3.3 очевидно, что вариант Pref2 выгоднее, причем он оправ-


дывает себя только в случае, когда M ax достаточно велико. Разумным
объяснением этого выглядит тот факт, что с уменьшением M ax уменьша-
ется количество кэш-промахов, поскольку уменьшается количество различ-
ных значений mid, используемых в процессе поиска различных ключей. С
уменьшением количества кэш-промахов инструкции PREFETCH теряют
свою эффективность и только создают лишние накладные расходы [28].
По сравнению с вариантом Pref1, вариант Pref2 более эффективно соче-
тает инструкции доступа к памяти и инструкции вычислений, что имеет
важное значение для общей производительности [29, 28]. Действительно,
в варианте Pref2 инструкции PREFETCH выполняются раньше во время
итерации цикла алгоритма, чем в Pref1, поэтому вероятность того, что дан-
ные, запрошенные инструкциями PREFETCH из памяти, будут готовы к
моменту, когда они понадобятся на следующей итерации, выше для Pref2,
чем для Pref1.

69
Глава 4.

Алгоритмы выполнения

операции соединения для

СУБД-ОП

В данной главе мы рассмотрим алгоритмы выполнения различных видов


операции соединения для СУБД-ОП. Соединение является одной из наибо-
лее распространенных операций в СУБД, поэтому эффективность реали-
зации соединения играет важнейшую роль для производительности СУБД
при выполнении пользовательских запросов. Рассматриваемые здесь алго-
ритмы используют общие методы оптимизации структур данных и алго-
ритмов для современных компьютерных архитектур, которые обсуждались
в главе 3.

4.1. Модели хранения данных

В дальнейшем важное значение во многих случаях будет иметь формат


представления хранимых отношений в памяти, или модель хранения дан-
ных. Модель хранения данных определяет, как расположены в памяти ат-
рибуты отдельных записей отношения. Как следует из предыдущих глав,
доступ к памяти в современных системах характеризуется неравномерно-
стью стоимости доступа в зависимости от расположения данных, поэтому

70
Рис. 4.1. Пример N -арной модели хранения данных: отношение, страница
в памяти и проекция отношения на кэш-блоки

модель хранения данных оказывает непосредственное влияние на произво-


дительность алгоритмов.
Традиционно в СУБД используется N-арная модель хранения данных
(N-ary storage model, NSM), при которой записи отношения хранятся по-
следовательно, значение одного атрибута за значением другого. Отношение
разбивается на страницы, каждая страница включает, помимо самих запи-
сей, таблицу смещений, которая содержит указатели на начало каждой
записи [64, 1]. В случае, если все атрибуты отношения имеют фиксирован-
ную длину, таблица указателей может отсутствовать [1]. Таким образом,
N-арная модель хранения характеризуется только порядком следования
атрибутов и размером страницы. Пример отношения, хранимого с исполь-
зованием N-арной модели, дан на рис. 4.1
Недостатком N -арной модели хранения данных для СУБД-ОП являет-
ся плохая утилизация кэш-блока в том случае, если при сканировании от-
ношения требуется лишь небольшое количество атрибутов. Например, рас-
смотрим отношение P eople(RID, SSN , N ame, Age) (SSN — Social Security
Number) на рис. 4.1. Предположим, что требуется вычислить средней воз-
раст по этому отношению, в этом случае функция ExamineNode из модели
раздела 3.1 использует только атрибут Age. Другие атрибуты лишь на-

71
Рис. 4.2. Пример декомпозированной модели хранения

прасно загружаются в кэш-память и занимают место в кэш-блоках, что


показано на рис. 4.1 справа, где используемый атрибут выделен.
Для преодоления недостатков N -арной модели (которые проявляются
не только в СУБД-ОП, но и в традиционных СУБД, где чтение лишних
атрибутов увеличивает количество читаемых с диска страниц) была пред-
ложена декомпозированная модель хранения (Decomposition Storage Model)
[11, 18]. В декомпозированной модели хранения каждый атрибут отноше-
ния хранится фактически в отдельной таблице, где содержатся последо-
вательно значения данного атрибута для всех записей отношения. Деком-
позированная модель хранения отличается лучшей (по сравнению с NSM)
утилизацией дисковых страниц (в традиционных СУБД) и кэш-блоков (в
СУБД-ОП) [11, 64], однако при восстановлении записи требуется чтение со-
ответствующих значений атрибутов из каждой из отдельных таблиц, что
ухудшает производительность. Можно сказать, что декомпозированная мо-
дель хранения представляет собой применение оптимизации удаления клю-
чевых полей 3.1.1.1 из исходного отношения. Отношение People, хранимое
с использованием DSM, представлено на рис. 4.2.
Таким образом, N -арная и декомпозированная модели хранения пред-
ставляют собой два крайних случая среди возможных схем хранения. Из-

72
вестны также попытки объединить обе этих схемы, взяв лучшее от каждой
из них. Модель PAX (Partition Attributes Across) [64] сочетает использова-
ние NSM и DSM так, что записи внутри NSM-страницы хранятся в виде
отдельных таблиц для каждого атрибута, как в DSM. Схема Data Morphing
[24] объединяет атрибуты в группы на основе анализа заранее заданного
набора запросов: те атрибуты, которые часто встречаются вместе, попа-
дают в одну группу. Значения атрибутов из одной группы хранятся как
записи в модели NSM.

4.2. Операция естественного соединения

Рассмотрим отношения R(AR R


1 , ..., ANR , C1 , ..., Ck ) и
S(B1S , ..., BN S
S
, C1 , ..., Ck ), где C1 , ..., Ck — общие атрибуты для
обеих отношений. Результатом естественного соединения на-
зывается отношение (AR R S
1 , ..., ANR , C1 , ..., Ck , B1 , ..., BNS )
S
: R ⊗
S = {(a1 , ..., aNR , c1 , ..., ck , b1 , ..., bNS )|(a1 , ..., aNR , c1 , ..., ck ) ∈
R, (b1 , ..., bNS , c1 , ..., ck ) ∈ S}.
Естественное соединение имеет важнейшее значение для реляционных
СУБД, поскольку является средством восстановления декомпозированных
в отношения в соответствии с правилами нормализации [3] данных. Наибо-
лее распространенным случаем является k = 1, т.е. соединяемые отноше-
ния имеют только один общий атрибут. В частности, этот атрибут может
быть первичным ключом R и внешним ключом S , что соответствует полу-
чению дочерних записей для каждой родительской записи [3, 1] — широко
встречающейся на практике операции.

4.2.1. Естественное соединение в СУБД-ОП

Эффективные алгоритмы для выполнения естественного соединения в


СУБД являются одной из наиболее активных тем исследований в области
баз данных, начиная с 70-х гг 20 века. За это время было предложено мно-
жество методов соединения, основанных на одной из нескольких базовых
схем: алгоритме вложенных циклов, методе сортировки, методе хэширова-

73
ния или использовании индексов [1]. Каждая из базовых схем имеет свою
область применения: так, метод сортировки позволяет выполнять соеди-
нение отношения, намного превосходящих по своим размерам доступный
объем оперативной памяти, а хэширование эффективно, если меньшее из
двух отношений помещается в оперативной памяти [1, 23].
Однако исследования в области алгоритмов соединения для традици-
онных СУБД оценивают эффективность алгоритмов в терминах модели
стоимости, включающей количество операций ввода-вывода, а также, воз-
можно, вычислительную сложность алгоритма [1, 55]. Как мы отмечали в
главе 2, такая модель является неадекватной для СУБД-ОП, поскольку не
учитывает простоев процессора из-за кэш-промахов, ошибок предсказания
переходов и других причин. Например, предположим, что оба отношения R
и S помещаются целиком в оперативную память. В этом случае алгоритм,
основанный на хэшировании (хэш-соединение), имеет нулевую компонен-
ту стоимости, связанную с операциями ввода-вывода, но производит много
кэш-промахов, поскольку использует произвольный метод доступа. Тем не
менее, хэш-соединение выглядит предпочтительным методом для СУБД-
ОП по сравнению с соединением, основанным на сортировке, поскольку не
требует относительно дорогой операции сортировки обоих исходных отно-
шений. Однако необходимость повышения эффективности использования
кэш-памяти при хэш-соединении является очевидной.

4.2.1.1. Близкие работы


За последние десятилетие был предложен ряд методов выполнения есте-
ственного соединения, учитывающих специфику СУБД-ОП. Статья [55]
была первой работой в этом направлении. Ее авторы рассмотрели ряд ал-
горитмов, применяемых в СУБД, с точки зрения эффективности исполь-
зования кэш-памяти и предложили усовершенствованные варианты алго-
ритмов. Кроме того, в работе были выделены некоторые общие методы
оптимизации кэш-памяти, такие как введение временных структур дан-
ных, разделение на блоки и распределение. Авторы предложили модифи-
цированную версию алгоритма хэш-соединения, PartitionedHash, которая

74
уменьшает количество кэш-промахов, возникающих при обращении к хэш-
таблице, построенной по одному из отношений, за счет использования рас-
пределения обоих отношений на разделы и применения исходного алго-
ритма к каждому из разделов. Размер раздела выбирается так, чтобы он
помещался в кэш-память уровня L2.
В работах [40, 38] рассматривается проблема оптимизаций операции со-
единения в СУБД-ОП Monet. Monet использует декомпозированную мо-
дель хранения, каждый атрибут хранится в отдельном бинарном отноше-
нии (BAT, Binary Association Table). Первым атрибутом BAT всегда яв-
ляется идентификатор записи; таким образом, для восстановления записи
необходимо соединение всех BAT по первому атрибуту, но это соединение
может быть выполнено эффективно, поскольку идентификаторы записей
образуют плотное множество и соединение сводится фактически к выборке
из массива. Над бинарными отношениями вводится алгебра, аналогичная
реляционной алгебре, но включающая несколько дополнительных опера-
ций, например, перестановку первого и второго атрибута отношения [12].
Для оптимизации соединений в Monet применяется техника распределения,
предложенная в [55]. Однако авторы справедливо замечают, что сам алго-
ритм распределения отношений на разделы является источником большого
количества кэш-промахов, так как использует произвольный метод доступа
с большим интервалом переиспользования [38]. Для уменьшения времен-
ного интервала авторы предлагают использование последовательного рас-
пределения по разрядам (radix-cluster partitioning), заключающегося в том,
что распределение выполняется за несколько этапов. На каждом из этапов
i рассматриваются разделы, построенные на предыдущем этапе (или исход-
ные отношения в случае i = 1), и формируется 2Ni разделов на основании
Ni последовательных разрядов значения атрибута (предположим, что это
значение является целым числом; в противном случае значения атрибута
обычно можно отобразить во множество натуральных чисел). Поскольку
количество разделов на каждом этапе невелико, модифицированный алго-
ритм имеет меньший интервал переиспользования, однако влечет дополни-
тельные накладные расходы, связанные с необходимостью многократного
выполнения распределения.

75
В работе [27] предлагается улучшенный вариант хэш-соединения, осно-
ванный на использовании явной предвыборки. Метод основан на разделе-
нии алгоритма хэш-соединения на этапы таким образом, что на каждом
этапе выполняется часть вычисления и осуществляется предвыборка дан-
ных, которые требуются на следующем этапе. Последовательно обрабаты-
ваются разделы — для каждой из записей раздела на первом этапе вычис-
ляется номер области (hash bucket) хэш-таблицы и выполняется предвы-
борка заголовка области, на втором — сканируется заголовок области и де-
лается предвыборка массива ячеек, на третьем — сканируется массив ячеек
и выполняется предвыборка соответствующей записи другого отношения
r, наконец, на последнем этапе рассматривается запись r и осуществляется
построение результирующей записи. Альтернативный вариант алгоритма
предполагает выполнение каждого из этапов последовательно для каждой
записи (а не для каждой группы).
Общим методом оптимизации соединений (не только естественных) яв-
ляется вычисление (с возможным последующим хранением и обновлени-
ем) индексов соединения (join indices). Логически индекс соединения от-
ношений R и S представляет собой список пар идентификаторов записей
(r,s), где r,s — записи отношений R и S соответственно, удовлетворяю-
щие условию соединения. Впервые такая структура была рассмотрена в
работе [63], где был предложен и алгоритм соединения с использованием
индекса соединения. Несмотря на то, что индекс соединения содержит в се-
бе всю информацию о том, какие записи исходных отношений соединения
входят в результат, нетривиальной частью алгоритма соединения являет-
ся эффективное (в смысле минимизации количества обращений к диску
/ кэш-промахов) формирование результирующих записей, для чего требу-
ется получение значений входящих в них атрибутов исходных отношений.
Работа [36] улучшает алгоритм, предложенный в [63] таким образом, что
каждое из исходных отношений зачитывается в основную память лишь
один раз. В [36] рассматриваются два алгоритма, отличающиеся способом
обработки промежуточных результатов, которые записываются на диск.
Алгоритмы используют разбиение правого отношения на блоки, которые
позволяют поместить и обрабатывать (в частности, сортировать) проме-

76
жуточные результаты в основной памяти. Заметим, что обе упомянутые
работы [63] и [36], посвященные индексам соединения, рассматривают тра-
диционную СУБД во вторичной памяти и алгоритмы в них оцениваются с
точки зрения минимизации операций ввода-вывода.

4.2.1.2. Мульти-индексы — структура для эффективного есте-


ственного соединения в СУБД-ОП
В данном разделе мы рассматриваем мульти-индексы — индексную струк-
туру, предназначенную для оптимизации операции естественного соедине-
ния в СУБД. Мы предполагаем, что эта структура, как и хранимые отно-
шения, целиком размещается в оперативной памяти, а доступ к диску не
происходит вообще. Это предположение оправдано, поскольку поддержа-
ние в актуальном состоянии индексной структуры в СУБД-ОП не влечет
дополнительных обращений к диску. Действительно, индексная структу-
ра представляет собой “вторичные данные” в том смысле, что она может
быть всегда восстановлена (в частности, после сбоя СУБД) по исходным
отношениям, поэтому изменения в ней не нуждаются в протоколировании.
Основная идея мульти-индекса заключается в хранении и обновлении
специализированной структуры данных, которая позволяет легко вычис-
лить результат соединения. Эта идея является более общей и применима
не только к естественным соединениям [63]. Таким образом, можно утвер-
ждать, что мульти-индекс является специальной формой индекса соедине-
ния. Заметим, что работы [63, 36], где шла речь об индексе соединения, не
уточняли, как именно реализован этот индекс, рассматривая его как от-
ношение, состоящее из пар идентификаторов записей (r,s), возможно, от-
сортированных по первому или второму компоненту пары. Поскольку мы
рассматриваем не произвольные соединения, а естественные, т.е. соедине-
ния по равенству значений атомарного атрибута, мы можем использовать
более эффективное представление индекса соединения, содержащее мень-
шее количество повторяющейся информации.
Для дальнейшего обсуждения нам понадобятся несколько формальных
определений. Пусть R и S - отношения, A1 и A2 — атрибуты R и S , со-

77
ответственно, причем Domain(A1 ) = Domain(A2 ) = Domain(A). Тогда
мульти-индекс является отображением IR,S : Domain(A) → {ri}, где ri —
идентификаторы записей из отношений R или S . Нашей задачей являет-
ся построение эффективной реализации этого отношения для СУБД-ОП.
Результирующая структура данных должна обновляться при изменении
отношений R и S , т.е. при удалении, добавлении или изменении записей.
Основные требования к ней следующие:

• Эффективное выполнение операции естественного соединения

• Незначительные накладные расходы на поддержание индекса при об-


новлении отношений R и S

• Возможность использования индексной структуры в качестве обыч-


ного индекса для отношений R и S по атрибутам A1 и A2

Совокупность этих требований позволяет надеяться на пригодность


мульти-индексов к практическому использованию. Как обычно, под эф-
фективностью мы понимаем не только вычислительную сложность, но и
количество кэш-промахов, возникающих при выполнении той или иной опе-
рации.
Следует заметить, что в дальнейшем под задачей выполнения естествен-
ного соединения мы будем понимать построение индекса соединения, т.е.
нахождение всех пар (r.rid, s.rid), r ∈ R, s ∈ S , r.A = s.A. Эта за-
дача формально является лишь частью проблемы построения результата
естественного соединения (в смысле определения естественного соедине-
ния, приведенного выше), поскольку для конструирования записей резуль-
тата соединения требуется также получение атрибутов соответствующих
записей исходных отношений. Эффективные методы конструирования ре-
зультата соединения по индексу соединения для СУБД-ОП рассмотрены
в работе [41]. В экспериментах для конструирования записей результата
мы использовали прямолинейный алгоритм, который обходит индекс со-
единения и получает атрибуты соответствующих записей r и s по r.rid и
s.rid.

78
Рис. 4.3. Структура индексной записи мульти-индекса

4.2.1.2.1. Организация мульти-индекса. Для мульти-индекса мы


предлагаем двухуровневую структуру [51], в которой на нижнем уровне ин-
формация о соответствующих парах RID (r,s) хранится в виде записей (ин-
дексных записей), организованных в страницы. На верхнем уровне находят-
ся стандартные индексные структуры (B-деревья или хэш-таблицы), отоб-
ражающие значения Domain(A) в записи нижнего уровня. Такая струк-
тура используется в СУБД для хранения отношений с данными, однако
особенностью предлагаемой структуры является организация индексных
записей, показанная на Рис. 4.3. Индексная запись содержит информацию
обо всех записях отношений R и S , имеющих данное значение v атрибутов
A 1 и A2 .
Индексная запись содержит не более Rmax идентификаторов записей от-
ношения R и не более Smax идентификаторов записей отношения S . Поля
Rcount и Scount хранят информацию о действительном количестве иденти-
фикаторов записей из R и S , содержащихся в данной индексной записи.
Массивы R0 , ..., RRmax −1 и S0 , ..., SSmax −1 содержат сами идентификаторы
записей R и S .
Алгоритм выполнения операции естественного соединения с использо-
ванием подобной структуры вполне очевиден. Для этого достаточно одно-
кратного просмотра всех страниц мульти-индекса, т.е. структуры нижнего
уровня. В процессе просмотра формируются результирующие записи. За-
метим, что рассмотренная структура обобщается очевидным образом на
случай более двух отношений. Возможной оптимизацией с точки зрения
утилизации кэш-памяти является размещение идентификаторов записей
отношений, которые, предположительно, часто участвуют в операции есте-
ственного соединения в запросах, рядом друг с другом в индексной записи
[51].
Рассмотрим более подробно эффективность использования кэш-памяти

79
данной структурой. Очевидно, количество кэш-промахов, требуемое для
выборки идентификаторов записей отношения R и S , минимально, по-
скольку идентификаторы записи из данного отношения размещены в па-
мяти последовательно. Такое размещение, однако, влечет необходимость
сдвига идентификаторов записей при вставке или удалении идентифика-
тора записи. Эти накладные расходы, предположительно, будут незначи-
тельны, если отношения используются в основном для чтения, а не для
обновления (в противном случае построения индекса все равно обычно не
имеет смысла). Альтернативный вариант организации индексной записи
предполагал бы размещение идентификаторов записей последовательно, в
порядке их добавления в индексную запись. Однако это не избавило бы
от необходимости сдвига при удалении и к тому же привело бы к необхо-
димости определения того, на запись какого отношения ссылается данный
идентификатор.
Представляет интерес организация верхнего уровня мульти-индекса.
Здесь возможны различные варианты: в случае двух отношений R и S ,
например, может быть две отдельных индексных структуры, ссылающи-
еся на индексные записи нижнего уровня, или одна общая структура. В
обоих случаях индексная структура отображает значения Domain(A) на
индексные записи. Вариант с двумя индексными структурами более эф-
фективен в случае использования мульти-индекса в качестве обычного ин-
декса по одному из отношений R или S , поскольку поиск в каждой из двух
отдельных структур более эффективен, чем поиск в объединенной индекс-
ной структуре (в случае B-деревьев высота каждого из отдельных деревьев
может быть ниже, чем высота объединенного дерева; в случае хэш-таблиц
длина списков коллизий в каждой из отдельных хэш-таблиц меньше, чем
длина списка коллизий в общей хэш-таблице). С другой стороны, объеди-
ненная индексная структура занимает меньше памяти, чем две отдельных
индексных структуры (в случае B-деревьев, очевидно, что в объединенном
дереве меньше указателей, чем в сумме в каждом из отдельных деревьев;
кроме того, многие ключи встречаются в обоих отдельных деревьях и по
одному разу — в общем). Еще одним аргументом в пользу общей индексной
структуры является то, что при использовании двух индексных структур

80
может возникнуть необходимость обновлять обе в случае, когда требуется
расширить индексную запись (и, следовательно, исправить ссылку на нее
в обеих структурах).
В случае более двух индексируемых отношений вариантов организации
индексных структур верхнего уровня еще больше, например, в случае 3-х
отношений получается уже 5 вариантов. Известно, что количество всевоз-
можных разбиений n-элементного множества на подмножества (и, следо-
вательно, количество вариантов организации мульти-индекса для данного
набора индексируемых отношений), является числом Белла B(n) [2]. Тем
не менее, представляется, что в наиболее практически важном случае двух
индексируемых отношений сделать выбор в пользу отдельных индексных
структур или общей структуры достаточно легко с учетом вышеизложен-
ных факторов и характера типичных запросов к базе данных.

4.2.1.2.2. Экспериментальная проверка эффективности мульти-


индекса. Мы выполнили экспериментальную проверку эффективно-
сти предложенной структуры путем сравнения ее с реализацией хэш-
соединения. Мы использовали улучшенный алгоритм хэш-соединения для
СУБД-ОП [55]. Заметим, что алгоритм хэш-соединения включает в себя
сканирование обоих входных отношений, и тем самым, предположительно,
приводит к большому количеству кэш-промахов первого обращения [51].
Введение временных структур данных (предложенное в [55]), которые за-
нимают меньше места, чем записи входных отношений, позволяет избежать
кэш-промахов на более поздних стадиях алгоритма — стадии распределе-
ния и собственно соединения, но тем не менее однократный просмотр запи-
сей входных отношений все же требуется. Усовершенствованный алгоритм
хэш-соединения выполняет распределение входных отношений на разделы
(используя для распределения атрибут соединения A), затем для каждого
раздела Si отношения S строит хэш-таблицу по атрибуту A и для каждой
записи раздела Ri отношения R находит соответствующие записи отноше-
ния S , используя хэш-таблицу.
Соединение с использованием мульти-индекса не требует дополни-
тельного этапа препроцессирования, достаточно однократного просмотра

81
мульти-индекса. Заметим, что утилизация кэш-памяти индексными запи-
сями намного лучше, чем записями входных отношений, поскольку индекс-
ные записи содержат лишь информацию, необходимую в процессе соеди-
нения, в то время как записи входных отношений могут содержать также
атрибуты, не используемые для соединения, которые лишь напрасно за-
нимают место в кэш-блоках, если записи представлены в N -арной модели
хранения. Утилизация кэш-памяти индексными записями особенно высока
в случае мульти-индекса по двум отношениям. Упомянутая выше оптими-
зация, заключающаяся в расположении рядом идентификаторов записей
отношений, которые часто встречаются в запросах в соединении, помога-
ет повысить утилизацию кэш-памяти в случае более двух индексируемых
отношений.
В рамках среды Memphis [51, 53] реализованы несколько алгоритмов
для операций естественного соединения, включая улучшенный алгоритм
хэш-соединения. Каждый эксперимент заключался в загрузке входных от-
ношений из текстового файла, построении необходимых индексных струк-
тур и выполнении операций соединения. Измерялось только время, затра-
ченное на выполнение операции соединения. В качестве входных отноше-
ний использовались как реальные, так и синтезированные наборы дан-
ных. Синтезированные наборы данных были сгенерированы с помощью
программы-генератора, входящих в среду Memphis. Эта программа прини-
мает в качестве входных данных такие параметры требуемых отношений,
как схему отношения, количество записей, распределение атрибутов и по-
рождает отношения в виде текстовых файлов, которые затем могут быть
загружены исполняющей программой для выполнения над ними операций.
Реальный набор данных Classes содержит информацию о клас-
сах в исходном коде большой системы, реализованной на объектно-
ориентированном языке. Эта информация хранится в нормализованной
форме: отношение R(Classes) имеет атрибуты ClassID и ClassN ame, а
отношение S (Members) содержит имена членов классов и состоит из атри-
буты ClassID (внешний ключ, связанный с атрибутом Classes.ClassID)
и M emberN ame. Выполняемая операция является соединением по внеш-
нему ключу.

82
SynthI1 и SynthI2 являются синтезированными наборами данных, каж-
дый из которых состоит из двух отношений. Каждое отношение состоит из
одного целочисленного и одного строкового атрибута. Целочисленный ат-
рибуты равномерно распределены в интервале [1, 100000] и используются
в качестве атрибутов соединения. Набор данных SynthS2 состоит из двух
отношений, каждое включает два строковых атрибута. Атрибут соедине-
ния является строковым атрибутом, представляющим собой короткую (3-5
символов) строку, и имитирует тем самым, например, код товара.
Результаты измерений приведены в таблице 4.1. Эксперименты выпол-
нялись на типичной рабочей станции 2004 года — системе S. В таблице 4.1
представлены мощности входных отношений |R| и |S|, THJ — время выпол-
нения хэш-соединения (HJ — Hash Join), TCM I — время, требуемое для по-
строения мульти-индекса (CMI — Create Multi-Index), TM IJ — время выпол-
нения алгоритма соединения, использующего мульти-индекс (MIJ — Multi-
Index Join) и SIZEM I — объем памяти, занимаемой мульти-индексом. Для
хранения отношений в памяти применялась традиционная N -арная мо-
дель. Количество разделов при использовании хэш-соединения было вы-
брано с учетом работы [55] и наших собственных экспериментальных про-
верок. Поскольку в данном эксперименте построение мульти-индекса вы-
полнялось непосредственно перед соединением, общее время соединения
равно TCM I + TM IJ . Заметим, однако, что на практике мульти-индекс, ве-
роятнее всего, поддерживается в актуальном состоянии при обновлении
индексируемых отношений, поэтому его построение не требуется и для об-
щего времени соединения остается лишь одна компонента TM IJ . Это да-
ет основание утверждать, что в трех из четырех рассмотренных случаев
естественное соединение с использованием мульти-индекса оказалось более
эффективно, чем хэш-соединение. Разница особенно заметна в ситуации,
когда атрибут соединения является строковым, поскольку при соединении
с использованием мульти-индекса не требуется сравнение значений атрибу-
та соединения, что является достаточно дорогой операцией для строковых
атрибутов.

83
Таблица 4.1. Результаты экспериментов с хэш-соединением
Dataset |R| |S| THJ , sec TCM I , sec TM IJ , sec SIZEM I
Classes 8000 6000 1.6 0.8 1 0.4 Mb
SynthI1 30000 50000 1.2 1 0.85 0.6 Mb
SynthI2 100000 300000 7.6 4.8 7.7 1.8 Mb
SynthS2 100000 300000 43 24 8 2.1 Mb

4.2.1.2.3. Запросы, включающие естественное соединение и вы-


борку. Мульти-индекс может оказаться полезным и для выполнения за-
просов более общего вида, включающих, помимо естественного соедине-
ния, выборку по одному из входных отношений. Например, для отно-
шений Students(StudentID, N ame, Age, Address) и M arks (M arkID,
StudentID, M ark , Subject, DateReceived) запрос
SELECT Name, Subject, Mark FROM Students, Marks WHERE
Students.StudentID = Marks.StudentID AND Age > 20
возвращает оценки, полученные студентами, которые старше 20 лет. Ес-
ли предполагаемое число таких студентов велико, то возможно использова-
ние плана выполнения этого запроса, который сначала вычисляет резуль-
тат соединения, а затем отбирает записи, удовлетворяющие критерию Age
> 20. Однако, если число студентов, удовлетворяющих этому условию, ма-
ло, то более выгодным обычно оказывается план, который сначала находит
всех таких студентов, и лишь затем вычисляет соединение.
Поскольку подобные запросы очень часто встречаются на практике,
мульти-индекс должен поддерживать их эффективное выполнение. В ра-
боте [51] предлагается модернизированный вариант мульти-индекса, при
котором в записи индексируемых отношений помещается дополнительный
атрибут — ссылка на соответствующую индексную запись. Заметим, что
поскольку в СУБД-ОП адресация элементов данных (в этом случае — ин-
дексной записи) проще и эффективнее, чем в традиционных СУБД, такое
изменение выглядит осуществимым. Однако оно имеет свои негативные по-
следствия. Помимо очевидного — увеличения объема памяти, занимаемой
отношениями, необходимо учитывать и эффект кэш-памяти. Если отно-
шение хранится в N -арной модели, то добавление лишнего поля (размер

84
которого по меньшей мере равен размеру указателя) делает записи длин-
нее и ухудшает утилизацию кэш-блока для запросов, которым эта запись
не требуется. Помимо этого, такой указатель необходимо обновлять при
расширении индексной записи, которое может произойти при добавлении
в индексируемые отношения новых записей.
Возможен и другой путь использования мульти-индекса для выполне-
ния запросов, сочетающих использование соединения с выборкой. Этот ме-
тод не требует расширения индексируемых отношений специальным атри-
бутом, однако в этом случае мульти-индекс не дает никаких преимуществ
по сравнению с обычным индексом по одному отношению. Метод заключа-
ется в получении идентификаторов записей, удовлетворяющих критерию
выборки, и последующем использовании мульти-индекса для нахождения
идентификаторов соответствующих записей второго отношения. Для это-
го может применяться поиск в мульти-индексе по значению атрибута со-
единения или просмотр мульти-индекса в порядке возрастания значения
атрибута соединения, если мульти-индекс поддерживает такую операцию
(что возможно в случае, если он реализован с помощью, например, B+-
деревьев) [1].

4.3. Операция соединения по предикату над

множественнозначными атрибутами

Операция естественного соединения, рассмотренная в предыдущем разде-


ле, является одной из наиболее часто встречающихся операций в запросах
в реляционных СУБД. Ее важнейший частный случай — соединение по
внешнему ключу — используется для восстановления информации, деком-
позированной в отношения с помощью стандартных средств представления
связей “один-ко-многим” и “многие-ко-многим” в реляционных СУБД.
Методы выполнения запросов, включающих операцию естественного со-
единения, хорошо изучены для традиционных СУБД и эффективно реали-
зованы во всех коммерческих системах. В предыдущем разделе мы пред-
ложили метод соединения на основе мульти-индексов, который во многих

85
случаях является более эффективным для СУБД-ОП, чем другие методы
естественного соединения.
Однако зачастую модель предметной области включает в себя атри-
буты, значения которых не атомарны, а представляют собой множество,
т.е. набор элементов. Например, рассмотрим гипотетическую базу данных
службы занятости населения, поддерживающую информацию о кандида-
тах и вакансиях. Наряду с такими атрибутами кандидата, как имя, адрес
и дата рождения, необходимо хранить информацию о его навыках. Фак-
тически эта информация представляет собой набор элементов из некото-
рого множества всех навыков, например, для отдельно взятого кандидата
на должность программиста это “владение языком C++; владение СУБД
Oracle”. Таким образом, навыки кандидата являются множественнознач-
ным атрибутом. Аналогично, для данной вакансии требуется определен-
ный набор навыков, которыми должен обладать кандидат, претендующий
на нее, т.е. у вакансии также есть множественнозначный атрибут “навыки”.
Кандидат соответствует вакансии, если множество навыков, которыми он
обладает, содержит в себе множество навыков, требуемых для данной ва-
кансии, в качестве подмножества. Получение списка пар (кандидат, вакан-
сия) сводится к выполнению операции соединения по предикату включения
множественнозначных атрибутов.
Другим примером предметной области, где возникают множествен-
нозначные атрибуты, является коллекция документов, характеризуемых
набором ключевых слов. В этом случае документ, все ключевые слова ко-
торого содержатся в другом документе, не несет никакой дополнительной
информации и может быть удален из коллекции. Поиск подобных избы-
точных документов сводится к выяснению отношения включения между
значениями множественнозначных атрибутов документов. Такая “очист-
ка” коллекции может выполняться периодически или после значительных
обновлений.
Предыдущие примеры включали в себя соединение по отношению вклю-
чения между множественнозначными атрибутами. Встречается на прак-
тике и примеры других предикатов. К примеру, можно представить базу
данных людей, каждый из которых характеризуется своими интересами.

86
Поиск пар людей, соответствующих друг другу по своим интересам, сво-
дится к проверке пересечения множеств их интересов. Если требуется не
просто наличие общих интересов, но и определенное их количество, то пере-
сечение заменяется на k-пересечение, которое истинно, если оба множества
имеют не менее k общих элементов (k ≥ 1).
Таким образом, множественнозначные атрибуты и операции с их уча-
стием возникают в практических ситуациях. Поэтому представляют инте-
рес алгоритмы и структуры данных, обеспечивающих эффективную под-
держку множественнозначных атрибутов в СУБД. К сожалению, традици-
онные реляционные СУБД не поддерживают множественнозначные атри-
буты непосредственно — уже первая нормальная форма [1] явно запрещает
атрибуты, значения которых не атомарны. Но, используя стандартный спо-
соб представления отношения “один-ко-многим” в реляционных СУБД,
можно эмулировать множественнозначные атрибуты — для этого доста-
точно создать дополнительное отношение (RecID, AttrValue), где RecID
— идентификатор записи в исходном отношении, а AttrValue — элемент
значения множественнозначного атрибута. Первичным ключом отношения
являются оба атрибута (RecID, AttrValue). Более того, стандарт SQL 2003
[45] определяет тип данных мультимножества и операции над ними, и этот
стандарт в той или иной степени реализован в основных коммерческих ре-
ляционных СУБД.
В данном разделе мы рассматриваем эффективные методы реализации
операции соединения по предикатам над множественнозначными атрибу-
тами в СУБД-ОП. Формально можно сказать, что результатом операции
соединения отношений R, S по предикату θ над значениями множествен-
нозначного атрибута A является отношение |RΘS| = {(r.rid, s.rid)|r ∈
R, s ∈ S, θ(r.A, s.A) = true}.В основном, используемые алгоритмы приме-
нимы с небольшими модификациями ко всем трем предикатам — включе-
ния, пересечения и k -пересечения. В каждом случае мы обсуждаем отличия
алгоритмов для этих предикатов.

87
4.3.1. Известные алгоритмы

Соединения по предикатам над множественнозначными атрибутам привле-


кают широкое внимание исследователей в области СУБД с середины 1990х
гг. За прошедшее время был предложен ряд основных алгоритмов и их усо-
вершенствований, которые мы рассмотрим ниже. Нашей задачей в данном
разделе является сравнение и оценка различных алгоритмов в контексте
СУБД-ОП, а также усовершенствование наиболее перспективных из них
для улучшения использования кэш-памяти.

4.3.1.1. Алгоритм вложенных циклов (SN L)


Метод вложенных циклов [1] является наиболее общим алгоритмом выпол-
нения операций соединения с произвольными предикатами. Этот алгоритм
заключается в сопоставлении всех возможных пар записей обоих отноше-
ний и вычислении предиката для каждой пары. Методы, рассмотренные в
разделе 3.2.4, в частности, разбиение на блоки, помогают уменьшить коли-
чество кэш- и TLB-промахов при применении алгоритма для СУБД-ОП.
Однако для предикатов по включению множеств алгоритм вложенных
циклов оказывается слишком неэффективным, поскольку вычисление пре-
диката на множественнозначных атрибутах является дорогой с вычисли-
тельной точки зрения операцией [25, 52]. Стоимость этой операции зависит
от представления множеств, в качестве которого могут использоваться би-
товые векторы, списки или массивы, но в любом случае вычисление пре-
диката на множествах оказывается существенно дороже, чем вычисление
предикатов отношения между числовыми значениями.
Алгоритм вложенных циклов для множественнозначных предикатов
может быть улучшен с помощью использования сигнатур. Именно, если
имеется множественнозначный атрибут A, элементы которого находятся
во множестве Domain(A), то сигнатурой порядка N (N ≤ |Domain(A)|)
с функцией отображения F для множества S ⊆ Domain(A) называется
битовый вектор BS размера N, такой что e ∈ S → BS [F (e)] = 1. Наиболее
простой функцией отображения является F (e) = e mod N , в предполо-
жении, что элементами Domain(A) являются целые числа (в противном

88
случае, между элементами Domain(A) и целыми числами можно устано-
вить взаимно-однозначное соответствие). Однако возможен и другой выбор
функции отображения, учитывающий особенности распределения значе-
ний Domain(A) в соединяемых отношениях [50].
Из определения сигнатур следуют их основное свойство, используемое
в алгоритмах: если - операция над множественнозначными атрибутами,
т.е. включение, пересечение или k -пересечение, то

S1 , S2 ⊆ Domain(A), S1 S2 ⇒ BS1 0 BS2 , (4.1)

где 0 — соответствующая операция над битовыми векторами. Для включе-


ния между битовыми векторами имеет место эквивалентность B1 ∈ B2 ⇔
B1 &¬B2 = 0N , позволяющая эффективно проверять это отношение (усло-
вие B1 ∈ B2 для битовых векторов понимается в том смысле, что мно-
жество установленных битов B1 является подмножеством установленных
битов B2 ). Хорошо известны и эффективные средства реализации проверки
пересечения и k -пересечения битовых векторов. Более того, современные
процессоры часто предоставляют специальные инструкции для этих опе-
раций [29].
Модификация алгоритма вложенных циклов с использованием сигна-
тур (SNL — Signature Nested Loops) разбивает алгоритм на три этапа
[25, 50]. На первом этапе выполняется вычисление сигнатур для всех за-
писей обоих отношений. Затем производится сопоставление всех пар запи-
сей методом вложенных циклов, но при этом вместо записей рассматрива-
ются соответствующие сигнатуры. Если для сигнатур двух записей (r,s)
выполнено условие Br 0 Bs , то такая пара записей (r,s) помечается для
дальнейшего рассмотрения. На третьем этапе рассматриваются список пар,
построенных на втором этапе. Для каждой из таких пар записей необходи-
мо вычисление предиката на множественнозначных атрибутах, поскольку
4.1 является лишь следствием, но не эквивалентностью. Вычислительную
сложность алгоритма можно оценить как

89
CSN L (R, S) = (|R| + |S|) ∗ Ccreate (φ) + |R| ∗ |S| ∗ (Csig + Phit ∗ Cset ), (4.2)

где φ — средняя мощность множеств значений атрибута A в отношениях


R и S, Ccreate — стоимость вычисления сигнатуры по значению атрибута,
Csig — стоимость сравнения сигнатур, т.е. вычисления операции 0 для
пары сигнатур, Phit — вероятность того, что условие Br 0 Bs выполнено
для сигнатур, и Cset — стоимость вычисления предиката над значениями
множественнозначного атрибута.
Рассмотрим способ доступа к памяти в этом алгоритме при реализации
его в СУБД-ОП. Мы будем предполагать, что результатом операции соеди-
нения является отношение, состоящие из пар (Lef tRecID, RightRecID),
где Lef tRecID и RightRecID — идентификаторы записей левого и правого
входных отношений, удовлетворяющие условию предиката соединения. Та-
ким образом, результатом соединения является индекс соединения. Это не
учитывает возможную необходимость проекции и конструирования резуль-
тирующих записей, однако эта часть выполнения запроса одинакова для
всех видов соединения и может быть вычислена отдельно (например, [41]).
На первом этапе происходит сканирование обоих отношений, при этом для
доступа к записям используется последовательный метод доступа. Кроме
того, при этом требуется получение значений множественнозначного ат-
рибута A, что сводится (при реализации множественнозначного атрибута
в виде вспомогательного отношения, как было отмечено выше) к выборке
всех записей из вспомогательного отношения (RecID, AttrV alue) по задан-
ному RecID. Если вспомогательное отношение кластеризовано по RecID,
т.е. порядок записей в памяти совпадает с порядком RecID, то этот до-
ступ также имеет последовательный характер, в противном случае — про-
извольный характер. Результатом первого этапа являются два отношения
(RecID, Sig ), где Sig — вычисленная сигнатура; при построении новые
записи добавляются в конец этого отношения, т.е. доступ к этому отноше-
нию имеет последовательный характер. На втором этапе оба этих отноше-
ния сканируются последовательно, в результате чего порождается новое

90
отношение (Lef tRecID, RightRecID), содержащие “подозрительные” па-
ры записей. Как и результат первого этапа, это отношение создается до-
бавлением новых записей в конец. Наконец, на третьем этапе отношение
(Lef tRecID, RightRecID) сканируется последовательно и порождается
результирующее отношение, состоящее из пар записей, удовлетворяющих
предикату соединения. Таким образом, несмотря на свою высокую вычис-
лительную сложность, алгоритм вложенных циклов с сигнатурами имеет
последовательный характер доступа к памяти на всех своих этапах.

4.3.1.2. Алгоритм распределения (P SJ )


Алгоритм распределения основан на тех же принципах, что и метод опти-
мизации использования кэш-памяти, обсуждавшийся в разделе 3.2.4. Ис-
ходные отношения распределяются на разделы, и алгоритм применяется к
каждой паре соответствующих разделов. В случае оптимизации использо-
вания кэш-памяти это сокращает интервал переиспользования элементов
данных, а в данном контексте целью является уменьшение вычислительной
работы, поскольку требуется рассматривать не все пары записей, а лишь
пары записей из соответствующих разделов.
В применении к соединению по предикатам над множественнозначными
атрибутами алгоритм распределения (Partitioning Set Join, PSJ) выглядит
следующим образом [52, 50]. Предположим, что входные отношения R и S ,
A — множественнозначный атрибут, предикат — r.A s.A, r ∈ R, s ∈ S ,
причем элементы A — натуральные числа (в противном случае их можно
отобразить в натуральные числа). Вначале определяется количество раз-
делов N (разделы — P0R , ..., PNR−1 и P0S , ..., PNS −1 ). Затем каждая запись r
левого отношения R направляется в раздел i = a mod N , a ∈ r.A, a —
случайно выбранный элемент r.A. Каждая запись s правого отношения S
направляется во все разделы j = b mod N, ∀b ∈ s.A. Таким образом, если
запись левого отношения направляется в раздел, соответствующий слу-
чайно выбранному элементу значения множественнозначного атрибута, то
запись правого отношения необходимо направить в разделы, определяемые
всеми элементами значения атрибута. Это означает, что для записей r, s из

91
различных разделов r ∈ PiR , s ∈ PjS условие r.A s.A заведомо ложно,
T

поэтому для них не может быть истинным ни один из предикатов включе-


ния, пересечения и k -пересечения. Кроме того, если предикатом является
включение множеств, то записи r ∈ R, для которых r.A = ∅, направляются
в каждый из разделов PiR — это необходимо для сохранения корректности
результата, поскольку пары (r, s), r ∈ R, s ∈ S, r.A = ∅ гарантированно
удовлетворяют предикату соединения. Затем к каждой паре соответству-
ющих разделов PiR , PiS применяется алгоритм SNL.
В предположении равномерного распределения значений Domain(A)
несложно оценить вычислительную сложность алгоритма. Тогда каждый
раздел PiR содержит |R|/N элементов. Пусть φS — средняя мощность зна-
чений множественнозначного атрибута A в отношении S . Вероятность то-
го, что элемент значения атрибута A в записи s отношения S относится к
разделу i равна 1/N , следовательно, вероятность того, что запись s отно-
сится к разделу i, составляет (1−(1−1/N )φS ), откуда для размера раздела
PiS получаем |S|(1 − (1 − 1/N )φS ). Общая вычислительная сложность тем
самым составляет

CP SJ (R, S) = Cpart + N ∗ CSN L (PiR , PiS ), (4.3)

где Cpart — стоимость фазы распределения, которая равна

Cpart = (|R| + |S|φS ) ∗ Cmap ,

где Cmap — сложность вычисления операции mod .


Как и алгоритм SN L, алгоритм P SJ использует последовательный спо-
соб доступа к памяти на всех своих этапах в предположении, что вспомо-
гательное отношение кластеризовано по RecID. Для хранения разделов,
представляющих собой отношения со схемой из одного атрибута (RecID),
может применяться стандартная страничная организация для хранения за-
писей фиксированной длины.

92
4.3.1.3. Алгоритм вложенных циклов с использованием инверти-
рованных списков (IF N L)
Инвертированные списки, широко применяемые для индексирования до-
кументов в задачах информационного поиска [66], оказываются полезными
и для выполнения соединений по предикатам над множественнозначны-
ми атрибутами. В контексте наших задач, как и выше, R - отношение,
A — множественнозначный атрибут, тогда инвертированный файл IFRA
можно определить как отображение : IFRA : Domain(A) → {RID}, дей-
ствующее по правилу IFRA (a) = {rid : r.rid = rid, a ∈ r.A}, т.е. инвер-
тированный список IFRA (a) включает в себя идентификаторы всех запи-
сей, которые содержат a среди элементов значения атрибута A. Мы бу-
дем предполагать, что инвертированные списки упорядочены по возрас-
танию RID. Кроме того, в данном и нескольких следующих разделах мы
будем считать, что значения атрибута A не являются пустым множеством:
r.A 6= ∅, s.A 6= ∅, r ∈ R, s ∈ S . Случай пустых множеств рассматривается
в разделе 4.3.1.6.
Инвертированные файлы позволяют быстро отвечать на запрос “какие
записи содержат данный набор элементов среди элементов своего значения
атрибута A?”. Отсюда следует способ их применения в задачах соединения
по предикатам над множественнозначными атрибутами [37]. Действитель-
но, если предикатом является включение множеств, то выполнив такой за-
прос для каждой записи r отношения R, взяв в качестве набора элементов
r.A, мы можем найти все записи отношения S , которые содержат r.A в каче-
стве подмножества s.A, и, тем самым, удовлетворяют предикату. В случае
соединения по предикату — пересечению множеств достаточно выполнить
объединение всех инвертированных списков для элементов r.A с последу-
ющим удалением дубликатов. Если предикатом является k -пересечение, то
все записи отношения S , удовлетворяющие условию соединения для данной
записи r ∈ R, можно найти с помощью простого алгоритма, поддержива-
ющего рабочий список пар (s.rid, counter), где counter = |r s|. Рабочий
T

список упорядочен по s.rid. Этот алгоритм последовательно рассматрива-


ет каждый элемент a ∈ r.A, получает инвертированный список IFSA (a),

93
foreach (r.rid in R)
{
Worklist: list S of (s.rid);
let r.A = {v} (r.A)rest ;
W orklist = IF Lookup(IFSA , v);
foreach (v in (r.A)rest )
{
lSv = IF Lookup(IFSA , v);
W orklist = M erge(W orklist, lSv );
}
foreach (s.rid in Worklist) add (r.rid, s.rid) to the result;
}

Рис. 4.4. Алгоритм IF N L

и осуществляет слияние инвертированного списка с рабочим списком, в


процессе которого в рабочий список добавляются записи (s.rid, 1), если
такого s.rid в нем нет, или увеличивается значение counter для s.rid, если
этот s.rid уже есть в списке. Поскольку оба списка отсортированы, сли-
яние выполняется эффективно. Алгоритм IF N L представлен на рисунке
4.4, где процесс слияния рабочих списков обозначается Merge.
Вычислительную сложность алгоритма вложенных циклов с исполь-
зованием инвертированных списков можно оценить следующим образом.
Пусть IF Lookup — операция получения инвертированного списка по эле-
менту Domain(A). Вычислительная сложность CIF Lookup зависит от струк-
туры данных, применяемой для реализации инвертированного файла, в
частности, для хэш-таблицы эту величину можно считать константой (а
если эта структура данных является B-деревом, то стоимость поиска в ней
зависит от |Domain(A)|, а не от размера входных отношений). Тогда имеем

CIF N L = |R|(φR CIF Lookup + (φR − 1)CM erge ), (4.4)

где CM erge — стоимость операции слияния инвертированных списков, по-


лученных для каждого из элементов r.A. Под слиянием понимается пере-
сечение списков для предиката включения множеств, объединение списков
для предиката пересечения множеств и рассмотренный выше алгоритм для
предиката k -пересечения.

94
Рассмотрим способ доступа к памяти в алгоритме IF N L. Как и преж-
де, просмотр отношения R выполняется последовательно, последователь-
ный способ доступа используется и для получения элементов r.A. Одна-
ко доступ к инвертированному файлу имеет произвольный характер, сле-
довательно, интервал переиспользования инвертированных списков велик
и потенциально может являться источником большого количества кэш-
промахов. Слияние инвертированных списков выполняется с помощью по-
следовательного просмотра и не влечет дополнительных кэш-промахов.
Следует заметить, что оригинальный алгоритм, предложенный в работе
[37], рассчитан на применение в традиционных, дисковых СУБД и исполь-
зует другую стратегию обработки промежуточных результатов: промежу-
точные результаты записываются на диск в виде временных файлов, ко-
торые впоследствии считываются в память и осуществляется их слияние.
Инвертированный файл обрабатывается блоками, которые помещаются в
оперативную память. Однако эта стратегия приводит к большому размеру
промежуточных результатов и не выглядит эффективной для СУБД-ОП.
Кроме того, произвольный доступ к инвертированному файлу, невозмож-
ный по соображениям эффективности в дисковой СУБД, хоть и приводит
к большому количеству кэш- и TLB-промахов в СУБД-ОП (разумеется,
в предположении, что инвертированный файл целиком помещается в опе-
ративную память), но не представляется настолько неэффективным, по-
скольку стоимость кэш- и TLB-промахов намного меньше, чем стоимость
страничных промахов и операций ввода-вывода. В последующих разделах
мы рассмотрим модификацию данного алгоритма, призванную миними-
зировать негативный эффект произвольного доступа к инвертированному
файлу в СУБД-ОП.

4.3.1.4. Алгоритм соединения инвертированных файлов (IF J )


Очевидно, инвертированный файл IFRA в смысле определения раздела
4.3.1.3 является структурой, используя которую можно восстановить пол-
ную информацию о значениях r.A, r ∈ R. Это наблюдение приводит к еще
одному алгоритму соединения по предикату над множественнозначными

95
Workmap : map : RID -> list of RID;
foreach (lRv in IFRA )
{
lSv = IF Lookup(IFSA , v);
foreach (RID r.rid in lRv )
{
Lr = W orkmapLookup(r.rid);
Lr = M erge(Lr , lSv );
W orkmapP ut(r.rid, Lr );
}
}

Рис. 4.5. Алгоритм IF J

атрибутами. Данный алгоритм принимает на вход два инвертированных


файла IFRA и IFSA и осуществляет их слияние таким образом, что резуль-
тирующая структура данных содержит результат соединения (или может
быть легко преобразована в него).
Базовой рабочей структурой алгоритма является ассоциативная струк-
тура W orkmap, представляющая собой отображение из RID отношения R в
рабочие списки, аналогичные рассмотренным в разделе 4.3.1.3. Эта струк-
тура поддерживает операцию W orkmapLookup и W orkmapP ut, выполня-
ющие получение рабочего списка по RID и сохранение рабочего списка по
заданному RID. Как и в случае самого инвертированного файла, реализа-
ция W orkmap может быть хэш-таблицей, поскольку поддержание опреде-
ленного порядка хранения рабочих списков не требуется. Алгоритм выпол-
няет просмотр одного из инвертированных файлов IFRA , получая для каж-
дого из инвертированных списков lR
v
соответствующий инвертированный
список второго файла lSv и сливая список lSv c рабочим списком для каждо-
го из идентификаторов записей в списке lR
v
(операция слияния полностью
идентична рассмотренной в предыдущем разделе). Алгоритм представлен
на рисунке 4.5.
Алгоритм IF J (Inverted File Join) для традиционных СУБД был пред-
ложен и изучен в работе [37]. Как и в случае с алгоритмом IF N L, в ориги-
нальном алгоритме предполагалась другая стратегия обработки промежу-
точных результатов, заключавшаяся в записывании их в виде временных
файлов на диск с последующей их загрузкой и слиянием. Кроме того, в ра-

96
боте [37] предложены некоторые простые оптимизации, направленные на
уменьшение размера промежуточных результатов. Например, если имеется
эффективный способ получения по s.rid величины |s.A|, то в случае пре-
дикатов включения (r.A ⊆ s.A) и k -пересечения из рабочих списков мож-
но исключить такие s.rid, которые удовлетворяют условиям |s.A| < |r.A|
и |s.A| < k соответственно, поскольку такие записи s заведомо не удо-
влетворяют условию соединения. Дополнительной оптимизацией являет-
ся поддержание в отдельных ассоциативных структурах данных Rcount и
Scount информации о количестве элементов значений r.A и s.A, которые
были обработаны к данному моменту. Тогда, в случае предиката включе-
ния условие |r.A| − Rcount [r.rid] > |s.A| − Scount [s.rid] означает, что s.rid не
может удовлетворять условию соединения и может быть исключен из рабо-
чего списка для r.rid, поскольку среди значений Domain(A), которые еще
предстоит рассмотреть, имеется такое, которое входит в r.A, но не входит
в s.A.
Представленный алгоритм IF J принимает на вход два инвертиро-
ванных файла IFRA и IFSA . В разделе 4.2.1.2 был определен мульти-
индекс — индекс, который позволяет эффективно получать по элемен-
ту Domain(A) идентификаторы записей, значения которых равны это-
му элементу. Несложно распространить понятие мульти-индекса и на
множественнозначные атрибуты. Действительно, комбинируя определения
мульти-индекса и инвертированного файла, получаем совместный инвер-
тированный файл для отношений R и S и множественнозначного атрибута
A: IFRSA
: Domain(A) → RID по правилу : IFRS A
(a) = {rid : t ∈ R ∨ t ∈
S, t.rid = rid, a ∈ t.A}.
Алгоритм очевидным образом модифицируется для использования
совместного инвертированного файла. Поскольку такая структура дан-
ных обеспечивает возможность эффективного просмотра всех значений
Domain(A) и получения соответствующих инвертированных списков, ис-
чезает необходимость выполнения IF Lookup(IFSA , v) для получения lSv . В
остальном алгоритм остается неизменным.
Вычислительная сложность алгоритма IF J с использованием совмест-
ного инвертированного файла аналогична вычислительной сложности ал-

97
горитма IF N L. Действительно, поскольку каждый из r.rid встречается в
A
IFRS в среднем φR раз и для каждого вхождения r.rid выполняется по-
лучение соответствующего рабочего списка из структуры W orkmap, мы
получаем

CIF J = |R|(φR (CW orkmapLookup + CW orkmapP ut ) + (φR − 1)CM erge ), (4.5)

где CM erge — стоимость слияния инвертированного и рабочего списка.


Как и для алгоритма IF N L, оба списка хранятся в отсортированном
виде, поэтому слияние выполняется эффективно. Однако, в отличие от
алгоритма IF N L, величины CW orkmapLookup и CW orkmapP ut зависят не от
|Domain(A)|, а от |R|. Поскольку обычно |R| > |Domain(A)|, можно ожи-
дать CIF Lookup < {CW orkmapLookup , CW orkmapP ut } и может показаться, что ал-
горитм IF J заведомо должен проигрывать алгоритму IF N L.
Однако это не совсем так, и причина заключается в различиях в доступе
к памяти в алгоритмах IF N L и IF J . Как и в случае операции IF Lookup
для IF N L, доступ к W orkmap в алгоритме IF J имеет произвольный ха-
рактер, поэтому интервал переиспользования рабочих списков, загружен-
ного из W orkmap, может быть велик и вызывать кэш-промахи. С другой
стороны, алгоритм IF J рассматривает каждый из инвертированных спис-
ков lR
v
, lSv только однократно и поэтому эти списки загружаются однократ-
но в кэш-память, в отличие от IF N L, где один и тот же инвертированный
список требуется многократно и, следовательно, может многократно за-
гружаться в кэш-память при возникновении кэш-промаха. В последующих
разделах мы сравним алгоритмы IF J и IF N L, более детально рассмотрим
использование кэш-памяти в алгоритме IF J и предложим улучшенный
вариант алгоритма, уменьшающий интервал переиспользования рабочих
списков.

4.3.1.5. Алгоритм, использующий индекс пересечения (IX )


Предыдущие алгоритмы вычисления результата соединения выполняли
большую часть работы в процессе выполнения запроса, включающего со-

98
единение по предикату над множественнозначным атрибутом. В разделе
4.2.1.2 были рассмотрены мульти-индексы, которые позволяют фактически
хранить и поддерживать в актуальном состоянии предварительно вычис-
ленный результат операции естественного соединения. Та же идея — пред-
варительное вычисление и хранения результата соединения — оказывается
применимой и к операции соединения по предикату над множественнознач-
ными атрибутами.
Однако непосредственное хранение результата соединения по предикату
над множественнозначными атрибутами оказывается слишком неэффек-
тивным. Во-первых, размер результата в случае, например, предиката пе-
ресечения множеств может быть весьма большим, если |Domain(A)| мало.
Во-вторых, быстрое обновление подобной структуры при удалении из мно-
жеств r.A элементов или их добавлении невозможно, поскольку требуется
просмотреть все места в индексе, где встречается множество r.A и перевы-
числить значение предиката.
Более пригодной в качестве материализованного результата операции
соединения оказывается индекс пересечения (Intersection indeX, IX), пред-
ложенный в работе [52]. Для каждой пары записей (r, s), r ∈ R, s ∈ S
индекс пересечения IXRS
A
содержит величину |r s|. Построение индек-
T

са пересечения может быть выполнено с помощью алгоритма IF N L или


IF J , при этом элементов рабочих списков являются пары (s.rid, counter),
где counter — количество общих элементов r и s, обнаруженных к данному
моменту. Таким образом, алгоритм построения индекса пересечения полно-
стью аналогичен алгоритму соединения в случае предиката k -пересечения.
Наличие индекса пересечения приводит к очевидному алгоритму со-
единения по предикатам включения, пересечения и k -пересечения. Для
получения результата достаточно однократного просмотра индекса пере-
сечения, при этом в результат добавляются пара (r, s), если |r s| = |r|
T

(в случае предиката включения) или |r s| ≥ k (в случае предиката


T

k -пересечения). Заметим, что величина |r| может быть вычислена пред-


варительным просмотром отношения R, но может и храниться в индек-
се пересечения для записи r наряду со списком (|r si )i . Таким обра-
T

зом, индекс пересечения IXRS A


можно рассматривать как отображение :

99
{r.rid} → (|r|, ((si .rid, |r si |))i ). Списки Lrx = ((si .rid, |r si |))i будем на-
T T

зывать списками пересечений.


Более проблематичным оказывается обновление индекса пересечения в
случае изменения состава множеств r.A. Мы предполагаем, что наряду с
индексом пересечения поддерживается в актуальном состоянии инвертиро-
ванный файл IFSA или IFRS A
. При добавлении нового элемента v в множе-
ство r.A, мы получаем инвертированный список lSv и для каждого s.rid ∈ lSv
либо добавляем пару (s.rid, 1) в список пересечения Lrx , если такого s.rid в
списке еще нет, либо увеличиваем значение |r s|, если s.rid в списке пе-
T

ресечения уже есть. Действия при удалении элемента v из множества r.A


аналогичны с той лишь разницей, что при наличии s.rid в списке пересе-
чения значение |r s| уменьшается, и пара (s.rid, 0) удаляется из списка
T

пересечения.
Вычислительная сложность построения индекса пересечения аналогич-
на сложности алгоритма IF J или IF N L. Сложность построения резуль-
тата соединения пропорциональна размеру индекса пересечения, который
(φRφ+φ S )(|Domain(A)|)
можно оценить как |R||S|(1− |Domain(A)| |Domain(A)| ). При построении исполь-
R φR +φS

( φR )( φS )
зуется последовательный доступ к памяти, поскольку поиск при этом не
требуется.

4.3.1.6. Обработка случая пустого множества в алгоритмах, ос-


нованных на инвертированных списках
Следует отметить, что алгоритмы IF N L, IF J и IX , основанные на ин-
вертированных списках, рассматривались в предположении, что значения
множественнозначного атрибута A являются непустым множеством. Слу-
чай пустых множеств заслуживает отдельного внимания. Очевидно, про-
блема с пустыми множествами возникает лишь если предикатом является
включение множеств, и тогда все пары вида (r, s), r ∈ R, s ∈ S , r.A = ∅ вхо-
дят в результат соединения. Одним из вариантов обработки случая пустых
множеств является дополнительное сканирование входных отношений по-
сле выполнения основной части алгоритма и добавление в результат всех
пар (r, s), r.A = ∅. Другое решение заключается в искусственном расши-

100
рении Domain(A) путем добавления туда элемента Ω, который является
частью любого значения r.A и s.A, и хранении инвертированного списка
(в который входят все записи отношений) для этого искусственного значе-
ния в инвертированных файлах. Преимуществом этого решения является
отсутствие необходимости отдельного рассмотрения пустых множеств. За-
метим, что в реализации не требуется хранить в списке, соответствующем
Ω, идентификаторы всех записей отношений, достаточно лишь отличать
Ω от остальных значений Domain(A). Поэтому мы можем считать, что
значения атрибута A являются непустыми множествами, имея в виду, что
для обработки пустых множеств используется один из двух упомянутых
способов.

4.3.2. Предварительные эксперименты с алгоритмами

соединения SN L, P SJ и IX
В данном разделе мы представляем результаты предварительного экспе-
риментального анализа алгоритмов соединения по предикату над множе-
ственнозначными атрибутами. В роли предиката в основном встречается
включение множеств, поскольку данный предикат, как продемонстрирова-
но в работах [50, 14], является наиболее сложным. Все рассмотренные выше
алгоритмы реализованы в среде Memphis. Множественнозначные атрибуты
реализованы с помошью вспомогательного отношения (RecID, AttrV alue),
кластеризованного по первому атрибуту, как описано выше. В качестве
структур данных для реализации инвертированных файлов и индексов пе-
ресечения используются хэш-таблицы. Поскольку инвертированные и рабо-
чие списки упорядочены по возрастанию RID, они могут быть эффективно
сжаты с помощью хорошо известных методов сжатия упорядоченной по-
следовательности натуральных чисел [66], и в ряде экспериментов мы изу-
чали эффект сжатия списков. В качестве метода сжатия мы использовали
Гамма-кодирование [66].
Рассматриваемые алгоритмы и их модификации были реализованы на
языке C#, в качестве хэш-функции в хэш-таблицах использован метод
Int32.GetHashCode из стандартной библиотеки .NET. Системы, на кото-

101
рых проводились эксперименты, работали под управлением OC Windows
XP. Для измерения времени выполнения применялись функции Win32 API
QueryPerformanceFrequency / QueryPerformanceCounter, которые предо-
ставляют программный доступ к высокоточному аппаратному счетчику.
Количество микроархитектурных событий, таких как кэш- и TLB-промахи,
измерялось с помошью профилятора Intel VTune Performance Analyzer [31].
Объем используемой алгоритмами памяти измерялся с помошью стандарт-
ной функции GC.GetTotalMemory, которая выполняет сборку “мусора” пе-
ред вычислением объема памяти, занимаемой объектами программы. Для
уменьшения флуктуации результатов измерений представлены усреднен-
ные результаты 3-4 запусков. В качестве экспериментальной платформы
использовалась система S .
Для экспериментов в основном использованы синтезированные набо-
ры данных, порожденные той же программой-генератором, что и в разде-
ле 4.2.1.2.2. Дополнительными параметрами генератора для множествен-
нозначных атрибутами являются средняя мощность значений атрибута φR
и параметры Domain(A) — нижняя и верхняя граница и распределение
элементов Domain(A). Все проведенные эксперименты следовали одному
сценарию. Вначале входные отношения из текстовых файлов загружаются
в память, затем выполняется построение необходимых индексных струк-
тур (инвертированные файлы и индекс пересечения), затем выполняется
собственно алгоритм соединения. В зависимости от конкретного экспери-
мента, в измеряемое время и количество кэш- и TLB-промахов включаются
либо только этап выполнения алгоритма соединения, либо этапы построе-
ния индексов и выполнения соединения.
В первых двух экспериментах мы сравниваем алгоритм IX с алгорит-
мами SN L и P SJ , при этом во время выполнения алгоритма IX включено
время, затрачиваемое на построение индекса пересечения. Параметры ал-
горитмов SN L(n) и P SJ(k, l) (n,l — длина вектора-сигнатуры и k — коли-
чество разделов) выбирались исходя из рекомендаций в работах [50, 37, 25]
и наших собственных экспериментов.

102
Таблица 4.2. Алгоритмы SN L, P SJ и IX : набор данных Classes
Алгоритм Время, с Память, Мб
SN L(512) 926 4
P SJ(8, 512) 541 2
IX 35 9

Таблица 4.3. Алгоритмы SN L, P SJ и IX : набор данных SD1


Алгоритм Время, с Память, Мб
SN L(128) 962 1.3
P SJ(8, 128) 764 1
IX 80 30

4.3.2.1. Набор данных Classes.


В данном эксперименте использован реальный набор данных Classes,
подобный тому, что использовался в разделе 4.2.1.2.2. Отношение
Classes(N ame,M ethods) содержит множественнозначный атрибут
M ethods, представляющий собой названия методов класса. Это отно-
шение соединяется с самим собой по предикату включения — задачей,
таким образом, является поиск классов, методы которых являются
подмножеством методов других классов. Такая информация могла бы
оказаться полезным, например, для выяснения того, какие общие ин-
терфейсы могут реализовывать эти классы или какой общий базовый
класс они могут унаследовать. Параметры этого набора данных таковы:
R = S, |R| = |S| = 10000, φR = φS = 6, |Domain(A)| = 30577, селек-
|R S|
тивность соединения α(R, S) = |R||S| = 1.9 ∗ 10−3 . Результат приведен
в таблице 4.2 (колонка ’Память’ содержит информацию о количестве
памяти, используемой временными структурами данных алгоритма).

4.3.2.2. Набор данных SD1: варьирующийся |Domain(A)|.


Этот синтезированный набор данных имеет следующие параметры |R| =
8000, |S| = 10000, φR = 8, φS = 25, |Domain(A)| = 5000, α(R, S) = 2 ∗ 10−3 .
Результаты для данного набора параметров приведены в таблице 4.3.
В данном эксперименте мы также изучаем эффект изменения

103
Таблица 4.4. Алгоритм IX : зависимость времени выполнения от
|Domain(A)|
|Domain(A)| Время, с Память, Мб
10000 39 12
5000 80 30
2500 141 50
2000 191 60

Рис. 4.6. Алгоритм IX : зависимость времени выполнения от |Domain(A)|

|Domain(A)|, оставляя прочие параметры |R|, |S|, φR , φS фиксированными.


Как следует из формул 4.2, 4.3, 4.4, 4.5, а также из наших экспериментов,
наибольшую зависимость от |Domain(A)| показывает алгоритм IX , поэто-
му в таблице 4.4 и на графике 4.6 представлена зависимость только для
этого алгоритма.

4.3.2.3. Выводы из предварительных экспериментов


Очевидно, алгоритм IX характеризуется сильной зависимостью времени
выполнения и объемом потребляемой памяти от величины |Domain(A)|.
Причина этого явления проста — чем меньше |Domain(A)|, тем (при фик-
сированных φR и φS ) длиннее инвертированные списки, т.е. тем больше
памяти они занимают и тем больше стоимость компоненты CM erge в фор-
мулах 4.4 и 4.5.
Из представленных экспериментальных результатов очевидно, что ал-
горитм IX оказывается эффективнее, чем методы SN L и P SJ , на вели-

104
чину порядка. Недостатком алгоритма IX является сильная зависимость
времени выполнения от величины |Domain(A)|. Однако даже при малых
значениях |Domain(A)|, как показывают наши результаты, алгоритм IX
намного превосходит SN L и P SJ .

4.3.3. Модификация алгоритмов IF N L и IF J для луч-

шего использования кэш-памяти

Представленные в предыдущем разделе результаты позволяют нам сосре-


доточиться на улучшении алгоритмов, основанных на использовании ин-
вертированных списков — IF N L, IF J и IX . Поскольку алгоритмы IF N L
и IF J фактически являются составной частью алгоритма IX , наши уси-
лия должны быть направлены на улучшение этих двух алгоритмов. За-
ключительный этап алгоритма IX , т.е. построение результата соединения
по индексу пересечения, не дает большого пространства для оптимизаций,
поскольку является простым обходом по индексу пересечения, использую-
щим последовательный доступ к памяти.
Алгоритмы IF N L и IF J в виде, описанном в главе 4.3.1, являются пря-
молинейной адаптацией алгоритмов, рассчитанных на применение в тра-
диционных, дисковых СУБД. Как мы отмечали в главе 2, такой подход не
приводит к оптимальным результатам для СУБД-ОП. Поэтому нашей за-
дачей в данном разделе является минимизация простоев процессора, возни-
кающих из-за кэш- и TLB-промахов. Средством для этого в нашем случае
является улучшение временной локальности за счет уменьшения интервала
переиспользования инвертированных и рабочих списков.

4.3.3.1. Алгоритм IF N L
Обратимся сначала к алгоритму IF N L (рис. 4.4). Оценим количество кэш-
промахов, возникающих во время выполнения прямолинейного варианта
алгоритма. Как IF Lookup, так и M erge могут использовать данные, за-
груженные ранее в кэш-память (или TLB). Это происходит, если инверти-
рованный список lSv был ранее загружен в кэш-память на предыдущих ите-

105
рациях внешнего цикла и с тех пор не успел быть вытеснен. Инвертирован-
ный список lSv мог быть загружен в кэш-память тогда, когда в предыдущий
раз выполнялась инструкция IF Lookup(IFSA , v). В предположении равно-
мерного распределения элементов Domain(A), инструкция IF Lookup с тех
пор выполнялась |Domain(A)| раз, и каждый раз в кэш-память загружа-
лось |lS |sizeof (int) байт, где |lS | — средняя длина инвертированного спис-
ка. Очевидно, можно оценить |lS | = |S|(1 − (1 − 1 φS
|Domain(A)| ) ) (поскольку
(1 − 1
|Domain(A)| )
φS
есть вероятность того, что s.A не содержит элемент v
среди своих элементов). Если кэш-память является полностью ассоциатив-
ной, то вероятность того, что lSv , загруженный предыдущей инструкцией
IF Lookup, останется в кэш-памяти до следующего вызова IF Lookup, рав-
|l |sizeof (int) |Domain(A)|
на Phit = (1 − S C ) , где C — объем L2 кэш-памяти.
Очевидно, что при фиксированном |S|, |lS | быстро увеличивается, ес-
ли |Domain(A)| уменьшается, что приводит к уменьшению Phit . С дру-
гой стороны, при фиксированном Domain(A) и увеличении |S|, |lS | также
увеличивается и Phit уменьшается. Тем самым, исходный алгоритм IF N L
имеет плохую временную локальность, что проявляется в большом коли-
честве кэш-промахов при достаточно большом значении |S| и / или малом
|Domain(A)|.
Для улучшения временной локальности мы воспользуемся методом ло-
гического распределения, рассмотренным в разделе 3.2.4 [53]. Заметим, что
метод разделения на блоки в данном случае оказывается неприменим, по-
скольку доступ к инвертированным спискам в алгоритме IF N L и рабочим
спискам в алгоритме IF J имеет произвольный характер. Неприменим и ме-
тод распределения, поскольку в данном случае для уменьшения интервала
переиспользования требуется распределение по элементам Domain(A), и
каждый r.rid попадает в среднем в φR разделов. В результате использова-
ние распределения привело бы к необходимости поддержания временных
структур данных большого размера, который сравним с размером самих
инвертированных файлов, что недопустимо для СУБД-ОП.
Предположим, что все значения v в IFSA упорядочены по некоторо-
му критерию. Тогда мы можем разделить значения на набор интервалов
[v1 , v2 )...[vn , vn+1 ] так, что все интервалы содержат приблизительно оди-

106
наковое количество значений из IFSA . Алгоритм IF N L разделяется на n
этапов, на k -ом этапе рассматриваются значения из интервала [vk , vk+1 ].
Модифицированный алгоритм поддерживает список структур W orkEntry ,
каждая из которых связана с одним из r.rid. Если предикатом является
включение множеств, то к началу k -ого этапа структура W orkEntry со-
держит r.rid и список s01 , ..., s0lr таких, что v ∈ r.A ⇒ v ∈ s0i .A, i = 1, ...lr , где
v ∈ [v1 , v2 )...[vk−1 , vk ). Таким образом, для предиката включения множеств,
к началу k -ого этапа WorkEntry для записи r содержит набор идентифи-
каторов тех записей s.rid, которые до сих пор удовлетворяли условию со-
единения. По мере работы алгоритма этот список уменьшается благодаря
пересечению его на k -ом этапе с инвертированными списками значений, по-
падающих в интервал [vk , vk+1 ]. Если предикатом является m-пересечение,
то список r.rid заменяется на список пар (r.rid, counter), который расши-
ряется по мере работы алгоритма и к началу k -ого этапа counter равен
количеству общих элементов r.A и s0 .A, лежаших в одном из интервалов
[v1 , v2 )...[vk−1 , vk ).
Модифицированный алгоритм IF N L(k) (k — количество интер-
валов/этапов алгоритма) должен вызывать меньшее количество кэш-
промахов, поскольку интервал переиспользования инвертированного спис-
ка не превышает среднее количество значений из Domain(A), лежащих в
интервале [vk , vk+1 ]. Однако, возникают дополнительные накладные рас-
ходы, связанные с необходимостью выбора значений r.A, лежащих в ин-
тервале [vk , vk+1 ]. С целью ускорить этот процесс выбора, в структурах
W orkEntry можно дополнительно хранить значения r.A в упорядоченном
виде, если реализация множественнозначных атрибутов не предоставля-
ет возможности эффективного получения их значений в упорядоченном
виде (так и сделано в нашей реализации, где вспомогательное отношение
(RecID, AttrV alue) кластеризовано по RecID, но не упорядочено внутри
кластера по AttrV alue).

107
4.3.3.2. Алгоритм IF J
Подобно алгоритму IF N L, алгоритм IF J (рис. 4.5) использует произ-
вольный метод доступа, но к структуре W orkmap, а не к инвертиро-
ванным файлам. Поэтому алгоритм IF J может многократно загружать
в кэш-память рабочие списки, но однократно загружает инвертирован-
ные списки. Однако если длина инвертированных списков не меняется во
время работы алгоритма, то рабочие списки либо уменьшаются (в слу-
чае предиката включения), либо увеличиваются (в случае предиката k -
пересечения), в зависимости от используемой операции слияния. Тем не
менее, при анализе использования кэш-памяти алгоритмом IF J мы будем
пренебрегать этим обстоятельством, и считать, что |Lr | — константа. Та-
кое допущение оправдано, поскольку в среднем |Lr | меняется в пределах
[min(|LResult |, |lS |), max(|LResult |, |lS |)], где |LResult | - средняя длина списка
(s.rid)i записей отношения S , удовлетворяющих предикату соединения для
записи отношения R, а |lS | — средняя длина инвертированного списка для
s.rid в IFRS
A
. Как было показано выше, |lS | = |S|(1 − (1 − |Domain(A)| 1
)φS ), а
для |LResult | имеем |LResult | = α(R, S)|S| (где α, как и ранее, селективность
|Result|
соединения, т.е. α(R, S) = |R||S| , |Result| — размер результата соедине-
|LResult |+|lS |
ния). Можно, таким образом, положить, что |Lr | = 2 . Применяя
то же рассуждение, что и для алгоритма IF N L, для вероятности нахож-
дения рабочего списка в кэш-памяти между последовательными обраще-
|Lr |sizeof (int) |R|
ниями к нему имеем Phit = (1 − C ) , где, как и ранее C — размер
кэш-памяти уровня L2.
В зависимости от предиката соединения, |Lr | может быть либо мень-
ше (в случае предиката включения или k -пересечения), либо больше (в
случае предиката пересечения или k -пересечения). В то же время обыч-
но |R|  |Domain(A)|. При увеличении |R| Phit быстро уменьшается,
поэтому, как и в случае исходного алгоритма IF N L, алгоритм IF J об-
ладает плохой временной локальностью, и интервал переиспользования
|R| увеличивается с ростом размера одного из входных отношений. Для
уменьшения интервала переиспользования мы используем технику, сход-
ную с применявшейся ранее для улучшения временной локальности ал-

108
горитма IF N L. На этот раз мы распределяем не элементы Domain(A),
а r.rid по n интервалам [rid1 , rid2 ]...[ridn−1 , ridn ] так, что в каждый из
них попадает примерно одинаковое количество r.rid. Если r.rid целиком
заполняют интервал [0, M axID], то набор интервалов определяется как
([ M axID
n ∗i, M axID
n ∗(i+1)])i . Алгоритм разбивается на этапы таким образом,
что на i-ом этапе рассматриваются только r.rid из интервала [ridi , ridi+1 ].
Поскольку lSv хранится в упорядоченном виде, такие r.rid могут быть опре-
делены эффективно, с помошью бинарного поиска.
Данная модификация приводит к алгоритму IF J(l) (l — количество
интервалов r.rid/этапов алгоритма), который отличается меньшим интер-
валом переиспользования рабочих списков (этот интервал теперь равен не
R, а среднему количеству r.rid в интервале [ridi , ridi+1 ]). Однако, как и в
случае алгоритма IF N L(k), появляются дополнительные накладные рас-
ходы на выборку r.rid для обработки на очередном этапе.
Представляет интерес априорная оценка количества интервалов n. При
слишком малом количестве интервалов активное множество рабочих спис-
ков не уменьшается в кэш, и возможны L2 кэш-промахи. Но чем выше
количество интервалов, тем больше циклов по IFRS A
выполняет алгоритм,
и тем больше становятся вычислительные расходы. Условие, что все Lr
должны умещаться в кэш-память, приводит к тому, что количество r.rid,
обрабатываемых на каждом этапе, не должно превышать size(L C
r)
(C , как
обычно, объем кэш-памяти L2, а size(Lr ) — длина рабочих списков в бай-
тах). Таким образом, количество этапов должно быть минимальным чис-
|R|size(Lr )
лом, которое не меньше C . Учитывая выведенную выше оценку для
Lr , мы получаем возможность априорной оценки количества интервалов:
Result Result
n ∈ [ min(size(L C ),size(lS ))|R| , max(size(L C ),size(lS ))|R| ], если известна селектив-
ность α(R, S) соединения.
Заметим, что представленные модификации алгоритмов IF N L(k) и
IF J(l) в известном смысле двойственны — роль элементов Domain(A)
в алгоритме IF N L(k) играют RID r.rid в алгоритме IF J(l). Кроме того,
несмотря на использование нами термина “этап” для обозначения обработ-
ки интервалов [ridk , ridk+1 ] в алгоритме IF J(l), между этими этапами нет
настоящей зависимости по данным, что означает возможность их распа-

109
раллеливания в системе, которая эффективно поддерживает параллельное
исполнение.

4.3.4. Экспериментальная проверка алгоритмов

IF N L(k) и IF J(l)
Мы выполняли эксперименты с использованием алгоритмов IF N L(k) и
IF J(l) в условиях, сходных с описанными в раздела 4.3.2. В качестве вход-
ных отношения использовались синтезированные наборы данных. В отли-
чие от раздела 4.3.2, здесь мы не включаем во время выполнения алго-
ритма время, необходимое для построение индексов, если явно не упомя-
нуто обратное. Причина этого заключается в том, что в данном разделе
мы в основном рассматриваем алгоритмы, которые требуют наличия ин-
вертированных файлов. Кроме того, предполагается, что в практической
ситуации инвертированные файлы уже построены перед выполнением за-
проса, включающего соединение по предикату над множественнозначными
атрибутами, и поддерживаются в актуальном состоянии. Во всех случаях
в качестве предиката использовалось включение множеств.

4.3.4.1. Эксперимент 1: оптимальное количество этапов для


IF J(n)

В данном эксперименте мы используем набор данных, состоящий из двух


отношений, каждой из которых включает два атрибута — первичный ключ
и множественнозначный атрибут. Параметры отношений: φR = φS =
5, |Domain(A)| = 5000, |R| = 150000, |S| = 300000 и селективность со-
единения α(R, S) = |R||S|
5007
= 1.2 ∗ 10−7 . Результат представлены в таблице
4.5 и на графике 4.7. Очевидно, что оптимальное время выполнения до-
стигается при n = 2, два интервала/этапа в данном случае обеспечивают
оптимальное соотношение между уменьшением количества кэш-промахов
и накладными расходами на дополнительные циклы по IFRS A
.
В то же время мы наблюдали, что в случае алгоритма IF N L(k) пред-
ложенная оптимизация не дает положительный эффект — время выполне-

110
Таблица 4.5. Зависимость времени выполнения и количества кэш-промахов
от n для алгоритма IF J
Число этапов Время, с Число кэш-промахов (∗106 )
1 9.3 60
2 8.4 55
3 8.6 54
5 8.7 53
9 8.9 52

Рис. 4.7. Зависимость времени выполнения от n для алгоритма IF J(n)

111
Таблица 4.6. Сравнение производительности IF N L и IF J
Время, с Число кэш-промахов
|S|(∗105 ) IF N L(1) IF J(3) IF N L(1) IF J(3)
1 1.2 2.3 42 ∗ 106 60 ∗ 106
3 6 9 1, 3 ∗ 107 1 ∗ 107
5 10 11 - -
7 19 18 - -

ния минимально при k = 1. Мы объясняет этот эффект тем, что стоимость


выбора значений v для обработки на очередном этапе выше в случае ал-
горитма IF N L(k). В алгоритме IF N L(k) эта операция выполняется для
каждой записи на каждом этапе, другими словами k|R| раз. В случае ал-
горитма IF J(l) выбор r.rid для обработки на очередном этапе выполня-
ется |Domain(A)|l раз. Поскольку обычно |Domain(A)|  |R|, алгоритм
IF N L(k), вообще говоря, влечет большие накладные расходы на дополни-
тельные циклы, чем алгоритм IF J(l), что и является объяснением неэф-
фективности предложенной оптимизации для IF N L(k). Поэтому в даль-
нейшем мы будем считать, что k = 1 для IF N L(k) и писать просто IF N L.

4.3.4.2. Эксперимент 2: сравнение IF N L и IF J(l)


Представляет интерес сравнение производительности рассматриваемых ал-
горитмов IF N L и IF J(l). Мы используем отношения со следующими ха-
рактеристиками: φR = 17, φS = 25, |R| = 250000, а |S| изменяется. Мы
установили (результаты здесь не представлены), что наилучшую произво-
дительность обеспечивает IF J(3). Таблица 4.6 и график 4.8 дают пред-
ставление о производительности алгоритмов в данном случае.
Очевидно, что со временем IF J(l) начинает выигрывать у IF N L. Объ-
яснение этому факту заключается в том, что при приблизительно одинако-
вой вычислительной сложности IF J обладает лучше временной локально-
стью (меньшим пространственным интервалом переиспользования рабочих
списков), и важность этого фактора возрастает с ростом |S|, поскольку с
ростом |S| увеличивается длина рабочих списков. Это наблюдение допол-
нительно подтверждается измерениями числа кэш-промахов в таблице 4.6

112
Рис. 4.8. Зависимость времени выполнения от |S| для IF N L и IF J

(некоторые ячейки там пропущены, поскольку программу исчерпала до-


ступную оперативную память и аварийно завершилась во время профиля-
ции с использованием VTune).

4.3.4.3. Эксперимент 3: эффект сжатия списков


Мы провели экспериментальное изучение эффекта сжатия списков в ал-
горитме IF J на время выполнения и объем требуемой памяти. Сжатие
списков реализовано с помощью Гамма-кодирования, и применяется как к
инвертированным спискам в IFRS
A
, так и к рабочим спискам Lr в процес-
се работы алгоритма. В данном случае размеры отношений фиксированы:
|R| = 100000, |S| = 250000. Зависимость между временем выполнения,
объемом используемой памяти, средней длиной инвертированных списков
и размером результата приведен в таблице 4.7. В данной таблице показаны
результаты для версий алгоритма IF J со сжатием и без. Одна из ячеек
таблицы пуста, поскольку программа не смогла завершить свою работу
из-за исчерпания доступной оперативной памяти.
Из представленных результатов очевидно, что более компактные спис-
ки в версии алгоритма со сжатием, хоть и уменьшают количество кэш-
промахов первого обращения, но не оправдывают значительно возросшую
стоимость операции слияния. Эта стоимость возрастает из-за необходимо-

113
Таблица 4.7. Эффект сжатия списков для IF J
Без сжатия Со сжатием
|lR | |lS | |Result| Время, с Память, Мб Время, с Память, Мб
20 50 137 1.5 110 3.2 113
75 188 1759 1.8 113 3.9 90
3000 7500 0 73 144 115 146
500 2250 50 ∗ 10 -
6
300 151 91

сти затрат на распаковку элементов списков-аргументов операции и сжа-


тия элемента в списке-результате операции. Таким образом, наиболее важ-
ным положительным эффектом сжатия списков является уменьшения объ-
ема памяти, требуемой для алгоритма. Как видно из третьей и четвертой
строки таблицы 4.7, этот эффект достигается только в случае, когда все
величины |lR |, |lS | и |Result| достаточно велики. Это вполне ожидаемое
явление, поскольку чем меньше размеры результата соединения (в край-
нем случае, как в третьей строке, результат соединения пуст), тем короче
рабочие списки и тем меньше эффект, достигаемый их сжатием.

4.3.4.4. Эксперимент 4: сравнение IF J(l) с другими известными


алгоритмами
Здесь мы сравниваем эффективность модифицированного алгоритма
IF J(l) с другими известными алгоритмами для соединения по включе-
нию множеств. В качестве других алгоритмов мы использовали SN L(k) и
P SJ(n). Мы рассматриваем одно отношение с фиксированными парамет-
рами φR = 5, |Domain(A)| = 10000, это отношение соединяется с самим
собой. Было быстро выяснено, что алгоритм SN L значительно проигрывал
другим алгоритмам, поэтому мы исключили данный алгоритм из рассмот-
рения. Как обычно, параметры алгоритмов (в частности, алгоритма P SJ )
были определены исходя из рекомендаций в соответствующих работах и
наших экспериментов. Мы также включили в данный эксперимент алго-
ритм IF N L. Для алгоритмов IF N L и IF J(l) мы включаем время постро-
ения необходимых инвертированных файлов в общее время выполнения.
Результаты представлены в таблице 4.8.

114
Таблица 4.8. Сравнение алгоритмов P SJ , IF N L и IF J(l) при большом
значении |Domain(A)|
|R| |Result| TSN L , sec TP SJ , sec TIF J , sec TIF N L , sec
20000 20019 53 45 0.23 0.3
75000 75000 - 70 1.11 1.17
150000 150000 - 140 2.43 2.37

Таблица 4.9. Сравнение алгоритмов P SJ , IF N L и IF J(l) при изменяю-


щемся |Domain(A)|
|Domain(A)| |Result| TP SJ , sec TIF J , sec TIF N L , sec
1000 15727 10 0.21 0.23
500 15727 10.8 0.22 0.26
250 26537 10.9 0.31 0.34
100 26537 15 0.7 0.78
50 339421 32 1.6 1.45
25 1544713 71 4.7 4.2
10 - 282 49 45

Поскольку эффективность алгоритмов IF N L и IF J в большой степени


зависит от длины инвертированных списков |lS |, можно ожидать, что она
быстро ухудшается с уменьшением |Domain(A)|. Для того, чтобы прове-
рить эту гипотезу, мы провели другой эксперимент с измененными значе-
ниями параметров отношения R: |R| = 15000, а |Domain(A)| изменяется.
Результаты даны в таблице 4.9 и проиллюстрированы графиками на рис.
4.9 (одна из ячеек таблицы пропущена, поскольку программа не смогла
завершиться из-за исчерпания оперативной памяти).
Как показывает этот эксперимент, в частности, изгиб кривой на графи-
ке 4.9, алгоритмы IF N L и IF J(l) действительно более чувствительны к
уменьшению |Domain(A)|, чем алгоритм P SJ . Уменьшение |Domain(A)|
с 1000 до 10 привело к замедлению P SJ в ≈ 28 раз и замедлению IF N L
и IF J(l) в ≈ 200 раз. Однако, во всех случаях алгоритмы, основанные на
инвертированных списках, продемонстрировали лучшую производитель-
ность, чем алгоритм P SJ .

115
Рис. 4.9. Зависимость времени выполнения от |Domain(A)| для алгоритмов
P SJ и IF J (логарифмическая шкала)

4.3.5. Выводы

В данном разделе мы исследовали производительность различных алго-


ритмов для выполнения операции соединения по предикату над множе-
ственнозначными атрибутами в предположении, что данные находятся в
основной памяти. Наши исследования показывают, что алгоритмы, осно-
ванные на использовании инвертированных файлов - IF N L, IF J и IX ,
оказываются значительно эффективнее, чем алгоритмы, применяющие для
ускорения соединения сигнатуры и метод распределения. Мы также пред-
ложили специализированную индексную структуру - индекс пересечения,
которая позволяет свести вычисление результата соединения к простому
обходу этой структуры.
Мы рассмотрели модифицированные варианты алгоритмов IF N L и
IF J , которые используют технику логического распределения для сокра-
щения активного набора данных и увеличения шансов их переиспользова-
ния в кэш-памяти. Этот прием оказался эффективным для алгоритма IF J ,
но не для алгоритма IF N L, поскольку для IF N L дополнительные наклад-
ные расходы, связанные с необходимостью выбора значения для обработки
на очередном этапе, перевешивают преимущества уменьшающегося коли-
чества кэш-промахов. Это означает, что алгоритм IF N L больше подходит
для отношений небольших размеров, в то время как алгоритм IF J более

116
пригоден для больших отношений, поскольку он лучше масштабируется с
ростом |S|. Мы также изучили эффект сжатия инвертированных файлов и
промежуточных результатов и сделали вывод, что основным положитель-
ным эффектом сжатия является сокращение требуемого объема памяти,
но это достигается только в случае, когда длины инвертированных спис-
ков и размер результата достаточно велики. Как средство уменьшения раз-
мера списков и сокращения количества кэш-промахов первого обращения
сжатие себя не оправдывает, поскольку вызывает значительные накладные
расходы на распаковку сжатых данных.

117
Глава 5.

Заключение

В данной работе исследованы аспекты СУБД-ОП, относящиеся к индекси-


рованию данных и эффективному выполнению запросов. В работе получе-
ны следующие основные результаты:

1. Предложены критерии оценки эффективности структур данных и ал-


горитмов для СУБД-ОП, учитывающие неоднородность доступа к
основной памяти в современных вычислительных системах.

2. Построена классификация, обобщающая большинство известных ме-


тодов оптимизации структур данных и алгоритмов для эффективного
использования кэш-памяти процессора.

3. Реализована среда для проведения экспериментов с индексными


структурами и алгоритмами выполнения реляционных операций в ос-
новной памяти Memphis, которая использована при проведении мно-
гих экспериментальных проверок.

4. Предложен и экспериментально проверен метод оптимизации опе-


рации соединения на основе мульти-индексов в СУБД-ОП, показа-
на его сравнительная эффективность по сравнению с методом хэш-
соединения в зависимости от параметров входных отношений.

5. Исследованы различные алгоритмы для выполнения операции со-


единения по предикату над множественнозначными атрибутами в

118
СУБД-ОП, предложена модификация алгоритма IF J (Inverted File
Join), основанного на использовании инвертированных файлов, для
лучшего использования кэш-памяти и экспериментально продемон-
стрировано, что эта модификация приводит к уменьшению времени
выполнения соединения.

Данная работа демонстрирует необходимость применения специальных


методов для реализации полноценных СУБД-ОП, которые эффективно ис-
пользуют кэш-память процессора, устройство предсказания переходов и
другие особенности архитектуры современных компьютеров. Предложен-
ные в работе алгоритмы могут применяться в СУБД-ОП для поддержки
расширенного набора реляционных операций, включающего операции над
множественнозначными атрибутами, во многих случаях предоставляющи-
ми естественный способ выражения понятий предметной области. Рассмот-
ренные методы имеют также важное значение при исследовании следую-
щего шага в развитии СУБД с точки зрения использования архитектурных
особенностей компьютерных систем — СУБД, которые могут эффективно
работать с данными на нескольких уровнях иерархии памяти, обеспечивая
оптимальное быстродействие как для данных, находящихся на диске, так
и для данных в основной памяти.

119
Литература

[1] Гарсиа-Молина Г., Ульман Д., Уидом Д. Системы баз данных. Полный
курс. / Пер. с англ. — М.:Вильямс, 2003.

[2] Грэхем Р., Кнут Д., Паташник О. Конкретная математика. Основа-


ние информатики. / Пер. с англ. — M.:Мир, 1998.

[3] К.Дж.Дейт. Введение в системы баз данных. 8-е издание. / Пер. с


англ. — М.:Вильямс, 2005.

[4] М.Р.Когаловский. Энциклопедия технологий баз данных. —


М.:Финансы и статистика, 2002.

[5] Руссинович М., Соломон Д. Внутреннее устройство Microsoft Windows


2000. / Пер. с англ. — СПб.:Русская редакция : Питер, 2001.

[6] Access Path Selection in a Relational Database Management System. /


P. G. Selinger, M. M. Astrahan, D. D. Chamberlin et al. // Proc. of the
SIGMOD 1979 Conference. — ACM, 1979. — P. 23–34.

[7] ARIES: A Transaction Recovery Method Supporting Fine-Granularity


Locking and Partial Rollbacks Using Write-Ahead Logging. / C. Mohan,
D. J. Haderle, B. G. Lindsay et al. // ACM Trans. Database Syst. —
1992. — Vol. 17, no. 1. — P. 94–162.

[8] Bayer R., McCreight E. M. Organization and Maintenance of Large


Ordered Indexes. // Record of the SIGFIDET 1970 Workshop. — ACM,
1970. — P. 107–141.

120
[9] Bernstein P. A., Newcomer E. Principles of Transaction Processing for
Systems Professionals. — Morgan Kaufmann, 1996.

[10] Bohannon P., McIlroy P., Rastogi R. Main-Memory Index Structures with
Fixed-Size Partial Keys. // Proc. of the SIGMOD 2001 Conference. —
2001.

[11] Boncz P. A., Manegold S., Kersten M. L. Database Architecture


Optimized for the New Bottleneck: Memory Access. // Proc. of the VLDB
1999 Conference. — Morgan Kaufmann, 1999. — P. 54–65.

[12] Boncz P., Kersten M. Monet: an Impressionist Sketch of an Advanced


Database System. — 1995.

[13] Cache-Conscious Concurrency Control of Main-Memory Indexes on


Shared-Memory Multiprocessor Systems. / S. K. Cha, S. Hwang, K. Kim,
K. Kwon // Proc. of the VLDB 2001 Conference. — Morgan Kaufmann,
2001. — P. 181–190.

[14] Cai J.-Y. et al. On the Complexity of Join Predicates // Proc. of the
PODS 2001 Conference. — 2001. — P. 207–214.

[15] Chatterjee S., Sen S. Cache-Efficient Matrix Transposition. // Proc. of the


HPCA 2000 Conference. — 2000. — P. 195–205.

[16] Chilimbi T. M., Hill M. D., Larus J. R. Cache-Conscious Structure


Layout. // Proc. of the PLDI 1999 Conference. — 1999. — P. 1–12.

[17] Chilimbi T. M., Larus J. R. Using Generational Garbage Collection To


Implement Cache-Conscious Data Placement. // Proc. of the ISMM 1998
Conference. — 1998. — P. 37–48.

[18] Copeland G. P., Khoshafian S. A Decomposition Storage Model. // Proc.


of the SIGMOD 1985 Conference. — ACM Press, 1985. — P. 268–279.

[19] Dalı́: A High Performance Main Memory Storage Manager. /


H. V. Jagadish, D. F. Lieuwen, R. Rastogi et al. // Proc. of the VLDB
1994 Conference. — Morgan Kaufmann, 1994. — P. 48–59.

121
[20] Data Compression in Full-Text Retrieval Systems. / T. C. Bell, A. Moffat,
C. G. Nevill-Manning et al. // JASIS. — 1993. — Vol. 44, no. 9. — P. 508–
531.

[21] DBMSs on a Modern Processor: Where Does Time Go? / A. Ailamaki,


D. J. DeWitt, M. D. Hill, D. A. Wood // Proc. of the VLDB 1999
Conference. — Morgan Kaufmann, 1999. — P. 266–277.

[22] Denning P. Locality (unpublished manuscript). — 2005.


[23] Graefe G. Query Evaluation Techniques for Large Databases. // ACM
Comput. Surv. — 1993. — Vol. 25, no. 2. — P. 73–170.
[24] Hankins R. A., Patel J. M. Data Morphing: An Adaptive, Cache-
Conscious Storage Technique. // Proc. of the VLDB 2003 Conference. —
2003. — P. 417–428.

[25] Helmer S., Moerkotte G. Evaluation of Main Memory Join Algorithms for
Joins with Set Comparison Join Predicates. // Proc. of the VLDB 1997
Conference. — Morgan Kaufmann, 1997. — P. 386–395.

[26] Hennessy J. L., Patterson D. A. Computer Architecture: A Quantitative


Approach. — 3rd edition. — Morgan Kaufmann, 2002.

[27] Improving Hash Join Performance through Prefetching. / S. Chen,


A. Ailamaki, P. B. Gibbons, T. C. Mowry // Proc. of the HDMS 2004
Conference. — 2004.

[28] Intel Corporation. IA-32 Intel Architecture Optimization Reference


Manual. — 2005.

[29] Intel Corporation. IA-32 Intel Architecture Software Developer’s


Manuals. — 2005.

[30] Intel Corporation. Intel C++ Compiler Documentation. — 2005.


[31] Intel VTune Performance Analyzer //
http://www.intel.com/software/products/vtune/.
122
[32] Ioannidis Y. E. The History of Histograms (abridged). // Proc. of the
VLDB 2003 Conference. — 2003. — P. 19–30.

[33] Kim K., Cha S. K., Kwon K. Optimizing Multidimensional Index Trees
for Main Memory Access. // Proc. of the SIGMOD 2001 Conference. —
2001.

[34] Lehman T. J., Carey M. J. A Study of Index Structures for Main Memory
Database Management Systems. // Proc. of the VLDB 1986 Conference. —
Morgan Kaufmann, 1986. — P. 294–303.

[35] Litwin W. Linear Hashing: A New Tool for File and Table Addressing. //
Proc. of the VLDB 1980 Conference. — IEEE Computer Society, 1980. —
P. 212–223.

[36] Li Z., Ross K. A. Fast Joins Using Join Indices. // VLDB Journal. —
1999. — Vol. 8, no. 1. — P. 1–24.

[37] Mamoulis N. Efficient Processing of Joins on Set-Valued Attributes //


Proc. of the SIGMOD 2003 Conference. — 2003. — P. 157–168.

[38] Manegold S., Boncz P. A., Kersten M. L. What Happens During a Join?
Dissecting CPU and Memory Optimization Effects. // Proc. of the VLDB
2000 Conference. — Morgan Kaufmann, 2000. — P. 339–350.

[39] Manegold S., Boncz P. A., Kersten M. L. Generic Database Cost Models
for Hierarchical Memory Systems. // VLDB. — 2002. — P. 191–202.

[40] Manegold S., Boncz P. A., Kersten M. L. Optimizing Main-Memory


Join on Modern Hardware. // IEEE Trans. Knowl. Data Eng. — 2002. —
Vol. 14, no. 4. — P. 709–730.

[41] Manegold S., Boncz P. A., Nes N. Cache-Conscious Radix-Decluster


Projections. // Proc. of the VLDB 2004 Conference. — Morgan Kaufmann,
2004. — P. 684–695.

[42] Manegold S., Boncz P. Cache-Memory and TLB Calibration Tool //


http://monetdb.cwi.nl/Calibrator/doc/calibrator.pdf.
123
[43] Muchnik S. P. Advanced Compiler Design and Implementation. — Morgan
Kaufmann, 1997.

[44] Nonlinear Array Layouts for Hierarchical Memory Systems. /


S. Chatterjee, V. V. Jain, A. R. Lebeck et al. // Proc. of the ICS
1999 Conference. — 1999. — P. 444–453.

[45] Oracle Corporation. SQL 2003 Standard Support in Oracle Database 10g.
An Oracle White Paper. — 2003.

[46] Penner M., Prasanna V. K. Cache-Friendly Implementations of Transitive


Closure. // Proc. of the PACT 2001 Conference. — IEEE Computer
Society, 2001. — P. 185–.

[47] Rao J., Ross K. A. Cache Conscious Indexing for Decision-Support in Main
Memory. // Proc. of the VLDB 1999 Conference. — Morgan Kaufmann,
1999. — P. 78–89.

[48] Rao J., Ross K. A. Making B+-Trees Cache Conscious in Main Memory. //
Proc. of the SIGMOD 2000 Conference. — ACM, 2000. — P. 475–486.

[49] Ross K. A. Selection Conditions in Main Memory. // ACM Trans.


Database Syst. — 2004. — Vol. 29. — P. 132–161.
[50] Set Containment Joins: The Good, The Bad and The Ugly. /
K. Ramasamy, J. M. Patel, J. F. Naughton, R. Kaushik // Proc. of the
VLDB 2000 Conference. — Morgan Kaufmann, 2000. — P. 351–362.

[51] Shaporenkov D. Multi-Indices - A Tool for Optimizing Join Processing in


Main Memory // Proc. of the Baltic DBIS 2004 Conference. — 2004. —
P. 105–114.

[52] Shaporenkov D. Performance Comparison of Main-Memory Algorithms for


Set Containment Joins // Proc. of the SYRCoDIS 2004 Symposium. —
2004. — P. 17–21.

124
[53] Shaporenkov D. Efficient Main-Memory Algorithms for Set Containment
Join Using Inverted Lists // Proc. of the ADBIS 2005 Conference. —
2005. — P. 139–152.

[54] Shaporenkov D. Partitioning Inverted Lists for Efficient Evaluation of Set-


Containment Joins in Main Memory // Proc. of the SYRCoDIS 2005
Symposium. — 2005. — P. 40–46.

[55] Shatdal A., Kant C., Naughton J. F. Cache Conscious Algorithms for
Relational Query Processing. // Proc. of the VLDB 2004 Conference. —
Morgan Kaufmann, 1994. — P. 510–521.

[56] Smith A. J. Cache Memories. // ACM Comput. Surv. — 1982. — Vol. 14,
no. 3. — P. 473–530.

[57] Sokolinsky L. B. Lfu-k: An effective buffer management replacement


algorithm. // Proc. of the DASFAA 2004 Conference. — 2004. — P. 670–
681.

[58] Stallings W. Computer Organization and Architecture: Designing for


Performance. — 6th edition. — Prentice Hall, 2003.

[59] Stallings W. Operating Systems: Internals and Design Principles. — 5th


edition. — Prentice Hall, 2003.

[60] Sun Microsystems Inc. UltraSPARC IV Processor Specifications. — 2005.


[61] Sutter H. The Free Lunch Is Over: A Fundamental Turn Toward
Concurrency in Software // Dr.Dobb’s Journal. — 2005. — Vol. 30, no. 3.

[62] The Architecture of the Dalı́ Main-Memory Storage Manager. /


P. Bohannon, D. F. Lieuwen, R. Rastogi et al. // Multimedia Tools Appl. —
1997. — Vol. 4, no. 2. — P. 115–151.

[63] Valduriez P. Join Indices. // ACM Trans. Database Syst. — 1987. —


Vol. 12, no. 2. — P. 218–246.

125
[64] Weaving Relations for Cache Performance. / A. Ailamaki, D. J. DeWitt,
M. D. Hill, M. Skounakis // Proc. of the VLDB 2001 Conference. — Morgan
Kaufmann, 2001. — P. 169–180.

[65] Whaley R. C., Dongarra J. Automatically Tuned Linear Algebra


Software. // Proc. of the PPSC 1999 Conference. — 1999.

[66] Witten I., Moffat A., Bell T. Managing Gigabytes : Compressing and
Indexing Documents and Images. — 2nd edition. — Morgan Kaufmann,
1999.

[67] Zhou J., Ross K. A. Implementing Database Operations Using SIMD


Instructions. // Proc. of the SIGMOD 2002 Conference. — ACM, 2002. —
P. 145–156.

126