Ответ 1
Это сложная проблема, тесно связанная с архитектурными особенностями современных процессоров и вашей интуицией, согласно которой случайное чтение выполняется медленнее, чем случайные записи, поскольку ЦП должен ждать, пока прочитанные данные не проверены (большую часть времени). Есть несколько причин, которые я подробно опишу.
-
Современные процессоры очень эффективно скрывают задержку чтения
-
в то время как запись в память дороже, чем чтение в память
-
особенно в многоядерной среде
Причина № 1 Современные процессоры эффективно скрывают задержку чтения.
Современный суперскаляр может выполнять несколько инструкций одновременно и изменять порядок выполнения команд (вне порядка выполнения). Хотя первая причина этих функций заключается в увеличении производительности команд, одним из наиболее интересных последствий является способность процессоров скрывать задержку записи в память (или сложных операторов, ветвей и т.д.).
Чтобы объяснить это, давайте рассмотрим простой код, который копирует массив в другой.
for i in a:
c[i] = b[i]
Один скомпилированный код, исполняемый процессором, будет как-то так
#1. (iteration 1) c[0] = b[0]
1a. read memory at b[0] and store result in register c0
1b. write register c0 at memory address c[0]
#2. (iteration 2) c[1] = b[1]
2a. read memory at b[1] and store result in register c1
2b. write register c1 at memory address c[1]
#1. (iteration 2) c[2] = b[2]
3a. read memory at b[2] and store result in register c2
3b. write register c2 at memory address c[2]
# etc
(это сильно упрощено, и реальный код более сложен и должен иметь дело с управлением циклами, вычислением адресов и т.д., но в настоящее время этой упрощенной модели достаточно).
Как сказано в вопросе, для чтения процессор должен ждать фактических данных. Действительно, 1b нужны данные, извлеченные 1a, и они не могут выполняться, пока 1a не завершена. Такое ограничение называется зависимостью, и мы можем сказать, что 1b зависит от 1a. Зависимости является основным понятием в современных процессорах. Зависимости выражают алгоритм (например, я пишу от b до c) и должны обязательно соблюдаться. Но, если между инструкциями нет зависимости, процессоры попытаются выполнить другие ожидающие инструкции, чтобы всегда поддерживать активный конвейер. Это может привести к неупорядоченному выполнению при условии соблюдения зависимостей (аналогично правилу "как будто").
Для рассматриваемого кода нет никакой зависимости между инструкциями высокого уровня 2. и 1. (или между инструкциями asm 2a и 2b и предыдущими инструкциями). На самом деле, конечный результат даже будет идентичен: 2. выполняется перед 1., и процессор попытается выполнить 2a и 2b, до завершения 1a и 1b. Между 2a и 2b все еще существует зависимость, но обе могут быть выданы. И аналогично для 3а. и 3б. и тд. Это мощное средство скрытия задержки памяти. Если по какой-либо причине 2., 3. и 4. могут прекратить работу до того, как 1. загрузит свои данные, вы можете даже не заметить никакого замедления.
Этот параллелизм на уровне команд управляется набором "очередей" в процессоре.
-
Очередь ожидающих инструкций в станциях резервирования RS (введите 128 µinstructions в последние пятидесятые годы). Как только ресурсы, требуемые инструкцией, становятся доступными (например, значение регистра c1 для инструкции 1b), инструкция может выполняться.
-
очередь ожидающих обращений к памяти в буфере порядка памяти MOB перед кэшем L1. Это необходимо для работы с псевдонимами памяти и для обеспечения последовательности при записи в память или при загрузке по одному и тому же адресу (тип. 64 загрузки, 32 хранилища)
-
очередь для обеспечения последовательности при обратной записи приводит к регистрам (буфер переупорядочения или ROB из 168 записей) по аналогичным причинам.
-
и некоторые другие очереди при получении инструкций для генерации мопов, буферов записи и пропуска в кэше и т.д.
В какой-то момент выполнения предыдущей программы будет много ожидающих хранения инструкций в RS, несколько загрузок в MOB и инструкций, ожидающих выхода в ROB.
Как только данные становятся доступными (например, чтение прекращается), могут выполняться различные инструкции, и это освобождает позиции в очередях. Но если завершение не происходит и одна из этих очередей заполнена, функциональный блок, связанный с этой очередью, останавливается (это также может произойти при выдаче команды, если в процессоре отсутствуют имена регистров). Задержки - это то, что создает потерю производительности, и чтобы избежать этого, заполнение очереди должно быть ограничено.
Это объясняет разницу между линейным и случайным доступом к памяти.
В линейном доступе 1/число пропусков будет меньше из-за лучшей пространственной локализации и потому что кеши могут предварительно выбирать доступы с регулярным шаблоном, чтобы еще больше его уменьшить, и 2/всякий раз, когда чтение завершается, это будет касаться полной строки кеша и может освободить несколько ожидающих загрузки инструкций, ограничивающих заполнение очередей инструкций. Таким образом, процессор постоянно занят, а задержка памяти скрыта.
Для произвольного доступа число пропусков будет выше, и при поступлении данных может обслуживаться только одна загрузка. Следовательно, очереди инструкций будут быстро насыщаться, зависание процессора и задержка памяти больше не будут скрыты при выполнении других инструкций.
Архитектура процессора должна быть сбалансирована с точки зрения пропускной способности, чтобы избежать насыщения очереди и остановок. Действительно, на каком-то этапе выполнения в процессоре обычно присутствуют десятки инструкций, и глобальная пропускная способность (т.е. Способность обслуживать запросы инструкций памятью (или функциональными единицами)) является основным фактором, определяющим производительность. Тот факт, что некоторые из этих ожидающих инструкций ожидают значения памяти, имеет незначительный эффект...
... за исключением случаев, когда у вас длинные цепочки зависимостей.
Существует зависимость, когда инструкция должна ждать завершения предыдущего. Использование результата чтения является зависимостью. И зависимости могут быть проблемой, когда участвуют в цепочке зависимостей.
Например, рассмотрим код for я in range(1,100000): s += a[i]
. Все чтения из памяти независимы, но есть цепочка зависимостей для накопления в s
. Никакое дополнение не может произойти, пока предыдущий не закончился. Эти зависимости позволят быстро заполнить станции резервирования и создать киоски в трубопроводе.
Но чтения редко участвуют в цепочках зависимостей. По-прежнему возможно представить патологический код, в котором все чтения зависят от предыдущего (например, for я in range(1,100000): s = a[s]
), но в реальном коде они редки. И проблема возникает из цепочки зависимостей, а не из-за того, что это чтение; ситуация была бы аналогичной (и даже, вероятно, еще хуже) с зависимым от вычислений кодом, подобным for я in range(1,100000): x = 1.0/x+1.0
.
Следовательно, за исключением некоторых ситуаций, время вычислений больше связано с пропускной способностью, чем с зависимостью чтения, благодаря тому, что суперскалярное выполнение или выполнение заказа скрывает задержку. А что касается пропускной способности, записи хуже, чем чтения.
Причина № 2: записи в память (особенно случайные) стоят дороже, чем чтение в память
Это связано с тем, как ведут себя кэши. Кэш-память - это быстрая память, которая хранит часть памяти (называемую линией) процессором. Строки кэша в настоящее время занимают 64 байта и позволяют использовать пространственную локальность ссылок на память: после сохранения строки все данные в строке становятся немедленно доступными. Важным аспектом здесь является то, что все передачи между кешем и памятью являются строками.
Когда процессор выполняет чтение данных, кэш проверяет, находится ли строка, к которой принадлежат данные, в кэше. Если нет, то строка извлекается из памяти, сохраняется в кеше, и нужные данные отправляются обратно в процессор.
Когда процессор записывает данные в память, кеш также проверяет наличие строки. Если строка отсутствует, кэш не может отправить свои данные в память (поскольку все передачи основаны на строке) и выполняет следующие шаги:
- кеш извлекает строку из памяти и записывает ее в строку кеша.
- данные записываются в кеш и полная строка помечается как измененная (грязная)
- когда строка подавляется из кэша, она проверяет наличие измененного флага, а если строка была изменена, она записывает ее обратно в память (обратная запись в кэш)
Следовательно, каждой записи в память должна предшествовать запись в память, чтобы получить строку в кеше. Это добавляет дополнительную операцию, но не очень дорого для линейных записей. При первом записанном слове произойдет пропуск кеша и чтение памяти, но последовательные записи будут касаться только кеша и будут попаданиями.
Но ситуация для случайных записей сильно отличается. Если важно число пропусков, каждая пропущенная кэш-память подразумевает чтение, за которым следует только небольшое количество записей, прежде чем строка будет извлечена из кеша, что значительно увеличивает стоимость записи. Если строка извлекается после одной записи, мы можем даже считать, что запись вдвое превышает временные затраты чтения.
Важно отметить, что увеличение количества обращений к памяти (чтение или запись) приводит к насыщению пути доступа к памяти и глобальному замедлению всех передач между процессором и памятью.
В любом случае запись всегда дороже, чем чтение. И многоядерные увеличивают этот аспект.
Причина № 3: Случайные записи создают ошибки кэша в многоядерных
Не уверен, что это действительно относится к ситуации вопроса. Хотя подпрограммы BLAS являются многопоточными, я не думаю, что базовая копия массива есть. Но это тесно связано и является еще одной причиной, почему записи стоят дороже.
Проблема многоядерности заключается в том, чтобы обеспечить надлежащую согласованность кэша таким образом, чтобы данные, совместно используемые несколькими процессорами, должным образом обновлялись в кэше каждого ядра. Это делается с помощью протокола, такого как MESI, который обновляет строку кеша перед ее записью и делает недействительными другие копии кеша (прочитано для владения).
Хотя никакие данные фактически не распределяются между ядрами в вопросе (или их параллельной версией), обратите внимание, что протокол применяется к строкам кэша. Всякий раз, когда необходимо изменить строку кэша, она копируется из кэша, содержащего самую последнюю копию, локально обновляется, а все остальные копии становятся недействительными. Даже если ядра обращаются к разным частям строки кэша. Такая ситуация называется ложным разделением, и это важная проблема для многоядерного программирования.
Что касается проблемы случайной записи, строки кэша занимают 64 байта и могут содержать 8 int64, и если компьютер имеет 8 ядер, каждое ядро будет обрабатывать в среднем 2 значения. Следовательно, существует важный ложный обмен, который замедляет запись.
Мы сделали некоторые оценки производительности. Это было выполнено в C для того, чтобы включить оценку влияния распараллеливания. Мы сравнили 5 функций, которые обрабатывают массивы int64 размера N.
-
Просто копия b в c (
c[i] = b[i]
) (реализуется компилятором с помощьюmemcpy()
) -
Копирование с линейным индексом
c[i] = b[d[i]]
гдеd[i]==i
(read_linear
) -
Скопируйте со случайным индексом
c[i] = b[a[i]]
гдеa
- случайная перестановка 0..N-1 (read_random
эквивалентенfwd
в исходном вопросе) -
Напишите линейное
c[d[i]] = b[i]
гдеd[i]==i
(write_linear
) -
Написать случайный
c[a[i]] = b[i]
сa
случайной перестановки 0..n-1 (write_random
эквивалентноinv
в вопросе)
Код был скомпилирован с помощью gcc -O3 -funroll-loops -march=native -malign-double
на процессоре Skylake. Производительность измеряется с помощью _rdtsc()
и задается в циклах на итерацию. Функция выполняется несколько раз (1000-20000 в зависимости от размера массива), выполняется 10 экспериментов и сохраняется наименьшее время.
Размеры массивов варьируются от 4000 до 1200000. Весь код был измерен в последовательной и параллельной версиях с openmp.
Вот график результатов. Функции бывают разных цветов, с последовательной версией в толстых линиях и параллельной с тонкими.
Прямое копирование (очевидно) является самым быстрым и реализуется gcc с высокооптимизированной memcpy()
. Это способ получить оценку пропускной способности данных с помощью памяти. Он варьируется от 0,8 цикла на итерацию (ИПЦ) для маленьких матриц до 2,0 ИПЦ для больших.
Линейные чтения выполняются примерно в два раза дольше, чем memcpy, но есть 2 чтения и запись, против 1 чтение и запись для прямой копии. Более индекс добавляет некоторую зависимость. Минимальное значение составляет 1,56 ИПЦ, а максимальное значение 3,8 ИПЦ. Запись линейная немного длиннее (5-10%).
Чтение и запись со случайным индексом являются целью исходного вопроса и заслуживают более длинных комментариев. Вот результаты.
size 4000 6000 9000 13496 20240 30360 45536 68304 102456 153680 230520 345776 518664 777992 1166984
rd-rand 1.86821 2.52813 2.90533 3.50055 4.69627 5.10521 5.07396 5.57629 6.13607 7.02747 7.80836 10.9471 15.2258 18.5524 21.3811
wr-rand 7.07295 7.21101 7.92307 7.40394 8.92114 9.55323 9.14714 8.94196 8.94335 9.37448 9.60265 11.7665 15.8043 19.1617 22.6785
-
малые значения (<10 КБ): кэш-память L1 имеет размер 32 КБ и может содержать массив из 4 КБ uint64. Обратите внимание, что из-за случайности индекса после ~ 1/8 итераций кэш L1 будет полностью заполнен значениями массива случайных индексов (поскольку строки кэша имеют размер 64 байта и могут содержать 8 элементов массива). При доступе к другим линейным массивам мы быстро сгенерируем много пропусков L1, и нам придется использовать кэш L2. Доступ к кэш-памяти L1 составляет 5 циклов, но он конвейерный и может обслуживать пару значений за цикл. Доступ L2 длиннее и требует 12 циклов. Количество пропусков одинаково для случайных операций чтения и записи, но мы видим, что мы полностью оплачиваем двойной доступ, необходимый для записи при небольшом размере массива.
-
средние значения (10k-100k): кэш-память L2 имеет размер 256 КБ и может содержать массив размером 32 КБ int64. После этого нам нужно перейти в кэш L3 (12Mo). По мере увеличения размера увеличивается число пропусков в L1 и L2 и соответственно увеличивается время вычисления. Оба алгоритма имеют одинаковое количество пропусков, в основном из-за случайного чтения или записи (другие обращения являются линейными и могут быть очень эффективно предварительно извлечены кэшем). Мы извлекаем фактор два между случайным чтением и записью, уже отмеченными в ответе BM. Это может быть частично объяснено двойной стоимостью записей.
-
большие значения (> 100 КБ): разница между методами постепенно уменьшается. Для этих размеров большая часть информации хранится в кеше L3. Размер L3 достаточен для хранения всего массива 1,5 М, и линии с меньшей вероятностью будут извлечены. Следовательно, для записей, после начального чтения, большее количество записей может быть выполнено без извлечения строки, и относительная стоимость операций записи по сравнению с чтением снижается. Для этих больших размеров есть также много других факторов, которые необходимо учитывать. Например, кэши могут обслуживать только ограниченное количество пропусков (тип 16), а когда количество пропусков велико, это может быть ограничивающим фактором.
Одно слово на параллельной версии случайного чтения и записи. За исключением небольших размеров, где наличие массива случайных индексов, распределенного по нескольким кэшам, может не быть преимуществом, они систематически ~ в два раза быстрее. Для больших размеров мы ясно видим, что разрыв между случайным чтением и записью увеличивается из-за ложного совместного использования.
Почти невозможно делать количественные прогнозы со сложностью современных компьютерных архитектур даже для простого кода, и даже качественные объяснения поведения являются сложными и должны учитывать множество факторов. Как упоминалось в других ответах, программные аспекты, связанные с питоном, также могут оказывать влияние. Но, хотя это может происходить в некоторых ситуациях, в большинстве случаев нельзя считать, что чтение обходится дороже из-за зависимости от данных.