суббота, 12 марта 2011 г.

Параллельная внешняя сортировка

Краткое содержание предыдущих серий(пост "Внешняя сортировка").

Алгоритм внешней сортировки имеет две фазы:
1) Фаза последовательной кусочной сортировки
2) Фаза слияния отсортированных кусков

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

Первая фаза паралелится очевидным образом: после того как мы загрузили кусок файла в память будем его сортировать в несколько потоков. Как это сделать наилучшим образом?

Вариант первый - натурально паралеллить алгоритм QuickSort().

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

Вариант второй - Parallel Sorting with Regular Sampling (PSRS). Подробное описание с картинками есть здесь

Суть такая:
0) Нам нужно распараллелить сортировку загруженного в память массива A из n элементов на p потоков
1) Делим исходный массив на p подмассивов A_p приблизительно одинаковой размерности
2) Запускаем p потоков сортировки каждого подмассива A_p.
3) Параллельно выбираем из каждого отсортированного подмассива p чисел - сэмплы. Выборка делается по принципу :

sample_p[i] = A_p[j(i)], i = 0 .. p-1, j(i) = i * n / p^2;

4) Сливаем все sample_p в один массив sample и сортируем его.
5) Выбираем опорные точки pivot.

pivot[i] = sample[j(i)], i = 0 .. p-2, if (i < p - 2) j(i) = i * p + p + [p/2] - 1 else j(i) = (p-1) * p + [p/2]

6) Параллельно разбиваем Ap на подмассивы App так, что все элементы Ap0 не больше pivot[0], все элементы Ap1 не больше pivot[1], но больше pivot[0] и т.д.
7) Собираем все первые кусочки всех Ap в массив B1, все вторые кусочки всех Ap в массив B2 и т.д.
Полученные массивы Bp обладают тем свойством, что pivot[p-1] < любой элемент Bp <= pivot[p]
8) Параллельно сортируем Bp.
9) Сконкатенированные, отсортированные Bp дают отсортированный массив A.

Так как Bp состоят из кусочков отсортированных Ap, то пункт 8 существенно эффективнее производить сортировкой слиянием, хотя это требует в два раза больше памяти,чем инплейс сортировка.

Этот алгоритм я реализовал распараллелив его с операциями чтения\записи данных с диска.
Т.е. пока производится сортировка считанного в память чанка, идёт запись на диск результатов предыдущей сортировки и вычитывание следующего чанка.

Что не получилось. Совершенно не получилось как-то распараллелить вторую фазу. И хотелосьбы услышать какие-нибудь идеи по этому поводу.

А вот исходники

воскресенье, 6 марта 2011 г.

Внешняя сортировка

Одна БольшаЯ КомпаниЯ (сокращенно: БЯКЯ) очень любит давать соискателям задачу про внешнюю сортировку. И я там был, мёд пиво пил, по усам текло, да в рот не попало. В БЯКЯ меня не взяли. Но официальные причины отказа никак не касались самого алгоритма, за оптимальность которого я, собственно, больше всего и боролся. Поэтому я, не имея конечной оценки качества алогритма, хочу его опубликовать и получить "по рогам" уже по существу. Ну собственно ценные замечания по стилистике тоже с радостью послушаю. Единственное, что после слов "так верстают только мудаки" очень прошу не забывать писать как же надо верстать на самом деле :)))

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

Ну, к примеру, есть файл на 100 гигабайт. Файл рассматриваем как совокупность беззнаковых целых 32-битных чисел. Т.е. каждый 4 байта файла - число. Есть машина на которой невозможно выделить боле 256 мегабайт памяти. Сортируйте.

Общий принцип решения становится ясен после прочтения одноименной вики-статьи. Расскажу, как я её понял.

Итак, у нас естественным образом выделяются две фазы.

1. Фаза последовательной сортировки: "рубим" весь файл на чанки, так чтобы каждый чанк целиком влезал в оперативную память, загружаем последовательно каждый чанк в память, сортируем стандартными методами внутренней сортировки(std::sort), кладём отсортированный чанк на диск, берём следующий чанк и т.д.

2. Фаза слияния. У нас есть много чанков, каждый из которых отсортирован, надо их "слить" в результирующий, глобально-отсортированный файл.

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

Теперь к нашей задаче. Стопки - это наши предсортированные чанки. Если у нас всего N чисел в исходном файле и M чанков, то наш алгортим должен сделать N операций по выборе минимума из M чисел. Т.е. сложность алгоритма при "лобовой" реализации O(N*M). Сразу хочется ускорить этот момент, благо ничего сложного в этом нет. Предлагаю хранить M "верхних" карт в приоиртетной очереди (std::priority_queue). И на каждом ходу - достаём самую маленькую (константное время) и запихиваем новую открывшуюся (O(ln(M))). В итоге получаем сложность O(N*ln(M)).

Вот впринципе и всё. Но. БЯКЯ, в постановке задачи, особо отметил необходимость эффективной работы в случае многоядерного процессора. Т.е. нужна многопоточность. Такая чтобы работало быстрее, чем без неё.

На сегодня всё, устал писать. Обещаю в течение нескольких дней запостить продолжение про распараллеливание. И выложить на поругание сорцы. Спасибо.

суббота, 5 марта 2011 г.

Сисиплюс, поехали!

Решил вот открыть блог по Сям. История этого решения простая и банальная - записать, чтобы не забыть.

Почему "сисиплюс"? Однажды подошёл ко мне сотрудник, непрограммерского профиля, и в непринужденной беседе спросил: "А ты на чём программируешь-то? На сисиплюс?". Сам он это придумал, или подсмотрел где-то - я не знаю, но мне понравилось. Вот поэтому.

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