Мне нужно извлечь ненулевые пиксели из изображения RGBA. Код ниже работает, но поскольку мне приходится иметь дело с действительно огромными изображениями, некоторое ускорение будет полезно. Получение «f_mask» — самая долгая задача. Можно ли как-то ускорить работу? Как быстрее удалить строки со всеми нулевыми значениями ([0, 0, 0, 0])?
import numpy as np
import time
img_size = (10000, 10000)
img = np.zeros((*img_size, 4), float) # Make RGBA image
# Put some values for pixels [float, float, float, int]
img[0][1] = [1.1, 2.2, 3.3, 4]
img[1][0] = [0, 0, 0, 10]
img[1][2] = [6.1, 7.1, 8.1, 0]
def f_img_to_pts(f_img): # Get non-zero rows with values from whole img array
f_shp = f_img.shape
f_newshape = (f_shp[0]*f_shp[1], f_shp[2])
f_pts = np.reshape(f_img, f_newshape)
f_mask = ~np.all(f_pts == 0, axis=1)
f_pts = f_pts[f_mask]
return f_pts
t1 = time.time()
pxs = f_img_to_pts(img)
t2 = time.time()
print('PIXELS EXTRACTING TIME: ', t2 - t1)
print(pxs)
Можно с уверенностью предположить, что значения будут [float, float, float, int]
Отредактированный вопрос
Вы не можете поместить значения с плавающей запятой в числовой массив dtype np.uint16
. img[0][1] = [1.1, 2.2, 3.3, 4]
эквивалентно img[0][1] = [1, 2, 3, 4]
. Попробуйте print(img[0][1])
.
Аааааа, ок, спасибо! Извините за глупую ошибку. Итак, теперь все должно быть установлено как float
Хорошо, это проясняет ситуацию! Но возможно я задал неправильный вопрос. Я хотел знать, можно ли избежать чисел с плавающей запятой и всегда использовать uint16 или uint8. Потому что если нам придется иметь дело только с uint16 или uint8, то у нас есть решение, которое будет как минимум в 10 раз быстрее.
К сожалению, мне нужно использовать float :/
~np.all(f_pts == 0, axis=1)
неоптимален по нескольким причинам:
Прежде всего, операция ограничена памятью (обычно пропускной способностью ОЗУ), поскольку изображение огромно (400 МБ в памяти) — даже логические маски огромны (100 МБ). Более того, Numpy создает несколько временных массивов: один для результата f_pts == 0
, один для np.all(...)
и даже один для ~np.all(...)
. Каждый массив полностью хранится в оперативной памяти и считывается обратно, что неэффективно. Хуже того: вновь выделенные массивы обычно не отображаются напрямую в физическую память из-за особенностей работы виртуальной памяти, поэтому за каждый временный массив приходится платить дорогостоящие накладные расходы, связанные с ошибками страниц. И последнее, но не менее важное: Numpy не оптимизирован для вычислений массивов, где основное измерение (т. е. обычно последнее) крошечное, как здесь (4 элемента). Однако это можно исправить, используя лучшую структуру памяти. См. AoS против SoA для получения дополнительной информации.
Обратите внимание, что np.zeros
резервирует некоторое обнуленное пространство в виртуальной памяти, но не отображает его физически. Таким образом, первый вызов f_img_to_pts
выполняется медленнее из-за ошибок страницы. В реальных приложениях это, как правило, не так, поскольку запись нулей в img
или однократное чтение приводит к физическому отображению всей страницы (т. е. к ошибкам страницы). Если это не так, то вам обязательно следует использовать разреженные матрицы вместо плотных. Предположим, img
уже отображен в физической памяти (это можно сделать, запустив код дважды или просто вызвав img.fill(0)
после np.zeros
.
Также обратите внимание, что dtype float
на самом деле является 64-битным типом с плавающей запятой, а не 32-битным. 64-битные числа с плавающей запятой обычно дороже, особенно в кодах, привязанных к памяти, поскольку они занимают вдвое больше места. Вам обязательно следует использовать 32-битные для вычислений изображений, поскольку они почти никогда не требуют такой очень высокой точности. Предположим, что входной массив теперь 32-битный.
Один из способов уменьшить накладные расходы — это итерация строка за строкой (циклы не так уж и плохи, как здесь кажется, а на самом деле совсем наоборот, если все сделано правильно). Хотя это должно быть быстрее, это все равно неоптимально. Невозможно сделать это намного быстрее из-за особенностей конструкции Numpy.
Чтобы сделать это еще быстрее, можно использовать модули, компилирующие функции Python в нативные функции, такие как Numba или Cython. С их помощью мы можем легко избежать создания временных массивов, специализировать код для 4-канальных изображений и даже использовать несколько потоков. Вот полученный код:
import numpy as np
import time
import numba as nb
img_size = (10000, 10000)
img = np.zeros((*img_size, 4), np.float32) # Make RGBA image
img.fill(0)
# Put some values for pixels [float, float, float, int]
img[0][1] = [1.1, 2.2, 3.3, 4]
img[1][0] = [0, 0, 0, 10]
img[1][2] = [6.1, 7.1, 8.1, 0]
# Compute ~np.all(f_pts==0,axis=1) and assume most items are 0
@nb.njit('(float32[:,:,::1],)', parallel=True)
def f_img_to_pts_optim(f_img): # Get non-zero rows with values from whole img array
n, m, c = f_img.shape
assert c == 4 # For sake of performance
f_mask = np.zeros(n * m, dtype=np.bool_)
f_pts = np.reshape(f_img, (n*m, c))
for ij in nb.prange(n * m):
f_mask[ij] = (f_pts[ij, 0] != 0) | (f_pts[ij, 1] != 0) | (f_pts[ij, 2] != 0) | (f_pts[ij, 3] != 0)
f_pts = f_pts[f_mask]
return f_pts
t1 = time.time()
pxs = f_img_to_pts_optim(img)
t2 = time.time()
print('PIXELS EXTRACTING TIME: ', t2 - t1)
print(pxs)
Вот тайминги на 10-ядерном процессоре Skylake Xeon:
Initial code (64-bit): 1720 ms
Initial code (32-bit): 1630 ms
Optimized code (sequential): 323 ms
Optimized code (parallel): 182 ms <----------
Оптимизированный код в 9 раз быстрее исходного кода (при том же типе ввода). Обратите внимание, что код не масштабируется в зависимости от количества ядер, поскольку чтение входных данных является узким местом в сочетании с последовательной операцией f_pts[f_mask]
. Можно построить f_pts[f_mask]
, используя несколько потоков (и это должно быть примерно в 2 раза быстрее), но это довольно сложно сделать (особенно с Numba).
Ого! Какой ответ! Большой! Огромное спасибо! Это ясное объяснение тоже потрясающе! Спасибо!
Отличный ответ. В самом внутреннем цикле вы присваиваете значение f_mask
на каждой итерации. Поскольку вы ранее инициализировали этот массив нулевым значением, есть ли какой-либо выигрыш в скорости при изменении значения только тогда, когда оно должно стать отличным от нуля? Я имею в виду избегать записи, если значение станет нулевым.
@MarkSetchell Хороший вопрос! Если это сделать, то возникнет условие, и условная ветвь часто не позволяет компиляторам генерировать быстрый SIMD-код. Однако я не проверял, эффективен ли здесь сгенерированный ассемблерный код. Numba не любит логические операторы... Кроме того, скорость зависит от предсказуемости условия (это нормально, если условие очень редко бывает истинным, что в данном случае, безусловно, довольно верно). Так что это зависит. На практике я ожидаю, что запись в f_mask[ij]
займет незначительное время. Действительно, f_pts
в памяти в 16 раз больше, чем f_mask
.
@JérômeRichard Спасибо, что нашли время ответить, очень ценю.
@MarkSetchell Добро пожаловать. Обратите внимание, что эффект, обнаруженный BadEnglish в отношении обнуления страниц, оказывает влияние и на этот случай (по-видимому, только в Linux). Написание заметок на страницах f_mask
поможет им остаться нетронутыми, когда вероятность того, что f_mask[i]
окажется правдой, очень-очень мала (<0,02%). В этом случае я ожидал, что чтение f_mask
вызовет первое касание страницы, приводящее к физическому сопоставлению, но в относительно недавних ядрах Linux это не так. В этом конкретном случае чтение может происходить быстрее, что приводит к небольшому увеличению производительности (в Linux, но, очевидно, не в Windows).
Также в Linux не нужно использовать огромные страницы (в зависимости от ядра и системного распределителя). В противном случае преимущество появляется только в том случае, если вероятность составляет <0,00004% для огромных страниц размером 2 МБ (размер по умолчанию для процессоров x86), что глупо мало. На самом деле, это настолько мало, что оно того редко стоит (за исключением случая OP, когда на одной огромной странице размером 2 МБ всего 3 значения).
Обычно я «прибегаю» к C++, когда мне нужно ускорить код Python. Однако ответ Жерома Ришара меня впечатлил, особенно использование numba, с которым я не был знаком. В качестве альтернативы, вот параллельное решение с использованием OpenMP, которое обеспечивает пятикратное ускорение по сравнению с подходом на основе JIT (на i7 11700K). Он использует тип __int128, доступный в GCC, для выполнения нулевых тестов для четырех чисел с плавающей запятой одновременно.
Сторона Python:
#!/usr/bin/env python3
import numpy as np
from ctypes import *
from time import time
import numba as nb
from numpy.ctypeslib import ndpointer
from math import prod
clib = CDLL('./libfilter.so')
clib.FindNZ_IndexesF32x4.restype = c_uint
clib.FindNZ_IndexesF32x4.argtypes = (c_void_p, # inp: int128 (see f_img_to_pts_cpp(..))
ndpointer(c_uint,
flags = "C_CONTIGUOUS"), # indexes
c_uint) # size
def f_img_to_pts_cpp(inp):
cpp = 4 # components per pixel
assert inp.shape[-1] == cpp
flat_size = prod(inp.shape)
inp = inp.reshape(flat_size)
# The ctypes module doesn't have a 128-bit type, so we manually assert
# the preconditions for safe casting
# (except alignment, but it works as it is, at least on x86_64)
assert inp.flags['C_CONTIGUOUS']
assert inp.dtype == np.float32
inp = inp.view(np.complex128) # cast to 128-bit elements
indexes = np.empty(flat_size, dtype=np.uint32) # uninitialzed array
size = clib.FindNZ_IndexesF32x4(inp.ctypes.data,
indexes,
flat_size//cpp)
indexes = indexes[:size]
out = inp[indexes].view(np.float32) # cast back to float
return out.reshape((size, cpp))
@nb.njit('(float32[:,:,::1],)', parallel=True)
def f_img_to_pts_optim(f_img): # Get non-zero rows with values from whole img array
n, m, c = f_img.shape
assert c == 4 # For sake of performance
f_mask = np.zeros(n*m, dtype=np.bool_)
f_pts = np.reshape(f_img, (n*m, c))
for ij in nb.prange(n*m):
f_mask[ij] = (f_pts[ij, 0] != 0) | (f_pts[ij, 1] != 0) | \
(f_pts[ij, 2] != 0) | (f_pts[ij, 3] != 0)
f_pts = f_pts[f_mask]
return f_pts
def py_opt(img):
print('warming up...') # "spin up" the processor
for i in range(4):
f_img_to_pts_optim(img)
tstart = time()
ret = f_img_to_pts_optim(img)
runtime = time() - tstart
print(f'numba runtime {runtime:.4f}')
return ret
def cpp_opt(img):
tstart = time()
pxs = f_img_to_pts_cpp(img)
runtime = time() - tstart
print(f'C++ runtime {runtime:.4f}')
return pxs
img = np.zeros((10000, 10000, 4), np.float32)
img[0][1] = [1.1, 2.2, 3.3, 4]
img[1][0] = [0, 0, 0, 10]
img[1][2] = [6.1, 7.1, 8.1, 0]
py_pxs = py_opt(img)
cpp_pxs = cpp_opt(img)
print(py_pxs)
assert (py_pxs == cpp_pxs).all()
Сторона С++:
#include <cstdint>
#include <atomic>
using u32 = uint32_t;
using int128 = __int128;
extern "C"
u32 FindNZ_IndexesF32x4(const int128 * inp,
u32 * indexes,
const u32 size) noexcept {
std::atomic_uint l(0);
#pragma omp parallel for
for(u32 i = 0; i < size; i++) {
if (inp[i]) {
u32 j = l.fetch_add(1);
indexes[j] = i;
}
}
return l;
}
Чтобы построить исходный код выше:
g++ -DNDEBUG -fPIC -Ofast -mtune=native -fopenmp -std=gnu++17 -shared -o libfilter.so filter.cpp
Выход:
warming up...
numba runtime 0.1169
C++ runtime 0.0163
[[ 1.1 2.2 3.3 4. ]
[ 0. 0. 0. 10. ]
[ 6.1 7.1 8.1 0. ]]
Как правильно заметил @Jérôme Richard, недетерминированная природа потоков означает, что порядок индексов (и, следовательно, порядок результата) может отличаться от варианта JIT. Однако, поскольку количество ненулевых элементов крайне мало, сортировка индексов практически не требует затрат.
Согласование: согласно этому Почему malloc выровнен по 16 байтам?, Адреса, выделенные с помощью malloc, в системе x86_64 выравниваются по 16 байтам, если только запрошенный размер не меньше 15 байт, что не имеет место в данном контексте.
#include <cstdint>
#include <atomic>
#include <algorithm>
using u32 = uint32_t;
using int128 = __int128;
extern "C"
u32 FindNZ_IndexesF32x4(const int128 * inp,
u32 * indexes,
const u32 size) noexcept {
std::atomic_uint l(0);
// It's always aligned on a x86_64 system
// const bool is_misaligned = (size_t(inp) & 15) != 0;
// Regarding -0.0:
int128 mask;
{
u32 * u = (u32*)&mask;
u[0] = (1u << 31) - 1;
u[1] = u[0];
u[2] = u[0];
u[3] = u[0];
}
#pragma omp parallel for
for(u32 i = 0; i < size; i++) {
if (inp[i] & mask) {
u32 j = l.fetch_add(1);
indexes[j] = i;
}
}
std::sort(indexes, indexes + l);
return l;
}
Вывод (с маскированием и сортировкой, i7 11700K):
warming up...
numba runtime 0.142
C++ runtime 0.0171
[[ 1.1 2.2 3.3 4. ]
[ 0. 0. 0. 10. ]
[ 6.1 7.1 8.1 0. ]]
Но я бы согласился, что мое решение несколько переоснащено. Функция JIT более универсальна и будет хорошо работать даже со значительным количеством ненулевых элементов.
@Jérôme Richard сделал еще одно наблюдение: эффективная пропускная способность памяти в моих результатах кажется слишком хорошей, чтобы быть правдой. Мне потребовалось некоторое тестирование и поиск в Google, чтобы понять, что происходит. Рассмотрим эту функцию:
...
extern "C"
u64 CPP_Reduce(const u64 * inp, const u32 size) noexcept {
u64 acc = 0;
#pragma omp parallel for reduction(+: acc)
for(u32 i = 0; i < size; i++)
acc += inp[i];
return acc;
}
...
Давайте передадим img
из Python:
clib.CPP_Reduce.argtypes = (ndpointer(c_uint64,
flags = "C_CONTIGUOUS"),
c_uint) # size
clib.CPP_Reduce.restype = c_uint64
...
as_vec64 = img.reshape(prod(img.shape)).view(dtype=np.uint64)
print(f'reduction input: size {as_vec64.nbytes} shape {as_vec64.shape}')
n_iters = 10
tstart = time()
for i in range(n_iters):
clib.CPP_Reduce(as_vec64, as_vec64.shape[0])
dt = (time() - tstart) / n_iters
print(f'dt: {dt:.4f} s')
bandwidth = as_vec64.nbytes / dt / 2**30
print(f'bandwidth: {bandwidth:.4f} GB/s')
...
Выход:
reduction input: size 1600000000 shape (200000000,)
dt: 0.0123 s
bandwidth: 120.8112 GB/s
Это i5 1335U с тактовой частотой оперативной памяти 4,267 ГГц. Общая ширина шины составляет 128 бит. Таким образом, его пиковая пропускная способность составляет 4,267e9 * 16/2**30 = 63,58 ГБ/с. На первый взгляд может показаться, что пример приведения некорректен, но это не так (надеюсь). Давайте запустим нашу функцию сокращения с другим входом:
img = np.arange(0, 10000*10000*4, 1, np.float32).reshape((10000, 10000, 4))
Выход:
reduction input: size 1600000000 shape (200000000,)
dt: 0.0458 s
bandwidth: 32.5458 GB/s
Этот результат гораздо менее удивителен (и, вероятно, не очень хорош, но это не по теме). Вы можете прочитать объяснение здесь: Ядро Linux: роль нулевого выделения страниц во время paging_init
По сути, из-за трюков с подкачкой img
может эффективно полностью поместиться в кэше ЦП, если он в основном равен нулям, но в противном случае «ведет себя так, как ожидалось».
Интересное решение, но я вижу несколько проблем в коде. Во-первых, я думаю, что атомарность приводит к тому, что результат не сохраняется в том же порядке, что и в коде OP (IDK, если это проблема для OP). Более того, я считаю, что разыменование указателя __int128*
— это неопределенное поведение в C++, если не гарантировано выравнивание указателя. Я не уверен inp.view(np.complex128)
гарантировать, что мне это кажется небезопасным. Однако memcpy
на каждом элементе должен это исправить. Я думаю, что было бы гораздо лучше использовать для этого встроенные функции SIMD (возможно, GCC). Вы можете явно выполнять с ними невыровненную загрузку.
Да, и имейте в виду, что -Ofast на самом деле включает -O3 + -ffast-math + несколько других нестандартных опций и -ffast-math
опасно, если массивы содержат такие значения, как NaN
, Inf
, отрицательные нули или субнормальные числа. Я не думаю, что стоит использовать здесь этот небезопасный вариант. Отрицательные нули не равны 0 после переинтерпретации в int128, поэтому перед проверкой необходимо удалить знак бита с помощью маски.
Это лучше :) . Примечание __declspec(dllexport)
требуется для работы кода в Windows.
Я получаю ускорение в 3,8 раза по сравнению с кодом Numba (это хорошо, но не так хорошо, как упоминалось, но близко). Я думаю, что код привязан к памяти на моей машине (ОЗУ 40 ГиБ/с). Ваши тайминги удивительны, так как это будет означать 10000**2*4*4/0.0171/1024**3=87 GiB/s
пропускную способность ОЗУ (изображение не помещается в кеш процессора). Предполагается, что ваш процессор в лучшем случае поддерживает ОЗУ 50 ГиБ/с (и только 2 модуля DIMM DDR4). Такие результаты указывают на DDR4 >= 6000 МГц, которого AFAIK не существует (и в любом случае он не будет поддерживаться вашим процессором). Я смущен. Я что-то пропустил?
Отличное объяснение! Я могу воспроизвести этот эффект в Linux, выделив массив np.zeros
и затем просуммировав значения с помощью np.sum
. С пустыми/несопоставленными страницами это происходит в два раза быстрее, чем с сопоставленными (все еще с нулями). Изначально я тестировал код на Windows, где такого эффекта не увидел, так что, возможно, Windows не выполняет такую оптимизацию виртуальной страницы (или с ней не намного быстрее). В моем Linux я получаю 0,1239 для Numba и 0,0105 для вашего исходного кода. Это примерно в 12 раз быстрее. За счет этого эффекта это намного больше, чем в Windows (у меня достигается 141 ГиБ/с на 40 ГиБ/с ОЗУ).
Благодарим вас за то, что нашли время изучить проблему с производительностью. В целом оно того стоит, поскольку помогает узнать что-то новое и/или повысить точность прошлых/будущих ответов ;).
Пожалуйста, перечислите все возможные типы изображений. Что это значит, когда значения могут быть плавающими? Некоторые из ваших изображений являются np.float32?