Di recente ci siamo trovati nella situazione di voler ottimizzare una nostra applicazione per la realta’ aumentata (http://picshare.jooink.com) su dispositivi mobile. Picshare e' interamente scritto in javascript ed essendo il nostro target quello di ottimizzarlo per dispositivi mobile la strada più naturale ci è sembrata quella di riscrivere nativamente parte degli algoritmi computazionalmente rilevanti e, con l’occasione, mettere a confronto diverse implementazioni ‘native’ al fine di capire quale strategia fosse preferibile.
Sfruttando il fatto che da sempre Intel mette a disposizione numerose librerie ottimizzate per i suoi processori e che adesso sono disponibili dispositivi mobile con processori intel abbiamo voluto capire anche quanto l’utilizzo di librerie ottimizzate per una specifica architettura potesse aumentare le performance.
In questo post mostreremo i risultati ottenuti implementando il medesimo algoritmo con ‘tecniche’ (linguaggi e libererie) diverse: java, C, C utilizzando le Intel Performance Primitives, C utilizzando le IPP e parallelizzando l’esecuzione utilizzando Intel Threading Building Blocks.
Tutti i test sono stati effettuatu su 3 dispositivi con processore Intel:
- Samsung Galaxy Tab 3, tablet 10’’, Intel Atom Z2560, Dual-core
- Dell Venue 8/3830, tablet 8’’, Intel Atom Z2580, Dual-core
- Lenovo k900, mobile 5’’, Intel Atom Z2580, Dual-core
L’algoritmo:
Non potendo certamente riscrivere l’intera applicazione 4 volte abbiamo ritenuto opportuno fare il confronto su un algoritmo semplice da implementare ma computazionalmente rilevante per il nostro caso d’uso.
La maggior parte degli algoritmi utilizzati nelle nostre applicazioni opera su immagini in toni di grigio e la conversione da rgb a grayscale è quindi un candidato ideale per i test; abbiamo quindi deciso di implementare con le diverse ‘tecniche’ il calcolo di una media pesata da un array 3*SIZE a un array SIZE:
gray ← 0.299 * red + 0.587 * green + 0.114 * blue
Allo scopo di minimizzare le complicazioni implementative abbiamo scritto l’algritmo di media in modo che operi su float.
Java version
L’implementazione in java dell’algoritmo di conversione è naturalmente estremamente semplice:
public void compute(float[] in, float[] out) { for(int i=0, j=0; i<out.length; i++, j+=3) out[i] = 0.299f * in[j] + 0.587f * in[j+1] + 0.114f * in[j+2]; }
Per eseguire ripetutamente la trasformazione (in maniera tutto sommato simile a come facciamo nel codice javascript attualmente utilizzato in produzione), abbiamo inserito le chiamate al metodo ‘compute’ in un AsyncTask che a sua volta viene creato ed eseguito da un Timer ma al fine di evitare che la creazione degli async task ed il timer falsassero i nostri test, abbiamo deciso di effettuare le misurazioni del tempo di esecuzione all’interno dell’async task stesso:
... long st = System.nanoTime(); compute(in,out); long et = System.nanoTime(); ...
La pressione sul gargbage collector è mantenuta bassa riutilizzando per tutte le iterazioni i medesimi array di input ed output (2 array bidimensionali di float uno di dimensione 3*1024*1024, interpretati come valori rgb, l’altro 1024*1024, interpretati come valori di una immaginaria scala di grigi).
Come si vede nella figura seguente, sui dispositivi che avevamo a disposizione la performance di questa prima implementazione è assolutamente soddisfacente ed in tutti i casi superiore ai 100Hz.
Native C
Fatta l’implementazione in java ci siamo concentrati su quella ‘nativa’ (in C).
L’implementazione nativa richiede l’istallazione del Native Development Kit (NDK).
Seguendo la documentazione che si trova su Android NDK for Intel Architecture, si capisce rapidamente che scrivere una applicazione android/ndk non è in linea di principio complicato (a patto di conoscere il C o il C++ naturalmente) ma, come sempre, il setup del progetto è la cosa che richiede più tempo.
Il setup del primo progetto con ndk è documentato completamente su Creating and Porting NDK based Android Apps for ia, ma se non volete perderci tempo in questo momento ne abbiamo predisposto uno pronto per l’uso su github github.com/jooink/ndk-cpuid.
Clonato il repo per compilarlo ed usarlo non avete da fare altro che
- entrare nella directory CPUIDApp/jni folder ed eseguire "ndk-build";
- tornare nella root directory (CPUIDApp) ed eseguire "ant debug";
- installare l’applicazione e provare su un dispositivo "adb install -r bin/CPUIdApp-debug.apk".
Per cimentarsi nello sviluppo di applicazioni ndk è indispensabile, oltre a comprendere bene la struttura del progetto e l’uso di ‘ndk-build’, una certa pratica con jni (Java Native Interface) e javah (C Header and Stub File Generator) in quanto ci troviamo nella situazione di voler chiamare codice C da java e quindi jni è sostanzialmente l’unica strada.
Il codice C, fatta eccezione per le ‘stranezze’ dovute appunto a JNI, è sostanzialmente identico a quello in java:
#include <stdlib.h> #include <math.h> #include "com..ToGrayscaleTaskNDK.h" JNIEXPORT void JNICALL Java_com...ToGrayscaleTaskNDK_grayscale( JNIEnv *env, jclass c, jfloatArray in, jfloatArray out) { jsize len_in = (*env)->GetArrayLength(env, in); jsize len_out = (*env)->GetArrayLength(env, out); jfloat *body_in = (*env)->GetFloatArrayElements(env, in, 0); jfloat *body_out = (*env)->GetFloatArrayElements(env, out, 0); int i,j; for(i=0, j=0; i< len_out; i++, j+=3) body_out[i] = 0.299f * body_in[j] + 0.587f * body_in[j+1] + 0.114f * body_in[j+2]; (*env)->ReleaseFloatArrayElements(env, in, body_in, 0); (*env)->ReleaseFloatArrayElements(env, out, body_out, 0); return; }
Il codice, come spiegato ad esempio su Creating and Porting NDK based Android Apps for ia, deve essere inserito in un file collocato nella directory jni del progetto e compilato con ndk-build dopo aver predisposto il file Andoid.ndk e quello Application.mk dei quali potete trovare una copia sempre facendo riferimento al progetto d’esempio su github.
Il passaggio da Java a C regala all’algoritmo una crescita della performance dell’ordine del 35% su tutti i dispositivi in esame.
Native C/IPP
Per guadagnare ancora performance non abbiamo altra strada a questo punto che cercare sfruttare meglio l’hardware dei dispositivi abbandonando la strada della portabilità e utilizzando librerie ‘specifiche’ per i processori utilizzati.
Fino ad ora il codice che abbiamo scritto era interamente portabile ed era quindi possibile eseguire l’applicazione su qualsiasi piattaforma. In quello che aggiungeremo a partire da questa sezione utilizzeremo invece librerie specifiche per intel ed anche solo per testare l’applicazione durante lo sviluppo è assai più comodo utilizzare anche un emulatore basato su processore x86.
Dunque, se sviluppate su una macchina con processore intel, consigliamo di scaricare ed installare l’emulatore x86 e HAXM, l’acceleratore che rende estremamente più rapido l’emulatore e quindi più piacevole la vita degli sviluppatori (per dettagli si veda ad esempio HAXM speeds up the Android emulator).
Naturalmente benché l’emulatore x86 con HAXM sia uno strumento molto comodo per lo sviluppo, non ci possiamo aspettare di poter fare analisi di performance usando l’emulatore e quindi un dispositivo ‘vero’ è indispensabile.
Preparato l’ambiente di sviluppo per programmare x86 e fatto il setup per l’ndk siamo a pronti per utilizzare le librerie Intel ottimizzate per Android.
Le Intel PP sono una collezione di librerie per “multimedia processing, data processing, communications applications” per Windows, Linux, Android, and OS X, tra le quali troviamo anche una versione ottimizzata dell’algoritmo di trasformazione da RGB a grayscale che ci permette di capire quanto il nostro codice ‘vanilla’ possa trarre vantaggio da una implementazione dedicata ad una precisa architettura.
La preview delle IPP per Android è inclusa in Beacon Mountain e su Building Android NDK Applications with Intel IPP viene descritto in modo dettagliato come fare il setup di un progetto che le utilizzi.
Il problema a questo punto pero’ è che che le IPP in Beacon Mountain sono davvero solo una ‘preview’: uno scheletro delle librerie in cui sono implementati solo un paio di metodi. Servono per provare che funzionano, non per usarle e di sicuro nemmeno per fare i nostri test.
Per avere una versione utilizzabile delle IPP occorre scaricare ed installare la versione trial per Linux (che è utilizzabile per 30 giorni, dopo bisogna acquistare quella commerciale); se come noi sviluppate su OSX la cosa davvero più facile è farsi una macchina virtuale Linux e usare quella per procedere all’istallazione.
Una volta installate le librerie sulla macchina Linux è sufficiente copiare gli include file da /opt/intel/ipp/include le librerie a 32 bit da /opt/intel/ipp/lib/ia32.
IPP è divisa in diversi moduli ma per le funzioni di “image conversion” basta copiare libippcore.a libippcc.a, mentre gli header tanto vale copiarli tutti.
Noi abbiamo messo sia le librerie che gli include direttamente dentro la directory jni.
Fatte le copie è il momento di preparare l’opportuno Android.mk che faccia riferimento alle librerie statiche e provveda al linking corretto:
LOCAL_PATH := $(call my-dir) include $(CLEAR_VARS) LOCAL_MODULE := grayscale LOCAL_STATIC_LIBRARIES := libippcc ippcore LOCAL_C_INCLUDES := . LOCAL_LDLIBS := -llog -lc -landroid -lm -ljnigraphics LOCAL_SRC_FILES := com_jooink_experiments_android_preformance_java_ToGrayscaleTaskNDK.c com_jooink_experiments_android_preformance_java_ToGrayscaleTaskIPP.c include $(BUILD_SHARED_LIBRARY)
include $(CLEAR_VARS) LOCAL_MODULE := ippcore LOCAL_SRC_FILES := libippcore.a include $(PREBUILT_STATIC_LIBRARY) include $(CLEAR_VARS) LOCAL_MODULE := ippcc LOCAL_SRC_FILES := libippcc.a include $(PREBUILT_STATIC_LIBRARY)
Si osserva che le 2 librerie sono citate sia nei 2 blocchi PREBUILT_STATIC_LIBRARY che nella variabile LOCAL_STATIC_LIBRARIES.
Il codice per la conversione in grayscale utilizzando IPP è quasi più semplice di quello scritto direttamente in C:
#include <stdlib.h> #include <math.h> #include <jni.h> #include "ipp.h" #include "com_jooink_experiments_android_preformance_java_ToGrayscaleTaskIPP.h" JNIEXPORT void JNICALL Java_com_jooink_experiments_android_preformance_java_ToGrayscaleTaskIPP_grayscaleIPP(JNIEnv *env, jclass c, jfloatArray in, jfloatArray out) { jsize len_in = (*env)->GetArrayLength(env, in); jsize len_out = (*env)->GetArrayLength(env, out); jfloat *body_in = (*env)->GetFloatArrayElements(env, in, 0); jfloat *body_out = (*env)->GetFloatArrayElements(env, out, 0); IppiSize srcRoi = { 1024, 1024 }; Ipp32f* pSrc = (jfloat*)body_in; Ipp32f* pDst = (jfloat*)body_out; ippiRGBToGray_32f_C3C1R(pSrc ,1024*4*3, pDst, 1024*4, srcRoi); (*env)->ReleaseFloatArrayElements(env, in, body_in, 0); (*env)->ReleaseFloatArrayElements(env, out, body_out, 0); return; }
che è sostanzialmente identica alla versione in C tranne per le righe in cui si effettua la chiamata a ippiRGBToGray_32f_C3C1R che opera la conversione.
Dopo aver messo, come al solito, il codice nella directory jni ed averlo compilato (ndk-build) possiamo generare l’apk (ad esempio attraverso eclipse).
Installiamolo sull’emulatore (adb install …) o su un dispositivo con processore Intel ed eseguiamolo.
Sull’emulatore questa volta nei nostri test si guadagna oltre un ordine di grandezza ma la cosa è meno interessante del fatto che consistentemente su tutti i dispositivi abbiamo un incremento del 20-25% ! visto che l’algoritmo è banale ci viene tutto sommato da domandarsi come sia fatta l’implementazione e quanto sia possibile guadagnare utilizzando gli algoritmi più sofisticati che si trovano in IPP.
Native C/IPP/TBB
Resta da capire a questo punto se con il solo utilizzo delle librerie IPP la nostra ricerca di performance debba considerarsi conclusa. Tra tutte le questioni quella che ci premeva di più capire era se effettivamente stessimo utilizzando tutti i core dei processori.
Per la verità leggendo le Release Notes si scopre, con nostro dolore, che le Performance Primitives da qualche versione hanno deprecato la gestione della parallelizzazione degli algoritmi su processori multicore e quindi si ‘limitano’ a fornire algoritmi ottimizzati per ogni singolo core lasciando agli sviluppatori il compito di trovare la giusta strategia di parallelizzazione.
Il perché di questa scelta risulta più chiaro se si osserva che nel vasto parco di librerie che Intel mette a disposizione ce ne è una dal nome Threading Building Blocks (Intel TBB) che specificatamente mira a fornire le primitive per la parallelizzazione multithread degli algoritmi.
La compilazione ed installazione di TBB esula dallo scopo di questo post ma è interessante osservare che TBB e IPP sembrano fatte apposta per essere usate insieme ed infatti è banale estendere l’algoritmo per diventare parallelo:
JNIEXPORT void JNICALL Java_com...grayscaleIPPTBB (JNIEnv *env, jclass c, jfloatArray in, jfloatArray out) { jsize len_in = env->GetArrayLength(in); jsize len_out = env->GetArrayLength(out); jfloat *body_in = env->GetFloatArrayElements(in, 0); jfloat *body_out = env->GetFloatArrayElements(out, 0); Ipp32f* pSrc = (jfloat*)body_in; Ipp32f* pDst = (jfloat*)body_out; tbb::parallel_invoke( [pSrc,pDst] { IppiSize srcRoi = { 1024, 512 }; ippiRGBToGray_32f_C3C1R(pSrc ,1024*4*3, pDst, 1024*4, srcRoi); }, [pSrc,pDst] { IppiSize srcRoi = { 1024, 512 }; Ipp32f* pSrcShifted = pSrc+3*(1024*512); Ipp32f* pDstShifted = pDst+(1024*512); ippiRGBToGray_32f_C3C1R(pSrcShifted ,1024*4*3, pDstShifted, 1024*4, srcRoi); }); env->ReleaseFloatArrayElements(in, body_in, 0); env->ReleaseFloatArrayElements(out, body_out, 0); return; }
dove si vede quanto risulti apprezzabile il fatto che tutti gli algoritmi di IPP che lavorano su array bidimensionali usino il concetto di Regions of Interest permettendoci di splittare in 2 parti (eseguite in parallelo) il calcolo della conversione in grayscale.
Abbiamo provato l’algoritmo parallelo basato su parallel_invoke (probabilmente la primitiva più semplice di TBB) sia usando 2 thread che usandone 4 ed i risultati sono quelli attesi: quasi un fattore 2 tra single thread e dual thread (tutti i dispositivi hanno processori a due core) ed un altro, sorprendente, 25-30% di guadagno passando alla versione a 4 thread.
Vale decisamente la pena di indagare meglio le TBB, partendo magari dai loro sorgenti che sono disponibili a http://www.threadingbuildingblocks.org.