На современных компьютерах только структуры памяти самого низкого уровня ( регистры ) могут перемещать данные за один такт. Однако регистры очень дороги, и большинство ядер компьютеров имеют менее нескольких десятков регистров (всего от нескольких сотен до, может быть, тысячи байт ). На другом конце спектра памяти ( DRAM ) память очень дешевая (т. Е. Буквально в миллионы раз дешевле ), но после запроса на получение данных занимает сотни циклов. Чтобы преодолеть этот разрыв между супер быстрым и дорогим и супер медленным и дешевым, кэш-память, названный L1, L2, L3 в уменьшении скорости и стоимости. Идея состоит в том, что большая часть исполняемого кода будет часто использовать небольшой набор переменных, а остальные (гораздо больший набор переменных) нечасто. Если процессор не может найти данные в кеше L1, он выглядит в кеше L2. Если не там, то кеш L3, а если нет, то основная память. Каждое из этих «промахов» дорого по времени.
(Аналогия заключается в том, что кэш-память относится к системной памяти, так как системная память является слишком жестким диском. Память на жестком диске очень дешевая, но очень медленная).
Кэширование является одним из основных методов уменьшения влияния задержки . Перефразируя Херба Саттера (см. Ссылки ниже): увеличить пропускную способность очень просто, но мы не можем выкупить выход из задержки .
Данные всегда извлекаются через иерархию памяти (от наименьшего == к быстрейшему к медленному). Кэш / промах обычно относится к хиту / промаху в самом высоком уровне кэша - памяти в CPU - на самом высоком уровне , я имею в виду самой большой == медленным. Частота обращений к кешу имеет решающее значение для производительности, так как каждая потеря кеша приводит к извлечению данных из ОЗУ (или хуже ...), что занимает много времени (сотни циклов для ОЗУ, десятки миллионов циклов для жесткого диска). Для сравнения, чтение данных из кэша (самого высокого уровня) обычно занимает всего несколько циклов.
В современных компьютерных архитектурах узкое место в производительности покидает процессор (например, доступ к ОЗУ или выше). Это будет только ухудшаться со временем. Увеличение частоты процессора в настоящее время более не актуально для повышения производительности. Проблема в доступе к памяти. Поэтому в настоящее время усилия по проектированию аппаратного обеспечения в процессорах сосредоточены на оптимизации кэшей, предварительной выборке, конвейерах и параллелизме. Например, современные процессоры тратят около 85% кристаллов на кеши и до 99% на хранение / перемещение данных!
На эту тему можно сказать очень много. Вот несколько замечательных ссылок о кешах, иерархиях памяти и правильном программировании:
Страница Агнера Фога . В его превосходных документах вы можете найти подробные примеры, охватывающие языки, начиная от ассемблера до C ++.
Очень важный аспект кода, дружественного к кешу, связан с принципом локальности , цель которого - разместить связанные данные близко к памяти, чтобы обеспечить эффективное кэширование. Что касается кэша ЦП, важно знать о строках кэша, чтобы понять, как это работает: как работают строки кэша?
Следующие конкретные аспекты имеют большое значение для оптимизации кэширования:
Временная локализация: когда к определенной ячейке памяти обращались, вполне вероятно, что к той же локации снова будет получен доступ в ближайшем будущем. В идеале эта информация все еще будет кешироваться в этот момент.
Пространственная локализация : это относится к размещению связанных данных близко друг к другу. Кэширование происходит на многих уровнях, а не только в процессоре. Например, когда вы читаете из ОЗУ, обычно выбирается больший кусок памяти, чем было запрошено, потому что очень часто программе требуются эти данные в ближайшее время. Кэши HDD придерживаются той же линии мысли. Специально для кэшей ЦП важно понятие строк кэша .
Простой пример кеш-дружественных и кеш-недружественных C ++«S std::vectorпротив std::list. Элементы a std::vectorхранятся в непрерывной памяти, и, таким образом, доступ к ним намного более удобен для кэша, чем доступ к элементам в a std::list, который хранит свой контент повсюду. Это связано с пространственной локализацией.
Очень хорошая иллюстрация этого дана Бьярном Страуструпом в этом клипе на YouTube (спасибо @Mohammad Ali Baydoun за ссылку!).
Не пренебрегайте кешем в структуре данных и разработке алгоритмов
По возможности старайтесь адаптировать свои структуры данных и порядок вычислений таким образом, чтобы максимально использовать кеш. Общепринятым методом в этом отношении является блокировка кэша (версия Archive.org) , которая имеет чрезвычайно важное значение в высокопроизводительных вычислениях (см., Например, ATLAS ).
Знать и использовать неявную структуру данных
Еще один простой пример, который иногда забывают многие в этой области, - это колонка-майор (напр. Фортран,MATLAB) против упорядочения по ряду строк (напр. с,C ++) для хранения двухмерных массивов. Например, рассмотрим следующую матрицу:
1234
При упорядочении основных строк это сохраняется в памяти как 1 2 3 4; при упорядочении по основным столбцам это будет храниться как 1 3 2 4. Легко видеть, что реализации, которые не используют этот порядок, быстро столкнутся (легко избежать!) С проблемами кэширования. К сожалению, я вижу вещи , как это очень часто в моей области (машинном обучении). @MatteoItalia показал этот пример более подробно в своем ответе.
При извлечении определенного элемента матрицы из памяти также будут извлечены элементы рядом с ним и сохранены в строке кэша. Если упорядочение используется, это приведет к меньшему количеству обращений к памяти (поскольку следующие несколько значений, которые необходимы для последующих вычислений, уже находятся в строке кэша).
Для простоты предположим, что кэш содержит одну строку кеша, которая может содержать 2 матричных элемента, и что, когда данный элемент выбирается из памяти, следующий тоже. Скажем, мы хотим взять сумму по всем элементам в приведенной выше примере матрицы 2x2 (давайте назовем ее M):
Использование порядка (например, изменение индекса столбца первым в C ++):
В этом простом примере использование порядка приблизительно удваивает скорость выполнения (поскольку доступ к памяти требует гораздо больше циклов, чем вычисление сумм). На практике разница в производительности может быть намного больше.
Избегайте непредсказуемых веток
Современные архитектуры имеют конвейеры и компиляторы становятся очень хорошими в переупорядочении кода, чтобы минимизировать задержки из-за доступа к памяти. Когда ваш критический код содержит (непредсказуемые) ветви, трудно или невозможно предварительно выбрать данные. Это косвенно приведет к большему количеству промахов в кеше.
В контексте C ++, virtualМетоды представляют собой спорный вопрос в отношении промахов кэша (общий консенсус , что их следует избегать , если это возможно с точки зрения производительности). Виртуальные функции могут вызывать пропадание кеша при поиске, но это происходит только в том случае, если конкретная функция вызывается не часто (в противном случае она, вероятно, будет кэшироваться), поэтому некоторые считают это проблемой. Для справки об этой проблеме, посмотрите: Какова стоимость производительности наличия виртуального метода в классе C ++?
Общие проблемы
Общая проблема в современных архитектурах с многопроцессорным кэшем называется ложным разделением . Это происходит, когда каждый отдельный процессор пытается использовать данные в другой области памяти и пытается сохранить их в той же строке кэша . Это приводит к тому, что строка кэша, содержащая данные, которые может использовать другой процессор, перезаписывается снова и снова. По сути, разные потоки заставляют друг друга ждать, вызывая пропуски кэша в этой ситуации. Смотрите также (спасибо @Matt за ссылку): как и когда выровнять по размеру строки кэша?
Чрезвычайным признаком плохого кеширования в оперативной памяти (что, вероятно, не является тем, что вы имеете в виду в этом контексте) является так называемая порка . Это происходит, когда процесс непрерывно генерирует сбои страниц (например, обращается к памяти, которой нет на текущей странице), которые требуют доступа к диску.
возможно, вы могли бы немного расширить ответ, объяснив, что в многопоточном коде данные также могут быть слишком локальными (например, ложное совместное использование)
TemplateRex
2
Кэш может иметь столько уровней, сколько разработчики чипа считают полезными. Обычно они балансируют скорость и размер. Если бы вы могли сделать ваш кэш L1 таким же большим, как L5, и столь же быстрым, вам понадобился бы только L1.
Рафаэль Баптиста
24
Я понимаю, что пустые сообщения о согласии не одобряются в StackOverflow, но это, честно говоря, самый ясный, лучший ответ, который я когда-либо видел. Отличная работа, Марк.
Джек Эйдли,
2
@JackAidley спасибо за вашу похвалу! Когда я увидел объем внимания, который получил этот вопрос, я подумал, что многих людей может заинтересовать несколько обширное объяснение. Я рад, что это полезно.
Марк Клазен
1
Что вы не упомянули, так это то, что структуры данных, дружественные к кешу, спроектированы так, чтобы вписываться в строку кеша и выравниваться по памяти, чтобы оптимально использовать строки кеша. Отличный ответ, хотя! классно.
Мэтт
140
В дополнение к ответу @Marc Claesen, я думаю, что поучительным классическим примером недружественного кэшу кода является код, который сканирует двумерный массив C (например, растровое изображение) по столбцам, а не по строкам.
Элементы, которые являются смежными в ряду, также являются смежными в памяти, таким образом, последовательный доступ к ним означает доступ к ним в порядке возрастания памяти; это дружественно к кешу, поскольку кеш имеет тенденцию предварительно выбирать смежные блоки памяти.
Вместо этого доступ к таким элементам по столбцам является неприемлемым для кэша, поскольку элементы в одном и том же столбце находятся в памяти на расстоянии друг от друга (в частности, их расстояние равно размеру строки), поэтому при использовании этого шаблона доступа вы прыгают в памяти, потенциально тратя силы на кеширование элементов, находящихся поблизости в памяти.
И все, что нужно, чтобы испортить представление, это пойти от
// Cache-friendly version - processes pixels which are adjacent in memoryfor(unsignedint y=0; y<height;++y){for(unsignedint x=0; x<width;++x){... image[y][x]...}}
в
// Cache-unfriendly version - jumps around in memory for no good reasonfor(unsignedint x=0; x<width;++x){for(unsignedint y=0; y<height;++y){... image[y][x]...}}
Этот эффект может быть весьма значительным (на несколько порядков по скорости) в системах с небольшими кэшами и / или работающих с большими массивами (например, 10+ мегапикселей 24-битных изображения на современных машинах); по этой причине, если вам приходится делать много вертикальных сканирований, часто лучше сначала повернуть изображение на 90 градусов, а затем выполнить различный анализ, ограничив неприемлемый для кэша код только поворотом.
Современные графические редакторы используют плитки в качестве внутреннего хранилища, например блоки размером 64x64 пикселей. Это гораздо более удобно для кэша для локальных операций (размещение мазка, запуск фильтра размытия), потому что большую часть времени соседние пиксели находятся в памяти в обоих направлениях.
Maxy
Я попытался рассчитать аналогичный пример на моей машине, и обнаружил, что времена были одинаковыми. Кто-нибудь еще пытался определить время?
gsingh2011
@ I3arnon: нет, первый - дружественный к кешу, поскольку обычно в C-массивах хранятся в основном порядке строк (конечно, если ваше изображение по какой-то причине хранится в основном порядке столбцов, обратное верно).
Matteo Italia
1
@ Gauthier: да, первый фрагмент хороший; Я думаю, что когда я писал это, я думал так: «Все, что нужно [чтобы испортить работоспособность приложения], - это перейти от ... к ...»
Matteo Italia
88
Оптимизация использования кэша в основном сводится к двум факторам.
Справочная информация
Первым фактором (на который уже ссылались другие) является местность ссылки. Местность ссылки действительно имеет два измерения: пространство и время.
пространственная
Пространственное измерение также сводится к двум вещам: во-первых, мы хотим плотно упаковать нашу информацию, чтобы больше информации поместилось в этой ограниченной памяти. Это означает (например), что вам нужно значительное улучшение вычислительной сложности для обоснования структур данных на основе небольших узлов, соединенных указателями.
Во-вторых, мы хотим, чтобы информация, которая будет обрабатываться вместе, также находилась вместе. Типичный кеш работает в «строках», что означает, что при доступе к некоторой информации другая информация по близлежащим адресам будет загружена в кеш с той частью, к которой мы прикоснулись. Например, когда я касаюсь одного байта, кэш может загружать 128 или 256 байтов рядом с этим. Чтобы воспользоваться этим преимуществом, вы, как правило, хотите, чтобы данные располагались так, чтобы максимизировать вероятность того, что вы также будете использовать эти другие данные, которые были загружены одновременно.
Для очень простого примера это может означать, что линейный поиск может быть намного более конкурентоспособным с бинарным поиском, чем вы ожидаете. После того, как вы загрузили один элемент из строки кэша, использование оставшихся данных в этой строке кэша практически бесплатно. Бинарный поиск становится заметно быстрее только тогда, когда данные настолько велики, что бинарный поиск уменьшает количество строк кэша, к которым вы обращаетесь.
Время
Измерение времени означает, что когда вы выполняете некоторые операции с некоторыми данными, вы хотите (насколько это возможно) выполнять все операции с этими данными одновременно.
Так как вы добавили это как C ++, я укажу на классический пример относительно кэш-недружелюбным дизайн: std::valarray. valarrayПерегрузки наиболее арифметические операции, так что можно (например) сказать a = b + c + d;(где a, b, cиd все valarrays) , чтобы сделать поэлементное сложение этих массивов.
Проблема в том, что он проходит через одну пару входных данных, помещает результаты во временную, проходит через другую пару входных данных и так далее. При большом количестве данных результат одного вычисления может исчезнуть из кэша, прежде чем он будет использован в следующем вычислении, поэтому мы заканчиваем чтение (и запись) данных несколько раз, прежде чем мы получим наш конечный результат. Если каждый элемент конечного результата будет что - то вроде (a[n] + b[n]) * (c[n] + d[n]);, мы обычно предпочитаем читать каждый a[n], b[n], c[n]и d[n]один раз, делать вычисления, записываем результат, приращение nи повторите «сезам мы сделали. 2
Совместное использование линии
Вторым важным фактором является избегание разделения линии. Чтобы понять это, нам, вероятно, нужно сделать резервную копию и посмотреть, как организованы кэши. Самая простая форма кеша - это прямое сопоставление. Это означает, что один адрес в основной памяти может храниться только в одном конкретном месте в кэше. Если мы используем два элемента данных, которые отображаются в одно и то же место в кэше, это работает плохо - каждый раз, когда мы используем один элемент данных, другой должен быть сброшен из кэша, чтобы освободить место для другого. Остальная часть кэша может быть пустой, но эти элементы не будут использовать другие части кэша.
Чтобы предотвратить это, большинство кэшей называется так называемым «множеством ассоциаций». Например, в четырехстороннем ассоциативно-множественном кэше любой элемент из основной памяти может храниться в любом из 4 различных мест в кэше. Таким образом, когда кэш собирается загрузить элемент, он ищет наименее использованные 3 элемента среди этих четырех, сбрасывает его в основную память и загружает новый элемент на его место.
Проблема, вероятно, довольно очевидна: для кеша с прямым отображением два операнда, которые оказываются в одной и той же ячейке кеша, могут привести к плохому поведению. N-сторонний наборно-ассоциативный кэш увеличивает число от 2 до N + 1. Организация кэша в несколько «способов» требует дополнительных схем и, как правило, работает медленнее, поэтому (например) ассоциативный кэш с 8192 путями редко является хорошим решением.
В конечном счете, этот фактор труднее контролировать в переносимом коде. Ваш контроль над тем, где размещены ваши данные, обычно довольно ограничен. Хуже того, точное сопоставление адреса с кешем варьируется между аналогичными процессорами. В некоторых случаях, однако, может быть целесообразно сделать такие вещи, как выделение большого буфера, а затем использовать только части того, что вы выделили, чтобы обеспечить защиту данных от совместного использования одних и тех же строк кэша (даже если вам, вероятно, потребуется определить точный процессор и действовать соответственно, чтобы сделать это).
Ложный обмен
Есть еще один связанный элемент под названием «ложный обмен». Это возникает в многопроцессорной или многоядерной системе, где два (или более) процессора / ядра имеют отдельные данные, но попадают в одну и ту же строку кэша. Это заставляет два процессора / ядра координировать их доступ к данным, даже если у каждого есть свой отдельный элемент данных. Особенно, если они изменяют данные поочередно, это может привести к значительному замедлению, поскольку данные должны постоянно перемещаться между процессорами. Это не может быть легко вылечено путем организации кэша в несколько «способов» или что-то в этом роде. Основной способ предотвратить это - гарантировать, что два потока редко (предпочтительно никогда) не изменяют данные, которые могут находиться в одной и той же строке кэша (с одинаковыми предостережениями относительно сложности управления адресами, по которым распределяются данные).
Те, кто хорошо знает C ++, могут задаться вопросом, открыта ли она для оптимизации с помощью чего-то вроде шаблонов выражений. Я почти уверен, что ответ таков: да, это можно было бы сделать, и если бы это было так, это, вероятно, было бы довольно существенной победой. Однако я не знаю, чтобы кто-нибудь сделал это, и, учитывая, как мало valarrayпривыкаешь, я был бы, по крайней мере, немного удивлен, увидев, что кто-то так поступит.
В случае, если кому-то интересно, как valarray(разработан специально для производительности) это может быть ужасно неправильно, это сводится к одному: он действительно был разработан для машин, таких как старые Crays, которые использовали быструю оперативную память и не использовали кеш. Для них это действительно был почти идеальный дизайн.
Да, я упрощаю: большинство кешей на самом деле не точно измеряют наименее недавно использованный элемент, но они используют некоторую эвристику, которая должна быть близка к ней без необходимости сохранять полную метку времени для каждого доступа.
Мне нравятся дополнительные фрагменты информации в вашем ответе, особенно valarrayпример.
Марк Клазен
1
+1 Наконец: простое описание заданной ассоциативности! РЕДАКТИРОВАТЬ дальше: Это один из наиболее информативных ответов на SO. Спасибо.
инженер
32
Добро пожаловать в мир Data-Oriented Design. Основная мантра - Сортировка, Устранение филиалов, Пакет, Устранение virtualзвонков - все шаги к лучшей локации.
Если Nон большой, например, если N * sizeof(elemType)он больше размера кэша, то каждый отдельный доступ к нему src2[k][j]будет пропущен.
Есть много разных способов оптимизировать это для кеша. Вот очень простой пример: вместо чтения одного элемента на строку кэша во внутреннем цикле используйте все элементы:
Если размер строки кэша составляет 64 байта, и мы работаем с 32-разрядными (4 байтами) числами с плавающей запятой, то в каждой строке кэша имеется 16 элементов. А количество пропущенных кешей благодаря этому простому преобразованию уменьшается примерно в 16 раз.
Более сложные преобразования работают с 2D-фрагментами, оптимизируются для нескольких кэшей (L1, L2, TLB) и т. Д.
Людям, которые читают это, также может быть интересна моя статья о умножении матриц, где я протестировал «дружественный к кешу» алгоритм ikj и недружественный алгоритм ijk, умножив две матрицы 2000x2000.
Мартин Тома
3
k==;Я надеюсь, что это опечатка?
TrebledJ
13
Сегодня процессоры работают со многими уровнями каскадных областей памяти. Таким образом, процессор будет иметь кучу памяти, которая находится на самом чипе процессора. У него очень быстрый доступ к этой памяти. Существуют разные уровни кэша, каждый из которых медленнее (и больше), чем следующий, до тех пор, пока вы не доберетесь до системной памяти, которая не находится на центральном процессоре и относительно медленнее для доступа.
Логично, что к набору команд ЦП вы просто обращаетесь к адресам памяти в гигантском виртуальном адресном пространстве. Когда вы получаете доступ к одному адресу памяти, ЦПУ будет его извлекать. в старые времена он получал только этот единственный адрес. Но сегодня процессор будет извлекать кучу памяти из запрошенного вами бита и копировать его в кеш. Предполагается, что если вы попросили указать конкретный адрес, весьма вероятно, что вы собираетесь запросить адрес поблизости очень скоро. Например, если вы копируете буфер, вы читаете и пишете с последовательных адресов - один за другим.
Итак, сегодня, когда вы выбираете адрес, он проверяет первый уровень кеша, чтобы увидеть, прочитал ли он уже этот адрес в кеш, если он не находит его, то это промах кеша, и он должен перейти на следующий уровень кеш, чтобы найти его, пока он в конечном итоге не должен выходить в основную память.
Кэш-дружественный код пытается держать доступы близко друг к другу в памяти, чтобы минимизировать потери в кеше.
Например, можно представить, что вы хотите скопировать гигантскую двумерную таблицу. Это организовано с последовательным рядом в памяти, и один ряд следует за следующим сразу после.
Если вы скопировали элементы по одной строке слева направо - это было бы удобно для кэша. Если вы решите копировать таблицу по одному столбцу за раз, вы скопируете точно такой же объем памяти, но это будет недружелюбно кэшироваться.
Необходимо уточнить, что не только данные должны быть дружественными к кешу, но и для кода. Это в дополнение к предсказанию переходов, переупорядочению команд, избеганию фактических делений и другим методам.
Как правило, чем плотнее код, тем меньше строк кэша потребуется для его хранения. Это приводит к большему количеству строк кэша, доступных для данных.
Код не должен вызывать функции повсюду, поскольку они обычно требуют одной или нескольких собственных строк кэша, что приводит к меньшему количеству строк кэша для данных.
Функция должна начинаться с адреса, удобного для выравнивания строк в кэше. Хотя для этого есть (gcc) ключи компилятора, имейте в виду, что если функции очень короткие, для каждого из них может быть расточительно занимать всю строку кэша. Например, если три из наиболее часто используемых функций помещаются в одну 64-байтовую строку кэша, это менее затратно, чем если бы у каждой была своя собственная строка, и в результате две строки кэша стали менее доступны для другого использования. Типичное значение выравнивания может быть 32 или 16.
Поэтому потратьте дополнительное время, чтобы сделать код плотным. Тестируйте различные конструкции, компилируйте и просматривайте размер и профиль сгенерированного кода.
Как отметил @Marc Claesen, одним из способов написания кода, удобного для кэша, является использование структуры, в которой хранятся наши данные. В дополнение к этому еще один способ написания кода, удобного для кэширования: изменить способ хранения наших данных; затем напишите новый код для доступа к данным, хранящимся в этой новой структуре.
Это имеет смысл в случае, когда системы баз данных линеаризуют кортежи таблицы и сохраняют их. Существует два основных способа хранения кортежей таблицы: хранилище строк и хранилище столбцов. В хранилище строк, как следует из названия, кортежи хранятся по строкам. Давайте предположим, что таблица с именем Productхранится имеет 3 атрибуты , то есть int32_t key, char name[56]и int32_t price, таким образом , общий размер кортежа 64байт.
Мы можем смоделировать очень простое выполнение запроса хранилища строк в основной памяти, создав массив Productструктур размером N, где N - количество строк в таблице. Такая схема памяти также называется массивом структур. Таким образом, структура для Product может быть такой:
structProduct{int32_t key;char name[56];int32_t price'}/* create an array of structs */Product* table =newProduct[N];/* now load this array of structs, from a file etc. */
Точно так же мы можем смоделировать очень простое выполнение запроса хранилища столбцов в основной памяти, создав 3 массива размера N, один массив для каждого атрибута Productтаблицы. Такое расположение памяти также называется структурой массивов. Таким образом, 3 массива для каждого атрибута Product могут выглядеть так:
/* create separate arrays for each attribute */int32_t* key =newint32_t[N];char* name =newchar[56*N];int32_t* price =newint32_t[N];/* now load these arrays, from a file etc. */
Теперь, после загрузки как массива структур (расположение строк), так и 3 отдельных массивов (расположение столбцов), Productв нашей памяти присутствует хранилище строк и столбцов в нашей таблице .
Теперь перейдем к части кода, дружественной к кешу. Предположим, что рабочая нагрузка на нашу таблицу такова, что у нас есть запрос агрегации по атрибуту цены. Такие как
SELECT SUM(price)
FROM PRODUCT
Для хранилища строк мы можем преобразовать указанный выше SQL-запрос в
int sum =0;for(int i=0; i<N; i++)
sum = sum + table[i].price;
Для хранилища столбцов мы можем преобразовать указанный выше SQL-запрос в
int sum =0;for(int i=0; i<N; i++)
sum = sum + price[i];
Код для хранилища столбцов будет быстрее, чем код для размещения строк в этом запросе, поскольку он требует только подмножество атрибутов, а в размещении столбцов мы делаем именно это, то есть только доступ к столбцу цен.
Предположим, что размер строки кэша составляет 64байты.
В случае разметки строк при чтении строки кэша считывается значение цены только для 1 ( cacheline_size/product_struct_size = 64/64 = 1) кортежа, поскольку размер нашей структуры составляет 64 байта, и он заполняет всю нашу строку кэша, поэтому для каждого кортежа происходит пропадание кэша в случае, если макета строки.
В случае размещения столбца при чтении строки кэша значение цены равно 16 (cacheline_size/price_int_size = 64/4 = 16 ) кортежей, потому что 16 смежных значений цены, хранящихся в памяти, заносятся в кэш, поэтому для каждого шестнадцатого кортежа в кеше происходит пропадание макет столбца.
Таким образом, расположение столбцов будет быстрее в случае заданного запроса и быстрее в таких запросах агрегации для подмножества столбцов таблицы. Вы можете попробовать такой эксперимент для себя, используя данные из теста TPC-H , и сравнить время выполнения для обоих макетов. Википедии статья на колонке ориентированных баз данных также хорошо.
Таким образом, в системах баз данных, если рабочая нагрузка запроса известна заранее, мы можем хранить наши данные в макетах, которые будут соответствовать запросам в рабочей нагрузке, и получать доступ к данным из этих макетов. В приведенном выше примере мы создали макет столбца и изменили наш код для вычисления суммы, чтобы он стал более дружественным к кешу.
Имейте в виду, что кеши не просто кешируют непрерывную память. Они имеют несколько строк (не менее 4), поэтому разрывная и перекрывающаяся память часто может храниться с такой же эффективностью.
Чего не хватает во всех приведенных выше примерах - это измеренных ориентиров. Существует много мифов о производительности. Если вы не измеряете это, вы не знаете. Не усложняйте свой код, если у вас нет заметного улучшения.
Ответы:
прелиминарии
На современных компьютерах только структуры памяти самого низкого уровня ( регистры ) могут перемещать данные за один такт. Однако регистры очень дороги, и большинство ядер компьютеров имеют менее нескольких десятков регистров (всего от нескольких сотен до, может быть, тысячи байт ). На другом конце спектра памяти ( DRAM ) память очень дешевая (т. Е. Буквально в миллионы раз дешевле ), но после запроса на получение данных занимает сотни циклов. Чтобы преодолеть этот разрыв между супер быстрым и дорогим и супер медленным и дешевым, кэш-память, названный L1, L2, L3 в уменьшении скорости и стоимости. Идея состоит в том, что большая часть исполняемого кода будет часто использовать небольшой набор переменных, а остальные (гораздо больший набор переменных) нечасто. Если процессор не может найти данные в кеше L1, он выглядит в кеше L2. Если не там, то кеш L3, а если нет, то основная память. Каждое из этих «промахов» дорого по времени.
(Аналогия заключается в том, что кэш-память относится к системной памяти, так как системная память является слишком жестким диском. Память на жестком диске очень дешевая, но очень медленная).
Кэширование является одним из основных методов уменьшения влияния задержки . Перефразируя Херба Саттера (см. Ссылки ниже): увеличить пропускную способность очень просто, но мы не можем выкупить выход из задержки .
Данные всегда извлекаются через иерархию памяти (от наименьшего == к быстрейшему к медленному). Кэш / промах обычно относится к хиту / промаху в самом высоком уровне кэша - памяти в CPU - на самом высоком уровне , я имею в виду самой большой == медленным. Частота обращений к кешу имеет решающее значение для производительности, так как каждая потеря кеша приводит к извлечению данных из ОЗУ (или хуже ...), что занимает много времени (сотни циклов для ОЗУ, десятки миллионов циклов для жесткого диска). Для сравнения, чтение данных из кэша (самого высокого уровня) обычно занимает всего несколько циклов.
В современных компьютерных архитектурах узкое место в производительности покидает процессор (например, доступ к ОЗУ или выше). Это будет только ухудшаться со временем. Увеличение частоты процессора в настоящее время более не актуально для повышения производительности. Проблема в доступе к памяти. Поэтому в настоящее время усилия по проектированию аппаратного обеспечения в процессорах сосредоточены на оптимизации кэшей, предварительной выборке, конвейерах и параллелизме. Например, современные процессоры тратят около 85% кристаллов на кеши и до 99% на хранение / перемещение данных!
На эту тему можно сказать очень много. Вот несколько замечательных ссылок о кешах, иерархиях памяти и правильном программировании:
Основные понятия для кеш-кода
Очень важный аспект кода, дружественного к кешу, связан с принципом локальности , цель которого - разместить связанные данные близко к памяти, чтобы обеспечить эффективное кэширование. Что касается кэша ЦП, важно знать о строках кэша, чтобы понять, как это работает: как работают строки кэша?
Следующие конкретные аспекты имеют большое значение для оптимизации кэширования:
Используйте соответствующие C ++ контейнеры
Простой пример кеш-дружественных и кеш-недружественных C ++«S
std::vector
противstd::list
. Элементы astd::vector
хранятся в непрерывной памяти, и, таким образом, доступ к ним намного более удобен для кэша, чем доступ к элементам в astd::list
, который хранит свой контент повсюду. Это связано с пространственной локализацией.Очень хорошая иллюстрация этого дана Бьярном Страуструпом в этом клипе на YouTube (спасибо @Mohammad Ali Baydoun за ссылку!).
Не пренебрегайте кешем в структуре данных и разработке алгоритмов
По возможности старайтесь адаптировать свои структуры данных и порядок вычислений таким образом, чтобы максимально использовать кеш. Общепринятым методом в этом отношении является блокировка кэша (версия Archive.org) , которая имеет чрезвычайно важное значение в высокопроизводительных вычислениях (см., Например, ATLAS ).
Знать и использовать неявную структуру данных
Еще один простой пример, который иногда забывают многие в этой области, - это колонка-майор (напр. Фортран,MATLAB) против упорядочения по ряду строк (напр. с,C ++) для хранения двухмерных массивов. Например, рассмотрим следующую матрицу:
При упорядочении основных строк это сохраняется в памяти как
1 2 3 4
; при упорядочении по основным столбцам это будет храниться как1 3 2 4
. Легко видеть, что реализации, которые не используют этот порядок, быстро столкнутся (легко избежать!) С проблемами кэширования. К сожалению, я вижу вещи , как это очень часто в моей области (машинном обучении). @MatteoItalia показал этот пример более подробно в своем ответе.При извлечении определенного элемента матрицы из памяти также будут извлечены элементы рядом с ним и сохранены в строке кэша. Если упорядочение используется, это приведет к меньшему количеству обращений к памяти (поскольку следующие несколько значений, которые необходимы для последующих вычислений, уже находятся в строке кэша).
Для простоты предположим, что кэш содержит одну строку кеша, которая может содержать 2 матричных элемента, и что, когда данный элемент выбирается из памяти, следующий тоже. Скажем, мы хотим взять сумму по всем элементам в приведенной выше примере матрицы 2x2 (давайте назовем ее
M
):Использование порядка (например, изменение индекса столбца первым в C ++):
Не использовать порядок (например, сначала изменить индекс строки в C ++):
В этом простом примере использование порядка приблизительно удваивает скорость выполнения (поскольку доступ к памяти требует гораздо больше циклов, чем вычисление сумм). На практике разница в производительности может быть намного больше.
Избегайте непредсказуемых веток
Современные архитектуры имеют конвейеры и компиляторы становятся очень хорошими в переупорядочении кода, чтобы минимизировать задержки из-за доступа к памяти. Когда ваш критический код содержит (непредсказуемые) ветви, трудно или невозможно предварительно выбрать данные. Это косвенно приведет к большему количеству промахов в кеше.
Это очень хорошо объясняется здесь (спасибо @ 0x90 за ссылку): почему обработка отсортированного массива выполняется быстрее, чем обработка несортированного массива?
Избегайте виртуальных функций
В контексте C ++,
virtual
Методы представляют собой спорный вопрос в отношении промахов кэша (общий консенсус , что их следует избегать , если это возможно с точки зрения производительности). Виртуальные функции могут вызывать пропадание кеша при поиске, но это происходит только в том случае, если конкретная функция вызывается не часто (в противном случае она, вероятно, будет кэшироваться), поэтому некоторые считают это проблемой. Для справки об этой проблеме, посмотрите: Какова стоимость производительности наличия виртуального метода в классе C ++?Общие проблемы
Общая проблема в современных архитектурах с многопроцессорным кэшем называется ложным разделением . Это происходит, когда каждый отдельный процессор пытается использовать данные в другой области памяти и пытается сохранить их в той же строке кэша . Это приводит к тому, что строка кэша, содержащая данные, которые может использовать другой процессор, перезаписывается снова и снова. По сути, разные потоки заставляют друг друга ждать, вызывая пропуски кэша в этой ситуации. Смотрите также (спасибо @Matt за ссылку): как и когда выровнять по размеру строки кэша?
Чрезвычайным признаком плохого кеширования в оперативной памяти (что, вероятно, не является тем, что вы имеете в виду в этом контексте) является так называемая порка . Это происходит, когда процесс непрерывно генерирует сбои страниц (например, обращается к памяти, которой нет на текущей странице), которые требуют доступа к диску.
источник
В дополнение к ответу @Marc Claesen, я думаю, что поучительным классическим примером недружественного кэшу кода является код, который сканирует двумерный массив C (например, растровое изображение) по столбцам, а не по строкам.
Элементы, которые являются смежными в ряду, также являются смежными в памяти, таким образом, последовательный доступ к ним означает доступ к ним в порядке возрастания памяти; это дружественно к кешу, поскольку кеш имеет тенденцию предварительно выбирать смежные блоки памяти.
Вместо этого доступ к таким элементам по столбцам является неприемлемым для кэша, поскольку элементы в одном и том же столбце находятся в памяти на расстоянии друг от друга (в частности, их расстояние равно размеру строки), поэтому при использовании этого шаблона доступа вы прыгают в памяти, потенциально тратя силы на кеширование элементов, находящихся поблизости в памяти.
И все, что нужно, чтобы испортить представление, это пойти от
в
Этот эффект может быть весьма значительным (на несколько порядков по скорости) в системах с небольшими кэшами и / или работающих с большими массивами (например, 10+ мегапикселей 24-битных изображения на современных машинах); по этой причине, если вам приходится делать много вертикальных сканирований, часто лучше сначала повернуть изображение на 90 градусов, а затем выполнить различный анализ, ограничив неприемлемый для кэша код только поворотом.
источник
Оптимизация использования кэша в основном сводится к двум факторам.
Справочная информация
Первым фактором (на который уже ссылались другие) является местность ссылки. Местность ссылки действительно имеет два измерения: пространство и время.
Пространственное измерение также сводится к двум вещам: во-первых, мы хотим плотно упаковать нашу информацию, чтобы больше информации поместилось в этой ограниченной памяти. Это означает (например), что вам нужно значительное улучшение вычислительной сложности для обоснования структур данных на основе небольших узлов, соединенных указателями.
Во-вторых, мы хотим, чтобы информация, которая будет обрабатываться вместе, также находилась вместе. Типичный кеш работает в «строках», что означает, что при доступе к некоторой информации другая информация по близлежащим адресам будет загружена в кеш с той частью, к которой мы прикоснулись. Например, когда я касаюсь одного байта, кэш может загружать 128 или 256 байтов рядом с этим. Чтобы воспользоваться этим преимуществом, вы, как правило, хотите, чтобы данные располагались так, чтобы максимизировать вероятность того, что вы также будете использовать эти другие данные, которые были загружены одновременно.
Для очень простого примера это может означать, что линейный поиск может быть намного более конкурентоспособным с бинарным поиском, чем вы ожидаете. После того, как вы загрузили один элемент из строки кэша, использование оставшихся данных в этой строке кэша практически бесплатно. Бинарный поиск становится заметно быстрее только тогда, когда данные настолько велики, что бинарный поиск уменьшает количество строк кэша, к которым вы обращаетесь.
Измерение времени означает, что когда вы выполняете некоторые операции с некоторыми данными, вы хотите (насколько это возможно) выполнять все операции с этими данными одновременно.
Так как вы добавили это как C ++, я укажу на классический пример относительно кэш-недружелюбным дизайн:
std::valarray
.valarray
Перегрузки наиболее арифметические операции, так что можно (например) сказатьa = b + c + d;
(гдеa
,b
,c
иd
все valarrays) , чтобы сделать поэлементное сложение этих массивов.Проблема в том, что он проходит через одну пару входных данных, помещает результаты во временную, проходит через другую пару входных данных и так далее. При большом количестве данных результат одного вычисления может исчезнуть из кэша, прежде чем он будет использован в следующем вычислении, поэтому мы заканчиваем чтение (и запись) данных несколько раз, прежде чем мы получим наш конечный результат. Если каждый элемент конечного результата будет что - то вроде
(a[n] + b[n]) * (c[n] + d[n]);
, мы обычно предпочитаем читать каждыйa[n]
,b[n]
,c[n]
иd[n]
один раз, делать вычисления, записываем результат, приращениеn
и повторите «сезам мы сделали. 2Совместное использование линии
Вторым важным фактором является избегание разделения линии. Чтобы понять это, нам, вероятно, нужно сделать резервную копию и посмотреть, как организованы кэши. Самая простая форма кеша - это прямое сопоставление. Это означает, что один адрес в основной памяти может храниться только в одном конкретном месте в кэше. Если мы используем два элемента данных, которые отображаются в одно и то же место в кэше, это работает плохо - каждый раз, когда мы используем один элемент данных, другой должен быть сброшен из кэша, чтобы освободить место для другого. Остальная часть кэша может быть пустой, но эти элементы не будут использовать другие части кэша.
Чтобы предотвратить это, большинство кэшей называется так называемым «множеством ассоциаций». Например, в четырехстороннем ассоциативно-множественном кэше любой элемент из основной памяти может храниться в любом из 4 различных мест в кэше. Таким образом, когда кэш собирается загрузить элемент, он ищет наименее использованные 3 элемента среди этих четырех, сбрасывает его в основную память и загружает новый элемент на его место.
Проблема, вероятно, довольно очевидна: для кеша с прямым отображением два операнда, которые оказываются в одной и той же ячейке кеша, могут привести к плохому поведению. N-сторонний наборно-ассоциативный кэш увеличивает число от 2 до N + 1. Организация кэша в несколько «способов» требует дополнительных схем и, как правило, работает медленнее, поэтому (например) ассоциативный кэш с 8192 путями редко является хорошим решением.
В конечном счете, этот фактор труднее контролировать в переносимом коде. Ваш контроль над тем, где размещены ваши данные, обычно довольно ограничен. Хуже того, точное сопоставление адреса с кешем варьируется между аналогичными процессорами. В некоторых случаях, однако, может быть целесообразно сделать такие вещи, как выделение большого буфера, а затем использовать только части того, что вы выделили, чтобы обеспечить защиту данных от совместного использования одних и тех же строк кэша (даже если вам, вероятно, потребуется определить точный процессор и действовать соответственно, чтобы сделать это).
Есть еще один связанный элемент под названием «ложный обмен». Это возникает в многопроцессорной или многоядерной системе, где два (или более) процессора / ядра имеют отдельные данные, но попадают в одну и ту же строку кэша. Это заставляет два процессора / ядра координировать их доступ к данным, даже если у каждого есть свой отдельный элемент данных. Особенно, если они изменяют данные поочередно, это может привести к значительному замедлению, поскольку данные должны постоянно перемещаться между процессорами. Это не может быть легко вылечено путем организации кэша в несколько «способов» или что-то в этом роде. Основной способ предотвратить это - гарантировать, что два потока редко (предпочтительно никогда) не изменяют данные, которые могут находиться в одной и той же строке кэша (с одинаковыми предостережениями относительно сложности управления адресами, по которым распределяются данные).
Те, кто хорошо знает C ++, могут задаться вопросом, открыта ли она для оптимизации с помощью чего-то вроде шаблонов выражений. Я почти уверен, что ответ таков: да, это можно было бы сделать, и если бы это было так, это, вероятно, было бы довольно существенной победой. Однако я не знаю, чтобы кто-нибудь сделал это, и, учитывая, как мало
valarray
привыкаешь, я был бы, по крайней мере, немного удивлен, увидев, что кто-то так поступит.В случае, если кому-то интересно, как
valarray
(разработан специально для производительности) это может быть ужасно неправильно, это сводится к одному: он действительно был разработан для машин, таких как старые Crays, которые использовали быструю оперативную память и не использовали кеш. Для них это действительно был почти идеальный дизайн.Да, я упрощаю: большинство кешей на самом деле не точно измеряют наименее недавно использованный элемент, но они используют некоторую эвристику, которая должна быть близка к ней без необходимости сохранять полную метку времени для каждого доступа.
источник
valarray
пример.Добро пожаловать в мир Data-Oriented Design. Основная мантра - Сортировка, Устранение филиалов, Пакет, Устранение
virtual
звонков - все шаги к лучшей локации.Поскольку вы пометили вопрос с C ++, вот обязательная типичная фигня C ++ . Подводные камни Тони Альбрехта в объектно-ориентированном программировании также являются отличным введением в предмет.
источник
Просто накапливаем: классический пример недружественного к кешу по сравнению с кеш-дружественным кодом - это «блокировка кеша» умножения матрицы.
Наивная матрица умножения выглядит так:
Если
N
он большой, например, еслиN * sizeof(elemType)
он больше размера кэша, то каждый отдельный доступ к немуsrc2[k][j]
будет пропущен.Есть много разных способов оптимизировать это для кеша. Вот очень простой пример: вместо чтения одного элемента на строку кэша во внутреннем цикле используйте все элементы:
Если размер строки кэша составляет 64 байта, и мы работаем с 32-разрядными (4 байтами) числами с плавающей запятой, то в каждой строке кэша имеется 16 элементов. А количество пропущенных кешей благодаря этому простому преобразованию уменьшается примерно в 16 раз.
Более сложные преобразования работают с 2D-фрагментами, оптимизируются для нескольких кэшей (L1, L2, TLB) и т. Д.
Некоторые результаты поиска "блокировки кеша":
http://stumptown.cc.gt.atl.ga.us/cse6230-hpcta-fa11/slides/11a-matmul-goto.pdf
http://software.intel.com/en-us/articles/cache-blocking-techniques
Хорошая видео анимация оптимизированного алгоритма блокировки кэша.
http://www.youtube.com/watch?v=IFWgwGMMrh0
Циклическая черепица очень тесно связана:
http://en.wikipedia.org/wiki/Loop_tiling
источник
k==;
Я надеюсь, что это опечатка?Сегодня процессоры работают со многими уровнями каскадных областей памяти. Таким образом, процессор будет иметь кучу памяти, которая находится на самом чипе процессора. У него очень быстрый доступ к этой памяти. Существуют разные уровни кэша, каждый из которых медленнее (и больше), чем следующий, до тех пор, пока вы не доберетесь до системной памяти, которая не находится на центральном процессоре и относительно медленнее для доступа.
Логично, что к набору команд ЦП вы просто обращаетесь к адресам памяти в гигантском виртуальном адресном пространстве. Когда вы получаете доступ к одному адресу памяти, ЦПУ будет его извлекать. в старые времена он получал только этот единственный адрес. Но сегодня процессор будет извлекать кучу памяти из запрошенного вами бита и копировать его в кеш. Предполагается, что если вы попросили указать конкретный адрес, весьма вероятно, что вы собираетесь запросить адрес поблизости очень скоро. Например, если вы копируете буфер, вы читаете и пишете с последовательных адресов - один за другим.
Итак, сегодня, когда вы выбираете адрес, он проверяет первый уровень кеша, чтобы увидеть, прочитал ли он уже этот адрес в кеш, если он не находит его, то это промах кеша, и он должен перейти на следующий уровень кеш, чтобы найти его, пока он в конечном итоге не должен выходить в основную память.
Кэш-дружественный код пытается держать доступы близко друг к другу в памяти, чтобы минимизировать потери в кеше.
Например, можно представить, что вы хотите скопировать гигантскую двумерную таблицу. Это организовано с последовательным рядом в памяти, и один ряд следует за следующим сразу после.
Если вы скопировали элементы по одной строке слева направо - это было бы удобно для кэша. Если вы решите копировать таблицу по одному столбцу за раз, вы скопируете точно такой же объем памяти, но это будет недружелюбно кэшироваться.
источник
Необходимо уточнить, что не только данные должны быть дружественными к кешу, но и для кода. Это в дополнение к предсказанию переходов, переупорядочению команд, избеганию фактических делений и другим методам.
Как правило, чем плотнее код, тем меньше строк кэша потребуется для его хранения. Это приводит к большему количеству строк кэша, доступных для данных.
Код не должен вызывать функции повсюду, поскольку они обычно требуют одной или нескольких собственных строк кэша, что приводит к меньшему количеству строк кэша для данных.
Функция должна начинаться с адреса, удобного для выравнивания строк в кэше. Хотя для этого есть (gcc) ключи компилятора, имейте в виду, что если функции очень короткие, для каждого из них может быть расточительно занимать всю строку кэша. Например, если три из наиболее часто используемых функций помещаются в одну 64-байтовую строку кэша, это менее затратно, чем если бы у каждой была своя собственная строка, и в результате две строки кэша стали менее доступны для другого использования. Типичное значение выравнивания может быть 32 или 16.
Поэтому потратьте дополнительное время, чтобы сделать код плотным. Тестируйте различные конструкции, компилируйте и просматривайте размер и профиль сгенерированного кода.
источник
Как отметил @Marc Claesen, одним из способов написания кода, удобного для кэша, является использование структуры, в которой хранятся наши данные. В дополнение к этому еще один способ написания кода, удобного для кэширования: изменить способ хранения наших данных; затем напишите новый код для доступа к данным, хранящимся в этой новой структуре.
Это имеет смысл в случае, когда системы баз данных линеаризуют кортежи таблицы и сохраняют их. Существует два основных способа хранения кортежей таблицы: хранилище строк и хранилище столбцов. В хранилище строк, как следует из названия, кортежи хранятся по строкам. Давайте предположим, что таблица с именем
Product
хранится имеет 3 атрибуты , то естьint32_t key, char name[56]
иint32_t price
, таким образом , общий размер кортежа64
байт.Мы можем смоделировать очень простое выполнение запроса хранилища строк в основной памяти, создав массив
Product
структур размером N, где N - количество строк в таблице. Такая схема памяти также называется массивом структур. Таким образом, структура для Product может быть такой:Точно так же мы можем смоделировать очень простое выполнение запроса хранилища столбцов в основной памяти, создав 3 массива размера N, один массив для каждого атрибута
Product
таблицы. Такое расположение памяти также называется структурой массивов. Таким образом, 3 массива для каждого атрибута Product могут выглядеть так:Теперь, после загрузки как массива структур (расположение строк), так и 3 отдельных массивов (расположение столбцов),
Product
в нашей памяти присутствует хранилище строк и столбцов в нашей таблице .Теперь перейдем к части кода, дружественной к кешу. Предположим, что рабочая нагрузка на нашу таблицу такова, что у нас есть запрос агрегации по атрибуту цены. Такие как
Для хранилища строк мы можем преобразовать указанный выше SQL-запрос в
Для хранилища столбцов мы можем преобразовать указанный выше SQL-запрос в
Код для хранилища столбцов будет быстрее, чем код для размещения строк в этом запросе, поскольку он требует только подмножество атрибутов, а в размещении столбцов мы делаем именно это, то есть только доступ к столбцу цен.
Предположим, что размер строки кэша составляет
64
байты.В случае разметки строк при чтении строки кэша считывается значение цены только для 1 (
cacheline_size/product_struct_size = 64/64 = 1
) кортежа, поскольку размер нашей структуры составляет 64 байта, и он заполняет всю нашу строку кэша, поэтому для каждого кортежа происходит пропадание кэша в случае, если макета строки.В случае размещения столбца при чтении строки кэша значение цены равно 16 (
cacheline_size/price_int_size = 64/4 = 16
) кортежей, потому что 16 смежных значений цены, хранящихся в памяти, заносятся в кэш, поэтому для каждого шестнадцатого кортежа в кеше происходит пропадание макет столбца.Таким образом, расположение столбцов будет быстрее в случае заданного запроса и быстрее в таких запросах агрегации для подмножества столбцов таблицы. Вы можете попробовать такой эксперимент для себя, используя данные из теста TPC-H , и сравнить время выполнения для обоих макетов. Википедии статья на колонке ориентированных баз данных также хорошо.
Таким образом, в системах баз данных, если рабочая нагрузка запроса известна заранее, мы можем хранить наши данные в макетах, которые будут соответствовать запросам в рабочей нагрузке, и получать доступ к данным из этих макетов. В приведенном выше примере мы создали макет столбца и изменили наш код для вычисления суммы, чтобы он стал более дружественным к кешу.
источник
Имейте в виду, что кеши не просто кешируют непрерывную память. Они имеют несколько строк (не менее 4), поэтому разрывная и перекрывающаяся память часто может храниться с такой же эффективностью.
Чего не хватает во всех приведенных выше примерах - это измеренных ориентиров. Существует много мифов о производительности. Если вы не измеряете это, вы не знаете. Не усложняйте свой код, если у вас нет заметного улучшения.
источник