Intel Learning Series para desarrolladores para Android*, n.º 7: Creación y portación de aplicaciones de Android* basadas en el NDK para la arquitectura Intel®

Las aplicaciones de Android* pueden incorporar código nativo con el uso del conjunto de herramientas del Kit de Desarrollo Nativo (Native Development Kit, NDK). Permite a los desarrolladores volver a usar código heredado, codificar a hardware de bajo nivel o diferenciar sus aplicaciones mediante el aprovechamiento de prestaciones que en otro caso no serían óptimas o posibles.

Este artículo es una introducción básica a la creación de aplicaciones basadas en el NDK para la arquitectura Intel, desde el principio hasta el fin, y también, en el caso de usos simples, de cómo portar aplicaciones basadas en el NDK ya existentes a dispositivos basados en la arquitectura Intel. Para mostrar el proceso, seguiremos el desarrollo de una aplicación paso a paso.

1. Introducción al Kit de Desarrollo Nativo

Sólo se debe considerar compilar aplicaciones nativas cuando el rendimiento sea cuestión de inquietud. El inconveniente de desarrollar aplicaciones nativas es que requiere mucho esfuerzo que la aplicación sea compatible con múltiples arquitecturas y diferentes generaciones de la misma arquitectura.

Damos por supuesto que se ha configurado correctamente el entorno para desarrollo de aplicaciones Java y que se tiene familiaridad con la creación de aplicaciones Java simples. Antes de continuar, es necesario instalar el NDK desde http://developer.android.com.

2. Compilación de una aplicación “Hello, world!” con el NDK

El objetivo de este capítulo es compilar la primera aplicación de ejemplo desde el NDK para un destino x86 tanto con el compilador GNU* como con el Compilador Intel® C++. Se enseñará a retocar el sistema de compilación para cambiar los indicadores de optimización de la aplicación, el módulo, un archivo específico, etc. Sólo los host Linux admiten Compilador Intel® C++, así que restringiremos nuestra atención a Linux.

2.1 Preparación y validación del entornoK/h3>

Para comenzar a experimentar con ejemplos de la distribución del NDK, es necesario familiarizarse con la utilidad ndk-build y con la sintaxis de dos archivos make: Application.mk y Android.mk.

La utilidad ndk-build extrae los detalles del sistema de compilación; Application.mk y Android.mk contienen definiciones de variables para configurar el sistema de compilación. Por ejemplo, estos archivos make especifican la lista de archivos fuente a partir de los cuales compilar la aplicación, los componentes externos de los cuales depende la aplicación, etc.

Hay que asegurarse de que las utilidades ndk-build y android del NDK y el SDK correspondientementes estén en el PATH propio:

PATH=<NDK>:<SDK>/tools:$PATH

La manera más fácil de validar el entorno es volver a compilar el ejemplo hello-jni del NDK. Copie hello-jni a algún lugar y ejecute:

ndk-build –B en el nuevo directorio.

Compruebe que tenga al menos un destino que admita ABI x86[1]. Para ello, ejecute:

android list targets

Si falta, use el comando:

android sdk

para instalarlo.

2.2 Compilación con el compilador GNU*

En la sección anterior, validamos la configuración y compilamos libhello-jni.so en libs/armeabi. La nueva biblioteca sólo se puede ejecutar en ARM* porque este es el destino predeterminado para las aplicaciones nativas. Como nuestro interés principal es desarrollar para x86, tenemos que configurar esta opción como corresponde.

Cree el archivo hello-jni/jni/Application.mk y agregue la siguiente definición en el archivo:

APP_ABI := x86

Esto indica que se debe usar un compilador cruzado y otras herramientas binarias con destino x86 para crear libhello-jni.so. Ahora ya está todo listo para compilar libhello-jni.so para x86. Esta vez habilitaremos la salida de ndk-build detallada, con la opción V=1 desactivada, para ver los comandos y sus parámetros cuando son invocados por make. Ejecute ndk-build –B V=1. La opción –B hace que se recompile por completo la biblioteca libhello-jni.so. Una vez terminada la creación de una biblioteca nativa, hay que finalizar la creación del APK para prueba.

El nombre del destino x86 en la versión r8b del NDK es android-15. Siempre puede usar el comando android list targets para revisar la lista de destinos disponibles.

En este punto, HelloJni aparecerá en la lista de aplicaciones instaladas en el dispositivo o el emulador, como se muestra en la Figura 1.


Figura 1. HelloJni aparece en la lista de aplicaciones instaladas en el dispositivo o el emulador.

Hay varias variables importantes de Application.mk que son relevantes para la optimización del rendimiento.

La primera es APP_OPTIM. Se le debe establecer el valor release para obtener el binario optimizado. En forma predeterminada, la aplicación está compilada para depurar. Para comprobar qué opciones de optimización están habilitadas de manera predeterminada, actualice APP_OPTIM y ejecute ndk-build –B V=1.

Si no lo satisfacen las opciones de optimización predeterminadas, hay variables especiales para agregar opciones durante la compilación. Son APP_CFLAGS y APP_CPPFLAGS. Las opciones especificadas en la variable APP_CFLAGS se agregan durante la compilación tanto de archivos de C como de C++, mientras que la variable APP_CPPFLAGS sólo se aplica a archivos de C++.

Para aumentar el nivel de optimización a –O3, agregue APP_CFLAGS:=-O3 a Application.mk.

2.3 Cómo compilar con el Compilador Intel® C++

El compilador de Intel no forma parte del NDK y se lo debe integrar a este antes de que se pueda usar con la utilidad ndk-build. En esencia, hay que crear una nueva cadena de herramientas.

  • Cree el directorio para la nueva cadena de herramientas:
    mkdir –p /toolchains/icc-12.1/prebuilt/.
  • El nombre icc-12.1 se escogió arbitrariamente. Se usa como valor del parámetro
    NDK_TOOLCHAIN de ndk-build.
  • Copie el directorio de nivel superior <ICC_ROOT> que contiene el compilador Intel instalado al directorio <NDK>/toolchains/icc-12.1/prebuilt/intel. Para ahorrar espacio, puede crear un vínculo simbólico de <ICC_ROOT> a <NDK>/toolchains/icc/prebuilt/intel.
  • Copie config.mk y setup.mk del directorio GCC:
    cp <NDK>/toolchains/x86-4.6/{setup.mk,config.mk} <NDK>/toolchains/icc-12.1
  • Cambie la variable TOOLCHAIN_NAME del nuevo archivo setup.mk a cualquier valor conveniente, por ejemplo, icc.
  • Modifique la variable TOOLCHAIN_PREFIX de setup.mk para que apunte a la nueva cadena de herramientas:
    TOOLCHAIN_PREFIX := $(TOOLCHAIN_ROOT)/prebuilt/intel/bin/
  • Observe la “/” al final del valor de TOOLCHAIN_PREFIX.
  • Especifique la ruta al compilador x86 GCC en setup.mk
    export ANDROID_GNU_X86_TOOLCHAIN=$(TOOLCHAIN_ROOT)/../x86-4.6/prebuilt/linux-x86/
  • Especifique la ruta al root del sistema de destino. El root del sistema es necesario para localizar las bibliotecas del sistema y los archivos de encabezado:
    export ANDROID_SYSROOT=$(call host-path,$(SYSROOT))[1]
  • Especifique la versión del GCC que usa el compilador de Intel: TOOLCHAIN_VERSION:= 4.6
  • Especifique las rutas a los componentes del compilador de Intel en <NDK>/toolchains/icc /setup.mk
    TARGET_CC:=$(TOOLCHAIN_PREFIX)icc
    TARGET_CXX:=$(TOOLCHAIN_PREFIX)icpc
  • Cree el directorio para la nueva cadena de herramientas: mkdir –p <NDK>/toolchains/icc-12.1/prebuilt/.
  • El nombre icc-12.1 se escogió arbitrariamente. Se usa como valor del parámetro NDK_TOOLCHAIN de ndk-build.
  • Copie el directorio de nivel superior <ICC_ROOT> que contiene el compilador Intel instalado al directorio <NDK>/toolchains/icc-12.1/prebuilt/intel. Para ahorrar espacio, puede crear un vínculo simbólico de <ICC_ROOT> a <NDK>/toolchains/icc/prebuilt/intel.
  • Copie config.mk y setup.mk del directorio GCC:
    cp <ndk>/toolchains/x86-4.6/{setup.mk,config.mk} <NDK>/toolchains/icc-12.1</ndk>
  • Cambie la variable TOOLCHAIN_NAME del nuevo archivo setup.mk a cualquier valor conveniente, por ejemplo, icc.
  • Modifique la variable TOOLCHAIN_PREFIX de setup.mk para que apunte a la nueva cadena de herramientas:
    TOOLCHAIN_PREFIX := $(TOOLCHAIN_ROOT)/prebuilt/intel/bin/
  • Observe la “/” al final del valor de TOOLCHAIN_PREFIX.
  • Especifique la ruta al compilador x86 GCC en setup.mk
    export ANDROID_GNU_X86_TOOLCHAIN=$(TOOLCHAIN_ROOT)/../x86-4.6/prebuilt/linux-x86/
  • Especifique la ruta al root del sistema de destino. El root del sistema es necesario para localizar las bibliotecas del sistema y los archivos de encabezado:
    export ANDROID_SYSROOT=$(call host-path,$(SYSROOT))[1]
  • Especifique la versión del GCC que usa el compilador de Intel:
    TOOLCHAIN_VERSION:= 4.6
  • Especifique las rutas a los componentes del compilador de Intel en <NDK>/toolchains/icc /setup.mk
    TARGET_CC:=$(TOOLCHAIN_PREFIX)icc
    TARGET_CXX:=$(TOOLCHAIN_PREFIX)icpc
    TARGET_LD:=$(TOOLCHAIN_PREFIX)xild
    TARGET_AR:=$(TOOLCHAIN_PREFIX)xiar
    TARGET_STRIP:=$(ANDROID_GNU_X86_TOOLCHAIN)/i686-android-linux/bin/strip
    TARGET_LIBGCC:=$(shell env ANDROID_GNU_X86_TOOLCHAIN=$(ANDROID_GNU_X86_TOOLCHAIN) $(TARGET_CC) -print-libgcc-file-name)

Ejecute ndk-build -B V=1 NDK_TOOLCHAIN=icc-12.1. Observe que la biblioteca compartida es compilada por el compilador de Intel. Sin embargo, esta vez la aplicación no funcionará, porque depende de bibliotecas compartidas suplementarias de la distribución del compilador de Intel. La solución más sencilla es activar la vinculación estática de bibliotecas de Intel. Agregue a <WORK_DIR>/hello-jni/jni/Android.mk la siguiente línea:

LOCAL_LDFLAGS := -static-intel

Ahora vuelva a compilar e instalar la aplicación. Debería funcionar según lo esperado en el emulador o el dispositivo.

Las opciones no admitidas -funwind-tables y -funswitch-loops generan varias advertencias. Estas advertencias se pueden ignorar. Hablaremos de la compatibilidad de las opciones en una sección posterior.

2.4. Empaquetamiento de bibliotecas compartidas del Compilador Intel® C++

Si la aplicación es lo suficientemente grande, entonces no se justifica la vinculación estática. En este caso, es necesario empaquetar las bibliotecas junto con la aplicación. La compilación de la aplicación con el compilador de Intel depende de las siguientes bibliotecas[2]:

  • libintlc.so, que contiene rutinas de memoria y de cadenas optimizadas;
  • libimf.so y libsvml.so, que contienen funciones matemáticas optimizadas.

Para empaquetar las bibliotecas del compilador de Intel, agregue a <WORK_DIR>/hello-jni/jni/Android.mk las siguientes líneas:

include $(CLEAR_VARS)
LOCAL_MODULE    := libintlc
LOCAL_SRC_FILES := libintlc.so
include $(PREBUILT_SHARED_LIBRARY)

También necesita una configuración similar para libimf.so y libsvml.so. Luego copie libintlc.so, libimf.so y libsvml.so libraries a <WORK_DIR>/hello-jni/jni. Por último, vuelva a compilar e instalar el paquete.

3. Opciones del Compilador Intel® C++

El compilador de Intel es compatible con la mayoría de las opciones del compilador GNU, pero no con todas. Cuando el compilador de Intel encuentra una opción desconocida o no admitida, emite una advertencia, como en este caso con -funswitch-loops. Siempre revise las advertencias.

3.1. Opciones de compatibilidad

Existen varias incompatibilidades relacionadas con las advertencias. Hay mucho debate acerca de qué construcciones son peligrosas y cuáles no. En general, el compilador de Intel produce más advertencias que el compilador GNU. También observamos que el compilador GNU cambió la configuración de advertencias entre las versiones 4.4.3 y 4.6. El compilador de Intel intenta admitir el formato de opciones de GNU para las advertencias, pero la compatibilidad puede dejar de ser completa a medida que el compilador GNU evoluciona.

El compilador GNU usa diversos nombres mnemónicos con –W<diag name> y –Werror=<diag name>. La primera opción habilita una advertencia adicional y la segunda hace que este compilador la trate como un error. En ese caso, el compilador no genera un archivo de salida y sólo produce datos de diagnóstico. Hay una opción complementaria, –Wno-<diag name>, que suprime la advertencia correspondiente. La opción -fdiagnostics-show-option del compilador GNU ayuda a deshabilitar opciones: por cada advertencia allí emitida se agrega una sugerencia que explica cómo controlar la advertencia.

El compilador de Intel no reconoce algunas de estas opciones; se las puede ignorar o, mejor aún, corregir. Con la opción –Werror, algunas veces todas las advertencias se convierten en errores. En este caso, es posible que la compilación con el compilador Intel falle. Hay dos maneras de evitar este problema: corregir la advertencia en el código fuente o deshabilitarla con –diag-disable <id>, donde <id> es un número único que se asigna a la advertencia. Este número de identificación único <id> es parte del texto de la advertencia. Si se cree que la construcción de lenguaje informada es peligrosa, el método recomendado es corregir el código fuente.

Creamos la imagen entera de Android OS con el compilador de Intel y encontramos varias opciones no admitidas y que no se relacionan con advertencias. A la mayoría se las puede ignorar, como se explica en la Tabla 1. Para varias opciones usamos las opciones equivalentes del compilador de Intel.

Opción del compilador GNU Opción equivalente del compilador de Intel
-mbionic, pone en conocimiento del compilador que la implementación de la biblioteca C de destino es Bionic. No es necesaria. Es el modo predeterminado del compilador de Intel para Android.
-mandroid, habilita la generación de código de acuerdo con ABI de Android. No es necesaria. Es el modo predeterminado del compilador de Intel para Android.
-fno-inline-functions-called-once, invalidación de heurística en línea. No es necesaria.
-mpreferred-stack-boundary=2, alinea el puntero de pila en 4 bytes. -falign-stack=assume-4-byte
-mstackrealign, alinea la pila a 16 bytes en cada prólogo. Se necesita para la compatibilidad entre código viejo, suponiendo una alineación de 4 bytes, y código nuevo con alineación de pila de 16 bytes. -falign-stack=maintain-16-byte
-mfpmath=sse, usa la instrucción SSE para la aritmética de punto flotante de escalares No es necesaria. Cuando el conjunto de instrucciones de destino es al menos SSE2, el compilador de Intel genera instrucciones SSE para la aritmética de punto flotante[3].
-funwind-tables, activa la generación de tablas de desenredo de pila. No es necesaria. El compilador de Intel genera estas tablas de manera predeterminada.
-funswitch-loops, invalida la heurística del compilador y activa la optimización “loop unswitching” en –O2 y –O1. No es necesaria. Permita que el compilador de Intel use su propia heurística.
-fconserve-stack, deshabilita las optimizaciones que aumentan el tamaño de la pila. No es necesaria.
-fno-align-jumps, deshabilita la optimización que alinea destinos de rama. No es necesaria.
-fno-delete-null-pointer-checks, elimina suposiciones necesarias para implementar algunas optimizaciones. No es necesaria.
-fprefetch-loop-arrays, habilita la generación de instrucciones de captura previa (prefetch). La captura previa puede degradar el rendimiento. Se debe usar con prudencia. No es necesaria, pero si quiere experimentar, use -opt-prefetch.
-fwrapv. De acuerdo con el C estándar, el resultado de la aritmética de enteros no es especificado si se produce desbordamiento. Esta opción completa la especificación y declara que el resultado debe ser como si se produjera “wrapping around”. Puede que esta opción deshabilite algunas optimizaciones. No es necesaria. Es el modo predeterminado para el compilador de Intel.
-msoft-float, implementa la aritmética de punto flotante en el software. Esta opción se usa durante la compilación del kernel. No se implementa. Durante la compilación del kernel, se deshabilita la generación de instrucciones de procesador para operaciones de punto flotante. El compilador de Intel generaría error si el código contuviera operaciones con datos de punto flotante. No encontramos tales errores.
-mno-mmx, -mno-3dnow, deshabilitan la generación de instrucciones de MMX* y 3DNow*. No es necesaria. El compilador de Intel no las genera.
-maccumulate-outgoing-args, habilita la optimización que asigna espacio anticipadamente para argumentos de salida de llamadas en la pila. No es necesaria.

Tabla 1: Comparación de opciones de compilador

Se pueden encontrar más detalles sobre la compatibilidad de los compiladores de Intel y GNU en las notas de producto de la página web http://software.intel.com/es-es/articles/intel-c-compiler-for-linux-compatibility-with-the-gnu-compilers/.

3.2. Opciones de rendimiento

Cuando se trabaja en el rendimiento, siempre hay que hacer concesiones. Los procesadores x86 difieren en la microarquitectura y, para obtener un rendimiento óptimo, es necesario optimizar en forma específica para el procesador. Antes de empezar a hacer ajustes al código, se debe decidir si la aplicación va a ejecutarse en procesadores Intel o AMD, si está dirigida a smartphones o tabletas, y así sucesivamente.

La mayor parte de la optimización en el compilador de Intel está ajustada para procesadores Intel. Damos por supuesto que el procesador de destino es el Intel® Atom™, porque este es el procesador de Intel para dispositivos móviles. En este caso, a fin de obtener el mejor rendimiento, es necesario agregar opciones –xSSSE3_ATOM durante la compilación. Si espera que el código se vaya a ejecutar en dispositivos basados en AMD, use en cambio –march=atom. En este caso, la aplicación se ejecutará en cualquier procesador que admita instrucciones de Intel Atom, pero es posible que se desactiven algunas optimizaciones agresivas.

Para habilitar –xSSSE3_ATOM para todos los archivos de la aplicación “Hello, world!”, agregue a hello-jni/jni/Application.mk la siguiente línea:

APP_CFLAGS := -xSSSE3_ATOM

Después de haber elegido el procesador objetivo, queda a su criterio modificar el nivel de optimización. En forma predeterminada, el sistema de compilación deshabilita por completo las optimizaciones con –O0 en modo de depuración y establece el nivel de optimización predeterminado –O2 en el modo de compilación. Puede intentar hacer optimizaciones agresivas con –O3, pero esto podría aumentar el tamaño del código. Por otra parte, si el tamaño del código es un parámetro de importancia, intente con –Os.

Se puede cambiar el nivel de optimización para toda la aplicación; para ello, se debe agregar –O0 -- –O3 a APP_CFLAGS:

APP_CFLAGS := -xSSSE3_ATOM –O3

Las restantes opciones de optimización se verán en secciones aparte: “Vectorización” y “Optimización interprocedimental”.

4. Vectorización

El compilador de Intel admite la generación avanzada de código, incluida la autovectorización. Para el Compilador Intel C/C++, la vectorización es de expansión de bucles (loop unrolling) con generación de instrucciones SIMD que operan en varios elementos a la vez. El desarrollador puede expandir bucles en forma manual e insertar llamadas a funciones apropiadas correspondientes a las instrucciones SIMD. Este enfoque no es escalable hacia delante e implica un alto costo en el desarrollo. Habrá que volver a hacer el trabajo cuando se lance el nuevo microprocesador que admita instrucciones avanzadas. Por ejemplo, los primeros microprocesadores Intel Atom no se beneficiaban de que la vectorización de bucles procesara el punto flotante de precisión doble mientras la instrucción SIMD procesaba con precisión simple de manera eficaz.

La vectorización simplifica las tareas de programación, ya que libera al programador de tener que aprender conjuntos de instrucciones para un microprocesador en particular, porque el compilador de Intel siempre es compatible con las generaciones más recientes de microprocesadores Intel.

Las opciones -vec activan la vectorización en el nivel de optimización predeterminado para microprocesadores que admiten la arquitectura IA32, tanto de Intel como otros. Para mejorar la calidad de la vectorización, es necesario especificar el microprocesador de destino en el cual se ejecutará el código. Si se busca lograr un rendimiento óptimo en smartphones Android basados en la arquitectura Intel, se recomienda usar la opción –xSSSE3_ATOM. La vectorización se habilita en el Compilador Intel® C++ a niveles de optimización de -O2 y superiores.

Muchos bucles se vectorizan automáticamente y el compilador de tiempo genera código óptimo por sí solo, pero a veces necesita que el programador lo guíe. La mayor dificultad en relación con la eficiencia de la vectorización es hacer que el compilador haga un cálculo lo más preciso posible de las dependencias de datos.

Para aprovechar al máximo la vectorización en el compilador de Intel, son muy útiles las siguientes técnicas:

  • Generar un informe de vectorización y entenderlo
  • Mejorar el rendimiento mediante la desambiguación de punteros
  • Mejorar el rendimiento mediante el uso de optimización interprocedimental
  • Pragmas del compilador

4.1. Informe de vectorización

Comenzaremos con la implementación del copiado de memoria. El bucle tiene la estructura que se usa habitualmente en las fuentes Android:

// It is assumed that the memory pointed to by dst

// does not intersect with the memory pointed to by src

void copy_int(int *dst, int *src, int num) 

{

    int left = num;

    if(left<=0) return;

    do { 

        left--; 

        *dst++ = *src++; 

    } while (left > 0); 

}

Para los experimentos con vectorización, no crearemos un objeto aparte, sino que volveremos a usar el proyecto hello-jni. Agregue la función al nuevo archivo jni/copy_cpp.cpp. Agregue este archivo a la lista de archivos fuente en jni/Android.mk:

LOCAL_SRC_FILES := hello-jni.c copy_int.cpp

Para habilitar el informe de vectorización detallado, agregue la opción –vec-report3 a la variable APP_CFLAGS en jni/Application.mk:

APP_CFLAGS := -O3 -xSSSE3_ATOM -vec-report3

Si vuelve a compilar libhello-jni.so, observará que se generaron varios comentarios:

jni/copy_int.cpp(6): (col. 5) remark: loop was not vectorized: existence of vector dependence.

jni/copy_int.cpp(9): (col. 10) remark: vector dependence: assumed ANTI dependence between src line 9 and dst line 9.

jni/copy_int.cpp(9): (col. 10) remark: vector dependence: assumed FLOW dependence between dst line 9 and src line 9.

...

Lamentablemente, la autovectorización ha fallado porque había demasiado poca información disponible para el compilador. Si la vectorización fuese exitosa, entonces la asignación

*dst++ = *src++;

sería reemplazada con

*dst = *src;

*(dst+1) = *(src+1);

*(dst+2) = *(src+2);

*(dst+3) = *(src+3);

dst += 4; src += 4;

y las instrucciones de SIMD realizarían las primeras cuatro asignaciones en paralelo. Pero la ejecución paralela de asignaciones no es válida si a la memoria a la cual se accede desde los términos de la izquierda también se accede desde los términos de la derecha de la asignación. Consideremos, por ejemplo, el caso en que dst+1 es igual a src+2; en este caso, el valor final en la dirección dst+2 sería incorrecto.

Los comentarios indican qué tipos de dependencias supone el compilador de manera conservadora para prevenir la vectorización:

  • La dependencia de flujo (FLOW) es una dependencia entre un almacenamiento anterior y una carga posterior en la misma ubicación de la memoria.
  • La dependencia ANTI es, por el contrario, una dependencia entre una carga anterior y un almacenamiento posterior en la misma ubicación de la memoria.
  • La dependencia de salida (OUTPUT) es el tercer tipo de dependencia entre dos almacenamientos en la misma ubicación de la memoria.

Del comentario sabemos que el autor requería que la memoria a la que apuntaban dst y src no se superpusiera. Para comunicar información al compilador, alcanza con agregar calificadores restrict a los argumentos dst y src:

void copy_int(int * __restrict__ dst, int * __restrict__ src, int num)

El calificador restrict se agregó al C estándar publicado en 1999. Para habilitar la compatibilidad con C99, es necesario agregar –std=c99 a las opciones. Una alternativa es usar la opción –restrict para habilitarla para C++ y otros dialectos de C. En el código de arriba insertamos la palabra clave __restrict__ que siempre se reconoce como sinónimo de la palabra clave restrict.

Si vuelve a compilar la biblioteca, notará que los bucles estarán vectorizados:

jni/copy_int.cpp(6): (col. 5) remark: LOOP WAS VECTORIZED.

En nuestro ejemplo, la vectorización falló debido a que el compilador hizo un análisis conservador. Hay otros casos en los cuales el bucle no se vectoriza:

  • El conjunto de instrucciones no permite vectorizar con eficiencia; los siguientes comentarios indican este tipo de problemas:
    • “Non-unit stride used” (se usó un paso no unitario)
    • “Mixed Data Types” (tipos de datos diferentes)
    • “Operator unsuited for vectorization” (operador no adecuado para la vectorización)
    • “Contains unvectorizable statement at line XX” (contiene una instrucción no vectorizable en la línea XX)
    • “Condition may protect exception” (la condición puede proteger la excepción)
  • La heurística del compilador previene la vectorización; en realidad es posible, pero puede ser causa de lentitud; el diagnóstico contendrá:
    • "Vectorization possible but seems inefficient" (la vectorización es posible, pero parece ineficiente)
    • “Low trip count” (conteo bajo de ciclos)
    • “Not Inner Loop” (bucle no interno)
  • Fallas del vectorizador:
    • “Condition too Complex” (condición demasiado compleja)
    • “Subscript too complex” (subscript demasiado complejo)
    • “Unsupported Loop Structure” (estructura de bucle no admitida)

–vec-reportN controla la cantidad de información que produce el vectorizador. Encontrará más detalles en la documentación del compilador.

4.2. Pragmas

Como vimos, el calificador de puntero restrict se puede usar para evitar hacer suposiciones conservadoras sobre la dependencia de datos. Pero a veces podría ser complicado insertar palabras clave restrict. Si se accede a muchas matrices en el bucle, podría también ser muy laborioso anotar todos los punteros. Para simplificar la vectorización en estos casos, hay un pragma específico de Intel, “simd”. Se usa para vectorizar bucles internos cuando se supone que no hay dependencias entre las iteraciones.

El pragma simd sólo se aplica a bucles “for” que operan en tipos de entero nativo y de punto flotante[4]:

  • El bucle “for” debe ser contable y se debe conocer la cantidad de iteraciones antes de que se inicie el bucle.
  • El bucle debe ser interior.
  • Ninguna referencia a la memoria en el bucle debe producir error (es importante para referencias indirectas enmascaradas).

Para vectorizar nuestro bucle con un pragma, necesitamos reescribirlo en la forma “for”:

void copy_int(int *dst, int *src, int num)
{
    #pragma simd
    for (int i = 0; i < num; i++) {
        *dst++ = *src++;
    }
}

Vuelva a compilar el ejemplo y observe que el bucle se vectorizó

La reestructuración de bucle simple para “pragma simd” y las inserciones de “#pragma simd” en las fuentes de Android OS nos permitieron mejorar en un 40 % el rendimiento indicado por el banco de pruebas Softweg, sin modificación del banco de pruebas.

4.3. Optimizaciones interprocedimentales

En las secciones anteriores, describimos estrategias para cuando uno comprende bien el código antes de comenzar a ocuparse del rendimiento. Si uno no está familiarizado con el código, entonces para ayudar al compilador a analizar el código, puede extender el alcance del análisis. En el ejemplo del copiado, el compilador debe hacer suposiciones conservadoras porque no sabe nada acerca de los parámetros de la rutina copy_int. Si los sitios de llamadas están disponibles para el análisis, entonces el compilador puede intentar probar que los parámetros son seguros para la vectorización.

Para ampliar el alcance del análisis, es necesario habilitar las optimizaciones interprocedimentales. Hay muy pocas de estas optimizaciones que ya están habilitadas de manera predeterminada durante la compilación de archivo único. Las optimizaciones interprocedimentales se describirán en una sección aparte.

4.4. Limitaciones de la autovectorización

No se puede usar la vectorización para acelerar el código del kernel de Linux, porque las instrucciones SIMD están deshabilitadas en el modo kernel con la opción –mno-sse. Es algo que hicieron intencionalmente los desarrolladores del kernel.

4.5. Optimización interprocedimental

El compilador puede llevar a cabo optimizaciones adicionales si es capaz de optimizar a través de límites de funciones. Por ejemplo, si el compilador sabe que algún argumento que llama a una función es constante, entonces puede crear una versión especial de la función ajustada especialmente a este argumento constante. Esta versión especial se puede optimizar posteriormente cuando se tengan conocimientos del valor del parámetro.

Para habilitar la optimización dentro de un único archivo, especifique la opción –ip. Cuando se especifica esta opción, el compilador genera un archivo de objeto final que el vinculador del sistema puede procesar. La desventaja de generar un archivo de objeto es la pérdida casi total de información; el compilador ni siquiera intenta extraer información de los archivos de objeto.

El archivo único puede ser insuficiente para el análisis debido a la pérdida de información. En este caso, es necesario agregar la opción –ipo. Cuando se incluye esta opción, el compilador compila archivos a una representación intermedia que luego es procesada por herramientas especiales de Intel: xiar y xild.

La herramienta xiar se debe usar para crear bibliotecas estáticas en lugar del archivador ar de GNU, y xild se debe usar en lugar del vinculador ld de GNU. Sólo es necesario hacerlo cuando se llama al vinculador y el archivador en forma directa. Una mejor estrategia es usar el controlador del compilador icc o icpc para la vinculación fina[5]. El aspecto negativo del alcance extendido es que se pierde la ventaja de la compilación por separado: cada modificación de la fuente requiere de revinculación, y la revinculación conduce a la recompilación completa.

Hay una amplia lista de técnicas de optimización avanzadas que se benefician del análisis global. Algunas se detallan en la documentación de referencia. Debe tenerse en cuenta que algunas optimizaciones son específicas de Intel y se habilitan con las opciones –x*[6].

Desafortunadamente, en Android las cosas son algo más complicadas respecto de las bibliotecas compartidas. En forma predeterminada, todos los símbolos globales son interrumpibles (preemptable). Este concepto es fácil de explicar con un ejemplo. Consideremos dos bibliotecas vinculadas al mismo ejecutable.

libone.so:

<i> 
int id(void) {
  return 1;
 }
  
libtwo.so:
int id(void) {
  return 2;
 }
int foo(void) {
  return id();
 }
 <i>

Supongamos que para crear las bibliotecas simplemente se ejecutó icc –fpic –shared –o .so .c. Sólo se dan las opciones estrictamente necesarias –fpic[7] y –shared[8].

Si el vinculador dinámico del sistema carga la biblioteca libone.so antes de la biblioteca libtwo.so, entonces la llamada a la función id() desde la función foo() se resuelve en la biblioteca libone.so.

Cuando el compilador optimiza la función foo(), no puede usar lo que sabe de id() desde la biblioteca libtwo.so. Por ejemplo, no puede incluir en línea la función id(). Si el compilador incluyera en línea la función id(), habría problemas con libone.so y libtwo.so.

En consecuencia, cuando se escriben bibliotecas compartidas, se debe tener cuidado de especificar qué funciones se pueden interrumpir. En forma predeterminada, todas las funciones y variables globales son visibles fuera de las bibliotecas compartidas y se las puede interrumpir. La configuración predeterminada no es conveniente cuando se implementan pocos métodos nativos. En este caso, es necesario exportar sólo símbolos que sean llamados directamente por la máquina virtual Java* Dalvik*.

El atributo de visibilidad de símbolos es una manera de especificar si los símbolos son visibles fuera del módulo y si se los puede interrumpir:

  • La visibilidad “predeterminada” hace visibles a los símbolos globales[9] fuera de la biblioteca compartida y los hace interrumpibles.
  • La visibilidad “protegida” hace visibles a los símbolos fuera de la biblioteca compartida, pero no se los puede interrumpir.
  • La visibilidad “oculta” hace visibles a los símbolos globales[9] sólo dentro de la biblioteca compartida e imposibilita que sean interrumpidos.

De regreso en la aplicación hello-jni, necesitamos especificar que la visibilidad predeterminada es la oculta y que las funciones exportadas para JVM tienen visibilidad protegida.

Para establecer la visibilidad predeterminada como oculta, se agrega -fvisibility=hidden a la variable APP_CFLAGS en jni/Application.mk:

APP_CFLAGS := -O3 -xSSSE3_ATOM -vec-report3 -fvisibility=hidden -ipo

Para invalidar la visibilidad de Java_com_example_hellojni_HelloJni_stringFromJNI, se agrega el atributo a la definición de la función:

Jstring __attribute__((visibility("protected")))

Java_com_example_hellojni_HelloJni_stringFromJNI(JNIEnv* env, jobject thiz)

Volver a compilar y a instalar.


[1] Es posible que el NDK no necesite esta variable para la integración con Intel® C/C++ Compiler versión 13.0.

[2] Intel C++ Compiler versión 13.0 cuenta con la biblioteca adicional libirng.so que contiene una función optimizada para la generación pseudoaleatoria de números.

[3] Las instrucciones SSE escalares no realizan aritmética con precisión extendida. Si su aplicación depende de la precisión extendida para cálculos intermedios, use la opción –mp.

[4] Hay otras limitaciones menores; consulte las referencias. El compilador da una advertencia si no se respeta la sintaxis o si falla la vectorización con el pragma simd.

[5] Durante la configuración del NDK, hicimos que TARGET_AR apuntara a xiar y TARGET_LD, a xild

[6] Por ejemplo, para un destino Intel Atom, especifique –xSSSE3_ATOM.

[7] La opción –fpic especifica que el código se debe compilar de una manera tal que permita la carga en la dirección arbitraria. PIC son las siglas de position independent code (código independiente de la posición).

[8] La opción –shared especifica que el módulo vinculado es una biblioteca compartida.

[9] Un símbolo global es un símbolo que es visible desde otras unidades de compilación. A las funciones y variables globales se puede acceder desde cualquiera de los archivos de objeto que compongan la biblioteca compartida dada. Los atributos de visibilidad especifican relaciones entre función y variables en diferentes bibliotecas compartidas.


[1] Interfaz binaria de aplicación. Android sigue la System V ABI para arquitectura i386: http://www.sco.com/developers/devspecs/abi386-4.pdf

[2] No usamos el comando “ant release” porque requiere de la configuración apropiada de la firma del paquete.

Para obter informações mais completas sobre otimizações do compilador, consulte nosso aviso de otimização.