Структуры данных на практике. Глава 1: Разрыв в производительности |
||||||||||||||||||||
|
МЕНЮ Главная страница Поиск Регистрация на сайте Помощь проекту Архив новостей ТЕМЫ Новости ИИ Голосовой помощник Разработка ИИГородские сумасшедшие ИИ в медицине ИИ проекты Искусственные нейросети Искусственный интеллект Слежка за людьми Угроза ИИ Атаки на ИИ Внедрение ИИИИ теория Компьютерные науки Машинное обуч. (Ошибки) Машинное обучение Машинный перевод Нейронные сети начинающим Психология ИИ Реализация ИИ Реализация нейросетей Создание беспилотных авто Трезво про ИИ Философия ИИ Big data Работа разума и сознаниеМодель мозгаРобототехника, БПЛАТрансгуманизмОбработка текстаТеория эволюцииДополненная реальностьЖелезоКиберугрозыНаучный мирИТ индустрияРазработка ПОТеория информацииМатематикаЦифровая экономика
Генетические алгоритмы Капсульные нейросети Основы нейронных сетей Промпты. Генеративные запросы Распознавание лиц Распознавание образов Распознавание речи Творчество ИИ Техническое зрение Чат-боты Авторизация |
2026-01-13 12:25 Часть I: Основы
Загадка Два часа утра. Я смотрю на совершенно нелогичные данные профилирования. В процессе работы над загрузчиком для SoC RISC-V у нас возникла проблема с производительностью. Загрузчик должен был искать конфигурации устройств в таблице: примерно пятьсот элементов, каждый с 32-битным ID устройства и указателем на данные конфигурации. Всё просто. Мой коллега реализовал эту систему с помощью хэш-таблицы. «Поиск за O(1), — сказал он уверенно, — лучше уже некуда». Но загрузчик работал медленно. Недопустимо медленно. Время загрузки должно было находиться в пределах 100 мс, но превышало это значение на три порядка. Я попробовал использовать очевидную оптимизацию: заменить хэш-таблицу двоичным поиском по отсортированному массиву. Двоичный поиск занимает O(log n), что теоретически хуже, чем O(1). Так написано в учебниках. Мой преподаватель алгоритмов был бы разочарован. Но в результате загрузчик оказался на 40% быстрее. Как O(log n) смогло победить O(1)? Что происходит? Расследование Я запустил Linux-инструмент профилирования производительности Вот и ответ: хэш-таблица обеспечивает частоту промаха кэша в 71,5%. При двоичном поиске промахов оказывается всего 21,1%. В этой системе каждый промах кэша стоит примерно сто тактов CPU. Хэш-таблица тратила основную часть времени на ожидание памяти. Хэш-таблица O(1) выполняла меньше операций, но каждая операция была дорогой. Двоичный поиск O(log n) выполнял больше операций, но каждая операция была дешёвой. «Железо» победило алгоритм. Почему это важно Моя серия статей посвящена этому разрыву — разнице между тем, что написано в учебниках и тем, что происходит на самом деле, когда код работает на реальном кремнии. На традиционных курсах по структурам данных учат подходить к алгоритмам с точки зрения сложности «O» большого:
Это полезные абстракции, позволяющие нам рассуждать об алгоритмах в целом. Но они не всеобъемлющи. Они подразумевают, что все операции доступа к памяти стоят одинаково. Они подразумевают, что операции выполняются изолированно. Они подразумевают работу на идеальном компьютере, которого не существует. У реальных компьютеров есть:
А если вы работаете со встраиваемыми системами, то ограничений ещё больше:
Модель реальной производительности Вот более подробная ментальная модель современных компьютеров: Время = Операции ? (Вычислительные затраты + Затраты памяти) Где:
При реализации многих алгоритмов преобладают затраты памяти. Давайте покажем это на примере реальных значений типичной встраиваемой системы с RISC-V:
Единственный промах кэша может стоить до 100 операций с регистрами. Из этого следует:
Наш первый бенчмарк: массив против связанного списка Давайте добавим конкретики, проведя простой эксперимент. Сравним два способа суммирования 100000 целых чисел:
Сложность обоих O(n). В учебниках написано, что их показатели будут схожими. Посмотрим, что происходит на самом деле. Вот версия с массивом: И версия со связанным списком: Воспользуемся нашим фреймворком бенчмаркинга (о котором мы подробно поговорим в Главе 3): Один и тот же алгоритм (последовательное суммирование), одинаковая сложность O(n), но массив в 2,5 раза быстрее. Почему? Давайте взглянем на поведение кэша: Паттерн доступа к массиву: Паттерн доступа к связанному списку: Массив выигрывает благодаря пространственной локальности — при доступе к Связанный список страдает от следования за указателями — каждый узел распределяется по отдельности Иерархия памяти Чтобы понять, почему кэш так важен, нужно разобраться с иерархией памяти. Современные компьютеры — это уже не простая модель «CPU + RAM», которую дают на вводных курсах. Больше они похожи на такую схему: Каждый уровень:
Разрыв в скорости огромен. Для процессора RISC-V частотой 1 Гц:
Для сравнения: если бы для доступа к кэшу L1 требовалась 1 секунда, то для доступа к DRAM требовалось бы 50 секунд. Линии кэша: фундаментальная единица измерения Крайне важно понимать, что CPU получает не отдельные байты, а линии кэша. Линия кэша обычно содержит 64 байт. При доступе к одному байту CPU получает весь 64-байтный блок, содержащий этот байт. Это влечёт за собой очень серьёзные последствия: Хорошие: при доступе к соседним данным они уже находятся в кэше (пространственная локальность) Плохие: при каждом получении разбросанных данных мы теряем впустую 63 байта Ещё хуже: если структура данных устроена плохо, вам придётся расплачиваться за данные, которые вы не используете Упреждающая выборка: оборудование пытается нам помочь В современных CPU есть устройства для упреждающей выборки (prefetcher), пытающиеся спрогнозировать, к чему будет выполнять доступ в ближайшее время. Они хорошо справляются с распознаванием простых паттернов: Последовательный доступ: prefetcher обожает его Доступ с периодическим шагом: prefetcher может с ним справиться Следование по указателям: prefetcher сдаётся Именно поэтому связанные списки работают так медленно — prefetcher ничем не может помочь. Каждое разыменование указателя становится для него неожиданностью. Встраиваемые системы: ещё более жёсткие ограничения При работе со встраиваемыми системами ситуация ещё более серьёзная: Типичный микроконтроллер RISC-V:
При кэше L1 размером 16 КБ весь рабочий набор должен умещаться в 16 КБ, иначе пострадает кэш. Для сравнения:
Именно поэтому разработчики встраиваемых систем так одержимы размером и конфигурацией структур данных. Важен каждый байт. Реальное время Во встраиваемых системах нас часто волнует не средний, а наихудший сценарий. Для примера возьмём контур системы управления, работающий с частотой 1 кГц (период 1 мс):
Если алгоритм работает с кэшем непредсказуемым образом, то гарантировать сроки выполнения в реальном времени невозможно. Поэтому в системах реального времени часто предпочтительны:
Даже если они «медленнее» в среднем сценарии с точки зрения «О» большого. Что вы узнаете из этой серии статей Она научит вас рассуждать о структурах данных с точки зрения реалий «железа»: Часть I: Основы
Часть II: Основные структуры данных
Часть III: Деревья и иерархии
Часть IV: Дополнительные темы
Часть V: Изучение примеров
Каждая глава будет содержать:
Требования и подготовка Чтобы извлечь из этой серии максимальную пользу, вы должны: Знать:
Иметь:
Необязательно, но полезно:
Что нас ждёт дальше В следующей главе мы глубже изучим иерархию памяти. Вы узнаете:
В Главе 3 мы создадим готовый фреймворк для бенчмаркинга, тот же самый, который применяется для всех измерений в этой серии статей. К концу Части I вы получите все необходимые инструменты и знания для анализа реальной производительности любой структуры данных. Давайте приступим. Подведём итог Загадка, которую я исследовал в два часа ночи, была решена. Двоичный поиск с O(log n) обгоняет хэш-таблицу с O(1) на 40%, потому что работа с кэшем здесь важнее, чем алгоритмическая сложность. 71,5% промахов кэша у хэш-таблицы и 21,1% промахов у двоичного поиска объяснили всё. «Железо» оказалось важнее алгоритма. Основные выводы:
Разрыв в производительности:
Источник: habr.com Комментарии: |
|||||||||||||||||||