Разработка кода для размещения в CPU Cache? - PullRequest
14 голосов
/ 30 ноября 2009

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

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

Ответы [ 7 ]

30 голосов
/ 30 ноября 2009

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

Редактировать (чтобы некоторые моменты были более явными):

Типичный процессор имеет несколько различных кешей. Современный настольный процессор обычно имеет как минимум 2, а часто и 3 уровня кеша. Согласно (по крайней мере, почти) универсальному соглашению, «уровень 1» - это кэш, «ближайший» к элементам обработки, и оттуда числа идут вверх (уровень 2 следующий, уровень 3 после него и т. Д.)

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

В большинстве случаев кэш организован как набор «строк». Содержимое кэша обычно читается, записывается и отслеживается по одной строке за раз. Другими словами, если ЦП будет использовать данные из любой части строки кэша, вся эта строка кэша будет считана из следующего более низкого уровня хранилища. Кэши, которые ближе к ЦП, обычно меньше и имеют меньшие строки кэша.

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

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

Это также влияет на то, как вы пишете код. Если у вас есть цикл вроде:

for i = 0 to whatever
   step1(data);
   step2(data);
   step3(data);
end for

Как правило, лучше объединить столько шагов, сколько сможете до суммы , которая поместится в кеше. В ту минуту, когда вы переполняете кеш, производительность может резко упасть. Если код для шага 3 выше был достаточно большим, чтобы он не помещался в кэш, вам лучше разбить цикл на две части, как это (если возможно):

for i = 0 to whatever
    step1(data);
    step2(data);
end for

for i = 0 to whatever
    step3(data);
end for

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

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

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

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

Другая проблема, связанная с кэшем, которая может возникнуть при параллельном кодировании, - это разделение (и ложное разделение) переменных. Если двум (или более) ядрам необходимо выполнить запись в одно и то же место в памяти, строка кэша, содержащая эти данные, может в конечном итоге перемещаться назад и вперед между ядрами, чтобы дать каждому ядру доступ к общим данным. Результатом часто является код, который работает медленнее параллельно, чем в последовательном (то есть на одном ядре). Существует разновидность так называемого «ложного совместного использования», при которой код на разных ядрах записывает в отдельные данные , но данные для разных ядер попадают в одну и ту же строку кэша. Поскольку кэш-память контролирует данные исключительно в виде целых строк данных, данные в любом случае перетасовываются назад и вперед между ядрами, что приводит к точно такой же проблеме.

11 голосов
/ 01 декабря 2009

Вот ссылка на действительно хорошую статью об кэшировании / оптимизации памяти Кристером Эрикссоном (известностью God of War I / II / III). Ему пару лет, но он все еще очень актуален.

7 голосов
/ 04 декабря 2009

Полезная статья, которая расскажет вам больше, чем вы когда-либо хотели знать о кешах, - Что каждый программист должен знать о памяти Ульриха Дреппера Хеннесси покрывает это очень тщательно. Кристер и Майк Актон тоже написали много хороших вещей об этом.

Я думаю, вам следует больше беспокоиться о кеше данных, чем о кеше команд & mdash; по моему опыту, промахи dcache более частые, более болезненные и более полезные.

5 голосов
/ 20 декабря 2013

ОБНОВЛЕНИЕ: 13.01.2014 По словам этого старшего дизайнера чипов, в настоящее время пропуски кеша являются ОЧЕНЬ доминирующим фактором в производительности кода, поэтому мы в основном вернулись к середине 80-х и к быстрым 286 чипам с точки зрения относительных узких мест производительности в загрузке, хранении, целочисленных значениях. арифметика и кеш отсутствует.

Ускоренный курс современного оборудования от Cliff Click @ Azul , , , , .

--- теперь мы вернем вас к вашей регулярной программе ---

Иногда пример лучше, чем описание того, как что-то сделать. В этом духе приведу особенно удачный пример того, как я изменил некоторый код, чтобы лучше использовать его в кешах. Это было сделано некоторое время назад на процессоре 486, а последний был перенесен на процессор Pentium 1-го поколения. Влияние на производительность было аналогичным.

Пример: отображение нижнего индекса

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

У меня был вектор двойного плавания длиной 1250 элементов, который представлял собой эпидемиологическую кривую с очень длинными хвостами. «Интересная» часть кривой имела только около 200 уникальных значений, но я не хотел, чтобы двухсторонний тест if () создавал беспорядок в процессоре ЦП (таким образом, длинные хвосты, которые могли бы использовать в качестве индексов самые экстремальные) значения кода Монте-Карло, которые были бы выплеваны), и мне понадобилась логика предсказания ветвлений для дюжины других условных тестов внутри «горячей точки» в коде.

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

Это сократило требования к хранилищу до 2 КБ для двойников и до 1250 байт для 8-разрядных индексов. Это сократилось на 10000 байт до 3298. Поскольку программа потратила 90% или больше своего времени в этом внутреннем цикле, эти 2 вектора так и не были вытеснены из кэша данных 8 КБ. Программа сразу удвоила производительность. Этот код получил ~ 100 миллиардов раз в процессе вычисления значения OAS для 1+ миллионов ипотечных кредитов.

Поскольку хвосты кривой редко затрагивались, вполне возможно, что в кеше фактически содержались только средние 200-300 элементов крошечного вектора int, а 160-240 средних двойников представляли 1/8 процента процентов интереса. , Это было замечательное увеличение производительности, выполненное во второй половине дня, по программе, которую я потратил на оптимизацию более года.

Я согласен с Джерри, поскольку, как я уже знал, наклон кода в сторону кэша команд не так успешен, как оптимизация для кэша данных. Это одна из причин, по которой я думаю, что обычные кэши AMD не так полезны, как отдельные кэши данных и инструкций Intel. IE: вы не хотите, чтобы инструкции перегружали кеш, потому что это не очень полезно. Отчасти это связано с тем, что наборы команд CISC изначально создавались, чтобы компенсировать огромную разницу между скоростью процессора и памяти, и, за исключением аберрации в конце 80-х, это почти всегда было так.

Еще одна любимая техника, которую я использую, чтобы отдавать предпочтение кешу данных и использовать кеш инструкций, заключается в использовании большого количества бит-битов в определениях структуры и наименьшем возможном размере данных в целом. Чтобы замаскировать 4-битное int для хранения месяца года или 9 бит для хранения дня года и т. Д., И т. Д., Требуется использование масок ЦП для маскирования целых чисел хоста, используемых битами, что сокращает данные, эффективно увеличивает кэш и размер шины, но требует больше инструкций. Хотя этот метод создает код, который не работает так хорошо на синтетических тестах, в загруженных системах, где пользователи и процессы конкурируют за ресурсы, он работает прекрасно.

4 голосов
/ 04 ноября 2013

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

Стало до боли очевидным, когда я написал здесь код в StackOverflow, чтобы обратить биты в 4096-битном массиве, которые старше 30 лет после появления ПК, микропроцессоры просто не уделяют битам большого внимания и ресурсов, и это Я надеюсь, что изменится. В частности, я бы хотел, во-первых, увидеть, что тип bool стал настоящим битовым типом данных в C / C ++, а не смехотворно расточительным байтом, которым он является в настоящее время.

Hazwell's new Bit Manipulation Instructions

ОБНОВЛЕНИЕ: 12/29/2013

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

Векторы «Голова», «Хвост» находились в памяти рядом друг с другом, за исключением случаев, когда сначала заголовок, а затем «Хвост» оборачивались и начинались обратно в начале массива. Однако (скользящий) суммарный фрагмент находился в фиксированном статически распределенном массиве, который не был особенно близок ни к одному из них, и даже не выделялся из кучи.

Думая об этом, и изучая код, несколько деталей привлекли мое внимание.

  1. Входящие требования были добавлены к заголовку и срезу Summary одновременно, рядом друг с другом в соседних строках кода.

  2. При срабатывании таймера хвост вычитался из среза «Сводка», а результаты оставлялись в срезе «Сводка», как и следовало ожидать

  3. 2-я функция вызывается, когда срабатывает таймер, передвигая все указатели, обслуживающие кольцо. Особенно.... Голова переписала Хвост, занимая тем же местом памяти Новый Хвост занимал следующие 512 ячеек памяти или был завернут

  4. Пользователь хотел больше гибкости в отношении количества управляемых требований, от 512 до 4098 или, возможно, больше. Я чувствовал, что самый надежный, защищенный от идиота способ сделать это состоит в том, чтобы выделить как 1000 временных интервалов, так и суммарный срез вместе, как один непрерывный блок памяти, так что среза Сводка будет НЕВОЗМОЖНО оказаться другой длины чем другие 1000 временных интервалов.

  5. Учитывая вышесказанное, я начал задаваться вопросом, смогу ли я получить больше производительности, если вместо того, чтобы срез «Сводка» оставался в одном месте, у меня он «бродил» между головой и хвостом, так что это всегда было прямо рядом с Главой для добавления новых требований, и прямо рядом с Хвостом, когда таймер сработал, и значения Хвоста должны были быть вычтены из Сводки.

Я сделал именно это, но потом обнаружил пару дополнительных оптимизаций в процессе. Я изменил код, который вычислял сводную сводку, чтобы результаты оставались в хвосте, а не в сводной части. Зачем? Потому что следующая функция выполняла memcpy () для перемещения фрагмента Summary в память, занятую только хвостом. (Странно, но верно, Хвост ведет Голову до конца кольца, когда он оборачивается). Оставляя результаты суммирования в хвосте, мне не нужно было выполнять memcpy (), мне просто нужно было назначить pTail для pSummary.

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

Следом до конца кольца (на самом деле барабан, шириной 512 дорожек) был хвост, но мне нужно было только сравнить его указатель с постоянным указателем pEndOfRing, чтобы обнаружить это состояние. Всем остальным указателям может быть назначено значение указателя вектора прямо перед ним. IE: мне понадобился только условный тест для 1: 3 указателей, чтобы правильно их обернуть.

В первоначальном проекте использовались байтовые числа для максимизации использования кэша, однако мне удалось ослабить это ограничение - удовлетворение запроса пользователей на обработку большего количества ресурсов на пользователя в миллисекунду - использовать шорты без знака и STILL удвоить производительность , поскольку даже при наличии 3 смежных векторов по 512 шорт без знака в кэш-памяти данных L1 в кэш-памяти L1 можно было легко хранить 3720 байт, 2/3 из которых были в только что использованных местах. Только когда Хвост, Сводка или Голова были обернуты 1 из 3, разделенных каким-либо значительным «шагом» в 8 МБ кэш-памяти L3.

Общий объем памяти во время выполнения для этого кода составляет менее 2 МБ, поэтому он полностью использует кэш-память на кристалле, и даже на чипе i7 с 4 ядрами можно запустить 4 экземпляра этого процесса без какого-либо снижения производительности. производительность вообще, а общая пропускная способность немного увеличивается при 5 запущенных процессах. Это Opus Magnum по использованию кэша.

2 голосов
/ 04 декабря 2009

Большинство компиляторов C / C ++ предпочитают оптимизировать по размеру, а не по "скорости". То есть меньший код обычно выполняется быстрее, чем развернутый код из-за эффектов кэша.

0 голосов
/ 30 ноября 2009

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

  • узкий цикл, не содержащий вызовов каких-либо функций, потому что если он вызывает какую-либо функцию, то ПК будет проводить большую часть своего времени в этой функции,
  • , что составляет значительную долю времени выполнения (например,> = 10%), которое вы можете определить из профилировщика. (Я просто пробую стек вручную.)

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...