Почему ARM NEON не быстрее обычного С++? - программирование

Почему ARM NEON не быстрее обычного С++?

Вот код С++:

#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. Вот разобранная версия С++:

                 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-битными целыми типами?

4b9b3361

Ответ 1

Конвейер 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
...

Там много неоновых регистров, чтобы вы могли развернуть его много. Integer код будет страдать той же проблемой, в меньшей степени, потому что целое число A8 лучше ударяет по промаху, а не останавливается. Узким местом будет пропускная способность/латентность памяти для тестов, столь больших по сравнению с кешем L1/L2. Вы также можете запустить тест с меньшими размерами (4KB..256KB), чтобы увидеть эффекты, когда данные полностью кэшируются в L1 и/или L2.

Ответ 2

Хотя в этом случае вы ограничены задержкой в ​​основной памяти, не совсем очевидно, что версия NEON будет медленнее, чем версия ASM.

Используя калькулятор циклов здесь:

http://pulsar.webshaker.net/ccc/result.php?lng=en

Ваш код должен пройти 7 циклов до штрафа за пропущенный кэш. Это медленнее, чем вы можете ожидать, потому что используете неуравновешенные нагрузки и из-за задержки между добавлением и хранилищем.

Между тем, генерируемый компилятором цикл занимает 6 циклов (он не очень хорошо спланирован или оптимизирован вообще). Но он делает на четверть столько работы.

Цифры из script могут быть не идеальными, но я не вижу ничего, что выглядит явно ошибочным, поэтому я думаю, что они, по крайней мере, были бы близки. Есть потенциал для того, чтобы взять дополнительный цикл на ветке, если вы не выберете максимальную пропускную способность (также если петли не выровнены по 64 битам), но в этом случае есть много киосков, чтобы скрыть это.

Ответ не в том, что целое число на Cortex-A8 имеет больше возможностей скрывать латентность. Фактически, у него обычно меньше, из-за шахматного конвейера NEON и очереди выдачи. Конечно, это верно только для Cortex-A8 - на Cortex-A9 ситуация вполне может быть отменена (NEON отправляется по порядку и параллельно с целым числом, в то время как целое имеет возможности не по порядку). Поскольку вы отметили этот Cortex-A8, я предполагаю, что вы используете.

Это требует большего расследования. Вот некоторые идеи, почему это может происходить:

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

Ответ 3

Ваш код на С++ также не оптимизирован.

#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 меньше циклов/итераций.

Кроме того, ваши результаты тестов не удивляют меня вообще.

32bit:

Эта функция слишком проста для NEON. Недостаточно арифметических операций, оставляющих место для оптимизации.

Да, это так просто, что и С++, и версия NEON страдают от проблем с конвейерами почти каждый раз без каких-либо реальных шансов извлечь выгоду из возможностей двойной проблемы.

Хотя версия NEON может извлечь выгоду из обработки 4 целых чисел одновременно, она страдает гораздо больше от каждой опасности. Это все.

8bit:

ARM очень медленно читает каждый байт из памяти. Это означает, что хотя NEON показывает те же характеристики, что и у 32-битного, ARM сильно отстает.

16 бит: Тут то же самое. Кроме ARM 16-битного чтения это не так.

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

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

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

И последнее, но не менее важное: PLD-команда ARM - лучший друг NEON. Размещенный должным образом, это принесет прирост производительности не менее 40%.

Ответ 4

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

Если вы можете: - используйте третий буфер для хранения результатов. - попытайтесь выровнять данные по 8 байтам.

Код должен быть чем-то вроде (извините, я не знаю синтаксиса gcc inline)

.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]

в ваш цикл.

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

Ответ 5

8 мс разницы SO мало, что вы, вероятно, измеряете артефакты кэшей или конвейеров.

РЕДАКТИРОВАТЬ. Вы пытались сравнить с чем-то подобным для таких типов, как float, short и т.д.? Я ожидаю, что компилятор еще лучше оптимизирует его и сократит разрыв. Также в вашем тесте вы сначала делаете версию С++, а затем версию 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 для еще лучшей защиты от сглаживания.