Эффективные алгоритмы процессора?

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

При этом я недавно задавал вопрос здесь Эффективные объекты AI для физической игры< /а>

Где мне сказали:

обычно скорость ЦП иссякает до исчерпания памяти

Мы провели некоторое тестирование, и оказалось, что упаковка/распаковка экономит оперативную память, но определенно снижает производительность.

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

Например, одним из основных аспектов скорости программы является динамическое выделение памяти, которое также направлено на сохранение оперативной памяти.

Что я хочу знать: что делает ЦП кода эффективным? Обладают ли языки более низкого уровня, такие как C, большей гибкостью для эффективности использования процессора? Если да, то почему/как?

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


person bigcodeszzer    schedule 19.12.2015    source источник
comment
Как правило, все, что вам нужно, это алгоритм с низкой сложностью. Затем компилятор может выполнить некоторую оптимизацию, и все готово.   -  person Alexguitar    schedule 19.12.2015
comment
Пожалуйста, не делайте ошибку, думая, что оптимизация компилятора улучшит плохой алгоритм. Компилятор понятия не имеет, чего вы пытаетесь достичь.   -  person Weather Vane    schedule 19.12.2015


Ответы (4)


Профилировщик

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

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

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

Оптимизация памяти

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

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

То, как работают машины и операционные системы, устанавливается в виде иерархической формы памяти, варьирующейся от самой быстрой, но самой маленькой памяти (регистры) до самой медленной и самой большой (диск).

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

[4 kilobyte chunk][*4 kilobyte chunk][4 kilobyte chunk][...]
// '*' indicates the chunk that's loaded in.

Когда вы запрашиваете доступ к виртуальной памяти в любом месте, окружающем выровненный фрагмент размером 4 килобайта, система выгружает этот фрагмент в DRAM. Но мы еще не закончили. Обычно, прежде чем мы сможем что-то сделать, мы должны загрузиться из DRAM в кэш ЦП, который сам разделен на иерархию. В этих случаях память может быть загружена в 64-байтовый выровненный фрагмент строки кэша, например:

[64-byte chunk][64-byte chunk][*64-byte chunk][...]

... поэтому доступ к памяти в конечном итоге загружается из DRAM в кеш ЦП таким образом. Когда вы запрашиваете доступ к памяти в DRAM вокруг одного из этих 64-байтовых фрагментов, весь 64-байтовый фрагмент загружается в кэш ЦП.

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

[64 bits][64 bits][64 bits][*64 bits][64 bits][...]

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

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

Оптимизация памяти

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

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

Используйте данные до их удаления

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

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

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

TL;DR

Если вы хотите узнать больше об этих предметах, я бы посоветовал изучить местонахождение ссылки. Эта статья также обязательна: http://lwn.net/Articles/250967/

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

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

Обновить

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

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

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

Распределители памяти

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

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

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

person Community    schedule 19.12.2015
comment
Что ж, ваш ответ чрезвычайно подробный и тщательный, и «профилировщик» может быть самым полезным ответом, но я не уверен, действительно ли вы ответили на вопрос. Опять же, возможно, я недостаточно объяснил. Я дал вам голос, но многое из того, что вы обсуждали, относится к манипуляциям на уровне сборки, которые, как я уже упоминал, выходят за рамки вопроса. Кстати, вы, кажется, ответили на всю тему так или иначе. - person bigcodeszzer; 20.12.2015
comment
@bigcodeszzer О, это темы компьютерной архитектуры, но вам не нужно писать ассемблерный код, чтобы извлечь выгоду из эффективности использования памяти. Например, если вы пишете код на Java, вы можете избежать ArrayList из Integer в пользу массива ints, так как Integer нужно выделять отдельно (рассеивая память и теряя локальность ссылки — в частности, пространственную локальность). Концепции применяются независимо от языка, поскольку аппаратное обеспечение одинаково. - person ; 20.12.2015
comment
Да, я просто не уверен, как вы можете на самом деле делать некоторые вещи, о которых вы говорили в «Оптимизация памяти» и «Вытеснение данных» на таких языках, как C, C++ или Java? - person bigcodeszzer; 20.12.2015
comment
Как и в C, я не думал, что вы можете контролировать, как память загружается в кеш и из него? - person bigcodeszzer; 20.12.2015
comment
@bigcodeszzer Часто вы не можете напрямую контролировать эти вещи, даже если пишете ассемблерный код. Например, вы можете контролировать использование большего количества непрерывных структур данных на основе массивов. В C и C++ вы можете написать распределители памяти, которые упаковывают вашу память в более непрерывную форму, подобную массиву, даже для таких структур, как двоичные деревья. Это хорошо работает с кешем, но подкачка и кэширование обычно выполняются за вашей спиной - это что-то вне вашего прямого контроля (но вы можете написать свой код так, чтобы он действительно хорошо подходил). - person ; 20.12.2015
comment
Ну, я, конечно, этого не знал, хотя вы, вероятно, не можете так сильно контролировать это в языке, который не поддерживает динамическое распределение, например Java. Если вы не говорите, что цель состоит в том, чтобы просто выделить большой массив, а затем разделить его по индексу? Это то, что вы имеете в виду под распределителем? - person bigcodeszzer; 20.12.2015
comment
Что меня действительно интересует, так это доступ. Если я правильно понимаю, в регистрах есть смещение ссылки на данные? Поэтому мне любопытно, что более/менее эффективно с точки зрения доступа к данным, а также их повторения. - person bigcodeszzer; 20.12.2015
comment
@bigcodeszzer Немного. Даже в Java всякий раз, когда вы используете ссылки на объекты, они распределяются через сборщик мусора, и сборщик мусора может поместить их в любое место в памяти. Когда вы выделяете массив в Java, даже если это сборщик мусора, массив должен иметь непрерывное представление. Таким образом, одна из стратегий в таком языке, как Java, если вы хотите оптимизировать, состоит в том, чтобы как бы стереть некоторые из ваших объектов и превратить их в большие старые массивы простых старых данных. Это дало бы им непрерывную структуру памяти и пространственную локальность (хотя потенциально большие затраты на ремонтопригодность кода). - person ; 20.12.2015
comment
Давайте продолжим обсуждение в чате. - person ; 20.12.2015
comment
@Ike Подробное объяснение, которое стоит прочитать - person Dilip Kumar; 20.12.2015

Что делает ЦП кода эффективным?

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

Обладают ли языки более низкого уровня, такие как C, большей гибкостью для эффективности использования процессора?

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

person Dilip Kumar    schedule 19.12.2015

Общий вопрос заслуживает общего ответа:

Вся оптимизация — это упражнение в кэшировании.

Особенно на современных многоуровневых архитектурах кэша.

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

Другими словами, оперативная память и дисковое пространство стоят дешево. Используйте их в своих интересах.

person Jens    schedule 19.12.2015
comment
Тогда есть узкое место фон Неймана... - person g24l; 24.12.2015

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

Проблема с разными языками в том, что они могут соблазнить вас делать что-то, что требует дополнительного времени. Например, C++ соблазняет вас использовать new, потому что это так просто, и существуют всевозможные классы-контейнеры, облегчающие выполнение причудливых вещей. Если вы работаете на C, гораздо сложнее делать причудливые вещи, так что угадайте, что вы не делаете (если только вам это действительно не нужно) и вы не платите цену за производительность.

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

person Mike Dunlavey    schedule 19.12.2015