Portage de sections bas niveau d'applications Android natives sur des plates-formes basées sur l'architecture Intel

N'hésitez pas à commenter cet article ! Commentez Donner une note à l'article (5)

Article lu   fois.

Les deux auteur et traducteur

Traducteur : Profil Pro

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Introduction

Il existe deux types d'applications pour Android. Le premier type correspond à l'application Dalvik qui est basée sur Java et qui peut tourner correctement sur n'importe quelle architecture sans aucune modification. Le second type concerne les applications NDK dont une partie du code est écrite en C/C++ ou ASM et qui doivent être compilées pour un processeur spécifique.

Cette discussion sera axée sur les applications NDK. On a généralement juste besoin de modifier le paramètre APPABI, dans le fichier application.mk et de compiler la partie NDK, ce qui lui permettra, par la suite, d'être exécuté sur le périphérique correspondant. Cependant, certaines parties des applications NDK ne peuvent pas être simplement compilées à nouveau si elles contiennent certains types de codes, comme un code en langage assembleur ou un code à instruction unique et données multiples (SIMD : Single Instruction Multiple Data).

Cet article explique comment traiter ces problèmes et présente les majeures considérations que les développeurs doivent connaître sur le fait de porter une application de plate-forme d'architecture non-Intel (x86) sur une plate-forme d'architecture Intel. On traitera également la conversion de boutisme du x86 vers les plates-formes non x86.

II. Instruction unique et données multiples (SIMD)

SIMD (Single Instruction Multiple Data) est une classe d'ordinateurs parallèles, comme décrit par la taxonomie de Flynn, avec de multiples éléments de traitement qui effectuent la même opération sur plusieurs points de données simultanément. Ces machines exploitent le parallélisme des données, car il y a simultanément des calculs. SIMD est particulièrement adéquat sur des tâches courantes telles que le réglage du contraste dans une image numérique, ou le réglage du volume du son numérique.

La plupart des modèles de processeurs incluent les instructions SIMD afin d'améliorer les performances pour une utilisation multimédia. Pour la plate-forme x86 mobile, SIMD est appelé « Intel Streaming SIMD Extensions » (Intel® SSE, SSE2, SSE3, etc.). Pour la plate-forme ARM*, SIMD est appelé « NEON technology ». Si vous avez besoin d'informations sur NEON, référez-vous à la documentation du constructeur.

II-A. Intel® Streaming SIMD Extensions (Intel® SSE)

D'abord, qu'est-ce que Intel SSE ? C'est essentiellement une collection de registres de processeur 128 bits. Ces registres peuvent être représentés en quatre scalaires de 32 bits qui permettent à une opération d'être effectuée sur chacun des quatre éléments simultanément.

En revanche, ça peut prendre quatre opérations ou plus en assembleur régulier pour faire la même chose. Dans le diagramme ci-dessous, vous pouvez voir deux vecteurs (registres Intel SSE) représentés avec des scalaires. Les registres sont multipliés avec MULPS qui stocke le résultat. Voilà quatre multiplications réduites en une seule opération. L'intérêt d'utiliser Intel SSE est suffisamment important pour ne pas être ignoré.

Image non disponible
Figure 1 : deux vecteurs (registres Intel SSE) représentés avec des scalaires


Maintenant, avec l'idée de base d'Intel SSE en tête, nous allons jeter un coup d'œil à quelques-unes des instructions les plus courantes.

Les instructions de mouvement de données
MOVUPS Déplacer 128 bits de données vers un registre SIMD de la mémoire ou registre SIMD. Non-aligné.
MOVAPS Déplacer 128 bits de données vers un registre SIMD de la mémoire ou registre SIMD. Aligné.
MOVHPS Déplacer 64 bits à des bits supérieurs d'un registre SIMD (élevé).
MOVLPS Déplacer 64 bits à bits de poids faible d'un registre SIMD (bas).
MOVHLPS Déplacer les 64 bits supérieurs du registre source vers 64 bits inférieurs du registre de destination.
MOVLHPS Déplacer les 64 bits inférieurs du registre source vers 64 bits supérieurs du registre de destination.
MOVMSKPS Déplacer les bits de signe de chacun des quatre scalaires emballés vers un registre x86 d'entiers.
MOVSS Déplacer 32 bits à un registre SIMD de la mémoire ou du registre SIMD.

Instructions arithmétiques

NOTE : un scalaire va effectuer l'opération uniquement sur les premiers éléments. La version parallèle va effectuer l'opération sur tous les éléments dans le registre.

Parallèle Scalaire Description
ADDPS ADDSS additionne les opérandes.
SUBPS SUBSS soustrait les opérandes.
MULPS MULSS multiplie les opérandes.
DIVPS DIVSS divise les opérandes.
SQRTPS SQRTSS racine carrée de l'opérande.
MAXPS MAXSS Maximum des opérandes.
MINPS MINSS Minimum des opérandes.
RCPPS RCPSS inverse de l'opérande.
RSQRTPS RSQRTSS inverse de la racine carrée de l'opérande.

Instructions de comparaison
Parallèle Scalaire
CMPPS, CMPSS Compare les opérateurs et retourne tout à 1 ou tout à 0.

Instructions logiques
ANDPS ET bit à bit des opérandes.
ANDNPS NON-ET bit à bit des opérandes.
ORPS OU bit à bit des opérandes.
XORPS OU exclusive des opérandes.

Instructions aléatoires
SHUFPS Mélanger les nombres d'un opérande à un autre ou lui-même.
UNPCKHPS Déballer les nombres d'ordre élevé pour un registre SIMD.
UNPCKLPS Déballer les nombres d'ordre bas pour un registre SIMD.

D'autres instructions non traitées ici incluent la conversion des données entre le x86 et les registres MMX, les instructions de contrôle de la cache et les instructions de la gestion du statut.

III. Comment convertir NEON en Intel SSE ?

Les instructions Intel SSE et NEON ne sont pas parfaitement équivalentes. Bien que basée sur le même principe de conception, la méthode d'implémentation est différente. Vous devez traduire chaque instruction une par une.

Le code suivant utilise les instructions NEON :

 
Sélectionnez
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));
// première erreur, ça produit un mauvais résultat
//q0 = vmaxq_s16(q0, vzipq_s16(q0, q0).val[1]);
// peut être que quelqu'un connait une meilleure méthode?
int16x4_t a_hi = vget_high_s16(q0);
q1 = vcombine_s16(a_hi, a_hi);
q0 = vmaxq_s16(q0, q1);

// Ceci est _mm_srli_si128(q0, 4)
q1 = vextq_s16(q0, zero, 2);
q0 = vmaxq_s16(q0, q1);

// Ceci est _mm_srli_si128(q0, 2)
q1 = vextq_s16(q0, zero, 1);
q0 = vmaxq_s16(q0, q1);

// lire le résultat
int16_t __attribute__ ((aligned (16))) x[8];
vst1q_s16(x, q0);
threshold = x[0] - 1;

Le code suivant utilise Intel SSE :

 
Sélectionnez
__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;

Pour plus d'informations à propos de la conversion NEON en Intel SSE, référez-vous au blog cité dans la section références [1]. Il fournit un fichier d'en-tête qui peut être utilisé pour créer automatiquement une carte des instructions NEON et Intel SSE.

IV. Activer le langage Assembleur

Les microprocesseurs à jeu d'instruction étendu (Complex Instruction Set Computer : CISC), comme ceux d'Intel, ont un ensemble d'instructions riches et capables d'effectuer des actions complexes avec une instruction unique (contrairement à l'architecture RISC qui vise des instructions plus généralistes ainsi que l'efficacité). Les instructions CISC ont un nombre relativement important de registres à usage généraliste, et d'instructions de données qui utilisent généralement trois registres : une destination et deux opérandes. Si vous avez besoin d'informations sur les instructions/l'architecture ARM, référez-vous à la documentation du constructeur.

Les processeurs Intel (cf. 386 et plus) ont huit registres 32 bits à usage général comme montré dans le diagramme suivant. Les noms d'un registre sont pour la plupart historique. Par exemple, EAX était appelé « l'accumulateur » puisqu'il était utilisé par un certain nombre d'opérations arithmétiques ; ECX était connu sous le nom de « calculateur » puisqu'il était utilisé pour maintenir un indice de boucle. Alors que la plupart des registres ont perdu leurs fins spéciales dans le jeu d'instructions modernes ; par convention, deux sont réservés à des fins particulières : le pointeur de pile (ESP) et le pointeur de base (EBP).

Image non disponible
Figure 2 : processeurs Intel x86 avec huit registres de 32 bits à usage général

Pour les registres EAX, EBX, ECX et EDX, des sous-ensembles peuvent être utilisés. Par exemple, les deux octets les moins significatifs de EAX peuvent être traités comme un registre de 16 bits appelé AX. L'octet le moins significatif de AX peut être utilisé comme un simple registre de 8 bits appelé AL, tandis que l'octet le plus significatif de AX peut être utilisé comme un simple registre de 8 bits appelé AH. Ces noms font référence au même registre physique. Quand une quantité de deux octets est placée dans DX, la mise à jour affecte la valeur de DH, DL et EDX. Ces sous-registres sont principalement les restants des anciennes versions 16 bits du jeu d'instructions. Cependant, ils sont parfois appropriés lorsqu'il s'agit de données qui sont plus petites que 32 bits (par exemple, les caractères ASCII sur un octet).

Puisqu'il y a des différences entre ARM et le langage assembleur x86, le code assembleur ARM ne peut pas être utilisé directement sur les plates-formes x86 :

  1. Implémenter la même fonction avec le langage assembleur x86 ;
  2. Remplacer le code ARM avec le code C.

Dans de nombreux programmes Open Source, le code ASM a été remplacé pour améliorer les performances, mais la performance n'est pas un problème dans ce cas, car les processeurs sont plus robustes que jamais. Cependant, contrairement au code ASM écrasé, le code C qui a implémenté la même fonction a été retenu dans le code source, cela nous permet de compiler du code C pour une plate-forme x86.

Par exemple, un éditeur de logiciels a développé un jeu qui utilise le format de compression audio Vorbis, un programme open source qui contient du code segmenté assembleur ARM. Donc, l'éditeur de logiciels n'a pas pu le convertir en x86 NDK. Au lieu d'avoir à réécrire cette section de code x86 ASM, le développeur la recompile en C de sorte qu'il s'exécute correctement sur x86. Le problème a été résolu en désactivant la macro_ARM_ASSEM_ et en activant la macro _LOW_ACCURACY_.

IV-A. La conversion du boutisme en ARM et x86

Dans les projets multiplate-forme, nous sommes souvent confrontés à un vieux problème de conversion de boutisme. Si le fichier est généré sur une machine petit-boutiste, un entier 255 peut être stocké comme suit :

 
Sélectionnez
ff 00 00 00

Mais quand la valeur est lue dans la mémoire, elle sera différente pour diverses plates-formes, ce qui provoquera un problème de portage.

 
Sélectionnez
int a;
fread(&a, sizeof(int), 1, file);
// sur une machine petit-boutiste, a = 0xff;
// mais sur une machine grand-boutiste, a = 0xff000000;  

Une manière très simple et efficace de résoudre ce problème est d'écrire une fonction appelée readInt() :

 
Sélectionnez
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];}

La fonction a l'avantage de marcher sur les plates-formes grand-boutiste et petit-boutiste, mais elle est incompatible avec la méthode habituelle pour lire une structure.

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

Si MyFileHeader contient beaucoup d'entiers, ça va résulter en plusieurs read(). Ce n'est pas seulement lourd à coder, mais en plus c'est lent en raison de l'exploitation accrue des opérations d'entrée-sortie. Je propose donc une autre méthode : laissons le code inchangé et utilisons plusieurs macros pour traiter les données plus tard.

 
Sélectionnez
fread(&header, sizeof(struct MyFileHeader), 1, file);
CQ_NTOHL(header.version);
CQ_NTOHL_ARRAY(&header.box, 4); // box est une structure RECT

Si le boutisme de l'ordinateur ne correspond pas à celle du fichier de données, ces macros seront soient définies pour exécuter certaines fonctions, soient définies vides :

 
Sélectionnez
#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

Cette approche a l'avantage de ne pas perdre des cycles du processeur si ENDIAN_CONVERSION n'est pas défini, et le code peut être conservé dans sa forme naturelle de sorte à lire toute une structure à la fois.

IV-B. Conclusion

ARM et x86 possèdent une architecture et des ensembles d'instructions différentes, il y a donc des divergences dans les fonctionnalités de bas niveau. J'espère que les informations contenues dans cet article vont vous aider à surpasser ces différences lors du développement d'applications Android NDK pour plusieurs plates-formes.

Vous trouverez également d'autres outils pour optimiser les performances de vos applications sur l'Intel Developer Zone Android.

IV-C. À propos de l'auteur

Peng Tao (tao.peng@intel.com) est un ingénieur d'applications logicielles dans le groupe Intel Software and Services. Il se concentre actuellement sur les jeux et l'activation du multimédia sur les applications et l'optimisation des performances, en particulier sur les plates-formes mobiles Android.

IV-D. Ressources

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Copyright © 2014 Tao Peng. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.