Как распараллелить простой цикл над матрицей?

У меня большая матрица, и я хочу вывести все индексы, в которых элементы матрицы меньше 0. У меня есть этот MWE в цифровом формате:


import numba as nb
import numpy as np
A = np.random.random(size = (1000, 1000)) - 0.1
@nb.njit(cache=True)
def numba_only(arr):
    rows = np.empty(arr.shape[0]*arr.shape[1])
    cols = np.empty(arr.shape[0]*arr.shape[1])
    idx = 0
    for i in range(arr.shape[0]):
        for j in range(A.shape[1]):
            if arr[i, j] < 0:
                rows[idx] = i
                cols[idx] = j
                idx += 1
    return rows[:idx], cols[:idx]

Время, я получаю:

%timeit numba_only(A)
2.29 ms ± 114 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Это быстрее, чем np.where(A<0) (без цифр), что дает:

%timeit numpy_only(A)
3.56 ms ± 59.1 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Но это немного медленнее, чем np.where, обернутое @nb.njit:

@nb.njit(cache=True)
def numba_where(arr):
    return np.where(arr<0)

который дает:

%timeit numba_where(A)
1.76 ms ± 84.9 µs per loop (mean ± std. dev. of 7 runs, 1 loop each)

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

Я не уверен, как использовать nb.prange для достижения этой цели из-за индекса idx в цикле.

Почему в Python есть оператор "pass"?
Почему в Python есть оператор "pass"?
Оператор pass в Python - это простая концепция, которую могут быстро освоить даже новички без опыта программирования.
Некоторые методы, о которых вы не знали, что они существуют в Python
Некоторые методы, о которых вы не знали, что они существуют в Python
Python - самый известный и самый простой в изучении язык в наши дни. Имея широкий спектр применения в области машинного обучения, Data Science,...
Основы Python Часть I
Основы Python Часть I
Вы когда-нибудь задумывались, почему в программах на Python вы видите приведенный ниже код?
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
LeetCode - 1579. Удаление максимального числа ребер для сохранения полной проходимости графа
Алиса и Боб имеют неориентированный граф из n узлов и трех типов ребер:
Оптимизация кода с помощью тернарного оператора Python
Оптимизация кода с помощью тернарного оператора Python
И последнее, что мы хотели бы показать вам, прежде чем двигаться дальше, это
Советы по эффективной веб-разработке с помощью Python
Советы по эффективной веб-разработке с помощью Python
Как веб-разработчик, Python может стать мощным инструментом для создания эффективных и масштабируемых веб-приложений.
2
0
76
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

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

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

@nb.njit(cache=True, parallel=True)
def numba_parallel_where(arr):
    flattenArr = arr.reshape(-1)
    arrSize = flattenArr.size
    chunkSize = 2048
    chunkCount = (arrSize + chunkSize - 1) // chunkSize
    counts = np.empty(chunkCount, dtype=np.int32)
    for chunkId in nb.prange(chunkCount):
        start = chunkId * chunkSize
        end = min(start + chunkSize, arrSize)
        count = 0
        for i in range(start, end):
            count += flattenArr[i] < 0
        counts[chunkId] = count
    offsets = np.empty(chunkCount + 1, dtype=np.int64)
    offsets[0] = 0
    for chunkId in range(chunkCount):
        offsets[chunkId + 1] = offsets[chunkId] + counts[chunkId]
    outSize = offsets[-1]
    rows = np.empty(outSize, dtype=np.int32)
    cols = np.empty(outSize, dtype=np.int32)
    n = np.int32(arr.shape[1])
    for chunkId in nb.prange(chunkCount):
        start = chunkId * chunkSize
        end = min(start + chunkSize, arrSize)
        idx = offsets[chunkId]
        for i in range(start, end):
            if flattenArr[i] < 0:
                rows[idx] = i // n
                cols[idx] = i % n
                idx += 1
    return rows, cols

Результаты производительности

Вот результаты производительности моего 6-ядерного процессора i5-9600KF с оперативной памятью 40 ГиБ/с (оптимальная пропускная способность для чтения) в Windows:

Sequential:                         1.68 ms
Parallel (no pre-allocations):      0.85 ms
Parallel (with pre-allocations):    0.55 ms   <-----
Theoretical optimal:                0.20 ms

Параллельная реализация примерно в два раза быстрее. На моей машине удалось достичь пропускной способности памяти ~19 ГиБ/с, что неоптимально, но не так уж и плохо. Хотя на 6-ядерном процессоре это кажется разочаровывающим, параллельная реализация не очень оптимизирована, и есть несколько моментов, которые следует учитывать:

  • выделение вывода происходит очень медленно (кажется, занимает 0,2-0,3 мс)
  • входной массив читается дважды, оказывая большую нагрузку на память
  • модуль/деление, используемые для вычисления индексов, немного дороже

Предварительное выделение выходных данных (заполненных вручную нулями, чтобы избежать возможных ошибок страниц) и передача их в функцию помогает значительно сократить время выполнения. Эта оптимизированная версия в 3 раза быстрее исходной (последовательной). Он достигает ~28 ГиБ/с, что относительно неплохо.


Примечания о производительности

Другие возможные оптимизации включают в себя:

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

AFAIK, уже несколько лет существует открытая ошибка медленного распределения Numba, но она, очевидно, еще не решена. Кроме того, относительно большие выделения могут быть довольно дорогими и в Windows. Код, скомпилированный в собственном коде (написанный на C/C++/Fortran/Rust) в Linux, не должен вызывать такой проблемы.

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

Цепочка зависимостей только от idx? То есть только откуда поставить вывод?

Simd 03.05.2024 19:40

Примерно да. значение idx зависит от того, были ли выполнены предыдущие условия или нет. Современные процессоры очень «умны», поскольку они могут предполагать, что условие здесь часто не учитывается (благодаря предсказанию ветвей) на основе внутреннего цикла обратной связи. Исходя из этого, они могут очень быстро протестировать многие состояния arr[i, j] < 0. Некоторые из них можно даже тестировать параллельно на последних процессорах. Однако если одно из условий оказывается неверно предсказанным, это катастрофа для производительности. Действительно, процессору необходимо отменить операции и повторно выполнить код из-за ошибочного прогнозирования.

Jérôme Richard 03.05.2024 19:51

Эта большая задержка неправильного прогнозирования (например,> 10 циклов) происходит на критическом пути: цепочке зависимостей idx инструкций увеличения. ЦП не может хранить ничего, зависящее от idx, пока оно не исправлено. Когда arr[i, j] < 0 часто бывает ложным (как здесь), процессоры могут почти игнорировать цепочку зависимостей и параллельно выполнять инструкции и конвейеризировать их. Когда это часто так, цепочка зависимостей — это увеличение idx. Когда результат не предсказуем, цепочка зависимостей представляет собой idx условное увеличение + задержку условной ветви.

Jérôme Richard 03.05.2024 19:58

По этой причине производительность последовательных/параллельных версий зависит от вероятности arr[i, j] < 0 и даже от ее предсказуемости. Параллельная версия должна масштабироваться лучше, когда arr[i, j] < 0 непредсказуемо (потому что здесь больше работы, связанной с ЦП, которая досадно параллельна).

Jérôme Richard 03.05.2024 20:01

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

Jérôme Richard 03.05.2024 20:08

Альтернативой может быть создание пустого массива длиной arr.shape[0]*arr.shape[1], запись в него по индексу i * arr.shape[0] + j, если arr[i, j] < 0, тем самым полностью устранив зависимость? Тогда потребуется еще один проход для перемещения всех записанных значений в начало массива, чтобы это не приводило к ускорению.

Simd 03.05.2024 20:27

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

Jérôme Richard 03.05.2024 20:37

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

Simd 03.05.2024 20:38

Другой проход — это проверка цикла if arr[i] == 0 и последующее увеличение idx, чтобы переместить значение в result[idx]. Примерно это и делает первоначальный алгоритм ;) .

Jérôme Richard 03.05.2024 20:39

Часть до сборки не такая уж и дорогая, и современный процессор может выполнять инструкцию arr[i, j] < 0 очень быстро параллельно, поскольку нет никаких зависимостей. Ваше решение, скорее всего, будет медленнее, поскольку для него требуется записать больше данных, а затем снова прочитать их из ОЗУ. Однако это может быть немного быстрее, если вы можете делать это по частям (в кеше L1) и если вы используете инструкции SIMD в первой части. Обратите внимание, что наборы инструкций SIMD, такие как AVX-512, позволяют векторизовать вторую часть.

Jérôme Richard 03.05.2024 20:42

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

Jérôme Richard 03.05.2024 20:46

На моей машине это происходит намного быстрее! %timeit numba_parallel_where(A) 657 мкс ± 35,9 мкс на цикл (среднее ± стандартное отклонение для 7 циклов по 1000 циклов каждый)

gaussplustwo 04.05.2024 19:47

Другие вопросы по теме