Am promis în discuția de acum câteva zile că voi explica mai pe larg cum am utilizat profilerul din Visual Studio Team System pentru a măsura viteza de execuție a unei aplicații C#. M-am gândit eu bine și am ajuns la concluzia că mai bine scriu acest post sub forma unui îndrumar pas cu pas, cu ocazia asta urmând să experimentez și modul în care se vor înțelege Windows Live Writer și Community Server la salvarea pozelor și la ce voi mai încerca eu să fac pe aici. Cu alte cuvinte, dacă nu iese din prima, cel puțin știți de ce. În plus, am făcut tot posibilul să scriu articolul în așa fel încât să vă descurcați și fără exemplele vizuale.
Configurarea sistemului
În primul rând, aveți nevoie de Visual Studio Team Edition for Developers, cu utilitarele de performanță instalate, pe o mașină rezonabil de puternică (pe o mașină cu performanțe mai slabe, viteza aplicației poate avea de suferit fără să fie de vină aplicația în sine, de exemplu din cauza paginării). Va trebui să lucrați dintr-un cont de administrator, deoarece pentru anumite configurații despre care vom discuta mai jos este necesară încărcarea unui driver în nucleul sistemului de operare, iar această operație are nevoie de privilegii indisponibile utilizatorilor obișnuiți. În fine, pentru a obține de la profiler lista corectă de funcții apelate, veți avea nevoie de simboluri corecte atât pentru aplicația voastră, cât și pentru binarele Microsoft.
Dacă aveți clientul de Symbol Server instalat corespunzător (conform instrucțiunilor de pe pagina Getting Started de pe site-ul dedicat Debugging Tools for Windows), nu mai aveți nimic de făcut. În particular, pe calculatorul meu am configurat variabila de mediu _NT_SYMBOL_PATH la valoarea SRV*C:\Symbols*http://msdl.microsoft.com/download/symbols pentru toți utilizatorii și obțin automat simbolurile publice de la Microsoft de fiecare dată când pornesc orice debugger de la Microsoft, de la WinDbg la Visual Studio. Mai puteți face "set _NT_SYMBOL_PATH=SRV*C:\Symbols*http://msdl.microsoft.com/download/symbols" dintr-o consolă de Windows și lansați Visual Studio de acolo și sunt destul de sigur că puteți configura calea și din Tools - Options - Debugging - Symbols, dar nu am încercat asta niciodată.
Aplicația
Presupunând că toate condiţiile de bază sunt îndeplinite, deschideți un Visual Studio și creați o aplicație C# de consolă. Eu am denumit proiectul "Grayscale", dar îi puteți spune cum doriți. Adăugați o referință la System.Drawing.dll, directivele using corespunzătoare, și adăugați următoarea funcție în clasa care conține programul principal:
static void Grayscale(Bitmap img)
{
if (img.PixelFormat != PixelFormat.Format24bppRgb)
{
// Mental note: Never embed strings in code like this
throw new ArgumentException("Expected a 24 bpp bitmap");
}
BitmapData bmpData = null;
try
{
bmpData = img.LockBits(
new Rectangle(0, 0, img.Width, img.Height),
ImageLockMode.ReadWrite, img.PixelFormat);
unsafe
{
for (int y = 0; y < img.Height; y++)
{
byte* src = (byte*)bmpData.Scan0 + y * bmpData.Stride;
for (int x = 0; x < img.Width; x++, src += 3)
{
src[0] = src[1] = src[2] = (byte)(0.2125 * src[2] +
0.7154 * src[1] + 0.0721 * src[0]);
}
}
}
}
finally
{
if (bmpData != null)
{
img.UnlockBits(bmpData);
}
}
}
Programul principal e destul de simplu - deschide primul fișier primit pe linia de comandă, îl transformă și îl salvează în al doilea:
static void Main(string[] args)
{
try
{
if (args.Length != 2)
{
// Mental note: Never embed strings in code like this
throw new ArgumentException("Usage: Grayscale InputFile.bmp OutputFile.bmp");
}
Bitmap bmp = new Bitmap(args[0]);
Grayscale(bmp);
bmp.Save(args[1]);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
}

Ajunși în stadiul acesta, trebuie să configurăm puțin proiectul pentru a îl putea compila cu succes. Deschideți proprietățile proiectului (în Solution Explorer, faceți clic pe dreapta pe proiectul Grayscale) și mergeți la pagina "Build". În caseta "Configuration" selectați "All Configurations", după care bifați opțiunea "Allow unsafe code". Vă mai recomand și să treceți prin pagina "Debug" și să deselectați opțiunea "Enable the Visual Studio hosting process" (Există în MSDN Library explicații despre Visual Studio hosting process; pe scurt, oferă servicii gen partial trust debugging și design-time expression evaluation. Nu este necesar pentru măsurătorile noastre, și cu cât avem mai puține procese pornite, cu atât mai bine).
Măsurătorile
Formalitățile fiind încheiate, putem trece la partea interesantă. Selectați configurația "Release" a proiectului (în general, e bine să aveți grijă să nu măsurați viteza versiunii de debug) și deschideți fereastra "Performance Explorer" (View - Other Windows - Performance Explorer). Dacă nu găsiți comanda, e posibil să nu aveți Visual Studio Team Edition for Developers instalat (vezi pasul 1) sau să vă fi particularizat mediul de dezvoltare în cine știe ce fel, caz în care va trebui să deschideți dialogul "Customize" cu Tools - Customize, să selectați pagina "Commands" și să căutați comanda "Performance Explorer" în grupul "View".
În fereastra "Performance Explorer", faceți clic pe "New Performance Session". Va fi creată automat o sesiune numită "PerformanceN", cu două subdirectoare, "Targets" și "Reports". Faceți clic pe dreapta pe "Targets" și selectați "Add Target Project". Întrucât proiectul Grayscale este singurul care face parte din soluție, va fi adăugat automat ca țintă. Faceți clic pe dreapta pe Grayscale și deschideți fereastra de proprietăți. Pe pagina "Launch", bifați "Override project settings", iar în caseta "Arguments" introduceți calea completă spre fișierul .BMP care va fi folosit drept intrare și calea completă spre ieșire (de exemplu, 'C:\Grayscale\Test\Input.bmp C:\Grayscale\Test\Output.bmp', fără apostrofuri). Eu am folosit ca intrare o poză oarecare, de 2048 x 1536 pixeli, convertită la formatul BMP 24 bpp (cam 9 MB și ceva). Nu uitați să folosiți ghilimele în cazul în care numele conțin spații. Salvați setările și închideți fereastra.
Salvați sesiunea de performanță pe care tocmai ați creat-o (eu am salvat-o în același director cu proiectul, și i-am dat același nume). Faceți clic pe dreapta pe sesiunea de performanță și deschideți fereastra de proprietăți. În pagina "General", configurați opțiunea "Profiling collection" pe valoarea "Instrumentation" (implicit este pe "Sampling"). Deoarece ne interesează să evaluăm doar viteza aplicației, nu vom configura secțiunea ".NET memory profiling collection" (deși sunt lucruri interesante de văzut și acolo). Verificați că în pagina "Launch" este bifat proiectul Grayscale (ar trebui să fie bifat implicit, și nu există alte opțiuni oricum). Salvați setările și închideți fereastra.
În acest moment, în "Performance Explorer" ar trebui să mai fi apărut un buton numit "Launch". Faceți clic pe el pentru a rula aplicația. După terminarea procesului, Visual Studio va descărca datele obținute și le va transforma într-o serie de rapoarte. În acest timp, e o idee bună să monitorizați fereastra Output - dacă observați mesage de genul "Failed to load pdb for module ...", înseamnă că nu ați configurat corespunzător simbolurile (vezi pasul 1).
În pagina de sumar sunt afișate funcțiile cele mai interesante din program. Primul grup afișează funcțiile apelate de cele mai multe ori, al doilea afișează funcțiile în care este petrecut cel mai mult timp (fără a pune la socoteală funcțiile apelate de acestea), iar al treilea afișează funcțiile care durează cel mai mult. După părerea mea, pagina aceasta este cel mai puțin interesantă, în special din cauza terminologiei alese pentru cele trei grupuri (Care e diferența între al doilea și al treilea grup? De ce în al doilea grup, funcția get_Width apare cu 1114 ms, iar în al treilea are 1253 ms?). Chiar și așa, e ușor de observat care sunt cele mai "interesante" apeluri de funcție.
Ceva mai interesantă este pagina "Functions" din raport. Puteți vedea o listă plată de funcții, sau le puteți grupa după modulul de care aparțin. Implicit, sunt afișate coloane pentru numărul de apeluri și pentru cele mai importante măsurători de timp. Puteți adăuga și alte coloane, dacă doriți, și puteți sorta datele cum vă este mai la îndemână.
"Exclusive time" este termenul pentru timpul petrecut exclusiv în codul unei funcții, fără a include în această valoare timpul petrecut în funcțiile apelate, pe când "Inclusive time" este termenul pentru timpul petrecut într-o funcție, cu tot cu graful de funcții pe care aceasta le-a apelat. Diferența dintre "Elapsed" și "Application" este simplă: Primul termen desemnează timpul fizic petrecut în funcția respectivă (numit și "wall-clock time"), pe când al doilea desemnează timpul de procesor consumat efectiv de acea funcție, convertit la milisecunde. Dacă tot suntem aici, explicații complete privind termenii utilizați în Visual Studio găsiți pe pagina Understanding Performance Terms din MSDN.
În practică, cele două valori nu coincid niciodată, cel puțin pentru că în sistemele de operare multitasking, nucleul sistemului întrerupe periodic fiecare aplicație pentru a oferi și altora șansa de a rula. Dacă tot suntem la acest subiect, spunem despre un program că este "CPU bound" atunci când "Application time" tinde spre "Elapsed time" sau, cu alte cuvinte când aplicația folosește procesorul la capacitatea maximă permisă de sistemul de operare. Cealaltă extremă este reprezentată de aplicații "I/O bound", în care "Elapsed time" este mult mai mare mare decât "Application time", deoarece aplicația depinde mai mult de sistemele de intrare/ieșire ale calculatorului (de exemplu, transferul unui fișier mare peste o conexiune lentă).
Între cele două extreme găsim un spectru foarte variat, despre care nu vom discuta azi. Merită totuși amintit că un aspect nu îl exclude complet pe celălalt: Cele mai multe aplicații au nevoie de ambele resurse, dar una din ele se întâmplă să domine. Există și aplicații care au nevoie în măsură aproximativ egală de timp de procesor și de operații de intrare/ieșire, cel mai comun exemplu de aplicație lacomă cu toate resursele fiind compilatorul de C++.
Trecând pe pagina "Call Tree", găsim aceleași informații, dar organizate într-un arbore de apeluri. Aceasta este una din vederile mele preferate, pentru că scoate foarte ușor în evidență locurile unde își petrece aplicația timpul. În cazul nostru, din datele obținute reiese că funcția Main este nesemnificativă, deoarece din timpul total de rulare (3054 ms), aplicația petrece 3 ms în Main. Se vede, în schimb, că execuția funcției Grayscale domină timpul total de rulare, cu 2485 ms, și că aplicația petrece 50 ms încărcând imaginea de pe disc și 515 ms salvând rezultatul (puteți investiga de ce apare această discrepanță ca exercițiu).
Continuând disecarea rezultatelor, observăm că Program.Grayscale petrece 1231 ms făcând operații proprii, dar că aproximativ 2485 - 1231 = 1254 ms sunt petrecute în funcțiile apelate. Dintre acestea, iese imediat în evidență faptul că 1253 ms sunt petrecute în Image.get_Width, care este apelată de un număr de ori apropiat de numărul pixelilor din imagine.
Pentru că am ales un program simplu ca exemplu, investigația noastră se oprește aici: Știm că dimensiunile pozei nu se schimbă pe parcursul execuției algoritmului, așa că vom apela o singură dată Image.get_Width și vom salva rezultatele într-o variabilă locală:
bmpData = img.LockBits(
new Rectangle(0, 0, img.Width, img.Height),
ImageLockMode.ReadWrite, img.PixelFormat);
int width = img.Width;
int height = img.height;
unsafe
{
for (int y = 0; y < height; y++)
{
byte* src = (byte*)bmpData.Scan0 + y * bmpData.Stride;
for (int x = 0; x < width; x++, src += 3)
{
src[0] = src[1] = src[2] = (byte)(0.2125 * src[2] +
0.7154 * src[1] + 0.0721 * src[0]);
}
}
}
(Am scos în afara buclei și apelul Image.get_Height, pentru simetrie).
Cu codul modificat, după încă o rundă de măsurători rezultatele se schimbă destul de radical: Timpul total de execuție scade la 157 ms, din care funcția de conversie la alb-negru ocupă 51 ms, încărcarea imaginii durează 45 ms, iar salvarea ei 57 ms (ciudat, chiar ar merita investigat de ce în cealaltă rundă de măsurări salvarea era de 10 ori mai lentă).
Înainte de a încheia, mai merită vorbit despre câteva lucruri. În primul rând, când am configurat sesiunea de performanță, ați observat că am schimbat tipul măsurării efectuate din "Sampling" în "Instrumentation". Cele două metode sunt radical diferite, și merită scoasă în evidență diferența dintre ele.
La instrumentarea unei aplicații, profilerul inserează cod suplimentar pentru fiecare apel de funcție (înaintea lui, și după el) pentru a măsura timpul de execuție al fiecărei componente. Această metodă are avantajul că este precisă, în sensul că oferă informații exacte despre numărul de apeluri efectuate, despre funcțiile care apelează și sunt apelate de o anumită funcție, și rezonabil de exacte despre timpii de execuție. Spun "rezonabil de exacte" deoarece inserarea codului suplimentar de către profiler afectează memoria ocupată, ca și viteza aplicației.
Tehnica de măsurare prin sampling este radical diferită: Periodic, sistemul de operare întrerupe execuția tuturor firelor de execuție din procesul monitorizat, parcurge stiva fiecărui fir de execuție din acel moment și memorează această stivă. Sunt de notat câteva lucruri aici: În primul rând, metoda de măsurare este inexactă, deoarece procesul nu poate fi suspendat la fiecare instrucțiune executată. Intervalul de timp la care are loc întreruperea este de obicei de ordinul milioanelor de perioade de ceas (1 - 10 milioane).
Apoi, pentru ca datele obținute să aibă valoare, ele trebuie să fie relevante din punct de vedere statistic; cu alte cuvinte, numărul de mostre adunate trebuie să fie cel puțin de ordinul miilor, dacă nu mai mare (cu cât mai mare, cu atât mai bine).
În al treilea rând, nu suntem obligați ca pe axa X să avem timpul ca unitate de măsură. De exemplu, putem instrui profilerul să ia o mostră la fiecare 50 de hard page faults. Evident, schimbând unitatea de măsură, schimbăm înțelesul datelor: Funcțiile care vor apărea în acest caz nu sunt cele mai interesante din punctul de vedere al vitezei, ci al accesului la memoria internă și externă a sistemului.
În al patrulea rând, contează extrem de mult frecvența cu care sunt adunate mostrele. Dacă intervalul ales e prea mare, ne vom alege cu mai puține mostre, deci scade relevanța statistică a setului de date colectat. Pe de altă parte, dacă intervalul e prea mic, vom ajunge să întrerupem procesul prea des și îi vom afecta performanțele în mod negativ fără ca procesul să fie vinovat de acest lucru (de exemplu prin introducerea forțată a unui număr prea mare de schimbări de context).
În fine, am lăsat la urmă unul din aspectele cele mai importante: Din punctul de vedere al sistemului de operare, o stivă e o stivă și atât. Nu contează că pe ea se află funcții native sau .NET, profilerul va evalua tot. În felul acesta, putem diseca mult mai precis modul în care lucrează o aplicație (așa am aflat de exemplu că apelul Image.get_Width este lent deoarece există o tranziție spre cod nativ și un nivel de sincronizare).
M-am lungit cu vorba, așa că mai bine mă opresc aici. Am pus la în pagina Profiling 101 toate imaginile pe care le-am cules cu ocazia acestui experiment. Nu voi intra azi în subiecte precum automatizarea acestui proces, configurarea mașinilor de test și a testelor pentru că mai am nevoie de subiecte și pentru altă dată. Și încă nu am ajuns la garbage collector.
În fine, nu mi-e clar nici măcar acum dacă pluralul pentru "apostrof" este "apostrofuri" (așa cum am scris aici) sau "apostroafe" (așa cum am scris prin alte articole). Am fost pe www.dexonline.ro și au vreo cinci intrări pentru acest cuvânt, provenind din diferite dicționare. Prima variantă apare mai des, dar apare și a doua (cel puțin o dată).