• No results found

CMap och const char * /estring (C++ 10)

6.5 Sammanfattning av huvudsyftet med applikationerna

8.1.10 CMap och const char * /estring (C++ 10)

Denna version liknar den förra, men här används CMap istället för hash_map. Implemente- ringen är principiellt likadan (förutom att CFile används till filhanteringen) men det tillkom- mer några detaljskillnader genom att CMap måste ”luras” att acceptera strängpekare. Det fungerar inte att använda const char*som mallargument vid skapandet av CMap-objektet. Orsaken är att det då inte finns något sätt (åtminstone har författaren inte funnit något; funktionen CompareElements verkade lovande men löste inte problemet) att få strängjäm- förelsen att ske på rätt sätt.

Lösningen är att skapa en mycket enkel ”skalklass” som kapslar in strängpekaren och som implementerar likhetsoperatorn. Denna klass döps till estring (efter embedded string) och är implementerad så här: class estring { public: const char* str; estring() {}

estring(const char* str) : str(str) {} bool operator==(const estring& other) const { return strcmp(str, other.str) == 0; } }; 1 2 3 4 5 6 7 8 9 10 11

C++

Därefter används estring istället för const char*i definitionen av HashKey och som mall- argument till CMap. Däremot finns ingen anledning att byta i word_item; istället plockas den inkapslade strängpekaren ut igen när vektorn med word_item-objekt byggs upp.

Användningen av estring tillför naturligtvis ett visst ”overhead” jämfört men version 9, men jämfört med versionerna där strängkopieringar sker bör det ändå bli en höjning av prestandan.

8.1.11 ”Optimerad” (C++ 11)

Denna version bygger vidare på versionerna 9 och 10 och återanknyter samtidigt till den första. I likhet med den används här ett minimum av ”externa element”. Skillnaden är att medan version 1 antogs vara något som skrivits av en oinformerad programmerare utan krav på maximal prestanda är denna version ett medvetet försök att maximera prestandan.

Den svagaste länken hos version 1 är implementeringen av punkt 3 i den övergripande algoritmen: kontrollen av om det nyfunna ordet redan hittats. Versionerna som använder

8.1. C++

map, hash_map och CMap kontrollerar detta på ett mycket effektivare sätt. Frågan som denna version kan ge ett svar på är om det är möjligt att göra detta ännu effektivare genom en smart egen implementering av en hashtabell, perfekt anpassad för uppgiften. Till exempel finns inget behov av att kunna ta bort element eftersom detta inte används vid ordräkningen. Några ytterligare förbättringar har också gjorts: Istället för den stora if -satsen som kon- trollerar om ett tecken är ett separeringstecken används en tabell. Steget mellan ordräkning- en och sorteringen har helt kunnat elimineras och den naiva sorteringsrutinen från version 1 har bytts ut mot en implementering av den effektiva quick sort-algoritmen.4

Koden börjar med följande globala element:

#define TABLE_SIZE 16384 struct word_item { char* word; int count; };

void quick_sort(word_item* data, int low, int high);

void insertion_sort(word_item* data, int N);

1 2 3 4 5 6 7 8 9

C++

Klassen word_item återfinns här i en kraftigt bantad version (som en struct). Quick sort- algoritmen använder sig av den enklare sorteringsrutinen insertion sort,5 så båda dessa rutiner (hämtade från [8]) deklareras här.

Efter filnamnshanteringen (som är likadan som i de andra versionerna) läses hela in- datafilen in i minnet. Detta skulle kunna göras med samma metod som i versionerna 1 och 9, men vid testningen visade det sig att den snabbaste metoden är att använda funktionerna i Win32-API:et. Inläsningen går därför till på följande sätt:

HANDLE file = CreateFile(filename, GENERIC_READ, FILE_SHARE_READ, 0, OPEN_EXISTING,

FILE_ATTRIBUTE_NORMAL, 0);

int file_length = GetFileSize(file, 0);

char* buffer = new char[file_length + 2];

ULONG bytes_read;

ReadFile(file, buffer, file_length, &bytes_read, 0);

CloseHandle(file); buffer[file_length++] = ' '; buffer[file_length++] = ' '; 1 2 3 4 5 6 7 8 9 10 11 12

C++

Funktionen CreateFile används trots namnet både för att öppna existerande filer (och vissa andra systemresurser) och för att skapa nya. För den som är intresserad av betydelsen av alla flaggor hänvisas till [15]. De flaggor som används här är att betrakta som standardvalet för att öppna en existerande fil.

Filen läses in i minnet med hjälp av funktionerna GetFileSize och ReadFile och stängs med CloseHandle. I tidigare versioner har ett separeringstecken lagts till i slutet av filen,

4Se t exen.wikipedia.org/wiki/Quick_sort 5Se t exen.wikipedia.org/wiki/Insertion_sort

med syftet att förenkla den efterföljande koden. Här läggs två separeringstecken till. Detta är en prestandaoptimering eftersom kontrollen av om hela filen har gåtts igenom därmed bara behöver göras i de fall då två separeringstecken förekommer i följd och inte varje gång ett separeringstecken hittas.

Koden fortsätter med följande deklarationer och initialiseringar:

unsigned char character_table[256];

word_item hashtable[TABLE_SIZE];

ZeroMemory(&hashtable, sizeof(word_item) * TABLE_SIZE);

ZeroMemory(&character_table, 256);

character_table[' '] = 1; character_table[','] = 1; character_table['.'] = 1; character_table['!'] = 1; character_table['?'] = 1; character_table[';'] = 1; character_table['"'] = 1; character_table[':'] = 1; character_table['('] = 1; character_table[')'] = 1; character_table['\n'] = 1; character_table['\r'] = 1; 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18

C++

För att så effektivt som möjligt testa om ett tecken är ett separeringstecken skapas en array där värdena för de index som svarar mot separeringstecknen sätts till 1 medan övriga är 0. (Om indatafilen enbart innehåller ASCII-tecken skulle det räcka med en 128 tecken lång tabell, men för att förhindra att programmet kraschar om andra tecken förekommer används 256.)

En array av word_item-objekt skapas också och döps till hashtable. Denna och tecken- tabellen nollställs effektivt med anrop till Win32-funktionen ZeroMemory.

På grund av teckentabellen och den manuella hashtabellen får kodens huvudslinga ett något annorlunda utseende jämfört med de tidigare versionerna:

int current_word_start = 0; unsigned int hash = 0; for (int i = 0;; i++)

{

char ch = buffer[i];

// Kolla om tecknet inte är ett separeringstecken

if (character_table[ch] == 0)

{

// Gör om eventuell versal till gemen

ch |= 32; buffer[i] = ch; // Uppdatera hash-värdet

hash = (hash << 5) ^ ch ^ hash; } 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15

C++

8.1. C++ else { if (i - current_word_start > 0) { buffer[i] = 0;

char* current_word = &buffer[current_word_start];

hash = hash % TABLE_SIZE; while (true)

{

word_item* cell = &hashtable[hash];

if (cell->count == 0)

{

// Ledig cell, nytt ord funnet

cell->word = current_word; cell->count = 1;

break; }

else if (strcmp(current_word, cell->word) == 0)

{

// Ordet fanns tidigare, öka antalet förekomster

cell->count++;

break; }

else {

// Kollision, pröva nästa cell (hoppa till början om slutet nåtts)

hash++;

if (hash == TABLE_SIZE) hash = 0; } } hash = 0; } else if (i == file_length - 1) {

// Slutet av bufferten nått, avsluta for-slingan

break; } current_word_start = 1 + i; } } 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55

C++

Utöver current_word_start deklareras och initialiseras före slingan variabeln hash som kommer innehålla aktuellt hashvärde. En finess med denna version jämfört med de tidigare är att hashvärdet kan beräknas undan för undan som varje tecken tas fram. for-satsen ser ”trasig” ut men det finns inget behov av att testa variabeln i för varje varv i slingan utan enbart då två separeringstecken i följd upptäcks (rad 48).

Inuti slingan kontrolleras med hjälp av tabellen om tecknet är ett separeringstecken, och om så inte är fallet görs tecknet som förut om till gemen. Dessutom uppdateras hash- variabeln (med samma formel som tidigare). När ett ord hittas transformeras hashvärdet till att bli ett index i word_item-arrayen.

Inuti while-slingan används hash-värdet för att få fram en pekare till aktuell cell i hash- tabellen. Om förekomstvärdet är 0 är cellen ledig, vilket betyder att det funna ordet inte hittats tidigare. Det läggs då till i cellen och förekomstvärdet sätts till 1 varefter slingan

avbryts. Om förekomstvärdet inte är noll görs en strängjämförelse mellan aktuellt ord och ordet i cellen. Om strängarna är lika betyder det att ordet fanns tidigare, och förekomstvärdet ökas med 1 (varefter slingan avbryts).

Om strängarna inte är lika har en så kallad kollision inträffat, vilket innebär att två olika strängar har fått samma hashvärde (eller att tidigare kollisioner inträffat så att sträng placerats i ”fel” cell). En viktig skillnad mellan olika hashtabellimplementeringar är på vilket sätt kollisioner hanteras. I detta fall har den metod som kallas linjär prövning (linear probing) valts. Detta är den enklaste metoden och innebär att om en cell är upptagen så testas nästa cell (med omstart från början om cellen var den sista). Detta fortgår tills en ledig cell hittas (nytt ord) eller jämförelsen blir sann (ordet fanns tidigare).

Linjär prövning lider av ett problem som kallas primär klumpbildning (primary cluster- ing) och innebär att när tillräckligt många kollisioner har inträffat tenderar elementen att klumpa ihop sig på vissa platser (se t ex [8]) vilket leder till ytterligare kollisioner. En förbättrad metod som minskar antalet kollisioner är kvadratisk prövning men den används inte här av följande skäl:

• De extra beräkningar som kvadratisk prövning kräver får en relativt stor inverkan på exekveringstiden.

• För typisk indata kommer de ord som är vanligast att förekomma tidigt och hashas till en ledig cell. Genom sin mängd är det de vanliga orden som dominerar exekve- ringstiden. Om det blir några extra kollisioner för ovanliga ord får detta ingen större inverkan.

Så länge det finns plats kvar i hashtabellen garanterar den linjära prövningen att en ledig cell kommer hittas. Blir tabellen full kommer programmet däremot hänga sig. Detta problem har lämnats därhän eftersom specifikationen endast kräver att 16384 olika ord ska kunna hanteras. En annan potentiell svaghet hos denna version är att det är möjligt att konstruera en indatafil som ger drastiskt mycket sämre prestanda än normalt. För en typisk textfil finns dock inte det problemet.

Inte mycket återstår av koden (frånsett sorteringsrutinen som inte visas här), endast:

quick_sort(hashtable, 0, TABLE_SIZE - 1);

for (int i = 0; i < 20; i++)

{

if (hashtable[i].count == 0) break; printf("%s %d\n", hashtable[i].word, hashtable[i].count); } delete[] buffer; 1 2 3 4 5 6 7 8 9 10

C++

Tack vare att hashtabellen egentligen bara är en array av word_item-objekt finns inte som i versionerna 2–10 något behov av att kopiera de funna orden (eller pekarna till dem) till en ny array. Istället sorteras helt enkelt den array som tidigare betraktades som en hashtabell. Arrayen kommer normalt att innehålla hål, men detta är inget problem eftersom sortering- en sker i fallande ordning efter antalet förekomster och antalet förekomster är noll för de tomma elementen.

8.1. C++

Om hashtabellen är väldigt stor och gles är det tänkbart att detta förfarande inte är någon prestandaoptimering utan tvärtom ger något längre exekveringstid. Det skulle då vara bättre att trycka ihop de icke-tomma elementen till början av arrayen (vilket är en

O

(N)- operation). Detta beror också på hur effektiv sorteringsrutinen är. För testfilerna och med den valda storleken av hashtabellen är den gjorda implementationen bättre.

En intressant detalj är att algoritmen inte explicit kommer fram till hur många olika ord indatafilen innehåller. Det sägs dock ingenstans i specifikationen att denna information ska tas fram, utan bara att (upp till) de 20 vanligaste orden med tillhörande antal förekomster ska presenteras. Det skulle inte kosta mycket att hålla reda på hur många ord som satts in hashtabellen, men det är onödigt. Enda konsekvensen av detta är att slingan som skriver ut orden ser lite annorlunda ut än i de andra versionerna, eftersom den inte vet från början om det finns färre än 20 ord. Om ett element med noll antal förekomster hittas var det föregående ordet det sista.

8.1.12 ”Optimerad”, version 2 (C++ 12)

Det finns en ytterligare förbättring som bör kunna öka prestandan något. Konverteringen till gemener innebär att samtliga ordtecken skrivs tillbaka till bufferten. I föregående ver- sion görs detta till samma position som där tecknet från början var placerat. Frågan är då om detta verkligen är den bästa strategin, eller om det kan vara bättre att skriva tecknen någon annanstans. Det finns ett begrepp som kallas referenslokalitet (locality of reference) som används i samband med ”avståndet” mellan upprepade referenser till minnet. På grund av moderna processorers cachearkitektur går det snabbare att läsa och skriva till en se- rie minnespositioner som ligger nära varandra än långt ifrån varandra (vad som är ”nära” och vad som är ”långt ifrån” beror på storleken av cacherna). När orden får ligga kvar på sina ursprungliga platser i indatabufferten tenderar referenslokaliteten — särskilt för stora indatafiler — att bli dålig.

Denna version förbättrar referenslokaliteten genom att placera alla funna ord sekven- tiellt efter varandra. Inget extra minne behöver allokeras, utan istället utnyttjas den existe- rande indatabufferten. I extremfallet att alla ord i filen är unika (och åtskiljs av endast ett separeringstecken) blir det ingen skillnad mot föregående version. När däremot ett ord som hittats tidigare upptäcks flyttas den aktuella skrivpositionen tillbaka så att nästa ord placeras på samma ställe. Detta kräver bara några mindre ändringar i källkoden för att implemente- ras. En ny variabel kallad write_pos deklareras före huvudslingans start, med initialvärdet 0. Den tidigare variabeln current_word_start anger nu var aktuellt ord börjar på den plats där det skrivs, inte där det låg från början.

Övriga ändringar är få. Testet för att avgöra om ett ord verkligen hittats använder write_- pos istället för räknarvariabeln i. När ett ord visar sig redan finnas eller om inget ord hittades (fler än ett separeringstecken i följd) återställs write_pos till current_word_start. När ett nytt ord hittats sätts istället current_word_start till write_pos för att flytta fram positionen till nästa lediga plats. write_pos räknas upp med 1 varje gång ett ordtecken hittas och då nollavslutningstecknet skrivs.

8.1.13 ”Optimerad” med assembler (C++ 13)

Moderna C++-kompilatorer (inklusive Visual C++) stöder möjligheten att baka in assem- blerinstruktioner i C++-koden, så kallad inline assembly. Detta kan användas av program- merare som tror att de kan optimera ett visst kodavsnitt bättre för hand än vad kompilatorn

klarar av (ibland genom utnyttjande av nya processorinstruktioner som kompilatorn inte an- vänder). I denna sista C++-version utnyttjas den här möjligheten för att ”handoptimera” den mest kritiska delen av algoritmen.

Den implementerade algoritmen (inklusive minneshanteringen) är exakt samma som i föregående version. Skillnaden ligger i att assemblerkod har använts för ordräkningsslingan (raderna 4–55 i listningen på sid 104–105). Hur denna kod fungerar i detalj förklaras inte här. Koden är lång och kunskaper i assembler för 80x86-processorer är inget krav för att kunna tillgodogöra sig rapportens innehåll. Den eventuella prestandavinsten fås tack vare effektivare användning av processorregistren (så att antalet läsningar/skrivningar till/från minnet minimeras) och att flödet av processorinstruktioner blir bättre. Det senare anpassades i flera fall experimentellt till den variant som gav bäst prestanda. Dessa tester gjordes på P3- maskinen (se avsnitt 11.2) vilket betyder att andra varianter kan vara snabbare för andra processorer.

För detaljer i implementeringen hänvisas till källkoden (tillgänglig via [12]) som har försetts med utförliga kommentarer.

Related documents