Оптимизация игр под iOS платформу. Векторизация кода

Давно назревало желание написать парочку статей, в которых я смогу выложить свой опыт и знания на тему оптимизации игр под ARMv7 архитектуру CPU и PowerVR SGX 5 серию GPU, читай iOS платформу. Но все, или почти все, советы в равной степени применимы под другие системы с тем же железом, читай Андроиды. Данный материал в может быть применен не только в играх но и в большинстве ресурсоемких приложений — обработка изображений, аудио, видео и т.д. Начну свою первую статью с наиболее важной, ИМХО, оптимизации – векторизации кода под NEON.

Эта статья началась как доклад, на конференцию, которая состоится 24.11. Кладезь советов по оптимизации под iPhone можно найти здесь. Следующие статьи будут развивать в ширь и глубь темы материала из этой презентации.

Что такое NEON? NEON – это SIMD движок общего назначения, используемый в ARM процессорах. На борту имеет 16 регистров по 128 бит каждый, которые можно рассматривать как 32 регистра по 64 бита. NEON делит свои регистры с VFP, хотя имеет свой отдельный пайплайн. Как и в случае с SSE данные должны быть выровнены на 16 байт. NEON так же умеет работать с невыровненными данными, но обычно это в 2 раза медленнее.

NEON умеет работать с:
  • Знаковыми\без знаковыми 8\16\32\64-битными целочисленными типами данных;
  • Числами с плавающей запятой одинарной точности – 32-х битный float.

Он великолепно подходит для мультимедийных задач, в том числе и игр.

Начнем с основного – сердца каждой современной мобильной системы, системы на чипе или SoC (System on Chip). Известно, что в iOS девайсах используется Apple A серия систем на чипе – А4, А5, А5х, А6 и А6х. Наиболее важные спецификации этих чипов приведены в таблице:
Спецификации CPU A4 A5 A5x A6
Архитектура ARMv7 ARMv7 ARMv7 ARMv7
Ядро Cortex A8 Cortex A9 Cortex A9 Собственной разработки
# ядер 1 2 2 2
Частота, МГц 800 1000 1000 1300
Расширения VFPv3 (VFPLite), NEON VFPv3, NEON VFPv3, NEON VFPv4, NEON
Спецификации GPU
Модель PowerVR SGX 535 PowerVR SGX 543MP2 PowerVR SGX 543MP4 PowerVR SGX 543MP3
Частота, МГц 200 200 200 266
* Примечание: NEON работает на частоте CPU

Легко заметить, что NEON имеет 5-ти кратный прирост частоты по сравнению с GPU! Конечно, это не значит, что мы получим 5-ти кратное увеличение производительности по сравнению с GPU – IPC, пайплайн, т.д. имеют весомое значение. Но у NEON’а есть одна киллер фича – он может параллельно обрабатывать 4 32-х битных флоата, в то время как PowerVR SGX – только один. Кажется, у PowerVR SGX 5-й серии SIMD регистры имеют длину в 64 бита, так как GPU может параллельно обрабатывать 4 флоата половинной точности (16 бит). Рассмотрим пример:

highp vec4 v1, v2; 
highp float s1, s2; 

// Плохо 
v2 = (v1 * s1) * s2; //v1 * s1 будет выполнено на скалярном процессоре – 4 операции, результат этого умножения будет умножен на s2, опять на скалярном процессоре - еще 4 операции.
//8 операций в общем

// Хорошо 
v2 = v1 * (s1 * s2); //s1 * s2 – 1 операция на скалярном процессоре; результат * v1 – 4 операции на скалярном.
//5 операций в общем

Теперь рассмотрим другой пример, исполняемый на векторном движке GPU:
mediump vec4 v1, v2, v3; 
highp vec4 s1, s2, s3;

v3 = v1 * v2; //исполняется на векторном процессоре – 1 операция
s3 = s1 * s2; //исполняется на скалярном процессоре – 4 операции

Вам понадобится highp спецификатор для ваших данных, к примеру, позиции вершин. Профит от NEON’а здесь очевиден.

Теперь перейдем к другому преимуществу NEON’а. Известно, что PowerVR SGX 5-й серии обладают USSE, шейдерный процессор, которому без разницы какой тип шейдеров обрабатывать – вершинный или пиксельный. Это значит, что у программиста есть некий бюджет мощности и ему решать, на что его потратить – вершинный или пиксельный процессинг. Вот тут-то и приходит на помощь NEON – это ваш новый вершинный процессор. Вы можете подумать, что я забыл здесь вставить троллфейс, но все вполне серьёзно. Производительность почти каждой мобильной системы ограничена филлрейтом, особенно в 2D играх, особенно в наше время гонки за разрешением экранов. Перенеся весь вершинный процессинг на NEON у вас высвобождаются ресурсы для пиксельного процессинга. В дополнение к этому NEON поможет сократить количество вызовов на отрисовку – посчитайте позиции всех вершин одного батча в мировых координатах и нарисуйте N объектов за один вызов.

С теорией покончено! Теперь перейдем к хардкору! Есть несколько способов воспользоваться преимуществами NEON’a:
  • Пусть компилятор векторизирует код вместо вас. Плохой способ. Компилятор может векторизировать… а может и не векторизировать. Даже если компилятор векторизирует код, то далеко не факт, что это будет оптимальный код. Но, с другой стороны, этот способ не требует никаких усилий с вашей стороны, а профит получить можно. Но все же не стоит слепо надеяться на компилятор, а вручную векторизировать хотя бы наиболее критичный код.
  • NEON ассемблер. А вот и он, хардкор. Путь истинного джедая и все такое. Придется учить темную магию, проводить ночи за мануалами от ARM и т.д. Также стоит иметь в виду, что NEON код работает в обоих ARM и Thumb-2 режимах.
  • NEON интринсики (такие же как SSE для x86). В отличии от ассемблера, где компилятор тупо вставит то, что ему дали, интринсики будут оптимизированны. С ними жить намного проще – нету необходимости изучать тайминги инструкций, перетасовывать их, чтобы избежать застоя пайплайна и т.д.
  • Использовать либы с уже векторизированным кодом – GLKMath, math neon.

Пришло время обнаружить все преимущества и недостатки каждого из методов. Для этого я написал простенькое демо – каждый кадр 10000 спрайтов будут менять свою позицию на случайную в пределах экрана. Цель – получить максимально быстрый код с минимальной нагрузкой на CPU – ведь в играх надо много чего считать, помимо данных для рендера.

Все данные хранятся в одном VBO. Метод Update перемножает матрицу проекции на ModelView матрицу случайной позиции. Далее каждая вершина каждого спрайта будет перемножена на результирующую ModelViewProjection матрицу. Финальная позиция каждой вершины будет просто передана в gl_Position в вершинном шейдере. Все данные выравнены на границу в 16 байт.

Код Update метода:
void Update()
{
    GLKMatrix4 modelviewMat =
    {
	1, 0, 0, 0,
	0, 1, 0, 0,
	0, 0, 1, 0,
	0, 0, 0, 1,
    };
    const u32 QUADS_COUNT = 10000;
    const u32 VERTS_PER_QUAD = 4;
    const float Y_DELTA = 420.0f / QUADS_COUNT;	//равномерно распределить все спрайты по Y
    float vertDelta = Y_DELTA;
    
    for (int i = 0; i < QUADS_COUNT * VERTS_PER_QUAD; i += VERTS_PER_QUAD)
    {
        float randX = random() % 260;	//Матрица смещения на случайное число
        
        modelviewMat.m[12] = randX;
        modelviewMat.m[13] = vertDelta;
        
        float32x4x4_t mvp;
        Matrix4ByMatrix4((float32x4x4_t*)proj.m, (float32x4x4_t*)modelviewMat.m, &mvp);
        
        for (int j = 0; j < 4; ++j) {
            Matrix4ByVec4(&mvp, &squareVertices[j], &data[i + j].pos);
        }
        
        vertDelta += Y_DELTA;
    }
	
    glBindBuffer(GL_ARRAY_BUFFER, vertexBuffer);
    glBufferData(GL_ARRAY_BUFFER, sizeof(data), data, GL_STREAM_DRAW);
}

Что ж, теперь мы подошли к сути этой статьи – векторизации кода. Далее будет представлен код, используемый в трех сравниваемых подходах для наиболее часто используемых операций в геймдеве – перемножение матрицы на вектор и перемножение матрицы на матрицу.

Копипаста с GLKMath:
static __inline__ GLKVector4 GLKMatrix4MultiplyVector4(GLKMatrix4 matrixLeft, GLKVector4 vectorRight)
{
    float32x4x4_t iMatrix = *(float32x4x4_t *)&matrixLeft;
    float32x4_t v;
    
    iMatrix.val[0] = vmulq_n_f32(iMatrix.val[0], (float32_t)vectorRight.v[0]);
    iMatrix.val[1] = vmulq_n_f32(iMatrix.val[1], (float32_t)vectorRight.v[1]);
    iMatrix.val[2] = vmulq_n_f32(iMatrix.val[2], (float32_t)vectorRight.v[2]);
    iMatrix.val[3] = vmulq_n_f32(iMatrix.val[3], (float32_t)vectorRight.v[3]);
    
    iMatrix.val[0] = vaddq_f32(iMatrix.val[0], iMatrix.val[1]);
    iMatrix.val[2] = vaddq_f32(iMatrix.val[2], iMatrix.val[3]);
    
    v = vaddq_f32(iMatrix.val[0], iMatrix.val[2]);
    
    return *(GLKVector4 *)&v;
}

static __inline__ GLKMatrix4 GLKMatrix4Multiply(GLKMatrix4 matrixLeft, GLKMatrix4 matrixRight)
{
    float32x4x4_t iMatrixLeft = *(float32x4x4_t *)&matrixLeft;
    float32x4x4_t iMatrixRight = *(float32x4x4_t *)&matrixRight;
    float32x4x4_t m;

    m.val[0] = vmulq_n_f32(iMatrixLeft.val[0], vgetq_lane_f32(iMatrixRight.val[0], 0));
    m.val[1] = vmulq_n_f32(iMatrixLeft.val[0], vgetq_lane_f32(iMatrixRight.val[1], 0));
    m.val[2] = vmulq_n_f32(iMatrixLeft.val[0], vgetq_lane_f32(iMatrixRight.val[2], 0));
    m.val[3] = vmulq_n_f32(iMatrixLeft.val[0], vgetq_lane_f32(iMatrixRight.val[3], 0));

    m.val[0] = vmlaq_n_f32(m.val[0], iMatrixLeft.val[1], vgetq_lane_f32(iMatrixRight.val[0], 1));
    m.val[1] = vmlaq_n_f32(m.val[1], iMatrixLeft.val[1], vgetq_lane_f32(iMatrixRight.val[1], 1));
    m.val[2] = vmlaq_n_f32(m.val[2], iMatrixLeft.val[1], vgetq_lane_f32(iMatrixRight.val[2], 1));
    m.val[3] = vmlaq_n_f32(m.val[3], iMatrixLeft.val[1], vgetq_lane_f32(iMatrixRight.val[3], 1));

    m.val[0] = vmlaq_n_f32(m.val[0], iMatrixLeft.val[2], vgetq_lane_f32(iMatrixRight.val[0], 2));
    m.val[1] = vmlaq_n_f32(m.val[1], iMatrixLeft.val[2], vgetq_lane_f32(iMatrixRight.val[1], 2));
    m.val[2] = vmlaq_n_f32(m.val[2], iMatrixLeft.val[2], vgetq_lane_f32(iMatrixRight.val[2], 2));
    m.val[3] = vmlaq_n_f32(m.val[3], iMatrixLeft.val[2], vgetq_lane_f32(iMatrixRight.val[3], 2));

    m.val[0] = vmlaq_n_f32(m.val[0], iMatrixLeft.val[3], vgetq_lane_f32(iMatrixRight.val[0], 3));
    m.val[1] = vmlaq_n_f32(m.val[1], iMatrixLeft.val[3], vgetq_lane_f32(iMatrixRight.val[1], 3));
    m.val[2] = vmlaq_n_f32(m.val[2], iMatrixLeft.val[3], vgetq_lane_f32(iMatrixRight.val[2], 3));
    m.val[3] = vmlaq_n_f32(m.val[3], iMatrixLeft.val[3], vgetq_lane_f32(iMatrixRight.val[3], 3));

    return *(GLKMatrix4 *)&m;
}
Легко заметить, что реализация этих операций от Apple использует далеко не оптимальный подход – передача переменных по значению, копирование переменных. Выглядит довольно медленно, по крайней мере в дебаг сборке оно и будет являться таковым. Посмотрим, как этот код покажет себя при профиллировке.

Ассемблерный подход:
inline void Matrix4ByVec4(float32x4x4_t* __restrict__ mat, const float32x4_t* __restrict__ vec, float32x4_t* __restrict__ result)
{
    asm
    (
     "vldmia %0, { d24-d31 } \n\t"
     "vld1.32    {q1}, [%1]\n\t"
     
     "vmul.f32 q0, q12, d2[0]\n\t"
     
     "vmla.f32 q0, q13, d2[1]\n\t"
     "vmla.f32 q0, q14, d3[0]\n\t"
     "vmla.f32 q0, q15, d3[1]\n\t"
     
     "vstmia %2, { q0 }"
     
     :
     : "r" (mat), "r" (vec), "r" (result)
     : "memory", "q0", "q1", "q8", "q9", "q10", "q11"
     );
}

inline void Matrix4ByMatrix4(const float32x4x4_t* __restrict__ m1, const float32x4x4_t* __restrict__ m2, float32x4x4_t* __restrict__ r)
{
    asm 
    (
     "vldmia %1, { q0-q3 } \n\t"
     "vldmia %2, { q8-q11 }\n\t"
     
     "vmul.f32 q12, q8, d0[0]\n\t"
     "vmul.f32 q13, q8, d2[0]\n\t"
     "vmul.f32 q14, q8, d4[0]\n\t"
     "vmul.f32 q15, q8, d6[0]\n\t"
     
     "vmla.f32 q12, q9, d0[1]\n\t"
     "vmla.f32 q13, q9, d2[1]\n\t"
     "vmla.f32 q14, q9, d4[1]\n\t"
     "vmla.f32 q15, q9, d6[1]\n\t"
     
     "vmla.f32 q12, q10, d1[0]\n\t"
     "vmla.f32 q13, q10, d3[0]\n\t"
     "vmla.f32 q14, q10, d5[0]\n\t"
     "vmla.f32 q15, q10, d7[0]\n\t"
     
     "vmla.f32 q12, q11, d1[1]\n\t"
     "vmla.f32 q13, q11, d3[1]\n\t"
     "vmla.f32 q14, q11, d5[1]\n\t"
     "vmla.f32 q15, q11, d7[1]\n\t"
     
     "vstmia %0, { q12-q15 }"
     :
     : "r" (result), "r" (m2), "r" (m1)
     : "memory", "q0", "q1", "q2", "q3", "q8", "q9", "q10", "q11", "q12", "q13", "q14", "q15"
     );
}
Для человека не знакомого с ассемблером все кажется довольно страшным – я сам такой, могу разбираться только в NEON ассемблере. Но на самом деле здесь все просто – q1-q15 это, собственно, NEON регистры. vldmia\vld1.32 – инструкции загрузки; vstmia – сохранения в память; vmul.f32\vmla.f32 — умножить\умножить и прибавить.

Метод интринсиков:
inline void Matrix4ByVec4(float32x4x4_t* __restrict__ mat, const float32x4_t* __restrict__ vec, float32x4_t* __restrict__ result)
{
    (*result) = vmulq_n_f32((*mat).val[0], (*vec)[0]);
    
    (*result) = vmlaq_n_f32((*result), (*mat).val[1], (*vec)[1]);
    (*result) = vmlaq_n_f32((*result), (*mat).val[2], (*vec)[2]);
    (*result) = vmlaq_n_f32((*result), (*mat).val[3], (*vec)[3]);
}

inline void Matrix4ByMatrix4(const float32x4x4_t* __restrict__ m1, const float32x4x4_t* __restrict__ m2, float32x4x4_t* __restrict__ r)
{
    (*r).val[0] = vmulq_n_f32((*m1).val[0], vgetq_lane_f32((*m2).val[0], 0));
    (*r).val[1] = vmulq_n_f32((*m1).val[0], vgetq_lane_f32((*m2).val[1], 0));
    (*r).val[2] = vmulq_n_f32((*m1).val[0], vgetq_lane_f32((*m2).val[2], 0));
    (*r).val[3] = vmulq_n_f32((*m1).val[0], vgetq_lane_f32((*m2).val[3], 0));
    
    (*r).val[0] = vmlaq_n_f32((*r).val[0], (*m1).val[1], vgetq_lane_f32((*m2).val[0], 1));
    (*r).val[1] = vmlaq_n_f32((*r).val[1], (*m1).val[1], vgetq_lane_f32((*m2).val[1], 1));
    (*r).val[2] = vmlaq_n_f32((*r).val[2], (*m1).val[1], vgetq_lane_f32((*m2).val[2], 1));
    (*r).val[3] = vmlaq_n_f32((*r).val[3], (*m1).val[1], vgetq_lane_f32((*m2).val[3], 1));
    
    (*r).val[0] = vmlaq_n_f32((*r).val[0], (*m1).val[2], vgetq_lane_f32((*m2).val[0], 2));
    (*r).val[1] = vmlaq_n_f32((*r).val[1], (*m1).val[2], vgetq_lane_f32((*m2).val[1], 2));
    (*r).val[2] = vmlaq_n_f32((*r).val[2], (*m1).val[2], vgetq_lane_f32((*m2).val[2], 2));
    (*r).val[3] = vmlaq_n_f32((*r).val[3], (*m1).val[2], vgetq_lane_f32((*m2).val[3], 2));
    
    (*r).val[0] = vmlaq_n_f32((*r).val[0], (*m1).val[3], vgetq_lane_f32((*m2).val[0], 3));
    (*r).val[1] = vmlaq_n_f32((*r).val[1], (*m1).val[3], vgetq_lane_f32((*m2).val[1], 3));
    (*r).val[2] = vmlaq_n_f32((*r).val[2], (*m1).val[3], vgetq_lane_f32((*m2).val[2], 3));
    (*r).val[3] = vmlaq_n_f32((*r).val[3], (*m1).val[3], vgetq_lane_f32((*m2).val[3], 3));
}
Почти такой же код, как и в GLKMath, но есть небольшие отличия. Пояснения: vmulq_n_f32 – умножение вектора на скаляр; vgetq_lane_f32 – макрос, выбирающий скаляр из вектора; vmlaq_n_f32 – умножить на скаляр и прибавить. Этот код – просто отражение ассемблера на интринсики. Посмотрим, как он покажет себя в сравнении с ним.

Я делал тест на iPod Touch 4. Таблица содержит результаты профиллирования Update функции:
Подход Время выполнения, мс CPU нагрузка, %
FPU 6058 + 5067* 35-38
GLKMath 2789 20-23
Ассемблер 5304 23-25
Интринсики 2803 18-20
*На скриншоте из Instruments можно заметить, что функция Matrix4ByMatrix4 не заинлайнилась.

Вот и еще один совет – агрессивно инлайнте ваш критический к производительности код. Предпочитайте __attribute__((always_inline)) перед обычным inline в таких случаях.

Обновленная таблица результатов:
Подход Время выполнения, мс CPU нагрузка, %
FPU forceinlined 6209 25-28
GLKMath 2789 20-23
Ассемблер 5304 23-25
Интринсики 2803 18-20
Принудительный инлайн дал очень хороший прирост производительности! Посмотрим, как покажет себя автовекторизация кода. Все, что нам необходимо – это добавить –mllvm –vectorize –mllvm –bb-vectorize-aligned-only в Other C Flags в настройках проекта.

Финальная таблица результатов:
Подход Время выполнения, мс Время выполнения (вектор), мс CPU нагрузка, % CPU нагрузка (вектор), %
FPU forceinlined 6209 5028 25-28 22-24
GLKMath 2789 2776 20-23 20-23
Ассемблер 5304 5291 23-25 22-24
Интринсики 2803 2789 18-20 18-20

Довольно странные результаты можно наблюдать в случае с ассемблером и интринсиками – по сути код один и тот же, но результат отличается кардинально – почти в 2 раза! Ответ на этот вопрос кроется в ассемблерном листинге (желающие заглянут сами). В случае с ассемблером мы видим в листинге именно то, что мы и написали. В случае с интринсиками компилятор оптимизировал код. Медленный, на первый взгляд, код GLKMath компилятор прекрасно оптимизировал что дало такое же время исполнения кода, как и у вручную написанных интринсиков.


Настало время подводить итоги. Можно сделать несколько выводов:
  • Инженеры из команды LLVM проделали великолепную работу. В итоге компилятор генерирует хорошо оптимизированный код для интринсиков. Я делал похожий тест более года назад, когда единственным компилятором в XCode был GCC 4.2 и он выдавал очень плохой результат – всего 10-15% прироста производительности по сравнению с FPU кодом. Это прекрасные новости – нет необходимости изучать ассемблер и я этому несказанно рад!
  • Clang компилятор умеет автовекторизировать код. Для программиста это бонус в производительности написав лишь 4 слова. Что тут еще можно сказать кроме того, что это крутая штука?!
  • NEON код дает очень хороший буст производительности по сравнению с обычным C кодом – 2.22 раз! По итогам проделанной оптимизации вершинный процессинг стал быстрее, чем копирование этих самых вершин на сторону GPU! Если заглянуть в ассемблер memcpy то можно увидеть, что там так же используется NEON код. Отсутствие аппаратного прифетча в Cortex A8, видимо, и является причиной более медленного кода.
  • Изучение всех этих лоу левел вещей стоит потраченного времени, особенно, если ваша цель — стать профессионалом.


Ссылки
www.arm.com/products/processors/technologies/neon.php
blogs.arm.com/software-enablement/161-coding-for-neon-part-1-load-and-stores
code.google.com/p/math-neon
llvm.org/devmtg/2012-04-12/Slides/Hal_Finkel.pdf
Демо проект
Поделиться публикацией

Похожие публикации

Комментарии 33
    +3
    Мсье знает толк. И это хорошо.
    • НЛО прилетело и опубликовало эту надпись здесь
        0
        Асм листинг :) Но я решил его не вставлять сюда — это нужно далеко не всем, тем кому это действительно надо не составит труда самому заглянуть в листинг — ссылка на проект в самом низу. Плюс ко всему статья и так обьемная получилась, около 8-ми страниц А4.
        • НЛО прилетело и опубликовало эту надпись здесь
            +1
            >почему ручной ассемблер такой неприлично медленный получился.
            Компилятор лучше меня знает все о инструкциях — количество циклов, какие могут застоллить пайплайн, как лучше их перетасовать, что бы избежать столлов, как протолкнуть данные в регистры и из регистров — VFP и NEON разделяют между собой набор регистров. Вот и компилятор в асм листинге для интринсиков протаскивал некоторые данные через VFP регистры.

            К тому же я не специалист по ассемблеру. Возможно, что кто-то более опытный, сразу заметит косяки в моем асме.

            >Я думал, у вас под рукой есть
            Тоже нету — я сейчас не за маком
        0
        Интересный тест.
        Судя по результатам самым быстрым оказался GLKMath. Интересно было бы посмотреть какой код выдает компилятор при использовании GLKMatrix4Multiply.
          0
          Самым быстрым оказались интринсики. GLKMath — это просто узкоспециализировання библиотека с векторизированным кодом. Шаг в сторону — и прийдется писать самому. Я здесь её больше привел для сравнения и что бы показать, что новый Clang очень хорошо оптимизирует код. GCC 4.2 жутко сливал в этой задаче и на нем GLKMath давал крошечный прирост.
            0
            А как поведет себя cblas не смотрели? Я использовал его для несколько больших массивов, но здесь он тоже, кажется, может быть применим.
              0
              >А как поведет себя cblas не смотрели?
              Нет. Я даже не в курсе, что это. Быстро погуглил, пробежался по коду — не заметил там никакой векторизации. Так что этот будет выполняться на FPU и даст соответствующий результат…
          +3
          каждое ядро процессора снабжено своим NEON юнитом, когда же VFP — один на процессор.
          Здрасьте, с чего это NEON-ов больше чем VFP?

          Но у NEON’а есть одна киллер фича – он может параллельно обрабатывать 4 32-х битных флоата, в то время как PowerVR SGX – только один.
          NEON в Cortex-A9 64х битный и умеет обрабатывать только ДВА * 32-х битных флоата параллельно.
          C какого испуга USSE выполняет лишь 1 флоат операцию? Или речь идёт про пайп а не про ядро?

          К сожалению, я не нашел в нем прифетча, что, видимо, и является причиной более медленного кода.
          У A9 есть аппаратный префетч (на несколько стримов)

            0
            Здрасьте, с чего это NEON-ов больше чем VFP?
            Моя ошибка :(

            NEON в Cortex-A9 64х битный и умеет обрабатывать только ДВА * 32-х битных флоата параллельно.
            128-ми битный. Пруф — infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.ddi0409e/Chdceejc.html

            C какого испуга USSE выполняет лишь 1 флоат операцию? Или речь идёт про пайп а не про ядро?
            USSE обрабатывает 32-х! битные флоаты на скалярном процессоре. Если флоаты 16-ти битные — тогда они выполняются на векторном движке. Это описано где-то в мануалах от Imagination. В Rogue этот недостаток будет исправлен. На этом делается акцент в спецификации OpenGL ES 3.0

            У A9 есть аппаратный префетч (на несколько стримов)
            У А8, который как раз в моем iPod Touch 4, нету апаратного прифетча
              +1
              128-ми битный
              Не все инструкции 128битные

              infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.ddi0409e/Chdceejc.html
              VMUL Dd,Dn,Dm[x] 1 такт
              VMUL Qd,Qn,Dm[x] 2 такта

              обьяснение остальных значений — чуть выше в п. 3.4.1. Instruction timing tables

              Krait и другие современные ARM процессоры — имеют полноразмерный NEON

              USSE обрабатывает 32-х! битные флоаты на скалярном процессоре.
              Если брать в расчёт только SGX535, то да.
              SGX543 это следующее поколение — USSE2, который обрабатывает 2 x FP32
              Табличка имеет разные GPU, соотвественно чтобы не создавать путаницы, лучше уточнять какой именно описывается.

                0
                Не все инструкции 128битные
                Я сразу не уловил замечание про размер инструкций. Думал, разговор идет о размере SIMD регистров.
                Замечание учтено. Спасибо.

                SGX543 это следующее поколение — USSE2, который обрабатывает 2 x FP32
                К сожалению мне не удалось найти какой либо официальной информации на этот счет. Потому взял профайлер шейдеров, посмотрел на количество циклов с 535-м компилятором и 543-м. Получилось одно и то же. С чего я и сделал соответствующий вывод. Если вы сможете дать достоверную, подтвержденную информацию на этот счет — буду очень благодарен. Пока я оставлю статью так, как она есть.
          +2
          Зачем так делать? Зачем перечитывать матрицу из памяти?
          Передавайте матрицу по значению. Компилятор не будет оптимизировать ваш асм код даже в случае инлайна.
          inline void Matrix4ByVec4(float32x4x4_t* __restrict__ mat, const float32x4_t* __restrict__ vec, float32x4_t* __restrict__ result)
          {
              asm
              (
                 "vldmia %0, { d24-d31 } \n\t"
                 "vld1.32    {q1}, [%1]\n\t"
          


          Cortex A8 кстати поддерживает dual-issue для вычислителной инструкции и load-store
          infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.ddi0344i/BABHBCCB.html
            0
            Зачем перечитывать матрицу из памяти?
            Я НЕ перечитываю матрицу из памяти. Я загружаю её в NEON регистры
              0
               for (int j = 0; j < 4; ++j) {
                          Matrix4ByVec4(&mvp, &squareVertices[j], &data[i + j].pos);
                      }
              


              А это что? 4 раза перечитываете то, что можно загрузить 1 раз.
              Я не в курсе, какой ABI у iOS, но если уж рассказыаваете об оптимизации,
              то использовать функции вида «умножить 1 вектор на матрицу» — не лучший подход.
              Нужно умножать сразу пачку векторов, тогда можно будет выполнять чтение данных параллельно
              вычислениям и не создавать пузыри в конвеере.

                0
                Сделал тест. Код:
                __attribute__((always_inline)) void CalculateSpriteVertsWorldPos(const float32x4x4_t* __restrict__ mvp, float32x4_t* __restrict__ v1, float32x4_t* __restrict__ v2, float32x4_t* __restrict__ v3, float32x4_t* __restrict__ v4)
                {
                    __asm__ volatile
                    (
                     "vldmia %0, { q8-q11 }\n\t"
                     "vldmia %1, { q0-q3 } \n\t"
                
                     "vmul.f32 q12, q8, d0[0]\n\t"
                     "vmla.f32 q12, q9, d0[1]\n\t"
                     "vmla.f32 q12, q10, d1[0]\n\t"
                     "vmla.f32 q12, q11, d1[1]\n\t"
                     
                     "vmul.f32 q13, q8, d2[0]\n\t"
                     "vmla.f32 q13, q9, d2[1]\n\t"
                     "vmla.f32 q13, q10, d3[0]\n\t"
                     "vmla.f32 q13, q11, d3[1]\n\t"
                     
                     "vmul.f32 q14, q8, d4[0]\n\t"
                     "vmla.f32 q14, q9, d4[1]\n\t"
                     "vmla.f32 q14, q10, d5[0]\n\t"
                     "vmla.f32 q14, q11, d5[1]\n\t"
                     
                     "vmul.f32 q15, q8, d6[0]\n\t"
                     "vmla.f32 q15, q9, d6[1]\n\t"
                     "vmla.f32 q15, q10, d7[0]\n\t"
                     "vmla.f32 q15, q11, d7[1]\n\t"
                     
                     "vstmia %2, { q12 }\n\t"
                     "vstmia %3, { q13 }\n\t"
                     "vstmia %4, { q14 }\n\t"
                     "vstmia %5, { q15 }"
                     
                     :
                     : "r" (mvp), "r" (squareVertices), "r" (v1), "r" (v2), "r" (v3), "r" (v4)
                     : "memory", "q0", "q1", "q2", "q3", "q8", "q9", "q10", "q11", "q12", "q13", "q14", "q15"
                     );
                }
                


                Результат изменился не значительно — было 5300, стало 4780, прирост — около 10%. Пытался перетасовать стор инструкции — особой разницы не заметил.
                • НЛО прилетело и опубликовало эту надпись здесь
                    0
                    тогда зачем там opengl часть вообще, можно выкинуть её и нормально замерять время.
                    по-моему это не принципиально. В обеих случаях ОГЛ часть будет отнимать одинаковое количество времени.
                    • НЛО прилетело и опубликовало эту надпись здесь
                    +1
                    Во-первых вы создаёте очереди зависимых инструкций вместо того чтобы
                    использовать возможности конвеера.
                    У вас же столлы после каждой инструкции идут.

                    На A8:
                    «vmul.f32 q12, q8, d0[0]\n\t»
                    [ждём 5 тактов]
                    «vmla.f32 q12, q9, d0[1]\n\t»
                    [ждем 8 тактов]

                    «vmul.f32 q13, q8, d2[0]\n\t»
                    «vmla.f32 q13, q9, d2[1]\n\t»

                    =>

                    «vmul.f32 q12, q8, d0[0]\n\t»
                    «vmul.f32 q13, q8, d2[0]\n\t»
                    «vmul.f32 q14, q8, d4[0]\n\t»
                    «vmul.f32 q15, q8, d6[0]\n\t»
                    [ждём 1 такт]

                    «vmla.f32 q12, q9, d0[1]\n\t»
                    «vmla.f32 q13, q9, d2[1]\n\t»

                    Во-вторых, я бы на вашем месте, раз уж если вы раскрыли цикл, обьединил эту функцию с Matrix4ByMatrix4(), так как они всегда выпоняются в последовательно в одинаковой конфигурации,
                    тем самым убрав операции соседние чтения / записи матрицы, также создающие столл.
                      0
                      Изменения не значительные. Результат — 4677
                        0
                        Ну значит тормозит не там, а например рандом() % N =)
                        Дизассемблер смотрели версии с интринсиками? Профайлинг что говорит? в iOS тулсете же есть профайлер?
                        Всё различие версии с интринсиками должно заключаться в переупорядочивании команд.
                          +1
                          Рандом считается отдельно. Это можно на скриншоте с профайлера увидеть.

                          Дизассемблер смотрели версии с интринсиками?
                          Всё различие версии с интринсиками должно заключаться в переупорядочивании команд.

                          В этом-то и вся магия) Я не спец по ассемблеру, в отличии от ребят, которые писали Clang. По коду видно, что компилятор делает много хитрой работы — данные тянет напрямую в d\q регистры и через VFP (s регистры). Опять же у меня под рукой сейчас нет Мака. Да и суть статьи в другом — юзайте интринсики. Они без особых усилий помогут получить максимум производительности, все остальное сделает компилятор. Так же на них можно быстро строить быстрый код из готовых блоков:
                                  float32x4x4_t mvp;
                                  Matrix4ByMatrix4((float32x4x4_t*)proj.m, (float32x4x4_t*)modelviewMat.m, &mvp);
                                  
                                  for (int j = 0; j < 4; ++j) {
                                      Matrix4ByVec4(&mvp, &squareVertices[j], &data[i + j].pos);
                                  }
                          

                          при условии, что соответствующие методы векторизированы.
                          Против написания кастомного кода под каждый конкретный случай:
                          __restrict__ mvp, float32x4_t* __restrict__ v1, float32x4_t* __restrict__ v2, float32x4_t* __restrict__ v3, float32x4_t* __restrict__ v4)
                          {
                              __asm__ volatile
                              (
                               "vldmia %0, { q8-q11 }\n\t"
                               "vldmia %1, { q0-q3 } \n\t"
                          
                               "vmul.f32 q12, q8, d0[0]\n\t"
                               "vmla.f32 q12, q9, d0[1]\n\t"
                               "vmla.f32 q12, q10, d1[0]\n\t"
                               "vmla.f32 q12, q11, d1[1]\n\t"
                               
                               "vmul.f32 q13, q8, d2[0]\n\t"
                               "vmla.f32 q13, q9, d2[1]\n\t"
                               "vmla.f32 q13, q10, d3[0]\n\t"
                               "vmla.f32 q13, q11, d3[1]\n\t"
                               
                               "vmul.f32 q14, q8, d4[0]\n\t"
                               "vmla.f32 q14, q9, d4[1]\n\t"
                               "vmla.f32 q14, q10, d5[0]\n\t"
                               "vmla.f32 q14, q11, d5[1]\n\t"
                               
                               "vmul.f32 q15, q8, d6[0]\n\t"
                               "vmla.f32 q15, q9, d6[1]\n\t"
                               "vmla.f32 q15, q10, d7[0]\n\t"
                               "vmla.f32 q15, q11, d7[1]\n\t"
                               
                               "vstmia %2, { q12 }\n\t"
                               "vstmia %3, { q13 }\n\t"
                               "vstmia %4, { q14 }\n\t"
                               "vstmia %5, { q15 }"
                               
                               :
                               : "r" (mvp), "r" (squareVertices), "r" (v1), "r" (v2), "r" (v3), "r" (v4)
                               : "memory", "q0", "q1", "q2", "q3", "q8", "q9", "q10", "q11", "q12", "q13", "q14", "q15"
                               );
                          }
                          

                          в который еще никто, кроме авторе не вникнет. Ну и для кроссплатформенности это еще один минус. У Эпиков, к примеру, в коде вообще нет асма, только интринсики вместо него.
                      0
                      С таймингами немного нагнал я, но посыл должен быть ясен.

                      infocenter.arm.com/help/index.jsp?topic=/com.arm.doc.ddi0344k/BCGDCECC.html
                      У инструкции VMLA значение аккумулятора должно быть готово на 3-м такте выполнения.
                      Результат VMUL готов через 5 тактов. Т.е. «первый» столл, по-идее, получается не 5, а 2 такта.

                      Органичения VMLA:

                      If a VMLA.F is followed by an VADD.F or VMUL.F with no RAW hazard, the VADD.F or VMUL.F stalls 4 cycles before issue. The 4 cycle stall preserves the in-order retirement of the instructions.

                      Т.е.
                      vmla.f32 q14, ...
                      [4 такта столл даже без Read-After-Write зависимостей]
                      vmul.f32 q15, ...

                      A VMLA.F followed by any NEON floating-point instruction with RAW hazard stalls for 8 cycles.
                      В случае «второго» столла, последовательность зависимых VMLA / VMLA могла бы выполнится быстрее если бы не это ограничение.
                  0
                  Меня тоже очень интересует почему ассемблер настолько медленнее — в моём случае это было +40%
                  Жду теста с использованием уже загруженной матрицы.
                  0
                  Дело в дата лэйауте. У меня помимо позиции на каждую вершину лежит и цвет. То есть прийдется писать специальную ф-цию для этого случая. Опять же, хорошее замечание. Я постараюсь в ближайшее время изменить код и посмотреть на результат. Соответственно, проапдейчу пост
                    0
                    Всё таки не увидели в конце результата — на сколько это даёт прироста по филрейту.
                    На сколько раельные сцены можно считать на CPU.

                    Мы некогда делали гонки под iPhone 3g ещё.
                    Там после всех отсечений скармливалось видяхе 20к поликов.
                    У вас обсчёт их обсчёт (считаем спрайт как два треугольника) сьел 18-20% CPU.
                    Но сегодня бы я не стал делать в 3д игре 20к поликов — т.к. это выглядит довольно деревянно, по современным меркам.
                    А увеличить кол-во поликов — и процессор будет только этим и занят, а надо ещё физику считать, и ещё отсекать от огромной сцены обьекты, чтоб не кормить видяхе лишнее, иначе она тоже захлебнётся, иногда успевать декодировать музыку, считать звук и прочее.

                    Вот физику посчитать на неоне — это наверно самое то было бы.
                      0
                      Во-первых — это демо, а не реальный игровой проект.

                      У вас обсчёт их обсчёт (считаем спрайт как два треугольника) сьел 18-20% CPU.
                      Тогда попытайтесь нарисовать 10к динамичных спрайтов любым другим способом, к примеру по draw call'у на спрайт — боюсь, что в таком случае ваш фпс просядет раз в 10-20…

                      Всё таки не увидели в конце результата — на сколько это даёт прироста по филрейту.
                      Ровно на столько, на сколько мы освободили USSE от вершинного процессинга. Здесь замкнутый круг — если я буду сравнивать свой, так сказать, софтварный инстансинг с draw call'ом на спрайт — то я получу громадную разницу, но сравнение будет не объективным, так как при таком инстансинге я упираюсь именно в филлрейт, а при ДИПе на спрайт — в синхронизацию процессора с гпу. Так же, к сожалению, я не могу померить использование USSE на iOS девайсе — что бы знать, на сколько я освободил его от расчетов вершинных шейдеров. Это можно сделать имея не залоченное железо на Андроиде, Линуксе или Винде.

                      А увеличить кол-во поликов — и процессор будет только этим и занят, а надо ещё физику считать, и ещё отсекать от огромной сцены обьекты
                      Ну так современные девайсы имеют больше одного ядра — зачем же им простаивать-то?!
                        0
                        Если игра 2д с кучей спрайтов, которые каждый кадр меняют своё положение — то да ваш способ действительно имеет смысл.
                        У меня и мысли не было что кому то в голову придёт скармливать видяхе по одному спрайту.
                        2д считаем на cpu, складываем в буфер и пачкой засылаем в GPU.

                        Но если игра 3д — то там же нет необходимости по паре полигонов гонять — засунули большой меш в GPU, оно его переварило и нарисовало.

                        Наверно я не совсем правильно воспринял, для чего всё это вы задумали. Пример со спрайтами должен был это подсказать :)
                          0
                          Все не ограничивается спрайтами. К примеру, если у тебя требование — OpenGL ES 1.1, то скининг мешей так же лучше делать на НЕОНе. Если множество низко полигональных объектов — их так же будет быстрее софтварно заинстансить, чем рисовать по одному.
                            0
                            Вобщем выигрыш просматривается тогда, когда нет возможности скормить ограниченное количество мешей, с большим количеством треугольников, а надо мелкими порциями скармливать, когда вызовы к драйверу просто всё убьют.

                    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                    Самое читаемое