Обновить

Структуры данных на практике. Глава 1: Разрыв в производительности

Уровень сложностиПростой
Время на прочтение9 мин
Охват и читатели11K
Всего голосов 62: ↑60 и ↓2+68
Комментарии28

Комментарии 28

а ссылку на pdf можно, а то так можно забыть с чего начал:-)

Спасибо.

Наверное, так, без оговорок, не совсем.верно писать?

  • Алгоритм O(n), оптимально использующий кэш, может победить алгоритм O(log n), использующий кэш плохо

При больших n вряд-ли.

Всегда найдётся достаточно большое n, что O(log(n)) будет меньше O(n). Но это в теории.
На практике реальная система может "всегда" работать с меньшими n.

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

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

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

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

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

Почему же не отражён? По определению O(1) - какая-то константа. Алгоритм, выполняющийся за O(1) может выполняться хоть мгновенно, хоть 2 года.

Ну вот вы ровно и показали, как это запутывает.
Если O - это константа, значит O(1) всегда меньше O(n) для n > 1.
Так можно понять, хотя на самом деле лишь подчеркивает динамику с изменением n

Проблема ведь в том, что нельзя игнорировать и алгоритм.

Элемент массива 4 байта, sizeof(node_t) 12 байт (если указатель x64) плюс служебные данные при выделении памяти, т.е. надо в 4-5 раз больше памяти для связного списка. Даже если все элементы списка в куче будут последовательно друг-за-другом, то для 10К элементов только чтение из памяти потребует лишние 3-4М тактов.

Невнимательно прочитал, там 100К элементов, тогда лишние 30-40М тактов.

Для чистоты эксперимента я бы сравнивал список не с массивом int[], а с массивом node_t[], так хотя бы расход памяти сопоставимый. Иначе ускорение за счет меньшей сложности алгоритма нейтрализуется бОльшим объемом используемой памяти.

Так накладные расходы списка по памяти - плата за "дешёвые" вставки в середину. Если их убрать, то это уже не список

Я к тому чтобы автор привел тест к более корректному виду, а то взял какой-то девайс с медленной памятью, сравнивает чтение для двух разных типов хранилищ, отличающихся в 5 раз по объему используемой памяти, и делает вывод: " O(log n) смогло победить O(1) "

Имеенно платформозависимая сложность и исследуется...

Если тормозит память, то надо оптимизировать работу с памятью. Как вариант можно ускорить двоичный поиск за счет промежуточного индекса размером в несколько кэшлиний: как понял у автора сортированный массив ~500 элементов, разбиваем его на блоки по 4 (индексы 0-3, 4-7 и т.д.) всего 125 блоков, далее делаем массив куда пишем ID первого устройства в блоке, это и есть индекс, он займет всего 8 кэшлиний. Для поиска сначала ищем в индексе равное или ближайшее меньшее ID, индекс (i) найденного элемента будет номером блока, далее если ID совпало то берем array[i*4], если меньше проверяем ID в array[i*4+2], затем если там ID больше то array[i*4+1] иначе array[i*4+3].

В данном случае индекс с большой вероятностью будет в кэше, т.к. он не большой и к нему будет постоянное обращение.

примерно пятьсот элементов, каждый с 32-битным ID устройства и указателем на данные конфигурации

Вариант попроще: разбить на 2 массива в одном ID в другом указатели, по массиву с ID находим нужный элемент, по его индексу читаем указатель из второго.

Вы описали Sparse set. Но для встраиваемых систем не всегда может подойти т.к ест много памяти.

Это не Sparse set. Просто замена массива из структур {int, указатель} на два массива {int} и {указатель}. Памяти столько же займет.

Сложность обоих O(n). В учебниках написано, что их показатели будут схожими

В моих учебниках писали, что сложность O(n) значит, что при увеличении n в два раза время работы вырастет в два раза.

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

Двоичный поиск занимает O(log n), что теоретически хуже, чем O(1). Так написано в учебниках. Мой преподаватель алгоритмов был бы разочарован. ...
Они подразумевают, что все операции доступа к памяти стоят одинаково. Они подразумевают, что операции выполняются изолированно. Они подразумевают работу на идеальном компьютере, которого не существует.

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

При фиксированном N O-нотация не обещает, что O(1) будет быстрее O(log N). Она обещает, что в пределе при N стремящемся к бесконечности, O(1) будет быстрее.

Доказательство того, что какая-нибудь quick sort в среднем работает за O(N log N), уже довольно сложное, если там пытаться ещё какие-то особенности процессора учитывать, то это будет ад. Вдобавок один и тот же математический алгоритм (типа хеш-таблиц) можно сильно по-разному сделать и получить сильно разную производительность. А ещё в зависимости от входных данных одна и та же хеш-таблица тоже будет иметь разную производительность. Одна и та же хеш-таблица с 70% заполненности и с 10% будет иметь сильно разное количество коллизий и скорость работы.

Например, есть подходы типа perfect hash: https://en.wikipedia.org/wiki/Perfect_hash_function - там коллизий вообще не будет и поиск в таблице гарантированно за O(1)

Я думаю, автор имеет в виду, что не стоит только на О нотацию смотреть при выборе алгоритма.

Ещё нужно учитывать константу, железо и ограничения на n.

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

То есть время загрузки составляло около 100 секунд?

Но в результате загрузчик оказался на 40% быстрее.

То есть новый загрузчик с бинарным поиском выполнялся за 60 секунд, и тогда наступило счастье?

В оригинале не на три порядка, а в три раза.

Будьте внимательнее.

Прикольная статья, за 20 лет я смотрю ничего не изменилось 😃. Крис Касперский по этой теме целую книгу написал "Техника оптимизации программ. Эффективное использование памяти".

https://www.livelib.ru/book/1000024314-tehnika-optimizatsii-programm-effektivnoe-ispolzovanie-pamyati-cdrom-kris-kasperski

За 20 лет всё стало хуже, доля разработчиков понимающих среду, в которой работает их ПО, упала очень сильно. Да и сама среда - это теперь не только железо, операционка и стандартная библиотека, но ещё и четыре слоя виртуализации, а также десяток слоёв разнообразной индирекции.

Вот +1000 про уровни кэша. Если нужна число дробилка, то компактный цикл со сложностью n! даёт фору сложности n2 при n до 8. Добавьте в алгоритм немного жадности и 8 превратится в 80-200.

Дешёвые по числу итерацией алгоритмы почти всегда кушают существенно больше памяти как для команд так и для данных.

Для баз данных история аналогичная. Бинарный индекс обеспечивает логарифмическую сложность при поиске, но вставка и чтение с определённого объёма начинают стоить в разы дороже при использовании индекса.

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

Если нужен реальный пример грамотной работы с кэшем -- замена бинарного дерева поиска на B-дерево, в котором размер узла может быть выставлен в соответствие размеру кэш линии.

Тут достаточно только "примерно пятьсот элементов". Остальную часть статьи можете удалить, это и так известно всем программистам. Напомню, что log2(500) ~ 9, а в хэш-таблице не именно 1 операция, как минимум ещё вычисление хэша, которое опускают при больших N.

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

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации