Как написать код, который лучше всего использует кеш процессора для повышения производительности?

Это может звучать как субъективный вопрос, но я ищу конкретные примеры, с которыми вы могли столкнуться, связанные с этим.

  • Как сделать код, эффективный кэш/кеш-память (больше кеш-хитов, как можно меньше промахов в кеше)? С обеих сторон, кэшем данных и кэшем программ (кэшем команд) то есть, какие вещи в одном коде, связанные с структурами данных и конструкциями кода, нужно позаботиться о том, чтобы сделать его эффективным.

  • Существуют ли какие-либо конкретные структуры данных, которые нужно использовать/избегать, или есть особый способ доступа к членам этой структуры и т.д.... для эффективного использования кеша кода.

  • Существуют ли какие-либо программные конструкции (if, for, switch, break, goto,...), code-flow (для внутри if, если внутри a for и т.д.) следует следовать/избегать в этом вопросе?

Я с нетерпением жду услышать индивидуальный опыт, связанный с созданием эффективного кода кэша. Это может быть любой язык программирования (C, С++, Assembly,...), любая аппаратная цель (ARM, Intel, PowerPC,...), любая ОС (Windows, Linux, S ymbian,...) и т.д..

Разнообразие поможет лучше понять его глубоко.

Ответ 1

Кэш предназначен для уменьшения количества попыток CPU, ожидающего выполнения запроса памяти (избегая задержки памяти), и в качестве второго эффекта, возможно, для уменьшения общего объема данных, которые должны быть (сохранение полосы пропускания памяти).

Методы, позволяющие избежать страдания от задержки на выборку памяти, как правило, являются первым делом, которые нужно учитывать, а иногда и помогает в долгий путь. Ограниченная полоса пропускания памяти также является ограничивающим фактором, особенно для многоядерных и многопоточных приложений, где многие потоки хотят использовать шину памяти. Различные методы помогают решить эту проблему.

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

Улучшение использования кеш-строк помогает в трех аспектах:

  • Он имеет тенденцию вставлять более полезные данные в кеш, существенно увеличивая размер эффективного кэша.
  • Он имеет тенденцию помещать более полезные данные в одну и ту же строку кэша, увеличивая вероятность того, что запрошенные данные можно найти в кеше.
  • Это уменьшает требования к пропускной способности памяти, так как будет меньше выборок.

Общие методы:

  • Использовать меньшие типы данных
  • Организуйте свои данные, чтобы избежать выравнивания отверстий (сортировка ваших элементов структуры путем уменьшения размера является одним из способов)
  • Остерегайтесь стандартного динамического распределителя памяти, который может вводить отверстия и распространять ваши данные в памяти по мере их прогрева.
  • Убедитесь, что все смежные данные фактически используются в горячих циклах. В противном случае рассмотрите разбиение структур данных на горячие и холодные компоненты, чтобы горячие контуры использовали горячие данные.
  • избегать алгоритмов и структур данных, которые демонстрируют неправильные шаблоны доступа, и предпочитают линейные структуры данных.

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

Современный процессор: у него часто есть один или несколько аппаратных префетов. Они тренируются по промахам в кеше и пытаются выявить закономерности. Например, после нескольких промахов к последующим строкам кэша, prefetcher hw начнет извлекать строки кэша в кеш, ожидая потребности приложения. Если у вас есть шаблон обычного доступа, предварительный набор аппаратных средств обычно выполняет очень хорошую работу. И если ваша программа не отображает обычные шаблоны доступа, вы можете улучшить ее, добавив инструкции предварительной выборки самостоятельно.

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

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

Слияние циклов, которые касаются одних и тех же данных (слияние циклов), и использование методов перезаписи, известных как тайлинг или блокировка, все стараются избежать дополнительных извлечений памяти.

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

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

Ответ 2

Я не могу поверить, что на это больше нет ответов. Во всяком случае, один классический пример - итерация многомерного массива "наизнанку":

pseudocode
for (i = 0 to size)
  for (j = 0 to size)
    do something with ary[j][i]

Причина, по которой этот кеш неэффективен, заключается в том, что современные процессоры будут загружать строку кэша с "ближайшими" адресами памяти из основной памяти при доступе к одному адресу памяти. Мы выполняем итерацию через "j" (внешние) строки в массиве во внутреннем цикле, поэтому для каждой поездки через внутренний цикл линия кэша будет выгружена и загружена линией адресов, которая находится рядом с [ j] [i]. Если это будет изменено на эквивалент:

for (i = 0 to size)
  for (j = 0 to size)
    do something with ary[i][j]

Он будет работать намного быстрее.

Ответ 3

Я рекомендую прочитать статью из 9 статей Что каждый программист должен знать о памяти Ульриха Дреппера, если вы заинтересованы в том, как память и программное обеспечение взаимодействуют между собой. Он также доступен как 104-страничный PDF.

Разделы, особенно имеющие отношение к этому вопросу, могут быть Часть 2 (кэши процессора) и Часть 5 (что могут сделать программисты - оптимизация кеша).

Ответ 4

Основные правила на самом деле довольно просты. Там, где становится сложно, как они применяются к вашему коду.

Кэш работает по двум принципам: временная локальность и пространственная локальность. Первая заключается в том, что если вы недавно использовали определенный фрагмент данных, вам, вероятно, понадобится его снова в ближайшее время. Последнее означает, что если вы недавно использовали данные по адресу X, вам, скорее всего, вскоре понадобится адрес X + 1.

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

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

Простым примером является обход 2D-массива, который показал 1800 ответов. Если вы пересекаете его по строке за раз, вы читаете память последовательно. Если вы сделаете это по столбцу, вы прочтете одну запись, затем перейдете в совершенно другое место (начало следующей строки), прочитайте одну запись и снова прыгаете. И когда вы, наконец, вернетесь к первой строке, она больше не будет в кеше.

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

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

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

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

Ответ 5

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

Это в основном фактор с структурами данных с выравниванием по памяти. "Обычная" мудрость гласит, что структуры данных должны быть выровнены на границах слов, потому что ЦПУ может получить доступ только к целым словам, а если слово содержит более одного значения, вам необходимо выполнить дополнительную работу (чтение-изменение-запись вместо простой записи), Но кеши могут полностью аннулировать этот аргумент.

Аналогично, булевский массив Java использует весь байт для каждого значения, чтобы позволить напрямую работать с отдельными значениями. Вы можете уменьшить размер данных в 8 раз, если вы используете фактические биты, но тогда доступ к отдельным значениям становится намного сложнее, требуя операции сдвига бит и маски (класс BitSet делает это для вас). Однако из-за эффектов кеша это может быть значительно быстрее, чем при использовании булева [], когда массив большой. IIRC я однажды добился ускорения в 2 или 3 раза.

Ответ 6

Наиболее эффективной структурой данных для кэша является массив. Кэши работают лучше всего, если ваша структура данных выставляется последовательно, так как CPU считывают целые строки кэша (обычно 32 байта или более) сразу из основной памяти.

Любой алгоритм, который обращается к памяти в случайном порядке, разбивает кэши, потому что для размещения случайно доступной памяти всегда нужны новые строки кэша. С другой стороны, алгоритм, который выполняется последовательно через массив, лучше всего, потому что:

  • Это дает CPU возможность читать вперед, например. спекулятивно класть больше памяти в кеш, к которому будут доступны позже. Это чтение-вперед дает огромный прирост производительности.

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

Ответ 7

Замечание к "классическому примеру" пользователя 1800 ИНФОРМАЦИЯ (слишком длинная для комментария)

Я хотел проверить разницу во времени для двух итерационных порядков ( "outter" и "inner" ), поэтому я сделал простой эксперимент с большим 2D-массивом:

measure::start();
for ( int y = 0; y < N; ++y )
for ( int x = 0; x < N; ++x )
    sum += A[ x + y*N ];
measure::stop();

а второй случай с петлями for заменен.

Более медленная версия ( "x first" ) составляла 0,88 сек, а более быстрая - 0,06 сек. Что сила кеширования:)

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

Ответ 8

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

Ответ 9

Одним из примеров, которые я видел в игровом движке, было перемещение данных из объектов и их собственных массивов. Объект игры, на который распространялась физика, может также содержать множество других данных. Но во время цикла обновления физики весь движок заботился о данных о позиции, скорости, массе, ограничивающей коробке и т.д. Таким образом, все это было помещено в собственные массивы и максимально оптимизировано для SSE.

Таким образом, во время цикла физики данные физики обрабатывались в порядке массива с использованием векторной математики. Объекты игры использовали свой идентификатор объекта как индекс в различных массивах. Это не был указатель, потому что указатели могут стать недействительными, если необходимо переместить массивы.

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

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

Ответ 10

Я могу ответить (2), сказав, что в мире С++ связанные списки могут легко убить кеш процессора. Массивы - лучшее решение, где это возможно. Нет опыта в том, относится ли это к другим языкам, но легко представить, что возникнут те же проблемы.

Ответ 11

Кэш расположен в "строках кеша", и (реальная) память считывается и записывается в куски такого размера.

Структуры данных, которые содержатся в одной строке кэша, поэтому более эффективны.

Аналогично, алгоритмы, которые обращаются к смежным блокам памяти, будут более эффективными, чем алгоритмы, которые перескакивают через память в случайном порядке.

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

Ответ 12

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

Я предлагаю прочитать об оптимизации, есть хорошие ответы на этом сайте. Что касается книг, я рекомендую в "Компьютерные системы: перспектива программистов" , в которых есть тонкий текст о правильном использовании кеша.

(btw - так же плохо, как прошивка кэш-памяти, может быть хуже, если программа paging из жесткого -Драйв...)

Ответ 13

Было много ответов на общие советы, такие как выбор структуры данных, шаблон доступа и т.д. Здесь я хотел бы добавить еще один шаблон проектирования кода, называемый программным конвейером, который использует активное управление кешем.

Идея заключается в заимствовании из других методов конвейерной обработки, например. Консолидация инструкций процессора.

Этот тип шаблона лучше всего относится к процедурам, которые

  • можно разбить на разумные несколько подэтапов, S [1], S [2], S [3],... время выполнения которых примерно сопоставимо с временем доступа к ОЗУ (~ 60-70 нс).
  • принимает пакет ввода и выполняет вышеупомянутые несколько шагов для получения результата.

Возьмем простой случай, когда существует только одна подпроцедура. Обычно код хотел бы:

def proc(input):
    return sub-step(input))

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

def batch_proc(inputs):
    results = []
    for i in inputs:
        // avoids code cache miss, but still suffer data(inputs) miss
        results.append(sub-step(i))
    return res

Однако, как говорилось ранее, если выполнение шага примерно совпадает с временем доступа к ОЗУ, вы можете еще больше улучшить код примерно так:

def batch_pipelined_proc(inputs):
    for i in range(0, len(inputs)-1):
        prefetch(inputs[i+1])
        # work on current item while [i+1] is flying back from RAM
        results.append(sub-step(inputs[i-1]))

    results.append(sub-step(inputs[-1]))

Поток выполнения будет выглядеть так:

  • prefetch (1) запросить CPU для предварительной выборки ввода [1] в кеш, где команда prefetch берет P циклов и возвращает, а в фоновом входе [1] поступает в кеш после циклов R.
  • works_on (0) холодный промах на 0 и работает на нем, который принимает M
  • prefetch (2) выдает другую выборку
  • works_on (1), если P + R <= M, тогда входы [1] должны находиться в кеше уже до этого шага, чтобы избежать промаха кэша данных
  • works_on (2)...

Может быть больше шагов, тогда вы можете спроектировать многоэтапный конвейер, если совпадение времени шагов и латентности доступа к памяти будет сопряжено с небольшим недостатком прошивки кода/данных. Однако этот процесс необходимо настроить во многих экспериментах, чтобы выяснить правильную группировку шагов и время предварительной выборки. Благодаря своим требуемым усилиям он видит большее применение в обработке данных с высокой производительностью/пакетным потоком. Хороший пример кода производства можно найти в дизайне трубопровода DPQK QoS Enqueue: http://dpdk.org/doc/guides/prog_guide/qos_framework.html Глава 21.2.4.3. Enqueue Pipeline.

Более подробную информацию можно найти:

https://software.intel.com/en-us/articles/memory-management-for-optimal-performance-on-intel-xeon-phi-coprocessor-alignment-and

http://infolab.stanford.edu/~ullman/dragon/w06/lectures/cs243-lec13-wei.pdf

Ответ 14

Напишите свою программу для минимального размера. Вот почему не всегда рекомендуется использовать оптимизацию -O3 для GCC. Он занимает больший размер. Часто -O так же хорош, как -O2. Все зависит от используемого процессора. YMMV.

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

Чтобы помочь вам лучше использовать временную/пространственную локальность команд, вы можете изучить, как ваш код преобразуется в сборку. Например:

for(i = 0; i < MAX; ++i)
for(i = MAX; i > 0; --i)

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

Ответ 15

Помимо выравнивания структуры и полей, если ваша структура, если выбрана куча, вы можете использовать распределители, которые поддерживают выровненные распределения; например _aligned_malloc (sizeof (DATA), SYSTEM_CACHE_LINE_SIZE); в противном случае у вас может быть случайное ложное совместное использование; помните, что в Windows куча по умолчанию имеет выравнивание по 16 байт.