Конкурс Acceler8 - оптимизация работы с памятью

Совсем недавно закончился конкурс Acceler8 2011, в котором я принимал участие. В данном посте я бы хотел поделиться, какие шаги были сделаны в плане оптимизаций работы с памятью в процессе выполнения конкурсной задачи.

Для поиска подматрицы с максимальной суммой элементов в матрице наша команда выбрала адаптированный одномерный алгоритм, предложенный Джеем Кадане в 1977 году. Насколько нам известно, для двумерного случая решения с помощью этого алгоритма не существует, или мы о нём пока не знаем. Поэтому пришлось адаптировать имеющийся одномерный алгоритм. Изменение было в следующем: мы складывали нужное количество строк матрицы в массив-аккумулятор и осуществляли поиск в нём с помощью известного алгоритма Кадане для числовых последовательностей.

Рассмотрим пример: пусть есть матрица 10 на 10. Сначала осуществляется одномерный поиск максимальной подматрицы в каждой из строк. Далее строки складываются попарно в массив-аккумулятор, и ищется максимальная подматрица в аккумуляторе. Т.е. складываем строки 0 и 1, ищем максимальную подматрицы, складываем строки 1 и 2, ищем максимальную подматрицу и т.д. Заканчивается последней подматрицей 10 на 10, которая совпадает с исходной матрицей.

Далее в ход пошли оптимизации:

1. Переиспользование результатов.



Помогает снизить интенсивность использования памяти и увеличить эффективность использования кэша. Представим, что в нашей матрице, описанной выше, мы уже добрались до сложения по 4 строки. Т.е. складываем строки 0, 1, 2 и 3, ищем максимальную подматрицы, складываем строки 1, 2, 3 и 4, ищем максимальную подматрицу и т.д. Можно заметить, некоторые строки мы складывали по нескольку раз. Получается, что достаточно из аккумулятора, оставшегося после предыдущего шага, вычесть первую строку, добавить следующую, и можно получить готовый аккумулятор для следующего шага. Т.е. для шага 4 складываем строки 0, 1, 2 и 3, ищем максимальную подматрицу, из аккумулятора вычитаем строку 0 и добавляем строку 4, ищем максимальную подматрицу. Тем самым уменьшается количество сложений. И чем больше высота подматрицы, которую мы ищем, тем большей экономии по количеству операций мы достигаем.
А если переставить операции вычитания и прибавления строк местами, то мы получим, что в какой-то промежуточный момент в аккумуляторе находилась сумма 5 строк. Можно использовать и этот момент. В итоге получилось следующее: сложили 4 строки, поискали максимум по 4-м строкам, добавили следующую строку, поискали максимум по 5ти строкам, вычли верхнюю строку, поискали максимум по 4м строкам, добавили и т.д. Пока не дошли до последней строки матрицы, имея в аккумуляторе последние 4 строки.
И даже этот результат можно переиспользовать – только теперь считать максимум по 6 и 7 сложенным строками и двигаться не вниз, а вверх.

В конечном итоге получилось следующее. На исходной матрице ищем максимумы по отдельным строкам, потом в большом цикле ищем максимумы по 2 и 3 сложенным строкам, двигаясь по матрице вниз, далее ищем максимум по 4 и 5 сложенным строкам, двигаясь вверх, далее ищем по 6 и 7 сложенным строкам, двигаясь вниз и т.д. Этим достигается огромная экономия операций обращений в память.

2. Уменьшение разрядности.



Не знаем, желая того или нет (первоначально акцент в конкурсе делался именно на параллелизацию), но организаторы сами опубликовали генерирующую функцию, свойства которая и являются источником большинства самых эффективных оптимизаций.
Первое, что можно было пронаблюдать, это что по формуле псевдо-случайного алгоритма генерации данных получалось, что все данные гарантированно лежат внутри диапазона от -20000 до 20000. После перехода с типа int на тип short int, уровень потребления памяти упал в 2 раза, а скорость генерации исходных матриц и скорость алгоритма подсчёта выросла от 10% до 50% в зависимости от размера матрицы. Опять же, это достигалось за счёт более эффективного использования кэша процессоров.

3. Переход на «индексированные» матрицы.



Последнее улучшение, сделанное нашей командой для работы с памятью, был переход на «индексированные матрицы». Если взять достаточно большую матрицу и развернуть её в строку, то мы заметим циклически повторяющиеся значения. Это связанно с особенностями генерации исходных данных. Представим, что наша матрица 10 на 10 состоит из циклически повторяющихся значений -5,-3,-1,1,3,5,7, и каждая строка начинается в «произвольном» месте данной последовательности. Получается, что нет необходимости хранить всю матрицу. Достаточно хранить только одномерный массив с несколькими повторяющимися последовательностями, и отдельно массив со смещениями, где хранится отступ в массиве, откуда начинается каждая строка. Максимальный размер массива, необходимого для данного представления матрицы, равен m + ширина исходной матрицы + высота исходной матрицы. В реальности, он ещё меньше, но мы оценивали максимальный необходимый массив. С такой оптимизацией мы достигли 70% экономии места под хранение матрицы. На больших матрицах экономия достигала 99%, и была полностью исключена необходимость выделять 800Мб памяти под максимальные матрицы, допускаемые задачей.

Существенное уменьшение объёма памяти для хранения матрицы позволило более эффективно осуществить распараллеливание непосредственно по матрицам в тесте, но свойства архитектуры нашего решения не входят в рамки этого описания.
For more complete information about compiler optimizations, see our Optimization Notice.
Categories: