Department of InformatiX
Microsoft .NET Micro Framework Tools & Resources

Tak dlouho jsem váhál, zda programovat některé věci tak či onak, až jsem se jednoho krásného večera rozhodl vyřešit to jednou provždy, a změřit, co je rychlejší. Předem upozorňuji, že se nejedná o žádné vědecké testy ze seriálové laboratoře. Jen jednoduchý prográmek, který může zkusit každý – zajímalo mě, jak to dopadne, tak třeba i někoho z vás.

Testování

Testování probíhalo na Meridianu/P, v kofiguraci release s připojeným debuggerem. Kód konzolové aplikace vypadal následovně:

using System; using Microsoft.SPOT; namespace MFConsoleApplication1 { public class Program { public static void Main() { DateTime start; TimeSpan cas1, cas2, cas3; start = DateTime.Now; Test1(); cas1 = DateTime.Now - start; start = DateTime.Now; Test1(); cas2 = DateTime.Now - start; start = DateTime.Now; Test1(); cas3 = DateTime.Now - start; Debug.Print("Test1: " + cas1 + "," + cas2 + "," + cas3); start = DateTime.Now; Test2(); cas1 = DateTime.Now - start; start = DateTime.Now; Test2(); cas2 = DateTime.Now - start; start = DateTime.Now; Test2(); cas3 = DateTime.Now - start; Debug.Print("Test2: " + cas1 + "," + cas2 + "," + cas3); } ... } }

Výsledky

Rozdíly, které nás zajímají, jsou velmi malé, řádově mikrosekundy nebo menší. Tak malé intervaly se obtížně měří, takže musíme instrukce mnohokrát opakovat, abychom dostali nějaká směrodatná čísla. Zvolil jsem opakování sto tisíckrát, tak aby výsledky byly zhruba kolem vteřiny. To je dost dlouho na schování režie CLR, přerušení a ostatního "šumu", ale ne zas tolik, abychom při měření usnuli. Pro ověření konzistentnosti výsledků jsem každé měření třikrát zopakoval.

Pokusy jsou seřazeny vzestupně dle velikosti rozdílu.

Negace booleovských proměnných.

Toto je vlastně jediný případ, který mě překvapil, ačkoliv rozdíl je opravdu zanedbatelný. Vypadá to, že negace je o něco dražší než xor. Zajímalo by mě, jestli je tedy !b pomalejší než b == false...

Xor Negace
private static void Test1() { bool b = false; for (int i = 0; i < 100000; i++) b ^= true; } private static void Test2() { bool b = false; for (int i = 0; i < 100000; i++) b = !b; }
1 018,4682 ms
1 018,2285 ms
1 017,6350 ms
1 021,5566 ms
1 020,7945 ms
1 020,7081 ms
Průměrný zisk: 2,9091 ms

Zvětšování proměnné.

Klasická lekce C++. Post-increment by měl být pomalejší, jelikož potřebuje naalokovat a udělat dočasnou kopii hodnoty, pak ji zvýšit a vrátit kopii. Pre-increment jen hodnotu zvýší a vrátí přímo. Zde je třeba připomenout, že kompilace byla optimalizována, a jelikož výsledek operace není nijak využit, v praxi bude rozdíl obvykle větší. Přesto však je patrný i zde, zajímavé.

Pre-increment Post-increment
private static void Test1() { int a = 0, b = 0; for (int i = 0; i < 100000; i++) ++a; } private static void Test2() { int a = 0, b = 0; for (int i = 0; i < 100000; i++) a++; }
921,2818 ms
921,0234 ms
920,4767 ms
924,2776 ms
923,6813 ms
923,5972 ms
Průměrný zisk: 2,9247 ms

Velikost parametrů.

Procesory, na kterých běží .NET Micro Framework jsou 32bitové, tedy mají i 32bitové registry. Je tedy přirozené, že práce s typy jako long (nebo ulong či double), které jsou 64bitové, stojí něco navíc. Jak je to ale s menšími typy? Stojí něco předávání např. bajtů?

Parametr typu Byte Parametr typu Int32
private static void Test1() { for (int i = 0; i < 100000; i++) ByteMethod(0); } private static void ByteMethod(byte b) { } private static void Test2() { for (int i = 0; i < 100000; i++) IntMethod(0); } private static void IntMethod(int i) { }
1 438,9812 ms
1 434,6358 ms
1 431,9616 ms
1 430,8419 ms
1 431,9995 ms
1 427,5788 ms
Průměrný zisk: 5,0528 ms

Posun vs. dělení.

Posun je hardwarově velmi jednoduché implementovat (pomocí klopných obvodů), za to dělení je mnohem náročnější. Není tedy tolik překvapující, že i tady je posun rychlejší.

Posun Dělení
private static void Test1() { int a = 0, b = 0; for (int i = 0; i < 100000; i++) b = a >> 1; } private static void Test2() { int a = 0, b = 0; for (int i = 0; i < 100000; i++) b = a / 2; }
921,2823 ms
921,1136 ms
920,4909 ms
991,2865 ms
990,5353 ms
990,4636 ms
Průměrný zisk: 69,7995 ms

Testování lichosti

Získat zbytek po dělení je sice o něco rychlejší než podíl, ale maskování je mnohem více rychlejší než posun. Test posledního bitu má tedy jasného favorita:

Modulo Maskování
private static void Test1() { int a = 0, b = 0; for (int i = 0; i < 100000; i++) a = b % 2; } private static void Test2() { int a = 0, b = 0; for (int i = 0; i < 100000; i++) a = b & 1; }
985,8108 ms
985,4435 ms
984,9099 ms
900,0148 ms
899,3200 ms
899,3257 ms
Průměrný zisk: 85,8346 ms

Iterace.

Tak tady to máme: O kolik je foreach pomalejší než for? Pro ty, co nevědí, proč tomu tak je, foreach musí nejdřív vytvořit instanci enumerátoru a pak každou iteraci zavolat jeho metodu MoveNext() a přečíst vlastnost Current.

static byte[] array = new byte[100000];
For Foreach
private static void Test1() { int a = 0; for (int i = 0; i < 100000; i++) a = array[i]; } private static void Test2() { int a = 0; foreach (int value in array) a = value; }
1 207,0700 ms
1 205,8343 ms
1 205,6557 ms
1 431,5822 ms
1 430,1819 ms
1 430,3134 ms
Průměrný zisk: 224.5058 ms

Prohazování proměnných.

Donedávna jsem se domníval, jak je prohazování proměnných xorem mazané. Pak nám na univerzitě řekli, že existují lidé, kteří si to myslí, ale že je to většinou stejně mnohem pomalejší — měli samozřejmě pravdu. (Stále si však vyhrazuji právo se domnívat, že prohazování ukazatelů na velké objekty xorem je dost mazané.)

Xor Dočasná proměnná
private static void Test1() { int a = 0, b = 0; for (int i = 0; i < 100000; i++) { a = a ^ b; b = a ^ b; a = a ^ b; } } private static void Test2() { int a = 0, b = 0; for (int i = 0; i < 100000; i++) { int c = b; b = a; a = c; } }
1 409,4336 ms
1 408,6215 ms
1 408,3136 ms
1 116,5805 ms
1 115,8080 ms
1 115,7313 ms
Průměrný zisk: 292,7497 ms

Proměnná vs. vlastnost.

Druhá nejčastěji opakovaná věc po záležitosti s foreach je upředňostňování proměnných před vlastnostmi (vlastnosti jsou kompilované do get_ resp. set_ metod), ale neuvědomoval jsem si, že rodíl je tak podstatný. Zhruba 12 μs každé zavolání!

static Test test = new Test(); class Test { public int Field; public int Property { get { return Field; } } }
Proměnná Vlastnost
private static void Test1() { int a = 0; for (int i = 0; i < 100000; i++) a = test.Field; } private static void Test2() { int a = 0; for (int i = 0; i < 100000; i++) a = test.Property; }
1 106,4459 ms
1 104,4360 ms
1 104,3929 ms
2 277,5279 ms
2 281,7123 ms
2 281,3857 ms
Průměrný zisk: 1 175,1170 ms

Závěr

Kromě rozdílů v testovaných případech jsme také získali pojem, kolik času zabírají různé úkony, a zjistili, že každé opakování testu vedlo k rychlejšímu celkovému času. Nicméně, vše zde uvedené je pouze pro vytvoření představy. Přestože by embedded zařízení měla být navrhována s výkonem na paměti, dobře zvažte, zda čitelnost kódu stojí za to, co získáte. Pokud je použitelnost vaší aplikace ovlivněná záchranou několika mikrosekund, pak už je pravděpodobně něco špatně někde jinde.

 

Bonusová kapitolka: přepínání výstupu na pinu

Jak rychle můžeme měnit výstupní hodnotu na nožičce procesoru? Nebo co je praktičtější, jak krátký impuls dokážeme vyrobit? A co je důležitější, pokude ne dost, zachrání nás porting kit?

Kód na zodpovězení těchto otázek je celkem snadný:

OutputPort port = new OutputPort(pin, false); bool value = false; while (true) { port.Write(value); value ^= true; }

A zde jsou výsledky (release build, bez debuggeru):

Řízený kód, oba puly šířky 18,4 μs.

Řekněme tedy, že se 100 MHz procesorem vyrobíme řízeným kódem pulsy o šířce zhruba 20 μs (okolo 25 kHz).

Avšak, zde nastupuje fakt, že .NET Micro Framework není systém reálného času: nikdo vám tyto hodnoty nezaručí. Nic nebrání CLR, aby si zrovna mezitím přepnula vlákno, nebo spustila garbage collector. I kdybyste se to pokusili zajistit, stále může nastat nativní přerušení během vykonávání metody Write. Například, uvedená smyčka v konzolové aplikaci bez jakéhokoliv dalšího kódu nevyprodukovala jen ten hezký obrázek, co jsme viděli, ale také tyto (a celkem často):

Řízený kód, nulový puls 58,4 μs, jedničkový 26.8 μs. Řízený kód, nulový puls 17.6 μs, jedničkový 77.6 μs.

Dobrá, potřebujeme tedy rychlejší změny, například k implementaci sběrnice 1-Wire®, kde jedničku reprezentuje puls o maximální šířce 15 μs. Co s tím? Zřejmě si na tomto hardwaru v řízeném kódu nevystačíme. Takže buď pořídit rychlejší procesor, anebo zkusit přesunout kritickou část kódu na nativní stranu. Ale jak rychle to funguje tam?

V porting kitu vlastně existuje několik vrstev, které lze pro tento účel použít, a vždy jde o kompromis mezi rychlostí a abstrakcí – nezávislostí na hardware. Na ten úplně nejkratší impuls byste museli zjistit jaký procesor vaše hardwarová platforma používá, sehnat jeho dokumentaci, zjistit, ve kterém registru se nachází stav daného pinu, a pomocí assembleru bit měnit. Jenže tohle je .NET Micro Framework, pojďme to udělat nezávisle na hardwaru:

while (TRUE) { ::CPU_GPIO_SetPinState(portId, TRUE); ::CPU_GPIO_SetPinState(portId, FALSE); }

A výsledek:

Native code, both pulses width of 2.16μs.

2.16 μs (kolem 463 kHz) není úplně zlé, ne? Jen pro zajímavost, jelikož ani tento kousek kódu nezakázal přerušení procesoru, stále nedostaneme 100 % pravidelný signál:

Native code, low pulse 2.20μs, high pulse 2.24μs.

(Zaznamenal jsem pulsy od 2.12 μs do 2.56 μs, což je ještě dobré) — ale na tom není nic špatného, neboť na generování pravidelného signálu je určen PWM, ne tento pochybný postup!

Comments
Sign in using Live ID to be able to post comments.