У меня есть список, содержащий 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
составлен из десятков тысяч изображений. Это делается с помощью кэша изображений. И хотя во многих запросах к кешу доступно большинство изображений, обычно есть хотя бы несколько изображений, которые необходимо загрузить. Я не могу удержать все изображения в памяти.
У меня есть класс кеша изображений с функцией, которая будет возвращать изображения из кеша или загружать их, если его еще нет. Обычно где-то от 100 до 1000 изображений. Большинство из них обычно находятся в кеше, поэтому это довольно быстро. Но я довольно часто компилирую такой список с разными комбинациями изображений, а затем мне нужно превратить его в один массив, потому что следующая функция Numba работает значительно медленнее с типизированным списком, чем с одним массивом. Кроме того, создание типизированного списка происходит значительно медленнее, чем создание списка Python и объединение его в один массив. Поэтому я стараюсь ускорить слияние.
Например, если вы можете создать кеш из 1000 изображений, используя один массив numpy (например, массив (1000, 1024, 1024, 4)
), то все, что вам нужно, — это функция Numba, которая берет один огромный массив numpy и извлекает из него 100 комбинаций. Это гораздо проще реализовать, чем тот, который принимает список.
Проблема в том, что всего изображений десятки тысяч. Самые актуальные когда-то обычно доступны в кеше, но далеко не все. Для следующей операции мне нужно всего 100–1000 изображений, но этот список создается из десятков тысяч доступных изображений. У меня просто нет необходимой памяти, чтобы вместить их все. И я заранее понятия не имею, какая комбинация изображений мне понадобится. Это зависит от ввода пользователя. Кроме того, в будущем размер изображений может не ограничиваться 1024x1024x4. Честно говоря, я должен был указать это в вопросе.
Изначально мне было интересно, а почему бы просто не np.concatenate(imgs, axis=1)
?
Потому что производительность np.concatenate(imgs, axis=1)
даже хуже, чем наивный пример, приведенный в вопросе. Я ищу что-то более быстрое. Поскольку я никогда не записываю в одну и ту же часть imgs_arr
, я предполагал, что в первом примере должна быть возможность распараллелить внутреннюю часть цикла. Но я должен признать, что я не уверен, как это сделать, чтобы это было быстрее, если предварительным условием является список массивов Numpy в Python.
@user-cd, я вижу. Есть еще две вещи, которые следует учитывать. 1: Можно ли повторно использовать imgs_arr
? Поскольку вы будете создавать его неоднократно, повторное использование должно удвоить производительность. 2. Можете ли вы объединить изображения вертикально, а не горизонтально? Не уверен, насколько это эффективно, но np.concatenate(imgs, axis=0)
работает на 30% быстрее, чем np.concatenate(imgs, axis=1)
на моем ПК.
Почему вы утверждаете, что ПАРАЛЛЕЛЬНЫЙ процесс улучшит производительность? Память, размещенная в процессе, не является «разделяемой», если только к исходным манипуляциям с данными Python Numpy не добавляются огромные дополнительные задержки и накладные расходы, связанные с кэшем процессора и ядра процессора, что закон Амдала почти наверняка приведет к ускорению << 1.0, поэтому Как вы думаете, почему попытка работать по-настоящему ПАРАЛЛЕЛЬНО, а не «просто» СОВРЕМЕННО или немного выиграть от маскировки задержки когда-либо принесет вам какое-либо положительное вознаграждение за производительность, если это когда-либо осуществимо на реальном оборудовании?
@ken 1. Да, я могу и делаю. 2. Раньше у меня были изображения, расположенные вертикально, но итерация по элементам в следующей функции Numba происходит медленнее. Так что скорость, которую я мог бы выиграть, я потеряю позже. По крайней мере, из того, что я пробовал.
@user3666197 Why do you claim a PARALLEL-process would improve a performance?
Я не разработчик программного обеспечения. Я просто не знаю лучшего. Вот почему я спросил.
Я думаю, вопрос не доходит до того, чего вы действительно хотите. Вы пишете о сохранении изображений (частично) на диск. Следовательно, копирование данных в память или создание массивов указателей на элементы памяти (ваше решение в виде списка) вообще бесполезно. Вместо этого вам следует взглянуть на формат файла HDF5 (h5py) или на какую-нибудь самодельную базу данных. HDF5 способен обрабатывать ТБ данных (частично в памяти или вне памяти). Конечно, возможны и самодельные решения, просто идея stackoverflow.com/questions/56708673/…
Было бы справедливо указать вашу цель производительности — насколько «Быстрее» вы хотите получить. Как мудро написал Льюис Кэррол: «Не имея цели, любая дорога может привести туда». Это справедливо, не так ли?
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?
Прежде всего спасибо за невероятно быстрый и хорошо написанный ответ! По крайней мере, теперь я понимаю, почему подход Numba практически не улучшает производительность и каков ограничивающий фактор. К сожалению, я не разработчик программного обеспечения, и мой опыт работы с C/C++ крайне ограничен. Так что я боюсь, что подход потоковых магазинов, хотя и интересен, но, вероятно, выходит за рамки моих возможностей. Ну, по крайней мере, на данный момент. Это означает, что, думаю, мне придется пока принять выступление. Тем не менее, еще раз спасибо за этот отличный ответ.
@ user3666197 В1) Ассемблер здесь не должен помочь больше, чем такие языки, как C/C++, в данном случае на основных процессорах x86-64. Действительно, что имеет значение, так это работа с памятью, поскольку код привязан к памяти, а встроенные функции SIMD C/C++ x86 эквивалентны целевым интересным ассемблерным инструкциям. Компиляторы могут генерировать неоптимальные циклы, но это проблема только для кодов, ориентированных на вычисления. Q2) Я не уверен, что понимаю ваш вопрос. Все решения, представленные в конце, не подходят для пользователей Python, но, к сожалению, я не знаю лучшего решения (оно уже не очень хорошо работает в C/C++).
@user-cd Спасибо. Я понимаю, тем более, что выигрыш довольно небольшой по сравнению с дополнительной сложностью. Мне бы хотелось, чтобы Numba включила такую функцию в будущем (как просили на GitHub), чтобы избежать написания сложного кода в таком случае (преимущество со временем увеличивается из-за эффекта под названием «стена памяти»).
Спасибо. Объявление Q2. Меня только что предупредили о тестировании O/P, где были рассчитаны как фактическое распределение памяти (дорогостоящее из-за низкой задержки), так и рандомизация числовых данных и циклов, некоторые из которых назначают ссылки на них в объект списка Python - нет признаки тщательного профилирования узких мест производительности. Учитывая отсутствие контекста варианта использования (за исключением статических (в основном не имеющих отношения к реальной производительности — C-порядок или F-порядок? и т. д.) размеров и примечания, некоторые файлы будут считываться только откуда-то на лету), спецификация проблемы нет источника данных, нет целевого использования данных, поэтому вся наша работа здесь сводится к неосведомленным догадкам
@user3666197 user3666197 Я предположил, что ОП хочет измерить основной цикл (а не генерацию случайных чисел, создание списка и т. д.), что в конечном итоге так и есть. Это неявно упоминается в таких предложениях, как «повысить производительность цикла». Но вы правы, то, что оценивается, четко не указано.
Как создать список изображений при запуске? Есть ли причина, по которой вы не можете с самого начала загружать изображения в большой буфер?