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

Вероятнее всего, что самым быстрым для разбиения данных будет такой код (я использую в качестве псевдокода Python; можете представить, что я пишу это на вашем любимом низкоуровневом языке):

groups = [[] for _ in range(n_groups)]
for element in elements:
    groups[element.group].append(element)

Он и в самом деле линеен (то есть асимптотически оптимален), и мы всё равно должны выполнять доступ к произвольным индексам, так что кэш здесь нам ни в чём бы не помог.

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

Решение

Представленный выше алгоритм обеспечивает почти гарантированный промах кэша при каждой итерации. Единственный способ предотвращения промахов — сделать операции доступа к памяти более упорядоченными. Если можно сделать так, чтобы элементы были упорядочены по group, то это замечательно. Если же нет, то вы всё равно можете отсортировать операции доступа до цикла for:

elements.sort(key = lambda element: element.group)

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

elements.sort(key = lambda element: element.group)
groups = [
    group_elements
    for _, group_elements
    in itertools.groupby(elements, key = lambda element: element.group)
]

Существует множество алгоритмов сортировки, учитывающих кэш, но поскольку индексы — это просто целые числа, здесь больше всего подойдёт поразрядная сортировка (radix sort). Среди всех готовых реализаций мне больше всего подошла в Rust radsort.

Ускорение

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

Генераторы

API сортировки пытаются создать впечатление, что данные сортируются на месте, даже если это не так. Для этого нужно, чтобы отсортированные данные явным образом записывались в память в определённом формате. Но если нам нужно выполнять итерации для групп, этого позволяют избежать генераторы или обратные вызовы:

# Предполагаем использование 32-битных индексов
def radix_sort(elements, bits = 32):
    # Базовый случай -- сортировать нечего или все индексы одинаковы
    if len(elements) <= 1 or bits <= 0:
        yield from elements
        return

    # Разбиваем по старшему байту индекса, если ещё его не видели
    buckets = [[] for _ in range(256)]
    for element in elements:
        buckets[(element.index >> max(0, bits - 8)) & 0xff].append(element)

    # Рекурсивно сортируем бакеты
    for bucket in buckets:
        yield from radix_sort(bucket, bits - 8)

Мы можем даже избавиться от этапа groupby и выполнять yield отдельных групп:

# Базовый случай -- сортировать нечего или все индексы одинаковы
if bits <= 0:
    if elements:
        # Группа!
        yield elements
    return

Перераспределения

Ещё одна проблема этого кода заключается в том, что он постоянно выполняет перераспределения массивов bucket при append. Это приводит к тому, что memcpy вызывается чаще необходимого, и это плохо для кэша. Часто для решения этой проблемы размеры вычисляют заранее:

def get_bucket(element):
    return (element.index >> max(0, bits - 8)) & 0xff

sizes = Counter(map(get_bucket, elements))

# На самом деле, Python не может оставлять место для списков, но притворимся, что `reserve` всё равно это делает.
# В C++ это `std::vector::reserve`. В Rust это `Vec::with_capacity`.
buckets = [reserve(sizes[i]) for i in range(256)]
for element in elements:
    buckets[get_bucket(element)].append(element)

Однако это требует двух итераций, а нам бы в идеале хотелось, чтобы код выполнялся за один проход. Если индекс случаен, то мы можем убить одним выстрелом двух зайцев: приблизительно оценить размер каждого бакета как len(elements) / 256 и зарезервировать пространство под них. Если наша приблизительная оценка будет заниженной, то возникнут остатки, которые можно сохранить в отдельном маленьком хранилище:

class Bucket:
    reserved: list
    leftovers: list

    def __init__(self, capacity):
        self.reserved = reserve(capacity) # псевдокод
        self.leftovers = []

    def append(self, element):
        if len(self.reserved) < self.reserved.capacity(): # псевдокод
            self.reserved.append(element)
        else:
            self.leftovers.append(element)

    def __len__(self):
        return len(self.reserved) + len(self.leftovers)

    def __iter__(self):
        yield from self.reserved
        yield from self.leftovers

Здесь вероятность распределения ведёт себя честно: при больших входных данных лишь крошечный процент элементов переполняется и попадает в leftovers, поэтому лишние затраты памяти относительно малы, перераспределения при отправке в leftovers быстры, а разбивка на бакеты (и итерации по бакетам) удобна для кэша.

Разбиение

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

Базовый случай

Переход к прямолинейному алгоритму при малом объёме входных данных повышает производительность, потому что в этом случае более явственно проявляется влияние кода O(n log n). Однако поскольку radix_sort рекурсивна, мы можем выполнять эту проверку на каждом уровне рекурсии, обеспечивая выигрыш даже при большом объёме данных:

# Базовый случай -- достаточно мал, чтобы использовать прямолинейный алгоритм
if len(elements) <= CUTOFF or bits <= 8:
    counts = [0] * 256
    for element in elements:
        counts[element.index & 0xff] += 1

    groups = [[] for _ in range(256)]
    for element in elements:
        groups[get_bucket(element)].append(element)

    for group in groups:
        if group:
            yield group
    return

Оптимальное значение CUTOFF сильно зависит от машины. Оно зависит от относительной скорости уровней кэша и RAM, а также от размеров кэша и типов данных. В случае 64-битных integer я встречала машины, для которых оптимальным значением было 50k200k и 1M. Наилучший способ определить его — бенчмарк в среде выполнения; это приемлемое решение для длительно работающего ПО, например для баз данных.

Бенчмарк

Структура

Вот небольшой бенчмарк.

Входные данные — это массив случайных 64-битных integer. Мы хотим сгруппировать их по простому хэшу, основанному на умножении, и выполним простой анализ бакетов, допустим, вычислим сумму минимумов среди бакетов. (В реальности бакеты бы использовались ниже по конвейеру каким-то другим дружественным к кэшу алгоритмом.)

Мы сравним две реализации:

  1. Прямолинейный алгоритм с оптимизированными распределениями.

  2. Группирование на основании поразрядной сортировки, со всеми представленными выше оптимизациями и оптимальной отсечкой (cutoff).

Средний размер группы равен 10.

Код выложен на GitHub.

Результаты

Относительная эффективность оптимизированного алгоритма растёт с увеличением объёма данных. И прямолинейный, и оптимизированный алгоритмы в конечном итоге останавливаются на фиксированной скорости обработки. В зависимости от машины, в пределе улучшения могут составлять от 2,5 до 9 раз.

Результаты (AYM — это разные устройства):

Заключение

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

По крайней мере, если вы работаете с big data, то про этот трюк стоит помнить.

Есть и ещё один вывод: все знают, что при работе с данными на диске не стоит просто отображать их в память и выполнять типичные алгоритмы работы с памятью. Это возможно, но производительность будет плохой. Урок заключается в том, что это применимо также к RAM и кэшу: если у вас больше, допустим, 32 МиБ данных, то нужно серьёзно задуматься над разбиением данных или над переходом к алгоритмам работы с внешней памятью.

Комментарии (21)


  1. vadimr
    22.12.2024 15:14

    Это всё очень сильно проявляется у процессоров с архитектурой Intel, и довольно слабо – на Apple Silicon, где память впаяна в процессор и кеш не является бутылочным горлышком.


    1. melodictsk
      22.12.2024 15:14

      Латентность памяти в эплах не меньше чем на х86.


      1. vadimr
        22.12.2024 15:14

        Пропускная способность больше.


        1. GidraVydra
          22.12.2024 15:14

          Они там пропускную способность крайне интересно считают...


          1. vadimr
            22.12.2024 15:14

            Да неважно, как считают. Эти вещи легко проверить на практике.


        1. tzlom
          22.12.2024 15:14

          пропускная способность != латентность, время ожидания блока считается как `латентность+размер блока/пропускная способность`

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


          1. vadimr
            22.12.2024 15:14

            Всё верно, но в итоге большой блок приедет быстрее.

            Я проводил сравнения, и получилось, что если на процессоре Intel последовательный и непоследовательный доступ к ячейкам памяти различался по скорости примерно в два раза, то на Apple различие было в пределах 1%.


            1. tzlom
              22.12.2024 15:14

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


        1. MessirB
          22.12.2024 15:14

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


          1. vadimr
            22.12.2024 15:14

            Ну собственно это часть архитектуры (хотя вопрос не только в количестве каналов). Разумеется, система команд тут не причём.


    1. WASD1
      22.12.2024 15:14

      Я думал что такого мифа на самом деле нет.
      Но вы меня переубедили - первый комментарий подтверждает, что миф есть.

      латентность разведённой памяти не сильно ниже DDR (и гораздо больше зависит от стратегии в контроллере памяти, чем от физической схемы).
      Пропускная способность может быть выше, но приведённые примеры намного больше упираются в латентность (и prefetch / eviction кэшей).


    1. SadOcean
      22.12.2024 15:14

      Вроде дело не в расстоянии, оно не велико, дело в частотах.
      Время обращения к кешу L1 на 2 порядка, и к L2 на один порядок меньше, чем к ОП потому что чтение из памяти завязано на частоту памяти и циклы чтения/записи.


  1. GCU
    22.12.2024 15:14

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


    1. tzlom
      22.12.2024 15:14

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


      1. nerudo
        22.12.2024 15:14

        Если сортировать пузырьком, то обращение всегда будет к соседним элементам!


    1. purplesyringa
      22.12.2024 15:14

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

      Кажется.


  1. AVaTar123
    22.12.2024 15:14

    Я как раз занимаюсь в своём хобби-проекте переносом стратегии "B-tree на HDD" на "B-tree в RAM". Мне там постоянно нужны три функции: Поиск, Вставка и Удаление. А данных там - 2^{32}. И скорость их обработки нужна максимальная. Так что подход, и алгоритм задачи, очень близок к этой статье. Пока я ещё не реализовал эту задачу в программе, но проектирую уже его детали и нюансы в прикладной области. Чувствую, что придётся тестировать по многообразию параметров - как исходных, так и промежуточных (в схеме разделения на непрерывные блоки, организации их связей между собой, и т.д). Ещё и память надо оптимизировать...

    Хотелось бы сразу сделать идеально, но знаю, что так не бывает. Только практика (тесты) покажут, как оно на самом деле. Но вот по теоретической части, в которой я ещё только новичок, думаю можно избежать неявных косяков. Где бы о таком узнать, почитать?


    1. zzzzzzerg
      22.12.2024 15:14

      В этом году был доклад на эту тему - B-tree Optimisation for Numerical In-memory Indexes | Talk at С++ Russia 2024 (слайды cpp-russia-03-06.pdf)


      1. AVaTar123
        22.12.2024 15:14

        Спасибо, изучаю.


  1. Akela_wolf
    22.12.2024 15:14

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


    1. AVaTar123
      22.12.2024 15:14

      Миф о всемогуществе RAM. :)

      Многие считают, что если [многочисленные] данные уже в оперативной памяти, то их обработка (последовательно, или в случайном порядке) пойдёт уже на максимальной скорости. Такая вот логическая связка, возникшая во времена, когда "640Кб хватало для всего".

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