Почему ARM NEON не быстрее простого C ++? - PullRequest
29 голосов
/ 20 апреля 2011

Вот код 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. Неоновые флаги оптимизации были включены. Вот разобранная версия 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 ]

44 голосов
/ 20 апреля 2011

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

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

12 голосов
/ 02 ноября 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 меньше циклов / итерацию.

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

32bit:

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

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

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

8 бит:

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

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

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

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

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

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

5 голосов
/ 07 июня 2011

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

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

втвоя петля.

скажи нам, если будет лучше ...

2 голосов
/ 20 апреля 2011
Разница в

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

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

...