Почему линейная запись с перемешиванием с чтением не быстрее, чем с линейной записью с перемешиванием? - PullRequest
0 голосов
/ 20 февраля 2019

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

Имея это в виду, я сделал следующий быстрый и грязный тест: я написал скрипт, который создает массив из N случайных чисел с плавающей точкой и перестановку, то есть массив, содержащий числа от 0 до N-1 в случайном порядке.Затем он многократно либо (1) считывает массив данных линейно и записывает его обратно в новый массив в шаблоне произвольного доступа, заданного перестановкой, либо (2) считывает массив данных в переставленном порядке и линейно записывает его в новый массив.

К моему удивлению (2) показалось постоянно быстрее, чем (1).Однако были проблемы с моим скриптом

  • Сценарий написан на python / numpy.Это довольно высокоуровневый язык, поэтому не ясно, насколько хорошо реализованы операции чтения / записи.
  • Возможно, я не правильно сбалансировал два случая.

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

Мой вопрос:

  • Какой (если таковые имеются) из двух должно быть быстрее?
  • Каковы здесь подходящие концепции кэширования;как они влияют на результат

Будем благодарны новичкам.Любой вспомогательный код должен быть на языке C / cython / numpy / numba или python.

Опционально:

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

Для справки, моя платформа Linux-4.12.14-lp150.11-default-x86_64-with-glibc2.3.4.Версия Python 3.6.5.

Вот код, который я написал:

import numpy as np
from timeit import timeit

def setup():
    global a, b, c
    a = np.random.permutation(N)
    b = np.random.random(N)
    c = np.empty_like(b)

def fwd():
    c = b[a]

def inv():
    c[a] = b

N = 10_000
setup()

timeit(fwd, number=100_000)
# 1.4942631321027875
timeit(inv, number=100_000)
# 2.531870319042355

N = 100_000
setup()

timeit(fwd, number=10_000)
# 2.4054739447310567
timeit(inv, number=10_000)
# 3.2365565397776663

N = 1_000_000
setup()

timeit(fwd, number=1_000)
# 11.131387163884938
timeit(inv, number=1_000)
# 14.19817715883255

Как указали @Trilarion и @Yann Vernier, мои фрагменты не сбалансированы должным образом, поэтому я заменилих с

def fwd():
    c[d] = b[a]
    b[d] = c[a]

def inv():
    c[a] = b[d]
    b[a] = c[d]

, где d = np.arange(N) (я перетасовываю все в обоих направлениях, чтобы уменьшить эффекты пробного кэширования).Я также заменил timeit на repeat и уменьшил количество повторов в 10 раз.

Тогда я получу

[0.6757169323973358, 0.6705542299896479, 0.6702114241197705]    #fwd
[0.8183442652225494, 0.8382121799513698, 0.8173762648366392]    #inv
[1.0969422250054777, 1.0725746559910476, 1.0892365919426084]    #fwd
[1.0284497970715165, 1.025063106790185, 1.0247828317806125]     #inv
[3.073981977067888, 3.077839042060077, 3.072118630632758]       #fwd
[3.2967213969677687, 3.2996009718626738, 3.2817375687882304]    #inv

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

Ответы [ 5 ]

0 голосов
/ 01 марта 2019

Это сложная проблема, тесно связанная с архитектурными особенностями современных процессоров, и ваша интуиция в том, что случайное чтение медленнее, чем случайные записи, поскольку ЦП должен ждать, пока прочитанные данные не проверены (большая частьвремя).Я объясню несколько причин.

  1. Современные процессоры очень эффективно скрывают задержку чтения

  2. , тогда как запись в память обходится дороже, чемчтение памяти

  3. особенно в многоядерной среде

Причина № 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 µинструкций в последних пенсиях).Как только ресурсы, требуемые инструкцией, становятся доступными (например, значение регистра c1 для инструкции 1b), инструкция может выполнить.

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

  • очередь для обеспечения последовательности при обратной записиприводит к регистрам (буфер переупорядочения или ROB из 168 записей) по аналогичным причинам.

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

В какой-то момент выполнения предыдущей программы будет много ожидающих сохраняющихся инструкций в RS, несколько загрузок в MOB и инструкций, ожидающих выхода в ROB.

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

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

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

... за исключением случаев, когда у вас длинные цепочки зависимостей.

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

Например, рассмотрим код for i in range(1,100000): s += a[i].Все чтения из памяти являются независимыми, но для накопления в s существует цепочка зависимостей.Никакое дополнение не может произойти, пока предыдущий не закончилсяЭти зависимости быстро заполняют станции резервирования и создают задержки в конвейере.

Но операции чтения редко включаются в цепочки зависимостей.По-прежнему возможно представить патологический код, в котором все чтения зависят от предыдущего (например, for i in range(1,100000): s = a[s]), но в реальном коде они редки.И проблема возникает из цепочки зависимостей, а не из-за того, что это чтение;Ситуация была бы аналогичной (и даже, вероятно, еще хуже) с зависимым от вычислений кодом, подобным for i in range(1,100000): x = 1.0/x+1.0.

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

Причина № 2: Операции записи в память (особенно случайные) дороже чтения в памяти

Это связано кэши ведут себя.Кэш-память - это быстрая память, которая хранит часть памяти (называемую line ) процессором.Строки кэша в настоящее время занимают 64 байта и позволяют использовать пространственную локальность ссылок на память: после сохранения строки все данные в строке становятся немедленно доступными.Важным аспектом здесь является то, что все передачи между кешем и памятью являются строками .

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

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

  1. кэш извлекает строку из памятии записывает его в строку кэша.
  2. данные записываются в кэш, а полная строка помечается как измененная (грязная)
  3. , когда строка подавляется из кэша, она проверяет наличиеизмененный флаг, и если строка была изменена, она записывает ее обратно в память (кэш обратной записи)

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

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

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

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

Причина № 3: Случайные записи создают ошибки кэша в многоядерных системах

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

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

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

Что касается проблемы случайной записи, строки кэша имеют размер 64 байта и могут содержать 8 int64, и есликомпьютер имеет 8 ядер, каждое ядро ​​будет обрабатывать в среднем 2 значения.Следовательно, существует важный ложный обмен, который замедляет запись.


Мы провели некоторые оценки производительности.Это было выполнено в C для того, чтобы включить оценку влияния распараллеливания.Мы сравнили 5 функций, которые обрабатывают массивы int64 размера N.

  1. Просто копия b в c (c[i] = b[i]) (реализовано компилятором с memcpy())

  2. Копирование с линейным индексом c[i] = b[d[i]], где d[i]==i (read_linear)

  3. Копирование со случайным индексом c[i] = b[a[i]], где a -случайная перестановка 0..N-1 (read_random эквивалентно fwd в исходном вопросе)

  4. Запись линейная c[d[i]] = b[i], где d[i]==i (write_linear)

  5. Запись случайного числа 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.

Вот график результатов.Функции имеют разные цвета, с последовательной версией в толстых линиях и параллельной версией в тонких.

enter image description here

Прямая копия (очевидно) самая быстраяи реализуется 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
  • небольшие значения (<10k): кэш-память L1 имеет размер 32 КБ и может содержать массив из 4 КБ uint64.Обратите внимание, что из-за случайности индекса после ~ 1/8 итераций кэш L1 будет полностью заполнен значениями массива случайных индексов (поскольку строки кэша имеют размер 64 байта и могут содержать 8 элементов массива).При доступе к другим линейным массивам мы быстро сгенерируем много пропусков L1, и нам придется использовать кэш L2.Доступ к кэш-памяти L1 составляет 5 циклов, но он конвейерный и может обслуживать пару значений за цикл.Доступ L2 длиннее и требует 12 циклов.Количество пропусков одинаково для случайного чтения и записи, но мы видим, что мы полностью оплачиваем двойной доступ, необходимый для записи, когда размер массива небольшой. </p>

  • средние значения (10k-100k): Кэш-память L2 имеет размер 256 КБ и может содержать массив размером 32 КБ Int64.После этого нам нужно перейти в кэш L3 (12Mo).По мере увеличения размера увеличивается число пропусков в L1 и L2 и соответственно увеличивается время вычисления.Оба алгоритма имеют одинаковое количество пропусков, в основном из-за случайного чтения или записи (другие обращения являются линейными и могут быть очень эффективно предварительно извлечены кэшем).Мы извлекаем фактор два между случайным чтением и записью, уже отмеченными в ответе BM.Это может быть частично объяснено двойной стоимостью записи.

  • большие значения (> 100k): разница между методами постепенно уменьшается.Для этих размеров большая часть информации хранится в кеше L3.Размер L3 достаточен для хранения всего массива 1,5 М, и линии с меньшей вероятностью будут извлечены.Следовательно, для записей, после начального чтения, большее количество записей может быть выполнено без извлечения строки, и относительная стоимость операций записи по сравнению с чтением снижается.Для этих больших размеров есть также много других факторов, которые необходимо учитывать.Например, кеши могут обслуживать только ограниченное число пропусков (тип 16), а когда количество пропусков велико, это может быть ограничивающим фактором.

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

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

0 голосов
/ 27 февраля 2019

Ваши два фрагмента NumPy b[a] и c[a] = b кажутся разумными эвристиками для измерения перемешанных / линейных скоростей чтения / записи, о чем я расскажу, рассмотрев базовый код NumPy в первом разделе ниже.

Что касается вопроса о том, что должно быть быстрее, то представляется вероятным, что shuffled-read-linear-write обычно может выиграть (как показывают тесты), но различие в скорости может зависеть от того, насколько «перемешано»индекс перетасовки равен одному или нескольким из:

  • Политики чтения / обновления кэша ЦП ( обратная запись против сквозной записи и т. д.).
  • Как процессор выбирает (пере) упорядочивает инструкции, необходимые для выполнения (конвейерная обработка).
  • Процессор распознает шаблоны доступа к памяти и предварительно выбирает данные.
  • Логика удаления кэша.

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

Тем не менее, во втором разделе ниже я попытаюсь рассуждать о том, почему shuffled-read-linear-write быстрее, учитывая некоторыепредположения.


«Тривиальное» необычное индексирование

Цель этого раздела - просмотреть исходный код NumPy, чтобы определить, есть ли какие-либо очевидные объяснения времени, а также получитькак можно яснее понять, что происходит при выполнении A[B] или A[B] = C.

Процедура итерации, лежащая в основе причудливого индексирования для операций getitem и setitem в этом вопросе « trivial »:

  • B - массив с одним индексом с одним шагом
  • A и B естьодин и тот же порядок памяти (как С-смежный, так и Фортран-смежный)

Более того, в нашем случае оба A и B равны Uint Aligned :

Код свернутой копии: Здесь,вместо этого используется "выравнивание uint".Если размер элемента [N] массива равен 1, 2, 4, 8 или 16 байтов, и массив выровнен по uint, то вместо [использования буферизации] numpy сделает *(uintN*)dst) = *(uintN*)src) для соответствующего N. В противном случае numpy копирует, выполнивmemcpy(dst, src, N).

Дело в том, что использование внутреннего буфера для обеспечения выравнивания исключается.Базовое копирование, реализованное с помощью *(uintN*)dst) = *(uintN*)src), столь же прямолинейно, как «поместите байты X со смещением src в байты X со смещением dst».

Компиляторы, скорее всего, очень просто переведут это в инструкции mov (на x86например) или аналогичный.

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

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

    while (itersize--) {
        char * self_ptr;
        npy_intp indval = *((npy_intp*)ind_ptr);

#if @isget@
        if (check_and_adjust_index(&indval, fancy_dim, 0, _save) < 0 ) {
            return -1;
        }
#else
        if (indval < 0) {
            indval += fancy_dim;
        }
#endif

        self_ptr = base_ptr + indval * self_stride; /* offset into array being indexed */

#if @isget@
        *(npy_uint64 *)result_ptr = *(npy_uint64 *)self_ptr;
#else
        *(npy_uint64 *)self_ptr = *(npy_uint64 *)result_ptr;
#endif

        ind_ptr += ind_stride;         /* move to next item of index array */
        result_ptr += result_stride;   /* move to next item of result array */

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

Дополнительные проверки индексов для setitem

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

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

Эта дополнительная предварительная проверка, вероятно, может оказать небольшое, но отрицательное влияние на скорость setitem (A[B] = C).


Кэш пропускает

Поскольку код для обоих фрагментов кодаСхоже, подозрение падает на процессор и то, как он обрабатывает доступ к базовым массивам памяти.

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

Для контекста строки кэша обычно имеют размер 64 байта.Кэш данных L1 (самый быстрый) на старом ЦП моего ноутбука составляет 32 КБ (этого достаточно для хранения около 500 значений int64 из массива, но имейте в виду, что ЦП будет выполнять другие операции, требующие другой памяти, пока выполняется фрагмент NumPy):

$ cat /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size
64
$ cat /sys/devices/system/cpu/cpu0/cache/index0/size
32K

Как вы, наверное, уже знаете, для чтения / записи памяти последовательное кэширование работает хорошо, потому что 64-байтовые блоки памяти выбираются по мере необходимости и хранятся ближе к ЦП.Повторный доступ к этому блоку памяти происходит быстрее, чем выборка из ОЗУ (или более медленный кэш более высокого уровня).Фактически, ЦП может даже превентивно извлечь следующую строку кэша, прежде чем она будет запрошена программой.

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

Так чтобыстрее обрабатывать ЦП: частые пропуски чтения данных или пропуски записи?

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

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

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

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

0 голосов
/ 21 февраля 2019

Ваша функция fwd не касается глобальной переменной c.Вы не сказали ему global c (только в setup), поэтому он имеет свою локальную переменную и использует STORE_FAST в cpython:

>>> import dis
>>> def fwd():
...     c = b[a]
...
>>> dis.dis(fwd)
  2           0 LOAD_GLOBAL              0 (b)
              3 LOAD_GLOBAL              1 (a)
              6 BINARY_SUBSCR
              7 STORE_FAST               0 (c)
             10 LOAD_CONST               0 (None)
             13 RETURN_VALUE

Теперь,давайте попробуем это с глобальным:

>>> def fwd2():
...     global c
...     c = b[a]
...
>>> dis.dis(fwd2)
  3           0 LOAD_GLOBAL              0 (b)
              3 LOAD_GLOBAL              1 (a)
              6 BINARY_SUBSCR
              7 STORE_GLOBAL             2 (c)
             10 LOAD_CONST               0 (None)
             13 RETURN_VALUE

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

В любом случае, если вы хотите записать в c, вам нужно что-то вроде c[:] = b[a] или c.fill(b[a]).Присвоение заменяет переменную (name) на объект с правой стороны, поэтому старый c может быть освобожден вместо нового b[a], и такой вид перестановки памяти может быть дорогостоящим.

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

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

0 голосов
/ 24 февраля 2019

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

$ cat test.py 
import numpy as np
from timeit import timeit
import numba

def fwd(a,b,c):
    c = b[a]

def inv(a,b,c):
    c[a] = b

@numba.njit
def fwd_numba(a,b,c):
    for i,j in enumerate(a):
        c[i] = b[j]

@numba.njit
def inv_numba(a,b,c):
    for i,j in enumerate(a):
        c[j] = b[i]


for p in range(4, 8):
    N = 10**p
    n = 10**(9-p)
    a = np.random.permutation(N)
    b = np.random.random(N)
    c = np.empty_like(b)
    print('---- N = %d ----' % N)
    for f in 'fwd', 'fwd_numba', 'inv', 'inv_numba':
        print(f, timeit(f+'(a,b,c)', number=n, globals=globals()))

$ python test.py 
---- N = 10000 ----
fwd 1.1199337750003906
fwd_numba 0.9052993479999714
inv 1.929507338001713
inv_numba 1.5510062070025015
---- N = 100000 ----
fwd 1.8672701190007501
fwd_numba 1.5000483989970235
inv 2.509873716000584
inv_numba 2.0653326050014584
---- N = 1000000 ----
fwd 7.639554155000951
fwd_numba 5.673054756000056
inv 7.685382894000213
inv_numba 5.439735023999674
---- N = 10000000 ----
fwd 15.065879136000149
fwd_numba 12.68919651500255
inv 15.433822674000112
inv_numba 14.862108078999881
0 голосов
/ 20 февраля 2019
  • Сначала опровержение вашей интуиции: fwd бьет inv даже без грубого механизма.

Это относится к этой numba версии:

import numba

@numba.njit
def fwd_numba(a,b,c):
    for i in range(N):
        c[a[i]]=b[i]

@numba.njit
def inv_numba(a,b,c):
    for i in range(N):
        c[i]=b[a[i]]

Время для N = 10 000:

%timeit fwd()
%timeit inv()
%timeit fwd_numba(a,b,c)
%timeit inv_numba(a,b,c)
62.6 µs ± 3.84 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
144 µs ± 2 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
16.6 µs ± 1.52 µs per loop (mean ± std. dev. of 7 runs, 10000 loops each)
34.9 µs ± 1.57 µs per loop (mean ± std. dev. of 7 runs, 100000 loops each)
  • Во-вторых, Numpy приходится сталкиваться с пугающими проблемами выравнивания и (кэширования) локальности.

По сути, это оболочка для низкоуровневых процедур из BLAS / ATLAS / MKL , настроенных для этого.Необычное индексирование - хороший инструмент высокого уровня, но еретический для этих проблем;нет прямой передачи этой концепции на низком уровне.

  • В-третьих, numy dev docs: подробности необычной индексации.В частности:

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

Мы в этом случае здесь.Я думаю, это может объяснить разницу, и почему set медленнее, чем get.

Это также объясняет, почему hand made numba часто быстрее: он ничего не проверяет и вылетает при несовместимом индексе.

...