Pull to refresh

Comments 36

Статья получилась очень большой, я уверен что большинство будут читать не в один присест. Пока что у нее ни одной оценки почти за час. Таким макаром она рискует вообще не вылезти наверх. Я прошу поставить оценку, даже если вы ещё не дочитали, уверяю дальше будет много интересного.

для замеров производительности нужно фиксировать частоту работы ядер процессора. Иногда следует отключать ядра. Почитайте про подсистема ядра ОС cpufreq.
для замеров производительности нужно

Кому нужно, для чего?


Если вы про это: «на ноутбуке частота процессора в диапазоне от 2,4 до 4,1 Ггц при разных сценариях», то я на нем поэтому и не тестировал. Не потому что не знал, как зафиксировать, а потому что любое измерение будет неверным.


У всех остальных процессоров, которые я тестировал, частоты примерно одинаковые при любых сценариях.

Нужно для того, кто замеряет. Для того, чтобы измерения были валидными. Фиксация частоты обеспечивает чистоту эксперимента. Думаю, что статья будет сильнее, если избегать оговорок о разбросе результатов (как на Apple M1), и если убедиться, что частота действительно фиксированна.
В любом случае, интересный материал.
Для того, чтобы измерения были валидными.

Как раз наоборот, если вы специальным образом модифицировали устройство перед измерением, ваши измерения становятся бессмысленными.


Та же векторизация может приводить к понижению частоты, и что толку с цифр, которые вы намерите на одинаковой частоте?


В любом случае, интересный материал.

Спасибо

Но на практике, как я понял, SVE реализован только в Fugaku supercomputer.

Из существующих — да. Ещё будет SiPearl Rhea с Neoverse V1.
Но тут давеча анонсировали ARMv9, имеющий SVE2 в базе.
www.anandtech.com/show/16584/arm-announces-armv9-architecture
Так что теперь заживём(с)

А ещё будет умножение матриц (GEMM) из ARMv8.6.
community.arm.com/developer/ip-products/processors/b/processors-ip-blog/posts/arm-architecture-developments-armv8-6-a

так и кастомная, которая может называться Apple Firestorm, Neoverse N1 или никак не называться.

Neoverse N1 это стоковое серверное ядро компании ARM.
Кастомные микроархитектуры используются в Apple A6+, Samsung M1-6, ThunderX/2/3, Ampere eMAG 8180, Phytium Mars.
Интересно почитать.
Задам вопрос, который меня давно волнует, но я так и не находил ответа на него.
В около компьютерных темах бывает проскакивают фразы, что x86_64 подтормаживает тот багаж, что оно должно тащить за собой. Т.е. какие-то инструкции, которые оно обязано тащить за собой. Действительно ли это так?

Я не спец по проектированию ядер процессора, но кое-что знаю.


Само по себе наличие каких-то инструкций может быть проедает транзисторный бюджет и увеличивает площадь кристалла, но вряд ли как-то сильно тормозить процессор. Гораздо сильнее x86 тормозит плохие архитектурные решения, в частности переменная длина инструкций. В не можете параллельно выбрать 4 инструкции и сразу начать их выполнять, нужно делать по одной.

А как это работает, если нужно знать, где начинается каждая инструкция, а это невозможно без хотя бы частичного декодирования предыдущих?


На ум приходит только декодировать каждый байт, а потом отбрасывать неверные варианты. Но это и есть «тормозить от плохих архитектурных решений».

Да, насколько я понимаю, параллельно декодируются все возможные варианты длин.
Но это и есть «тормозить от плохих архитектурных решений».
Только не «тормозить», а «греться». Добавляется ещё одна стадия в конвеер с 16 декодерами, которые умеют только считать длину и всё. Для «больших» ядер там там даже не так сильно транзисторный бюджет вырастает.

Если мы не можем сразу взять и начать декодировать несколько инструкций подряд, а нужна ещё какая-то стадия, то это тормозить.

Мне страшно становится. Вы точно пытаетесь что-то там ускорить работая на уровне машинных инструкций? И не имея представления о том, что конвеер в процессоры завезли ещё полвека назад?
Да, дополнительная стадия увеличивает нагрузку на предсказатель ветвлений.
Нет, это не приводит, само по себе, к тормозам.
Иначе бы в соврменных процессорах не удлиняли конвеер, чтобы увеличить IPC.
Вот тепловыделение это повышает однозначно, так что если вам нужен не быстрый однопоток, а сотни ядер, то это плохая идея.
В 2010(11) мне пришлось познакомиться с OpenMax и NEON. Очень понравилось. Особенно команды в которых реализовано то, что у RISC-V называют сейчас fusion. Один код делает сразу сложение, сдвиг и округление. Очень интересны команды загрузки. Правда некоторые очень неудобны в реализации, т.к. длинный операнд приходится грузить сразу в несколько разных регистров. Удобны команды с увеличением или уменьшением разрядности данных результата. Не нужно делать спец действий. Грамотно. Спасибо за информацию о последних новинках в векторной составляющей ARM, т.к. эту архитектуру я уже давно забросил :)
>> Имена некоторых интринсиков напоминают читы в играх: vqrshrn_n_u16, vqdmulh_s16

Тут всё просто

— vqrshrn_n_u16:
v — vector
q — saturating (ещё с ARMv6 так)
r — rounded
shr — shift right
n — narrow
_n_ immediate
u16 — unsigned 16

— vqdmulh_s16:
v — vector
q — saturating
d — doubling
mul — multiply
h — high half
s16 — signed 16

>> Моё предположение, что какое-то время работали слабые ядра.
Из состояния сна сначала включаются мелкие ядра, потом через 30мс происходит миграция на большие. Пиковая частота достигается через 100мс.
www.anandtech.com/show/14892/the-apple-iphone-11-pro-and-max-review/5
Но это в iOS. На macOS надо бы измерить.
vqdmulh_s16

А вот кстати, чего я не понял, для чего нужно doubling?

Это DSP инструкция.
Например, умножаем знаковый 16-битный семпл 0x7fff на fixed-point громкость 0x7fff (1.0)
Т.е. семпл должен оставаться на макс громкости -> в старшей части должны получить примерно те же 0x7fff.

integer round_const = if rounding then 1 << (esize — 1) else 0;
//esize == 16 -> round_const == 0x8000
product = (2 * element1 * element2) + round_const;
0x7FFF*0x7FFF
0x3FFF0001 * 2
0x7FFE0002 + 0x00008000
=
0x7FFE8002 -> 0x7FFE

Есть варианты без округления и с ним («if rounding»)
vqdmulh_s16
vqrdmulh_s16

upd: Пофиксил round_const
doubling это как раз случай когда операции с одной разрядностью (например, 16), а результат уже с удвоенной (32). Далее проводим промежуточные вычисления уже с 32-разрядными данными, а в конце результат уже к меньшему формату (16). Итого более точные вычисления.
Вы говорите про widening (противоположность narrowing).
int32x4_t vmlal_n_s16 (int32x4_t a, int16x4_t b, int16_t c)
Vector widening multiply accumulate with scalar

Инструкция, которая делает doubling (vqdmulh_s16) дополнительно умножает промежуточный результат на 2, как я расписал.
Да, инструкция считает с повышенной разрядностью, но слово doubling относится не к этому.
Signed saturating Doubling Multiply returning High half. This instruction multiplies the values of corresponding elements of the two source SIMD&FP registers, doubles the results, places the most significant half of the final results into a vector, and writes the vector to the destination SIMD&FP register.

Вот строка псевдокода из мануала, которая это описывает:
product = (2 * element1 * element2) + round_const;

Спасибо за поправку. Действительно я посмотрел только vqdmul и перепутал. То, что я подразумевал реально есть widening и narrowing. Подзабыл уже :)
«разработано расширение Scalable Vector Extension (SVE), которое позволяет выполнять один и тот же код на чипах, реализующих разный размер векторов. Но на практике, как я понял, SVE реализован только в Fugaku supercomputer.»
У RISC-V векторное расширение вроде бы из этой же оперы. Правда они все никак не могут остановится на конечном варианте :)
у м1 есть еще AMX перевод
но вот напрямую доступа к нему нет, только косвенно через accelerate framework.
зы — 2 недели назад на досуге начал ковырять neon. интересно посмотреть, есть ли разница между ним и использование апи accelerate на моих задачах
Спасибо за полезный материал! Жаль, что эти инструкции всё равно останутся невостребованными, так как компиляторы не умеют полноценно векторизовать сложные манипуляции с данными (например, когда в теле цикла нам нужны значения соседних пикселей), а в сфере мобильной разработки доминируют корпорации, призывающие людей не писать нативный код.
Native code is primarily useful when you have an existing native codebase that you want to port to Android, not for «speeding up» parts of your Android app written with the Java language.
— с developer.android.com

Я не рассматриваю ARM как платформу для мобильного разработки, мне он интересен как серверная платформа в ближайшие несколько лет, там мне никакие корпорации не указ.

Корпорации могут призывать к чему угодно, но когда те же самые корпорации тупо не предоставляют никакого 3D API или NN API (neural networks, тоже очень «тяжёлая» вещь) для управляемого кода — становится ясно, что таки в тех циклах, где вы захотите брать значения соседних пикселей у вас будет явно не Java.

Там на самом деле есть объективное отличие в векторизованном коде. Дело в том, что выражение Srgba[i + 0] * 255 + Drgba[i + 0] * (255 - Sa) это по-максимум 255*255 + 255*255, что переполнение для 16-битного числа: 0x1fc02. И казалось бы, переполнение будет в обоих версиях, так какая разница. Но в скаларной версии при делении на 255 самый старший бит попадёт в младший разряд 8-битного результата и увеличит его на 1 при сложении.


В векторизованном коде мы пользуемся знанием о том, что значения пикселей уже умножены на альфу, соответственно, если (255 - Sa) = 255, то Srgba[i + 0] = 0, а значит всё выражение всегда будет влезать в 16 бит. У компилятора таких знаний нет.


Кажется я пробовал искусственно ограничивать значение выражение:


DIV255((Srgba[i + 0] * 255 + Drgba[i + 0] * (255 - Sa)) & 0xffff)

Но вроде это не включало векторизацию, а скалярный код становился медленнее.

казалось бы, переполнение будет в обоих версиях, так какая разница

По идее в "скалярном" никаких переполнений не будет. Там будет numeric promotions. Там сразу все в "int" будет считаться, который на данных платформах 32 битный.


В векторизованном коде мы пользуемся знанием о том, что значения пикселей уже умножены на альфу, соответственно, если (255 — Sa) = 255, то Srgba[i + 0] = 0

Вы хотели сказать, что Srbga[i + 3] == 0, ведь Sa это Srgba[i + 3], а не Srgba[i + 0]?

Нет, я все правильно написал. Premultiplied alpha — это формат хранения RGBA пикселей, где каждое RGB заранее умножено на A. Это упрощает расчеты в большинстве случаев. И это означает, что все Srbga[i + x] не могут быть больше Srbga[i + 3].

А assume пробовали добавлять? Например clang __builtin_assume с инвариантом, что Srbga[i + x] <= Srbga[i + 3] ?

Нет. Попробуйте, ссылка на репозиторий в топике. Там всё предельно просто: есть ридми, make-файл, нет зависимостей.

Касательно оптимизации загрузки NEON векторов, я бы еще посмотрел в сторону выровненной загрузки и использования префетча:

#define PREFECH_SIZE 384

        template <bool align> inline uint8x16_t Load(const uint8_t * p);

        template <> inline uint8x16_t Load<false>(const uint8_t * p)
        {
#if defined(__GNUC__) && PREFECH_SIZE
            __builtin_prefetch(p + PREFECH_SIZE);
#endif
            return vld1q_u8(p);
        }

        template <> inline uint8x16_t Load<true>(const uint8_t * p)
        {
#if defined(__GNUC__)
#if PREFECH_SIZE
            __builtin_prefetch(p + PREFECH_SIZE);
#endif
            uint8_t * _p = (uint8_t *)__builtin_assume_aligned(p, 16);
            return vld1q_u8(_p);
#elif defined(_MSC_VER)
            return vld1q_u8_ex(p, 128);
#else
            return vld1q_u8(p);
#endif
        }

В некоторых случаях помогает (величиной PREFECH_SIZE лучше поиграться).
В общем дошли руки посмотреть вашу программу.
Это слишком маленькая нагрузка чтобы запустились быстрые ядра M1 на полной скорости.
Время тоже измеряется плохо. Нужно считать минимум. У вас же считается среднее от времени работы на разной частоте.
Даже если сделать 4 итерации вокруг внутреннего цикла, видно как происходит рост производительности.
Ещё я увеличил на 1 итерацию внешний цикл.

    size_t tmin = ~0; 
    for (int t = 0; t < 4; t++)
    {   
        gettimeofday(&tval_before, NULL);
        for (size_t i = 0; i < 20 * 1000; i ++) {
            opSourceOver_premul(Rrgba, Srgba, Drgba, len);
        }
        gettimeofday(&tval_after, NULL);
        timersub(&tval_after, &tval_before, &tval_result);
        size_t tdelta = tval_result.tv_sec * 1000000 + tval_result.tv_usec; 
        if (tmin > tdelta)
          tmin = tdelta;
    }
    printf("Time elapsed: %ld us\n", tmin);  


cc -Wall -O2 -o run.64 main.c impl.native.c && ./run.64
Time elapsed: 16890 us
Time elapsed: 14802 us
Time elapsed: 14788 us
Time elapsed: 14776 us
Time elapsed: 14792 us

cc -Wall -O2 -o run.64 main.c impl.native.c -fno-tree-vectorize && ./run.64
Time elapsed: 43059 us
Time elapsed: 43079 us
Time elapsed: 43182 us
Time elapsed: 43006 us
Time elapsed: 42992 us

cc -Wall -O2 -o run.64 main.c impl.native.c -ftree-vectorize && ./run.64
Time elapsed: 16938 us
Time elapsed: 14798 us
Time elapsed: 14772 us
Time elapsed: 14806 us
Time elapsed: 14799 us

cc -Wall -O2 -o run.64 main.c impl.neon.c && ./run.64
Time elapsed: 9123 us
Time elapsed: 6238 us
Time elapsed: 5118 us
Time elapsed: 4584 us
Time elapsed: 4446 us

cc -Wall -O2 -o run.64 main.c impl.neon_preload.c && ./run.64
Time elapsed: 9077 us
Time elapsed: 6194 us
Time elapsed: 5113 us
Time elapsed: 4580 us
Time elapsed: 4429 us


Видите ваши 9мс? Это примерно половина того, что делает M1 на полной частоте.

Хоть 128-битная версия на M1 всё еще выполняется быстрее, чем на x86, против AVX ему нечего противопоставить.

Как выясняется, вполне есть что.

Какая-то фантастика, в среднем 2,8 тактов на цикл из 17 инструкций. Как такое возможно?

А что тут такого?
Устойчивый темп 8 инструкций за такт, четыре блока NEON (512-бит).
Все данные в кэше сидят.

Кстати если сделать len побольше, скажем, 100000000, чтобы данные не влезали в кэши.
то тогда в однопотоке M1 будет ещё более быстрым чем x86.

Собрал под Windows и если ничего не напутал, получается так:
Ryzen 3950X SSE Time elapsed: 1261000 us
Ryzen 3950X AVX Time elapsed: 1124000 us
M1: Time elapsed: 447691 us

В общем рекомендую поменять методику тестирования и использовать данные разного размера — влезающие в кэши и не влезающие (А кеши сейчас составляют десятки мегабайт, напоминаю).
А так же вычислять минимальное время из нескольких итераций — иначе у вас сильный разброс будет и низкая точность.
Sign up to leave a comment.

Articles