Почему ARM NEON не быстрее простого C++?
Вот код C++:
#define ARR_SIZE_TEST ( 8 * 1024 * 1024 )
void cpp_tst_add( unsigned* x, unsigned* y )
{
for ( register int i = 0; i < ARR_SIZE_TEST; ++i )
{
x[ i ] = x[ i ] + y[ i ];
}
}
Вот неоновая версия:
void neon_assm_tst_add( unsigned* x, unsigned* y )
{
register unsigned i = ARR_SIZE_TEST >> 2;
__asm__ __volatile__
(
".loop1: \n\t"
"vld1.32 {q0}, [%[x]] \n\t"
"vld1.32 {q1}, [%[y]]! \n\t"
"vadd.i32 q0 ,q0, q1 \n\t"
"vst1.32 {q0}, [%[x]]! \n\t"
"subs %[i], %[i], $1 \n\t"
"bne .loop1 \n\t"
: [x]"+r"(x), [y]"+r"(y), [i]"+r"(i)
:
: "memory"
);
}
Тестовая функция:
void bench_simple_types_test( )
{
unsigned* a = new unsigned [ ARR_SIZE_TEST ];
unsigned* b = new unsigned [ ARR_SIZE_TEST ];
neon_tst_add( a, b );
neon_assm_tst_add( a, b );
}
Я протестировал оба варианта и вот отчет:
add, unsigned, C++ : 176 ms
add, unsigned, neon asm : 185 ms // SLOW!!!
Я также проверил другие типы:
add, float, C++ : 571 ms
add, float, neon asm : 184 ms // FASTER X3!
ВОПРОС: Почему неон медленнее с 32-разрядными целочисленными типами?
Я использовал последнюю версию GCC для Android NDK. Флаги оптимизации NEON были включены. Вот дизассемблированная версия C++:
MOVS R3, #0
PUSH {R4}
loc_8
LDR R4, [R0,R3]
LDR R2, [R1,R3]
ADDS R2, R4, R2
STR R2, [R0,R3]
ADDS R3, #4
CMP.W R3, #0x2000000
BNE loc_8
POP {R4}
BX LR
Вот разобрана версия неона:
MOV.W R3, #0x200000
.loop1
VLD1.32 {D0-D1}, [R0]
VLD1.32 {D2-D3}, [R1]!
VADD.I32 Q0, Q0, Q1
VST1.32 {D0-D1}, [R0]!
SUBS R3, #1
BNE .loop1
BX LR
Вот все стендовые тесты:
add, char, C++ : 83 ms
add, char, neon asm : 46 ms FASTER x2
add, short, C++ : 114 ms
add, short, neon asm : 92 ms FASTER x1.25
add, unsigned, C++ : 176 ms
add, unsigned, neon asm : 184 ms SLOWER!!!
add, float, C++ : 571 ms
add, float, neon asm : 184 ms FASTER x3
add, double, C++ : 533 ms
add, double, neon asm : 420 ms FASTER x1.25
ВОПРОС: Почему неон медленнее с 32-разрядными целочисленными типами?
5 ответов
Конвейер NEON на Cortex-A8 выполняется по порядку и имеет ограниченное число попаданий (без переименования), поэтому вы ограничены задержкой памяти (так как вы используете больше кеша L1/L2). Ваш код имеет непосредственную зависимость от значений, загруженных из памяти, поэтому он будет зависать постоянно, ожидая памяти. Это объясняет, почему код NEON немного (на небольшую величину) медленнее, чем не-NEON.
Вам необходимо развернуть монтажные петли и увеличить расстояние между нагрузкой и использованием, например:
vld1.32 {q0}, [%[x]]!
vld1.32 {q1}, [%[y]]!
vld1.32 {q2}, [%[x]]!
vld1.32 {q3}, [%[y]]!
vadd.i32 q0 ,q0, q1
vadd.i32 q2 ,q2, q3
...
Есть много неоновых регистров, так что вы можете развернуть его много. Целочисленный код будет страдать от той же проблемы, в меньшей степени, потому что целое число A8 имеет лучшее попадание под промах вместо остановки. Узким местом будет пропускная способность / задержка памяти для тестов, таких больших по сравнению с кешем L1/L2. Возможно, вы захотите запустить тест на меньших размерах (4 КБ.256 КБ), чтобы увидеть эффекты, когда данные полностью кэшируются в L1 и / или L2.
Хотя в этом случае вы ограничены задержкой для основной памяти, не совсем очевидно, что версия NEON будет медленнее, чем версия ASM.
Используя калькулятор цикла здесь:
http://pulsar.webshaker.net/ccc/result.php?lng=en
Ваш код должен занять 7 циклов, прежде чем кэш пропустит штрафы. Это медленнее, чем вы можете ожидать, потому что вы используете невыровненные нагрузки и из-за задержки между надстройкой и магазином.
Между тем сгенерированный компилятором цикл занимает 6 циклов (он не очень хорошо спланирован или вообще оптимизирован). Но он выполняет четвертую часть работы.
Подсчет циклов из сценария может быть не идеальным, но я не вижу ничего, что выглядит явно неправильно, поэтому я думаю, что они, по крайней мере, будут близки. Существует возможность для дополнительного цикла в ветке, если вы максимизируете пропускную способность выборки (также, если петли не выровнены по 64-битной схеме), но в этом случае есть много киосков, чтобы скрыть это.
Ответ не в том, что целое число в Cortex-A8 имеет больше возможностей скрыть задержки. На самом деле его обычно меньше из-за смещенного конвейера NEON и очереди выдачи. Конечно, это верно только для Cortex-A8 - на Cortex-A9 ситуация вполне может быть обращена вспять (NEON отправляется по порядку и параллельно с целым числом, в то время как целое число имеет возможности вне порядка). Так как вы пометили этот Cortex-A8, я предполагаю, что это то, что вы используете.
Это требует дополнительного расследования. Вот несколько идей, почему это может происходить:
- Вы не указываете какое-либо выравнивание в своих массивах, и, хотя я ожидаю, что new будет выравниваться по 8-байтам, оно может не выравниваться по 16-байтам. Допустим, вы действительно получаете массивы, которые не выровнены по 16 байтов. Тогда вы будете разбивать строки на доступ к кешу, что может привести к дополнительным штрафам (особенно при промахах)
- Сбой кеша происходит сразу после магазина; Я не верю, что Cortex-A8 имеет какие-либо неоднозначности в памяти и, следовательно, должен предположить, что загрузка может быть из той же строки, что и хранилище, поэтому требуется очистка буфера записи до того, как пропадет загрузка L2. Поскольку между конвейерными нагрузками NEON (которые инициируются в целочисленном конвейере) и хранилищами (инициированными в конце конвейера NEON) существует намного большее конвейерное расстояние, чем для целочисленных, потенциально может быть более длительный останов.
- Поскольку вы загружаете 16 байтов на доступ вместо 4 байтов, размер критического слова больше, и, следовательно, эффективная задержка для заполнения строки первым критическим словом из основной памяти будет выше (от L2 до L1 предполагается, что быть на 128-битной шине, поэтому не должно быть той же проблемы)
Вы спросили, что такое хороший NEON в подобных случаях - в действительности NEON особенно хорош для тех случаев, когда вы транслируете в / из памяти. Хитрость в том, что вам нужно использовать предварительную загрузку, чтобы максимально скрыть задержку основной памяти. Предварительная загрузка заблаговременно доставит память в кэш L2 (не L1). Здесь NEON имеет большое преимущество перед целым числом, потому что он может скрывать большую часть задержки кэша L2, из-за его разнесенного конвейера и очереди выдачи, а также потому, что он имеет прямой путь к нему. Я ожидаю, что вы увидите эффективную задержку L2 до 0-6 циклов и меньше, если у вас меньше зависимостей и вы не исчерпываете очередь загрузки, в то время как для целых чисел вы можете столкнуться с хорошими ~16 циклами, которых вы не можете избежать (вероятно, зависит от Cortex-A8, хотя).
Поэтому я бы порекомендовал вам выровнять массивы по размеру строки кэша (64 байта), развернуть циклы для выполнения хотя бы одной строки кэша за раз, использовать выравниваемые загрузки / хранилища (поставить:128 после адреса) и добавить Инструкция pld, которая загружает несколько строк кэша. Что касается количества строк: начинайте с малого и продолжайте увеличивать его, пока не перестанете видеть какую-либо пользу.
Ваш код C++ тоже не оптимизирован.
#define ARR_SIZE_TEST ( 8 * 1024 * 1024 )
void cpp_tst_add( unsigned* x, unsigned* y )
{
unsigned int i = ARR_SIZE_TEST;
do
{
*x++ += *y++;
} (while --i);
}
эта версия потребляет на 2 меньше циклов / итераций.
Кроме того, ваши результаты теста совсем не удивляют меня.
32 бита:
Эта функция слишком проста для NEON. Не хватает арифметических операций, которые оставляют место для оптимизаций.
Да, это так просто, что как версии C++, так и NEON страдают от опасностей конвейера почти каждый раз, не имея реальной возможности воспользоваться возможностями двойного выпуска.
Хотя версия NEON может выиграть от обработки 4 целых чисел одновременно, она также страдает гораздо больше от каждой опасности. Это все.
8 бит:
ARM очень медленно читает каждый байт из памяти. Это означает, что в то время как NEON показывает те же характеристики, что и в 32-битном, ARM сильно отстает.
16bit: то же самое здесь. За исключением того, что 16-битное чтение ARM не так уж плохо.
float: версия C++ будет компилироваться в коды VFP. И на Coretex A8 нет полноценного VFP, но есть VFP lite, который не передает ничего, что отстой.
Дело не в том, что NEON ведет себя странно, обрабатывая 32 бита. Это просто ARM, который соответствует идеальным условиям. Ваша функция не подходит для целей сравнительного анализа из-за ее простоты. Попробуйте что-нибудь более сложное, например, преобразование YUV-RGB:
К вашему сведению, моя полностью оптимизированная версия NEON работает примерно в 20 раз быстрее, чем моя полностью оптимизированная версия C, и в 8 раз быстрее, чем моя полностью оптимизированная версия сборки ARM. Я надеюсь, что это даст вам некоторое представление о том, насколько мощным может быть NEON.
И последнее, но не менее важное: инструкция ARD PLD - лучший друг NEON. При правильном размещении это повысит производительность как минимум на 40%.
Вы можете попробовать некоторые модификации, чтобы улучшить код.
Если вы можете: - использовать третий буфер для хранения результатов. Попробуйте выровнять данные на 8 байтов.
Код должен быть примерно таким (извините, я не знаю встроенный синтаксис gcc)
.loop1:
vld1.32 {q0}, [%[x]:128]!
vld1.32 {q1}, [%[y]:128]!
vadd.i32 q0 ,q0, q1
vst1.32 {q0}, [%[z]:128]!
subs %[i], %[i], $1
bne .loop1
Как говорит Exophase, у вас есть задержка конвейера. может быть, вы можете попробовать
vld1.32 {q0}, [%[x]:128]
vld1.32 {q1}, [%[y]:128]!
sub %[i], %[i], $1
.loop1:
vadd.i32 q2 ,q0, q1
vld1.32 {q0}, [%[x]:128]
vld1.32 {q1}, [%[y]:128]!
vst1.32 {q2}, [%[z]:128]!
subs %[i], %[i], $1
bne .loop1
vadd.i32 q2 ,q0, q1
vst1.32 {q2}, [%[z]:128]!
Наконец, ясно, что вы будете насыщать пропускную способность памяти
Вы можете попробовать добавить небольшой
PLD [%[x], 192]
в вашу петлю.
скажи нам, если будет лучше...
Разница в 8 мс настолько мала, что вы, вероятно, измеряете артефакты кэшей или конвейеров.
РЕДАКТИРОВАТЬ: Вы пытались сравнить с чем-то вроде этого для таких типов, как float и short и т. Д.? Я ожидаю, что компилятор оптимизирует его еще лучше и сократит разрыв. Также в вашем тесте вы сначала делаете версию C++, а затем версию ASM, это может повлиять на производительность, поэтому я бы написал две разные программы, чтобы быть более справедливыми.
for ( register int i = 0; i < ARR_SIZE_TEST/4; ++i )
{
x[ i ] = x[ i ] + y[ i ];
x[ i+1 ] = x[ i+1 ] + y[ i+1 ];
x[ i+2 ] = x[ i+2 ] + y[ i+2 ];
x[ i+3 ] = x[ i+3 ] + y[ i+3 ];
}
Последнее, что в подписи вашей функции, вы используете unsigned*
вместо unsigned[]
, Последнее предпочтительнее, потому что компилятор предполагает, что массивы не перекрываются и ему разрешено изменять порядок доступа. Попробуйте использовать restrict
Ключевое слово также для еще лучшей защиты от алиасинга.