Quantcast
Channel: Статьи Intel Developer Zone
Viewing all articles
Browse latest Browse all 13

Перенос низкоуровневого кода нативных приложений Android* на платформы с архитектурой Intel®

$
0
0

Введение

Существует два типа приложений для Android. Первый тип — приложения Dalvik, представ-ляющие собой приложения на базе Java*, способные правильно выполняться на любой архитектуре без каких-либо изменений. Второй тип — приложения NDK, у которых часть кода написана на C/C++ или на ассемблере, вследствие чего требуется перекомпилировать код для каждой архитектуры CPU.

В этой статье рассматриваются приложения NDK. Для этих приложений обычно нужно всего лишь изменить параметр APPABI в файле application.mk и скомпилировать код NDK, после чего приложение можно будет запустить на соответствующем устройстве. Тем не менее определенные части некоторых приложений NDK невозможно просто перекомпилировать, если в них используется код определенного типа, например ассемблерный код или код SIMD (обработка нескольких фрагментов данных одной инструкцией). В этой статье поясняется, как справляться с такими затруднениями, и приводится полезная для разработчиков информация о переносе приложений с архитектуры, отличной от Intel® (x86), на платформу с архитектурой Intel. Также обсуждается преобразование порядка следования байтов между платформами x86 и отличными от x86.

SIMD (одна инструкция, множество данных)

SIMD — это класс параллельных компьютеров, согласно описанию в таксономии Флинна, с множеством процессорных элементов, одновременно выполняющих одну и ту же операцию с множеством точек данных. Такие машины используют параллельную обработку на уровне данных, поскольку вычисления осуществляются одновременно (параллельно). Вычисления SIMD особенно эффективны для распространенных задач, таких как изменение контрастности цифровых изображений или регулировка громкости цифрового звука. Большинство современных ЦП используют инструкции SIMD для повышения производительности при обработке мультимедиа. На мобильной платформе x86 инструкции SIMD называются потоковыми расширениями Intel® SIMD (Intel® SSE, SSE2, SSE3 и т. п.). На платформе ARM* инструкции SIMD называются технологией NEON*. Дополнительные сведения о NEON см. в документации производителя.

Потоковые расширения Intel® SIMD (Intel® SSE)

Для начала, что такое Intel SSE? По сути, это набор 128-разрядных регистров ЦП. В эти регистры можно поместить по четыре 32-разрядных скаляра, что дает возможность выполнить операцию над каждым из этих четырех элементов одновременно. Для сравнения в обычном случае для достижения такого же результата может потребоваться четыре операции или даже больше. На следующей схеме показаны два вектора (регистры Intel SSE) со скалярами. Производится умножение регистров операцией MULPS, после чего результат сохраняется. Умножение четырех значений производится всего за одну операцию. Преимущества Intel SSE слишком значительны, чтобы их упускать.


Рисунок 1. Два вектора (регистры Intel® SSE) со скалярами

Теперь рассмотрим несколько распространенных инструкций.

Инструкции перемещения данных

MOVUPS

Переместить 128 бит данных в регистр SIMD из памяти или регистра SIMD. Без выравнивания.

MOVAPS

Переместить 128 бит данных в регистр SIMD из памяти или регистра SIMD. С выравниванием.

MOVHPS

Переместить 64 бита в верхние биты регистра SIMD (верх).

MOVLPS

Переместить 64 бита в нижние биты регистра SIMD (низ).

MOVHLPS

Переместить верхние 64 бита исходного регистра в нижние 64 бита регистра назначения.

MOVLHPS

Переместить нижние 64 бита исходного регистра в верхние 64 бита регистра назначения.

MOVMSKPS

Переместить знаковые разряды каждого из четырех скаляров в целочисленный регистр x86.

MOVSS

Переместить 32 бита данных в регистр SIMD из памяти или регистра SIMD.

Арифметические инструкции  Примечание:  Скалярная версия выполняет операцию только для первых элементов. Параллельная версия выполняет операцию для всех элементов в регистре.

Параллельная

Скалярная

ADDPS

ADDSS — сложение операндов

SUBPS

SUBSS — вычитание операндов

MULPS

MULSS — умножение операндов

DIVPS

DIVSS — деление операндов

SQRTPS

SQRTSS — извлечение квадратного корня из операнда

MAXPS

MAXSS — максимум операндов

MINPS

MINSS — минимум операндов

RCPPS

RCPSS — вычисление величины, обратной операнду

RSQRTPS

RSQRTPS — вычисление величины, обратной квадратному корню из операнда

Инструкции сравнения

Параллельная

Скалярная

CMPPS, CMPSS

Сравнивает операнды и возвращает все единицы или все нули

Логические инструкции

ANDPS

Побитовое «логическое И» операндов

ANDNPS

Побитовое «логическое И НЕ» операндов

ORPS

Побитовое «логическое ИЛИ» операндов

XORPS

Побитовое «логическое исключающее ИЛИ» операндов

Инструкции перестановки

SHUFPS

Перестановка чисел из одного операнда в другой или в этот же

UNPCKHPS

Распаковка чисел старшего разряда в регистр SIMD

UNPCKLPS

Распаковка чисел младшего разряда в регистр SIMD

Прочие инструкции, не показанные здесь, включают преобразование данных между регистрами x86 и MMX, инструкции по управлению кешем и инструкции по управлению состоянием.

Преобразование NEON в Intel SSE

IИнструкции Intel SSE и NEON не полностью совпадают. Они основываются на одинаковом принципе, но методы их реализации различаются. Следует преобразовывать каждую инструкцию по отдельности. Ниже представлены два фрагмента кода с одной и той же функцией; в одном фрагменте используются инструкции NEON, а в другом — Intel SSE.

В следующем коде используются инструкции NEON:

int16x8_t q0 = vdupq_n_s16(-1000), q1 = vdupq_n_s16(1000);
int16x8_t zero = vdupq_n_s16(0);
for( k = 0; k < 16; k += 8 )
{
    int16x8_t v0 = vld1q_s16((const int16_t*)(d+k+1));
    int16x8_t v1 = vld1q_s16((const int16_t*)(d+k+2));
    int16x8_t a = vminq_s16(v0, v1);
    int16x8_t b = vmaxq_s16(v0, v1);
    v0 = vld1q_s16((const int16_t*)(d+k+3));
    a = vminq_s16(a, v0);
    b = vmaxq_s16(b, v0);
    v0 = vld1q_s16((const int16_t*)(d+k+4));
    a = vminq_s16(a, v0);
    b = vmaxq_s16(b, v0);
    v0 = vld1q_s16((const int16_t*)(d+k+5));
    a = vminq_s16(a, v0);
    b = vmaxq_s16(b, v0);
    v0 = vld1q_s16((const int16_t*)(d+k+6));
    a = vminq_s16(a, v0);
    b = vmaxq_s16(b, v0);
    v0 = vld1q_s16((const int16_t*)(d+k+7));
    a = vminq_s16(a, v0);
    b = vmaxq_s16(b, v0);
    v0 = vld1q_s16((const int16_t*)(d+k+8));
    a = vminq_s16(a, v0);
    b = vmaxq_s16(b, v0);
    v0 = vld1q_s16((const int16_t*)(d+k));
    q0 = vmaxq_s16(q0, vminq_s16(a, v0));
    q1 = vminq_s16(q1, vmaxq_s16(b, v0));
    v0 = vld1q_s16((const int16_t*)(d+k+9));
    q0 = vmaxq_s16(q0, vminq_s16(a, v0));
    q1 = vminq_s16(q1, vmaxq_s16(b, v0));
}
q0 = vmaxq_s16(q0, vsubq_s16(zero, q1));
// first mistake it produce wrong result
//q0 = vmaxq_s16(q0, vzipq_s16(q0, q0).val[1]);
// may be someone knows faster/better way?
int16x4_t a_hi = vget_high_s16(q0);
q1 = vcombine_s16(a_hi, a_hi);
q0 = vmaxq_s16(q0, q1);

// this is _mm_srli_si128(q0, 4)
q1 = vextq_s16(q0, zero, 2);
q0 = vmaxq_s16(q0, q1);

// this is _mm_srli_si128(q0, 2)
q1 = vextq_s16(q0, zero, 1);
q0 = vmaxq_s16(q0, q1);

// read the result
int16_t __attribute__ ((aligned (16))) x[8];
vst1q_s16(x, q0);
threshold = x[0] - 1;

В следующем коде используются инструкции SSE:

__m128i q0 = _mm_set1_epi16(-1000), q1 = _mm_set1_epi16(1000);
for( k = 0; k < 16; k += 8 )
{
    __m128i v0 = _mm_loadu_si128((__m128i*)(d+k+1));
    __m128i v1 = _mm_loadu_si128((__m128i*)(d+k+2));
    __m128i a = _mm_min_epi16(v0, v1);
    __m128i b = _mm_max_epi16(v0, v1);
    v0 = _mm_loadu_si128((__m128i*)(d+k+3));
    a = _mm_min_epi16(a, v0);
    b = _mm_max_epi16(b, v0);
    v0 = _mm_loadu_si128((__m128i*)(d+k+4));
    a = _mm_min_epi16(a, v0);
    b = _mm_max_epi16(b, v0);
    v0 = _mm_loadu_si128((__m128i*)(d+k+5));
    a = _mm_min_epi16(a, v0);
    b = _mm_max_epi16(b, v0);
    v0 = _mm_loadu_si128((__m128i*)(d+k+6));
    a = _mm_min_epi16(a, v0);
    b = _mm_max_epi16(b, v0);
    v0 = _mm_loadu_si128((__m128i*)(d+k+7));
    a = _mm_min_epi16(a, v0);
    b = _mm_max_epi16(b, v0);
    v0 = _mm_loadu_si128((__m128i*)(d+k+8));
    a = _mm_min_epi16(a, v0);
    b = _mm_max_epi16(b, v0);
    v0 = _mm_loadu_si128((__m128i*)(d+k));
    q0 = _mm_max_epi16(q0, _mm_min_epi16(a, v0));
    q1 = _mm_min_epi16(q1, _mm_max_epi16(b, v0));
    v0 = _mm_loadu_si128((__m128i*)(d+k+9));
    q0 = _mm_max_epi16(q0, _mm_min_epi16(a, v0));
    q1 = _mm_min_epi16(q1, _mm_max_epi16(b, v0));
}
q0 = _mm_max_epi16(q0, _mm_sub_epi16(_mm_setzero_si128(), q1));
q0 = _mm_max_epi16(q0, _mm_unpackhi_epi64(q0, q0));
q0 = _mm_max_epi16(q0, _mm_srli_si128(q0, 4));
q0 = _mm_max_epi16(q0, _mm_srli_si128(q0, 2));
threshold = (short)_mm_cvtsi128_si32(q0) - 1;

Дополнительные сведения о преобразовании инструкций NEON в Intel SSE см. в блоге, ссылка на который приводится в разделе справочных материалов[1]. Предоставляется файл заголовка, который можно использовать для автоматического сопоставления инструкций NEON и Intel SSE.

Поддержка ассемблера

Процессоры с архитектурой CISC, такие как процессоры Intel, располагают богатым набором инструкций, способных выполнять сложные операции одной инструкцией (этим такие процессоры принципиально отличаются от RISC-процессоров, архитектура которых оптимизирована для более эффективной обработки более общих инструкций). CISC-процессоры обладают сравнительно большим количеством регистров общего назначения и инструкций обработки данных, обычно использующих три регистра: один целевой регистр (регистр назначения) и два регистра операндов. Дополнительные сведения об архитектуре и инструкциях в процессорах ARM см. в документации производителя.

Процессоры Intel (т. е. процессоры 386 и последующие) имеют восемь 32-разрядных регистров общего назначения, как показано на следующей схеме. Имена регистров по большей части сложились исторически. Например, регистр EAX раньше называли накопителем, поскольку он использовался рядом арифметических операций, а регистр ECX назывался счетчиком, поскольку в нем содержался индекс цикла. В современном наборе инструкций большинство регистров утратили свое специализированное назначение, но, по соглашению, два следующих регистра зарезервированы для особых целей: указатель стека (ESP) и базовый указатель (EBP).


Рисунок 2. Процессоры Intel® x86 с восемью 32-разрядными регистрами общего назначения

Для регистров EAX, EBX, ECX и EDX могут быть использованы подразделы. Например, два младших байта регистра EAX могут обрабатываться как 16-разрядный регистр с именем AX. Младший байт регистра AX может использоваться как одиночный 8-разрядный регистр с именем AL, а старший байт регистра AX может использоваться как одиночный 8-разрядный регистр с именем AH. Эти имена относятся к одному и тому же физическому регистру. При помещении двухбайтового числа в регистр DX это изменение влияет на значения DH, DL и EDX. Эти «вложенные регистры» являются своего рода наследием более старых, 16-разрядных версий набора инструкций. Тем не менее иногда ими удобно пользоваться, если данные меньше 32 разрядов (например, однобайтовые символы ASCII).

Из-за различий между ассемблером ARM и x86 ассемблерный код ARM невозможно напрямую использовать на платформах x86. Тем не менее существует два способа использования ассемблерного кода ARM при переносе приложения Android для ARM на архитектуру x86:

  1. Реализация этой же функции на ассемблере x86.
  2. Замена ассемблерного кода на код на языке С.

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

Например, разработчик создал игру, в которой используется формат сжатия звука Vorbis, — это программа с открытым исходным кодом, содержащая сегментированный ассемблерный код ARM. Разработчику не удалось преобразовать программу в приложение NDK для x86. Вместо замены этого фрагмента кода на ассемблер x86 разработчик переписал код на C, после чего код заработал на процессорах x86. Для решения этой проблемы нужно отключить Macro _ARM_ASSEM_и включить Macro _LOW_ACCURACY_.

Преобразование порядка следования байтов при переносе приложений между ARM и x86

В межплатформенных проектах мы зачастую сталкиваемся с одной из старейших проблем в истории программирования — с преобразованием порядка следования байтов. Если файл создан на машине с форматом little Endian (начиная с младшего байта), целое число 255 может быть записано следующим образом:

ff 00 00 00

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

int a;
fread(&a, sizeof(int), 1, file);
// on little endian machine, a = 0xff;
// but on big endian machine, a = 0xff000000; 

Эту проблему можно решить очень простым и эффективным образом: написать функцию под названием readInt():

void readInt(void* p, file)
{
    char buf[4];
    fread(buf, 4, 1, file);
    *((uint32*)p) = buf[0] << 24 | buf[1] << 16
                   | buf[2] << 8 | buf[3];}

Эта функция работает и на платформах с форматом big Endian (начиная со старшего байта), и на платформах с форматом little Endian. Но эта функция отличается от стандартного метода чтения структур.

fread(&header, sizeof(struct MyFileHeader), 1, file);

Если MyFileHeader содержит множество целочисленных значений, потребуется множество операций read(). При этом не только загромождается код, но и замедляется работа из-за увеличения количества операций ввода-вывода. Поэтому я предлагаю другой способ: оставим код без изменений и используем несколько макросов для постобработки данных.

fread(&header, sizeof(struct MyFileHeader), 1, file);
CQ_NTOHL(header.version);
CQ_NTOHL_ARRAY(&header.box, 4); // box is a RECT structure

Если порядок следования байтов в компьютере не совпадает с таким порядком в файле данных, эти макросы выполняют определенные функции; в противном случае эти макросы определяются как пустые:

#if defined(ENDIAN_CONVERSION)
#    define CQ_NTOHL(a) {a = ((a) >> 24) | (((a) & 0xff0000) >> 8) |
	(((a) & 0xff00) << 8) | ((a) << 24); }
#    define CQ_NTOHL_ARRAY(arr, num) {uint32 i;
	for(i = 0; i < num; i++) {CQ_NTOHL(arr[i]); }}
#else
#    define CQ_NTOHL(a)
#    define CQ_NTOHL_ARRAY(arr, num)
#endif

Преимущество этого подхода состоит в том, что циклы ЦП не растрачиваются впустую при неопределенном ENDIAN_CONVERSION, а код сохраняет естественный порядок чтения целых структур за один проход.

Заключение

Процессоры ARM и x86 обладают разной архитектурой и разными наборами инструкций. Определенные различия есть и на низком уровне. Надеюсь, что информация в этой статье поможет вам решить затруднения, связанные с этими различиями, при разработке приложений Android NDK для нескольких платформ.

Об авторе

Пэнь Тао (tao.peng@intel.com) работает инженером по программному обеспечению в отделе Intel Software and Services Group. В настоящее время он занимается поддержкой игр и мультимедиаприложений и оптимизацией производительности, в частности на мобильных платформах Android.

Ресурсы

[1] From ARM NEON to Intel SSE- The Automatic Porting Solution, Tips and Tricks


Viewing all articles
Browse latest Browse all 13

Latest Images

Trending Articles





Latest Images