Programming lesson
Radix Sort in C++ mit OpenMP parallelisieren – Tutorial für CME213
Lerne, wie du Radix Sort in C++ mit OpenMP parallelisierst. Schritt-für-Schritt-Anleitung mit Histogramm, Prefix-Summe und Reordering – perfekt für CME213.
Einführung in Radix Sort und OpenMP
Radix Sort ist ein effizienter, nicht-vergleichender Sortieralgorithmus, der Daten basierend auf ihren Bits sortiert. In diesem Tutorial implementierst du Radix Sort in C++ und parallelisierst ihn mit OpenMP. Das ist genau das, was in der CME213 Aufgabe verlangt wird. OpenMP ist eine API, die parallele Programmierung auf Shared-Memory-CPUs vereinfacht – ähnlich wie ein Team von Entwicklern gleichzeitig an verschiedenen Teilen eines Projekts arbeitet.
Stell dir vor, du sortierst die Highscores einer beliebten Gaming-Plattform wie Steam. Täglich werden Millionen von Scores eingereicht. Mit Radix Sort und OpenMP kannst du diese Daten in Sekundenschnelle sortieren – perfekt für Echtzeit-Ranglisten.
Grundlagen von Radix Sort
Radix Sort sortiert Zahlen, indem es sie Bit für Bit (oder Gruppe von Bits) verarbeitet. Der Algorithmus arbeitet in mehreren Durchläufen:
- Wähle die Anzahl der Bits pro Durchlauf (numBits). Typisch sind 4 oder 8 Bits.
- Erstelle ein Histogramm mit 2^numBits Buckets. Zähle, wie viele Elemente in jeden Bucket fallen.
- Ordne das Array neu basierend auf den Buckets (Reordering).
- Wiederhole für die nächste Bit-Gruppe, bis alle 32 Bits verarbeitet sind.
Ein Beispiel: Angenommen, du sortierst die Zahlen 5, 3, 8, 1 im Binärsystem. Mit numBits=2 betrachtest du die letzten 2 Bits: 01, 11, 00, 01. Das Histogramm zeigt: Bucket 0: 1 Element, Bucket 1: 2 Elemente, Bucket 2: 0, Bucket 3: 1. Nach dem Reordering erhältst du 8, 5, 1, 3. Dann das nächste Bit-Paar: 10, 01, 00, 00 → sortiert: 1, 3, 5, 8.
Parallelisierung mit OpenMP
OpenMP ermöglicht es, Schleifen einfach zu parallelisieren. Für Radix Sort sind drei Schritte parallelisierbar:
- Histogramm-Erstellung: Jeder Thread zählt einen Teil des Arrays. Achtung: Vermeide Datenkonflikte durch
#pragma omp parallel for reduction(+:histogram[:numBuckets])oder verwende private Arrays und summiere sie später. - Prefix-Summe (Scan): Berechne aus dem Histogramm die Startpositionen für jedes Bucket. Dieser Schritt ist schwer zu parallelisieren, aber du kannst eine sequentielle Version verwenden oder einen parallelen Scan-Algorithmus implementieren.
- Reordering: Jeder Thread kopiert seine Elemente in ein neues Array an die richtigen Positionen. Verwende
#pragma omp parallel forund greife mit#pragma omp atomic captureauf die Positionen zu, oder teile die Buckets auf Threads auf.
Ein typisches OpenMP-Konstrukt sieht so aus:
#pragma omp parallel for num_threads(4) reduction(+:histogram[:numBuckets]) for (int i = 0; i < n; i++) { int bucket = (arr[i] >> shift) & (numBuckets - 1); histogram[bucket]++; }Denk daran, die Anzahl der Threads zu setzen: export OMP_NUM_THREADS=4 oder omp_set_num_threads(4);.
Schritt-für-Schritt-Implementierung
1. Histogramm parallel erstellen
Zerlege das Array in Blöcke für jeden Thread. Jeder Thread zählt in einem privaten Histogramm. Am Ende werden die Histogramme addiert:
int *hist = new int[numBuckets](); #pragma omp parallel { int *private_hist = new int[numBuckets](); #pragma omp for for (int i = 0; i < n; i++) { int bucket = (arr[i] >> shift) & (numBuckets - 1); private_hist[bucket]++; } #pragma omp critical for (int b = 0; b < numBuckets; b++) { hist[b] += private_hist[b]; } delete[] private_hist; }2. Prefix-Summe (sequentiell)
Aus dem Histogramm werden die Startpositionen berechnet:
int *start = new int[numBuckets]; start[0] = 0; for (int b = 1; b < numBuckets; b++) { start[b] = start[b-1] + hist[b-1]; }Für eine parallele Version könntest du einen Hillis-Steele-Scan verwenden, aber für die Aufgabe reicht sequentiell.
3. Reordering parallel
Jeder Thread verarbeitet einen Teil des Arrays und fügt Elemente in ein neues Array temp ein. Verwende #pragma omp atomic capture, um Konflikte zu vermeiden:
int *temp = new int[n]; #pragma omp parallel for for (int i = 0; i < n; i++) { int bucket = (arr[i] >> shift) & (numBuckets - 1); int pos; #pragma omp atomic capture pos = start[bucket]++; temp[pos] = arr[i]; } // Kopiere temp zurück nach arr for (int i = 0; i < n; i++) arr[i] = temp[i]; delete[] temp;Beachte: start wird hier modifiziert, daher musst du für jeden Durchlauf eine Kopie verwenden oder die Startwerte neu berechnen.
Vollständige Funktion
Hier ist eine vollständige radixSort Funktion, die alle Schritte kombiniert:
void radixSort(int *arr, int n, int numBits) { int numBuckets = 1 << numBits; int mask = numBuckets - 1; int maxVal = 1 << 31; // 32-bit signed int for (int shift = 0; shift < 32; shift += numBits) { // Histogramm int *hist = new int[numBuckets](); #pragma omp parallel { int *private_hist = new int[numBuckets](); #pragma omp for for (int i = 0; i < n; i++) { int bucket = (arr[i] >> shift) & mask; private_hist[bucket]++; } #pragma omp critical for (int b = 0; b < numBuckets; b++) hist[b] += private_hist[b]; delete[] private_hist; } // Prefix-Summe int *start = new int[numBuckets]; start[0] = 0; for (int b = 1; b < numBuckets; b++) start[b] = start[b-1] + hist[b-1]; // Reordering int *temp = new int[n]; #pragma omp parallel for for (int i = 0; i < n; i++) { int bucket = (arr[i] >> shift) & mask; int pos; #pragma omp atomic capture pos = start[bucket]++; temp[pos] = arr[i]; } // Kopieren for (int i = 0; i < n; i++) arr[i] = temp[i]; delete[] hist; delete[] start; delete[] temp; } }Diese Funktion sortiert ein Array von 32-Bit-Integern. Du kannst sie in main_q1.cpp einbauen und mit make main_q1 kompilieren.
Testen und Optimieren
Teste deinen Code mit verschiedenen Eingabegrößen und Thread-Anzahlen. Verwende time oder omp_get_wtime(), um die Laufzeit zu messen. Ein guter Speedup ist, wenn die parallele Version auf 4 Kernen etwa 3-4x schneller ist als die sequentielle.
Mögliche Optimierungen:
- Verwende
std::vectorstatt dynamischer Arrays, um Speicherlecks zu vermeiden. - Nutze
#pragma omp parallel for schedule(static)für bessere Lastverteilung. - Vermeide
atomicdurch Aufteilung der Buckets auf Threads: Jeder Thread bekommt einen eigenen Bereich im Zielarray.
Ein Beispiel für die Aufteilung: Statt atomic kannst du jedem Thread einen Teil der Buckets zuweisen und die Elemente in ein privates Array schreiben, das später zusammengeführt wird.
Häufige Fehler und Tipps
- Vergiss nicht, die Thread-Anzahl zu setzen: Entweder über die Umgebungsvariable oder im Code.
- Datenrennen: Achte darauf, dass mehrere Threads nicht gleichzeitig auf dieselbe Speicherstelle schreiben. Verwende
criticaloderatomic. - Falsche Bit-Masken: Stelle sicher, dass du die richtige Anzahl Bits verwendest und die Maske korrekt ist (
mask = (1 << numBits) - 1). - Speicherverwaltung: Lösche alle dynamisch allokierten Arrays mit
delete[].
Zusammenfassung
In diesem Tutorial hast du gelernt, wie man Radix Sort in C++ implementiert und mit OpenMP parallelisiert. Du hast die drei Hauptschritte – Histogramm, Prefix-Summe und Reordering – kennengelernt und sie parallelisiert. Diese Techniken sind nicht nur für die CME213 Aufgabe nützlich, sondern auch für viele andere Anwendungen, bei denen große Datenmengen sortiert werden müssen, z.B. in der Finanzwelt (Sortieren von Transaktionen) oder in der Spieleentwicklung (Ranglisten).
Viel Erfolg bei deiner Implementierung! Denk daran, die Dateien main_q1.cpp und main_q2.cpp zu bearbeiten und die Makefile-Regeln einzuhalten.