Более быстрый/параллельный способ объединения нескольких 3D-массивов Numpy в один существующий 3D-массив

У меня есть список, содержащий 100 массивов/изображений Numpy формы (1024, 1024, 4). У меня есть второй уже существующий массив Numpy формы (1024, 102400, 4).

Я пытаюсь объединить их в уже существующий массив следующим образом:

import numpy as np

imgs = [np.random.randint(0, 255, (1024, 1024, 4), np.uint8) for _ in range(100)]
imgs_arr = np.empty((1024, 1024 * 100, 4), np.uint8)
for i in range(100):
    imgs_arr[:, i * 1024:(i + 1) * 1024] = imgs[i]

Занимает 0,1025 секунды.

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

Есть ли способ повысить производительность цикла или распараллелить его, чтобы заполнить массив списком изображений, используя Numba, многопроцессорную обработку, многопоточность или просто сам Numpy?

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

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

import numpy as np

from numba import carray, njit, prange
from numba.extending import intrinsic
from numba.typed import List

@intrinsic
def address_to_void_pointer(typingctx, src):
    from numba.core import types, cgutils
    sig = types.voidptr(src)

    def codegen(cgctx, builder, sig, args):
        return builder.inttoptr(args[0], cgutils.voidptr_t)

    return sig, codegen


@njit(cache=True, parallel=True)
def numba_parallelization_func(imgs_arr_addr, imgs, shape,
                               dtype, img_count):
    imgs_arr = carray(address_to_void_pointer(imgs_arr_addr), shape, dtype)
    for i in prange(img_count):
        imgs_arr[:, i * 1024:(i + 1) * 1024] = imgs.getitem_unchecked(i)

    return None


def numba_parallelization() -> None:
    imgs = List([np.random.randint(0, 255, (1024, 1024, 4), np.uint8) for _ in range(100)])
    imgs_arr = np.empty((1024, 1024 * 100, 4), np.uint8)
    imgs_arr_addr = imgs_arr.ctypes.data
    numba_parallelization_func(imgs_arr_addr, imgs, imgs_arr.shape,
                               imgs_arr.dtype, len(imgs))

    return None

numba_parallelization_func занимает 0,0975 после компиляции/поиска в кеше.

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

import numpy as np

def lst_append():
    arrs = [np.random.randint(0, 255, (1024, 1024, 4), np.uint8) for _ in range(100)]
    lst = []
    for arr in arrs:
        lst.append(arr)

    return None

Цикл занимает 5,4999e-06 секунд.

...типизированный список, даже если он предварительно выделен, настолько медленный, что делает бесполезным любой прирост производительности Numba:

import numpy as np

from numba.typed import List

def typed_lst_append():
    arrs = [np.random.randint(0, 255, (1024, 1024, 4), np.uint8) for _ in range(100)]
    typed_lst = List([np.random.randint(0, 255, (1024, 1024, 4), np.uint8) for _ in range(100)])
    for i in range(100):
        typed_lst[i] = arrs[i]

    return None

Цикл занимает 0,2541 секунды.

Обновлено: важно отметить, что эта проблема не ограничивается 100 массивами или формой (1024, 1024, 4). Это задумано как пример. Список imgs составлен из десятков тысяч изображений. Это делается с помощью кэша изображений. И хотя во многих запросах к кешу доступно большинство изображений, обычно есть хотя бы несколько изображений, которые необходимо загрузить. Я не могу удержать все изображения в памяти.

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

ken 11.06.2024 15:06

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

user-cd 11.06.2024 15:33

Например, если вы можете создать кеш из 1000 изображений, используя один массив numpy (например, массив (1000, 1024, 1024, 4)), то все, что вам нужно, — это функция Numba, которая берет один огромный массив numpy и извлекает из него 100 комбинаций. Это гораздо проще реализовать, чем тот, который принимает список.

ken 11.06.2024 15:52

Проблема в том, что всего изображений десятки тысяч. Самые актуальные когда-то обычно доступны в кеше, но далеко не все. Для следующей операции мне нужно всего 100–1000 изображений, но этот список создается из десятков тысяч доступных изображений. У меня просто нет необходимой памяти, чтобы вместить их все. И я заранее понятия не имею, какая комбинация изображений мне понадобится. Это зависит от ввода пользователя. Кроме того, в будущем размер изображений может не ограничиваться 1024x1024x4. Честно говоря, я должен был указать это в вопросе.

user-cd 11.06.2024 16:06

Изначально мне было интересно, а почему бы просто не np.concatenate(imgs, axis=1)?

hpaulj 11.06.2024 17:14

Потому что производительность np.concatenate(imgs, axis=1) даже хуже, чем наивный пример, приведенный в вопросе. Я ищу что-то более быстрое. Поскольку я никогда не записываю в одну и ту же часть imgs_arr, я предполагал, что в первом примере должна быть возможность распараллелить внутреннюю часть цикла. Но я должен признать, что я не уверен, как это сделать, чтобы это было быстрее, если предварительным условием является список массивов Numpy в Python.

user-cd 11.06.2024 17:34

@user-cd, я вижу. Есть еще две вещи, которые следует учитывать. 1: Можно ли повторно использовать imgs_arr? Поскольку вы будете создавать его неоднократно, повторное использование должно удвоить производительность. 2. Можете ли вы объединить изображения вертикально, а не горизонтально? Не уверен, насколько это эффективно, но np.concatenate(imgs, axis=0) работает на 30% быстрее, чем np.concatenate(imgs, axis=1) на моем ПК.

ken 11.06.2024 21:18

Почему вы утверждаете, что ПАРАЛЛЕЛЬНЫЙ процесс улучшит производительность? Память, размещенная в процессе, не является «разделяемой», если только к исходным манипуляциям с данными Python Numpy не добавляются огромные дополнительные задержки и накладные расходы, связанные с кэшем процессора и ядра процессора, что закон Амдала почти наверняка приведет к ускорению << 1.0, поэтому Как вы думаете, почему попытка работать по-настоящему ПАРАЛЛЕЛЬНО, а не «просто» СОВРЕМЕННО или немного выиграть от маскировки задержки когда-либо принесет вам какое-либо положительное вознаграждение за производительность, если это когда-либо осуществимо на реальном оборудовании?

user3666197 11.06.2024 21:43

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

user-cd 11.06.2024 21:51

@user3666197 Why do you claim a PARALLEL-process would improve a performance? Я не разработчик программного обеспечения. Я просто не знаю лучшего. Вот почему я спросил.

user-cd 11.06.2024 21:52

Я думаю, вопрос не доходит до того, чего вы действительно хотите. Вы пишете о сохранении изображений (частично) на диск. Следовательно, копирование данных в память или создание массивов указателей на элементы памяти (ваше решение в виде списка) вообще бесполезно. Вместо этого вам следует взглянуть на формат файла HDF5 (h5py) или на какую-нибудь самодельную базу данных. HDF5 способен обрабатывать ТБ данных (частично в памяти или вне памяти). Конечно, возможны и самодельные решения, просто идея stackoverflow.com/questions/56708673/…

max9111 11.06.2024 22:01

Было бы справедливо указать вашу цель производительности — насколько «Быстрее» вы хотите получить. Как мудро написал Льюис Кэррол: «Не имея цели, любая дорога может привести туда». Это справедливо, не так ли?

user3666197 12.06.2024 01:18
Структурированный массив Numpy
Структурированный массив Numpy
Однако в реальных проектах я чаще всего имею дело со списками, состоящими из нескольких типов данных. Как мы можем использовать массивы numpy, чтобы...
1
12
110
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

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

TL;DR: доступ к памяти неоптимален, и вы можете использовать потоковые хранилища, чтобы сделать это быстрее (в 1,5 раза). В качестве альтернативы вы можете попытаться сделать весь код более удобным для кэширования, предполагая, что это действительно возможно в вашем конкретном случае (вероятно, не на основе комментариев).


Занимает 0,1025 секунды.

В моей системе это занимает 42,8 мс.

Операция привязана к памяти. Действительно, пропускная способность чтения моей оперативной памяти составляет 19,2 ГиБ/с, а скорость записи — 9,3 ГиБ/с. Это означает, что совокупная глобальная пропускная способность составит 28,5 ГиБ/с. Чтобы понять, насколько это хорошо, нужно сделать сырую копию памяти в Numpy 17.6 + 17.6 = 35.2 GiB/s. Пропускная способность моей оперативной памяти теоретически составляет около 42 ГиБ/с. Это означает, что код, указанный в вопросе, наверняка занимает около 80% моей оперативной памяти. Оптимизация, связанная с вычислениями, в этом случае бесполезна.

numba_parallelization_func принимает 0,0975 после компиляции/поиска в кеше.

В моей системе это занимает 36,9 мс.

Использование большего количества ядер может иногда помочь уменьшить проблемы с задержкой на одном ядре. Более того, на серверах нередко одно ядро ​​не может насытить память (преднамеренно). Именно поэтому вы получаете небольшой прирост скорости в Numba, используя больше ядер, но на моей машине невозможно получить более 25% из-за насыщения оперативной памяти, пока используется тот же подход. На практике ускорение здесь составляет 16%. Пропускная способность оперативной памяти составляет 22.4 + 10.7 = 33.1 GiB/s. Это 94% (почти оптимальной) производительности копирования, и это очень хорошо. При этом я не рекомендую использовать код Numba, поскольку он небезопасен и использует много ядер (6 на моем процессоре) для очень небольшого увеличения скорости.

типизированный список, даже если он предварительно выделен, настолько медленный, что делает бесполезным любой прирост производительности Numba

Да. Это AFAIK известная проблема типизированных списков Numba.

Есть ли способ повысить производительность цикла или распараллелить его, чтобы заполнить массив списком изображений, используя Numba, многопроцессорную обработку, многопоточность или просто сам Numpy?

Есть один. Действительно, исходя из размера imgs_arr, мы должны ожидать пропускную способность чтения imgs_arr.size/42.8e-3/1024**3 = 9.1 GiB для кода Numpy, а не 19,2 ГиБ/с. То же самое должно произойти и с пропускной способностью записи (т. е. 9,3 против 9,1 ГиБ/с). Пропускная способность чтения значительно выше, чем хотелось бы!

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

Единственный известный мне способ решить эту проблему — использовать потоковые хранилища (также известные как невременные хранилища модулей SIMD) на процессорах x86-64. Его следует использовать оптимизированными функциями, такими как memcpy в C/C++, если целевой блок памяти достаточно велик. Действительно, потоковые хранилища имеют тенденцию обходить кэши и записывать непосредственно в ОЗУ (на практике это более сложно, но для ясности давайте сделаем это проще), а это означает, что делать это не очень хорошая идея, если блок памяти помещается в кеш. и используется повторно позже. Плохая новость заключается в том, что только разработчик может знать это (а иногда это даже нелегко узнать), не говоря уже о том, что разработчик нацелен на один конкретный размер кэша, что в наши дни встречается редко. Таким образом, использование потоковых хранилищ может быть хорошей идеей для некоторых процессоров с ограниченным размером кэша и хуже для процессоров с очень большим кэшем (при условии, что данные будут повторно использоваться позже, что и ожидается). При этом imgs_arr здесь занимает 400 МБ, и в настоящее время нет массовых процессоров с таким большим кэшем. Это означает, что потоковый магазин должен помочь. С ними код должен работать до 50% быстрее (оптимально для целевой операции).

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

AFAIK, лучшее решение - написать для этого собственный низкоуровневый код (например, на C/C++). Один из способов — вручную использовать следующие встроенные функции, специфичные для x86-64:

void _mm_stream_si128(void* mem_addr, __m128i a);    // movntdq
void _mm256_stream_si256(void* mem_addr, __m256i a); // vmovntdq
void _mm512_stream_si512(void* mem_addr, __m512i a); // vmovntdq

Примечание mem_addr должно быть выровнено (по границе 16 байт для 128-битного, 32 байта для 256-битного и 64 байта для 512-битного). Также обратите внимание, что лишь немногие процессоры поддерживают набор инструкций SIMD AVX-512 (даже некоторые последние процессоры Intel), тогда как AVX в настоящее время поддерживается более чем ~95% процессоров x86-64. Поэтому я посоветовал вам использовать 256-битную. Если вас волнует совместимость, вам следует использовать 128-битную версию (она работает на всех процессорах x86-64). Обратите внимание, что это решение требует написания кода, специфичного для каждого процессора ISA (например, x86/x86-64, ARM, POWER и т. д.), хотя отсутствие поддержки других платформ может не быть проблемой, если вы не планируете запускать свой код на Процессоры ARM или другие.

Альтернативное решение — использовать предложение Nontemporal директив OpenMP SIMD, хотя оно может не поддерживаться целевым компилятором. Чтобы быть уверенным, вам следует проверить сгенерированный ассемблерный код. К сожалению, похоже, что три основных компилятора GCC, Clang и MSVC в этом случае генерируют неэффективный код. ICC (старый компилятор Intel), кажется, генерирует хороший код, но для ICX (новый компилятор Intel) это неясно.

Другое похожее решение — использовать директиву препроцессора, специфичную для компилятора, когда она действительно доступна, хотя ее нельзя переносить с одного компилятора на другой. AFAIK, у компиляторов Intel есть такая директива, но решение OpenMP, которое должно быть переносимым, в любом случае кажется уже хорошим при компиляции с компиляторами Intel.

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

При всем уважении к усилиям, уже приложенным к этому, позвольте мне спросить ваши профессиональные размышления по этому поводу. Вопрос 1: Будет ли код на ассемблере работать еще быстрее при повторном использовании иерархии кэша L1/L2/L3 и аппаратном обеспечении mem-I/? О каналах, касающихся реорганизации базовых байтовых полей Numpy? -- Q2: Какова вероятность, по вашему мнению, питониста, использующего циклы for с использованием интерпретатора-GIL, когда-либо погрузиться в скомпилированный numba-JIT, тем более расширение на иностранном языке с настройкой C/C++ OpenMP?

user3666197 11.06.2024 21:25

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

user-cd 11.06.2024 21:48

@ user3666197 В1) Ассемблер здесь не должен помочь больше, чем такие языки, как C/C++, в данном случае на основных процессорах x86-64. Действительно, что имеет значение, так это работа с памятью, поскольку код привязан к памяти, а встроенные функции SIMD C/C++ x86 эквивалентны целевым интересным ассемблерным инструкциям. Компиляторы могут генерировать неоптимальные циклы, но это проблема только для кодов, ориентированных на вычисления. Q2) Я не уверен, что понимаю ваш вопрос. Все решения, представленные в конце, не подходят для пользователей Python, но, к сожалению, я не знаю лучшего решения (оно уже не очень хорошо работает в C/C++).

Jérôme Richard 11.06.2024 22:13

@user-cd Спасибо. Я понимаю, тем более, что выигрыш довольно небольшой по сравнению с дополнительной сложностью. Мне бы хотелось, чтобы Numba включила такую ​​функцию в будущем (как просили на GitHub), чтобы избежать написания сложного кода в таком случае (преимущество со временем увеличивается из-за эффекта под названием «стена памяти»).

Jérôme Richard 11.06.2024 22:21

Спасибо. Объявление Q2. Меня только что предупредили о тестировании O/P, где были рассчитаны как фактическое распределение памяти (дорогостоящее из-за низкой задержки), так и рандомизация числовых данных и циклов, некоторые из которых назначают ссылки на них в объект списка Python - нет признаки тщательного профилирования узких мест производительности. Учитывая отсутствие контекста варианта использования (за исключением статических (в основном не имеющих отношения к реальной производительности — C-порядок или F-порядок? и т. д.) размеров и примечания, некоторые файлы будут считываться только откуда-то на лету), спецификация проблемы нет источника данных, нет целевого использования данных, поэтому вся наша работа здесь сводится к неосведомленным догадкам

user3666197 12.06.2024 01:22

@user3666197 user3666197 Я предположил, что ОП хочет измерить основной цикл (а не генерацию случайных чисел, создание списка и т. д.), что в конечном итоге так и есть. Это неявно упоминается в таких предложениях, как «повысить производительность цикла». Но вы правы, то, что оценивается, четко не указано.

Jérôme Richard 12.06.2024 19:49

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