Почему ARM NEON не быстрее обычного 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-битными целочисленными типами?


person Smalti    schedule 20.04.2011    source источник
comment
@Cody есть вопрос в теме, может что?   -  person Igor Skochinsky    schedule 20.04.2011
comment
Является ли C++ быстрее для всех целочисленных типов? Я думаю, что ваша сборка не так оптимальна, как вы надеялись для целочисленных типов.   -  person rubenvb    schedule 20.04.2011
comment
Вопрос в том, почему неон медленнее в 32-битных целочисленных типах?   -  person Smalti    schedule 20.04.2011
comment
@rubenvb Я обновил отчет для всех типов.   -  person Smalti    schedule 20.04.2011
comment
Я вообще не знаю ассемблер ARM, но мне кажется, что вы выполняете цикл в 4 раза чаще, чем версия C++. Да?   -  person mcmcc    schedule 20.04.2011
comment
@mcmcc Нет. Ровно наоборот.   -  person Smalti    schedule 20.04.2011
comment
Это действительно странно. Но что-то не так — в вашем исходном коде ARR_SIZE_TEST — это целых 8 миллионов, а в выводе ассемблера, кажется, 0x12C0/4 = 1200. Почему? Ваши тайминги будут иметь больший вес для большего значения.   -  person TonyK    schedule 20.04.2011
comment
Для тех, кто запутался: NEON — это SIMD-расширение для ARM, позволяющее выполнять 128-битные операции, т.е. 4 32-битные операции за раз. Можно было бы ожидать, что это будет быстрее, чем инструкции, отличные от SIMD, во всех случаях. arm.com/products/processors/technologies/neon.php   -  person Mark Ransom    schedule 20.04.2011
comment
@TonyK Извините, это были старые разобранные образцы. Я исправил списки asm.   -  person Smalti    schedule 21.04.2011


Ответы (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.

person John Ripley    schedule 20.04.2011
comment
Спасибо за ответ. Я развернул цикл, используя 16 128-битных регистров за одну итерацию. Это ускоряет 32-битное целое число. Сейчас время: добавить, без знака, C++: 180 мс добавить, без знака, неоновый asm: 117 мс - person Smalti; 21.04.2011

Хотя в этом случае вы ограничены задержкой для основной памяти, не совсем очевидно, что версия 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, которая загружает несколько строк кэша. Что касается количества строк: начните с малого и продолжайте увеличивать его, пока не перестанете видеть какую-либо пользу.

person Exophase    schedule 30.05.2011
comment
Это не из-за невыровненных загрузок — это не объясняет огромную разницу, тем более что целое число тоже не выровнено. Cortex-A8 имеет устранение неоднозначности и допускает несколько промахов загрузки/сохранения. Основная причина заключается в том, что в конвейере A8 NEON нет метода «попадание-не-промах», поэтому вам необходимо разворачивать циклы. - person John Ripley; 14.06.2011
comment
Целочисленный конвейер также не попал под промах. NEON, с другой стороны, может заполнять свою очередь загрузки не по порядку (до начала конвейера NEON), что позволяет ему попасть в L1, пока обслуживается промах L2. Целочисленные хранилища не будут невыровненными, потому что malloc не вернет память, не выровненную по 4 байтам. Поэтому целочисленные хранилища не будут пересекать границы строки кэша. Но коренная причина того, что это медленнее, чем целочисленная версия, не связана с отсутствием развертывания, потому что целочисленная версия также не развертывается. - person Exophase; 16.06.2011
comment
Еще один разумный вопрос: перекрываются ли источник и место назначения (особенно если они совпадают). Я сомневаюсь, что у NEON есть какое-либо хранилище для загрузки переадресации, что было бы большим круговым путешествием, большим, чем для целых чисел. - person Exophase; 16.06.2011
comment
Я думаю, что ничего связанного с выравниванием нет. Подстрока инструкции neon автоматически помогает выравнивать данные в кеше. Помогите мне, если я ошибаюсь. :) - person Anoop K. Prabhu; 04.11.2011

Ваш код 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 сильно отстает.

16 бит: то же самое здесь. За исключением того, что 16-битное чтение ARM не так уж плохо.

float : версия C++ будет скомпилирована в коды VFP. И на Coretex A8 нет полноценного VFP, но есть облегченный VFP, который не конвейеризирует ничего, что отстойно.

Дело не в том, что NEON ведет себя странно при обработке 32-битных файлов. Это просто ARM, который соответствует идеальному состоянию. Ваша функция очень не подходит для бенчмаркинга из-за ее простоты. Попробуйте что-то более сложное, например преобразование YUV-RGB:

К вашему сведению, моя полностью оптимизированная версия NEON работает примерно в 20 раз быстрее, чем моя полностью оптимизированная версия C, и в 8 раз быстрее, чем моя полностью оптимизированная версия сборки ARM. Надеюсь, это даст вам некоторое представление о том, насколько мощным может быть NEON.

И последнее, но не менее важное: инструкция ARM PLD — лучший друг NEON. При правильном размещении он даст не менее 40% прироста производительности.

person Jake 'Alquimista' LEE    schedule 02.11.2011
comment
Ваши контрольные значения кажутся интересными.! Вы упоминали эти цифры для преобразования YUV-RGB? У меня получается в 7-8 раз быстрее. 20 раз очень интересно! - person Anoop K. Prabhu; 04.11.2011
comment
@Anoop Может быть, моя версия C была недостаточно хороша? :) Забыл сказать, что это был YUV420, планарный Y и упакованный UV. На упакованном YUV422 я, возможно, не получил бы такого прироста производительности. Преобразование изображения VGA на моем iPhone4 занимает менее 1 мс. - person Jake 'Alquimista' LEE; 04.11.2011
comment
Я изучал NEON последние пару месяцев, но никогда не пользовался инструкциями PLD. Ваши тесты были довольно интересными, сообщу здесь о повышении производительности, которое я получаю. Кстати, я работаю над биглбордом. - person Anoop K. Prabhu; 05.11.2011
comment
PLD, при правильном размещении, в одиночку обеспечит прирост скорости примерно на 40%, если вы имеете дело с достаточно большими блоками данных. Просто читайте далеко вперед. pld [pSrc, #64] чаще всего встречается в начале цикла. - person Jake 'Alquimista' LEE; 05.11.2011
comment
Спасибо за помощь. Будет с нетерпением ждать этого. :) - person Anoop K. Prabhu; 06.11.2011
comment
Поскольку это быстрее, чем OP, я полагаю, что компилятору нужна (необходима!) некоторая работа ;-). Все изменения, которые вы сделали, должны быть кандидатами, возникающими в результате базового анализа цикла исходной, более явной формы. Раньше я писал такой код [особенно. do{}while(--n)] потому что в 80-х, 90-х это имело большое значение. Затем был период лет, когда циклы, которые не соответствовали «нормальной» идиоме for (используемой OP), наказывались, потому что компиляторы не удосужились их анализировать. Так что я вообще перестал этим заниматься. Кесу уже 4 года, так что, вероятно, теперь все снова по-другому. - person greggo; 10.02.2016

Вы можете попробовать некоторые модификации для улучшения кода.

Если можете: - используйте третий буфер для хранения результатов. - попробуйте выровнять данные по 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]

в вашу петлю.

скажи нам, если это лучше ...

person webshaker    schedule 07.06.2011

Разница в 8 мс НАСТОЛЬКО мала, и вы, вероятно, измеряете артефакты кэшей или конвейеров.

EDIT: вы пробовали сравнивать с чем-то подобным для таких типов, как 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 для еще лучшей защиты от алиасинга.

person Giovanni Funchal    schedule 20.04.2011
comment
Да, но почему он не в 2 или 3 раза быстрее? - person TonyK; 20.04.2011
comment
Из-за пропускной способности памяти. Вы, вероятно, едете так быстро, как только можете, с точки зрения автобусных трансферов. - person Giovanni Funchal; 20.04.2011
comment
Я не эксперт, но я бы сказал, что вам нужны более сложные примеры, чтобы на самом деле увидеть преимущество, как с точки зрения объема работы, которую вы выполняете с данными (простой + не требует интенсивного использования ЦП), так и с точки зрения количества операций ( несколько тысяч миллионов вместо нескольких миллионов). И я ожидаю улучшения на 10-30%, а не на 200%. - person Giovanni Funchal; 21.04.2011
comment
200% реалистично для некоторых рабочих нагрузок. Примеры — просто патологические случаи: плохое разделение нагрузки и использования и 100% промах кеша. - person John Ripley; 21.04.2011
comment
Я не думаю, что это вопрос рабочей нагрузки, это скорее то, что вы делаете с данными, не является проблемой, интенсивно использующей процессор. - person Giovanni Funchal; 21.04.2011
comment
@Darhuuk И это даже не оптимизировано. Я бы сказал, что 1500~2000% вполне возможно за счет развертывания цикла, двойного выпуска и планирования в дополнение к предварительной загрузке кеша. - person Jake 'Alquimista' LEE; 04.11.2011
comment
unsigned arr[] не означает отсутствия псевдонимов; компиляторы обрабатывают его так же, как unsigned *arr, как того требует ISO C++. Только __restrict имеет значение (как расширение в большинстве компиляторов C++, таких как gcc и clang). - person Peter Cordes; 12.11.2020