Android snelheidsbenchmark
Opslagsnelheden meten onder Android
Meestal worden de prestaties van smartphones en tablets vergeleken aan de hand van hun processor- en geheugensnelheden. Maar de snelheid van het opslagmedium heeft net zo veel impact. We hebben een eigen app ontwikkeld om de vrij belangrijke performance te bepalen van de interne opslagruimte en de sd-kaart.
Apps moeten resources als code en afbeeldingen uit het flashgeheugen naar het werkgeheugen laden. Foto's en video's moeten worden opgeslagen en systeemdata en de cache komen in ieder geval tijdelijk in het flashgeheugen terecht. Voor meer opslagruimte worden foto's, data of complete apps op een sd-kaart gezet. Hoe snel dat alles gebeurt, heeft veel impact op hoe vloeiend een apparaat in het gebruik aanvoelt.
Benchmarks proberen dat te meten met scenario's die voor de dagelijkse praktijk meer of minder relevant zijn. Wat voor resultaten ze opleveren en hoe nauwkeurig die worden bepaald, is vaak een gegoochel met cijfers en meestal ondoorzichtig. Het is dan ook niet verrassend dat de talloze benchmark-apps in de app-store vaak uiteenlopende resultaten opleveren. Details over de uitdagingen en valstrikken bij metingen lees je in [1].
We hebben daarom een eigen benchmark-app ontwikkeld voor het meten van de snelheid van opslagmedia onder Android. Analoog aan de bekende c'tschijfbenchmark h2bench hebben we h2benchA (A voor Android) gemaakt. De broncode kun je vinden via de link onderaan dit artikel. De app vereist minimaal Android 4.0.3 (SDK 15, Ice Cream Sandwich) en draait daarmee op ruim 99 procent van alle apparaten die toegang tot Google Play hebben.
Extern en echt extern
Of en hoe apps overweg kunnen met geheugenkaarten is bij elke Android-versie weer anders (zie kader) en heeft voor verwarrende benamingen en API's gezorgd. Dat komt ook doordat Google het nut van geheugenkaarten zelf bagatelliseert: al vanaf het eerste Nexus-model (de Nexus One uit 2010) heeft geen van Googles eigen apparaten een geheugenkaartslot.
Android maakt een onderscheid tussen intern en extern geheugen. Met dat
laatste bedoelde de eerste generatie Android-apparaten de geheugenkaart, maar bij de meeste apparaten met Android 2 sloeg die term al op een deel van het interne geheugen. Dat is met een beetje goede wil als extern te beschouwen omdat het via usb te benaderen is vanaf een pc. Omdat dit interne en externe geheugen zich op dezelfde flashgeheugenchips bevinden, zit daar qua performance weinig verschil tussen. Echt verwisselbaar geheugen zoals geheugenkaarten en usb-sticks (verwarrend genoeg onder Android ook vaak aangeduid als extern geheugen) is meestal wel duidelijk langzamer.
Een eenvoudige methode om onafhankelijk van de gebruikte Android-versie een overzicht te krijgen van alle beschikbare vormen van apparaatopslag biedt de volgende functie uit de Android Support Library:
File[] externalStorageFiles = ContextCompat.getExternalFilesDirs(
context, null);
Dit levert een array terug met als eerste item altijd het ingebouwde flashgeheugen. Alle volgende items in de array zijn paden naar geheugenkaarten en andere verwisselbare opslagmedia. In tegenstelling tot wat de documentatie beweert, levert deze methode ook de via usb aangesloten apparaten terug, als daar tenminste drivers voor beschikbaar zijn. Houd er rekening mee dat de array bij ongeldige of zojuist verwijderde opslagmedia ook null-pointers kan bevatten.
Android kent geen quota's, elke app kan de beschikbare opslagruimte volledig vullen. Dat geldt ook voor de eigen map op de sd-kaart. Dat is bij benchmark-apps een voordeel, want de app kan daardoor zonder speciale rechten de volledige opslagruimte volschrijven – om dat na de meting natuurlijk weer ongedaan te maken.
De app bestaat uit drie Activities: de bij het opstarten aangeroepen MainActivity toont de gevonden opslaglocaties met de bijbehorende meetresultaten in een ExpandableListView. De eigenlijke benchmark wordt gestart via de FloatingActionButton rechtsonder. ChartActivity toont een eenvoudige grafische weergave van het meetverloop. De klassen PreferenceActivity en de bijbehorende PreferenceFragment maken het aanpassen van enkele parameters mogelijk.
De eigenlijke metingen vinden plaats in de klasse Benchmark. De kern daarvan is een codefragment dat in die vorm vaker terugkomt in allerlei apps die gegevens moeten schrijven of lezen:
while (bytesWritten < fileSize){ int bytesToWrite =(int) Math.min(
fileSize-bytesWritten, blocklength); outputStream.write(buffer, bytesWritten, bytesToWrite); bytesWritten += bytesToWrite;
}
Voor schrijven en lezen:
while (true){ int bytesRead = inputStream.read( buffer); if (bytesRead < 0) break;
... // verwerk data in buffer totalBytesRead += bytesRead;
}
De app verwerkt de data in blokken, waarbij een te klein buffer de performance benadeelt. We hebben gekozen voor een grootte van 8 kilobyte, ook omdat de Java-klassen BufferedReader en BufferedWriter deze blokgrootte gebruiken. Je kunt dat bij de instellingen van de app naar wens aanpassen.
Werkvertraging
Het systeem bereikt de optimale gegevensdoorvoer van processor, RAM en De benchmark-app h2benchA meet de lees- en schrijfsnelheden van alle interne en externe opslagmedia van het toestel.
flashgeheugen alleen bij sequentieel lezen en schrijven van grote bestanden, bijvoorbeeld bij het opnemen en afspelen van video's. Moeten er veel kleinere bestanden verwerkt worden, dan daalt de performance flink. Dat komt doordat flashgeheugen in blokken (normaal 4 kB) en pagina's (128 kB) is onderverdeeld. Bij het schrijven van slechts een paar bytes moet de flashcontroller toch een hele pagina lezen, bijwerken en terugschrijven naar de geheugencellen. Bovendien krijg je bij kleinere bestanden procentueel gezien meer metadata, zoals bestandsnamen, inode-nummers en beschreven sectoren. Ook het bestandssysteem heeft strikt genomen invloed op de doorvoersnelheid.
De meeste benchmarks geven daarom twee waarden voor lezen en schrijven: een voor het sequentieel lezen en schrijven van grote bestanden en een andere voor het verwerken van random kleine bestanden. Wij werken bij sequentieel lezen/schrijven standaard met 200 MB grote bestanden, wat overeenkomt met een korte video. Voor random lezen/schrijven gebruiken we een bestandsgrootte van 8 kB, wat qua grootte overeenstemt met een miniatuur- of profielafbeelding in JPEG-formaat.
Besturingssystemen proberen met behulp van een geheugencache en uitgesteld schrijven het aantal benaderingen van het flashgeheugen te beperken om betere prestaties te krijgen. De werkelijke door het flashgeheugen behaalde prestaties meet je dus pas als de app meer data verstouwt dan in deze buffer passen. Je kunt het flashgeheugen eigenlijk alleen zonder buffer betrouwbaar meten, maar dan krijg je onrealistische resultaten omdat deze normaliter wel actief is.
De app toont bij het tikken op een resultaat een grafische weergave van de metingen, die de invloed van de Androidbuffer verduidelijkt.
Op de achtergrond
De eigenlijke meting gebeurt op de achtergrond in de van AsyncTask afgeleide klasse BenchmarkTask. De app kan dan nog reageren op invoer, een voortgangsbalk tonen en een optie bieden om de meting te annuleren. Dat gebeurt via een ProgressDialog, die regelmatig door de eigenlijke benchmark-klasse wordt bijgewerkt via publishProgress().
Tijdens de meting moet het apparaat maximaal presteren en bijvoorbeeld niet overschakelen op een energiebesparingsstand of helemaal uitschakelen. Dat doe je met getWindow(). addFlags(WindowManager.LayoutParams. FLAG_KEEP_SCREEN_ON). Je moet daarbij natuurlijk niet vergeten de flag na het doorlopen van de benchmarks met getWindow().clearFlags(WindowManager. LayoutParams.FLAG_KEEP_SCREEN_ON) weer terug te zetten. Anders dan bij een WakeLock reset Android de flag ook automatisch als je overschakelt naar een andere app.
Het is verder aan te raden het apparaat in de vliegtuigmodus te zetten om de meting niet te laten beïnvloeden door achtergrondactiviteiten als app-updates. Sinds Android 4.2 kunnen apps die modus niet meer activeren, maar moet je dat zelf doen.
Bij Java-programma's kan op elk moment de Garbage Collector actief worden, die geheugen van objecten waar niet meer naar gerefereerd wordt vrijgeeft. Dat verstoort natuurlijk de meting. Om dat te kunnen vermijden, moet je zo min mogelijk objecten in de betreffende loop aanmaken. Bovendien forceert de app voor het begin van de meting een geheugenopschoning met System.gc().
De juiste timing
De standaardfunctie System.currentTimeMillis() is voor tijdmetingen niet bepaald geschikt. Ten eerste is de precisie in milliseconden voor benchmarks te onnauwkeurig en ten tweede kan de systeemtijd veranderen, bijvoorbeeld door synchronisatie met een tijdserver. Voor metingen van prestaties is System. nanoTime() beter geschikt. Die functie kan nanoseconden teruggeven, maar werkt normaliter met een nauwkeurigheid in microseconden. In tegenstelling tot System.currentTimeMillis() neemt de waarde daarvan gegarandeerd gestaag toe en worden geen negatieve of positieve sprongen gemaakt. De nauwkeurigheid in microseconden is trouwens vrij relevant: bij een doorvoersnelheid van 100 MB/s duurt het schrijven van een kilobyte slechts 10 microseconden.
Een principe bij programmeren is dat je bij alles wat met tijd en datums te maken heeft erg moet oppassen en zo veel mogelijk gebruik moet maken van systeemfuncties. Dat geldt ook voor het omrekenen van tijdseenheden. De klasse TimeUnit biedt voor het converteren van seconden naar nanoseconden bijvoorbeeld de functie TimeUnit.SECONDS. toNanos(timeInSeconds). Voor een passend opgemaakte en afgeronde weergave van opslagruimte en doorvoersnelheid levert Formatter.formatShortFileSize(context, size) goed leesbare waarden in kilo-, mega- of gigabytes.
De app moet de laatste meting onthouden om die bij de volgende start weer te kunnen tonen. Als opslaglocatie daarvoor ligt SharedPreferences voor de hand. Het omzetten van resultaten naar een string gaat erg gemakkelijk met het Gson-framework. Dat converteert alle eigenschappen van het opgegeven Javaobject op een elegante wijze via Javareflection naar JSON:
List<BenchmarkResult> bResults;
Gson gson = new Gson();
String json = gson.toJson(bResults);
en weer terug:
Type benchmarkResultType = new TypeToken
<ArrayList<BenchmarkResult>>() {}.getType(); bResults = gson.fromJson(json,
benchmarkResultType);
Multithreaded en native
Bij metingen op moderne quadcore- en octacore-processors is het belangrijk dat een enkele thread de snelheid van het opslagmedium niet maximaal benut. We hebben daarom een multithread-implementatie ingebouwd, waarbij naar wens meerdere BenchmarkTask parallel gestart worden. Als de eerste Task klaar is met een deeltaak, stopt hij alle andere taken. De schrijfsnelheid volgt dan uit de som van het aantal door alle threads geschreven bytes, gedeeld door de tijd waarna de eerste thread klaar was. Hoeveel threads de app moet gebruiken, kan worden bepaald bij de instellingen.
Anders dan je misschien verwacht, draaien meerdere AsyncTask vanaf Android 3 overigens niet parallel, maar opeenvolgend in een enkele background-thread. Dat moet volgens de documentatie helpen om programmeerfouten te vermijden. Om daadwerkelijk meerdere AsyncTask gelijktijdig te starten, moet in plaats van execute() de functie task.executeOnExecutor(threadPoolExecutor) worden gebruikt.
De standaardimplementatie van AsyncTask.THREAD_POOL_EXECUTOR start een thread minder dan het aantal processorkernen, zodat er in ieder geval één thread voor de UI overblijft. Het maximum is echter vier. Op moderne apparaten met acht of meer cores blijft dan veel ruimte over. We zorgen daarom voor onze eigen Exececutor met het gewenste aantal threads:
Executor threadPoolExecutor = new ScheduledThreadPoolExecutor (numThreads);
De sequentiële doorvoersnelheid wordt daardoor hoger. De random leessnelheid bereikt soms erg utopische waarden, maar dit ligt vooral aan meer treffers in de Android-buffer. In de praktijk zullen veel apps afbeeldingen en andere data slechts in een enkele thread laden. Als er werkelijk meerdere threads een rol spelen, gaat het doorgaans om netwerktoegang en overheerst dit de latenties volledig. Multithreaded-meetwaarden zeggen dus meer over de prestaties van gelijktijdig draaiende apps dan over die van een enkele app. Om te controleren of het programmeren in Java en de byte-code runtime invloed hebben op de metingen, hebben we een eenvoudige native C-implementatie van de lees- en schrijfroutine in de app ingebouwd. Die leest en schrijft gegevens met de stdio-functies fread() en fwrite(). De verstreken tijd wordt met dezelfde nauwkeurigheid als in Java gemeten via clock_ gettime():
int64_t nanoTime() { struct timespec now; clock_gettime(CLOCK_MONOTONIC,&now); return (int64_t) now.tv_sec *
NANOSEC_PER_SEC + now.tv_nsec;
}
Een app programmeren in C-code en debuggen vereist extra tools. In Android-SDKmanager moet je de NDK en de CMaketools en indien nodig de Debugger LLDB extra installeren. Als je alleen een Javaversie wilt maken, zet je in het bestand build.gradle gewoon de sectie externalNativeBuild tussen commentaartekens. Het aanroepen van de C-functies gebeurt in de van de benchmark afgeleide klasse BenchmarkNative. In de praktijk konden we daarmee slechts minimale verbeteringen vaststellen. De meest betrouwbare en best reproduceerbare resultaten kregen we bij de meeste apparaten met metingen met C, op twee threads en met ingeschakelde vliegtuigmodus.
Veel Android-apps bewaren hun data in een SQLite-database. Daarom meten sommige benchmark-apps die functionaliteit apart. Het resultaat wordt dan aangegeven in QPS (queries per second, databaseraadplegingen per seconde). Natuurlijk hangen die metingen sterk af van het gebruikte databaseschema en de hoeveelheid data. Als je dat wil, mag je die functionaliteit natuurlijk zelf aan onze app toevoegen. (mdt)