Multithreading-Benchmark: Haskell schlägt Java und Python

Seite 3: Countdown-Test

Inhaltsverzeichnis

Vorweg sei gesagt, dass der Performance-Test einer Programmiersprache nicht in völliger Leere stattfinden kann, sondern vom Gesamtsystem, also Bibliotheken, Compilern, Interpretern usw. abhängt. Wir haben versucht, hier eine realistische Umgebung zu schaffen, die einen Vergleich unter quasi lebensechten Bedingungen ermöglicht. Andere Umgebungen können durchaus zu anderen Ergebnissen führen.

Der Countdown-Test bestand darin, von einer großen ganzzahligen Zahl runter auf 0 zu zählen. Um einen repräsentativen Vergleich zu erzielen, nahmen wir pro Compiler jeweils drei Testrunden auf den beiden Testrechnern vor. Dabei wurde die Countdown-Funktion mit den Werten N = 500.000, N = 1.000.000 und N = 10.000.000 getestet. Die Implementation einer auf Multithreading-basierten Countdown-Funktion unterscheidet sich von Programmiersprache zu Programmiersprache.

Unter Python ist es am einfachsten, zunächst eine simple Countdown-Funktion zu erstellen, die solange den Wert einer Variable n um eins reduziert, bis n = 0 ist:

Listing 1: Implementation eines Countdowns unter Python

def countdown(n):
    while n>0:
        n -= 1
        print(n)

Um die Ausführung der Countdown-Funktion zu beschleunigen, kommt die externe Bibliothek Trio-Parallel zum Einsatz:

import trio

async def time_countdown(n):
    async with trio.open_nursery() as nursery:
        computational_result_aiter = nursery.start_soon(trio_parallel.run_sync, countdown,n)

Unserer Einschätzung zufolge handelt es sich bei Trio-Parallel um die einfachste Art, alle CPU-Kerne während der Ausführung voll auszunutzen. Der Countdown-Test wird schließlich durch den folgenden Code ausgelöst:

import trio
import sys
import multiprocessing

counterN1 = 500000
counterN2 = 1000000
counterN3 = 10000000

if __name__ == '__main__':
    multiprocessing.freeze_support()
    trio.run(time_countdown,counterN3)

Im Vergleich zu Python lässt sich unter Java die Countdown-Funktion unter Zuhilfenahme des Schlüsselworts synchronized thread-sicher implementieren, was dazu führt, dass mehrere Threads gleichzeitig zugreifen können:

public class Countdown {
    private long value = 0;
    private volatile boolean cancelled = false;
    
    public Countdown(long n) {
        this.value = n; 
    }
    
    public synchronized long getValue() {
        return value;
    }
    
    public synchronized void decrement() {
        if (! isCancelled()) {
            while (value > 0) {
                this.value--;
                System.out.println("Current value:"+this.value);
            }
            this.cancel();
        } 
    }

    public boolean isCancelled() {
        return this.cancelled;
    }

    private void cancel() {
        this.cancelled = true;
    }

}

Um so viele Tasks zu erstellen, wie die CPU über Kerne verfügt, wird ein Task für den Countdown vom Typ Callable implementiert, dem als Argument ein zuvor initialisiertes Objekt vom Typ Countdown übergeben wird. Das stellt sicher, dass alle Threads auf ein und denselben Counter zurückgreifen:

public class CountingTask implements Callable <CountingTask>{
    private Countdown countdown;
    
    public CountingTask(Countdown c) {
        this.countdown = c;
    }
    
    public CountingTask call() {
            if (!Thread.currentThread().isInterrupted()) {
                this.countdown.decrement();
            }
            return this;
    }

}

Zum Verwalten der asynchronen Tasks kommt die Schnittstelle ExecutorService zum Einsatz, die so viele Callables vom Typ Countdown erstellt, wie an Threads numThreads festgelegt worden sind:

public class CountdownService  {
    // ruft die Zahl der CPU-Kerne ab   
    public static final int MAX_THREADS = Runtime.getRuntime().availableProcessors();
    private ExecutorService executorService;
    private Countdown countdown;
    private int numThreads = 0;
    private Set<Callable<CountingTask>> tasks = Collections.synchronizedSet(new HashSet<Callable<CountingTask>>());
    private List<Future<CountingTask>> futures;
    
    public CountdownService(Countdown c, int n) {
        this.countdown = c;
        this.numThreads = n;
        this.executorService = Executors.newFixedThreadPool(MAX_THREADS);
        this.futures = new ArrayList<Future<CountingTask>>();
    }
    
    // erstellt die Tasks
    public void prepare() {
        for (int i = 0; i < this.numThreads; i++) {
            CountingTask task = new CountingTask(this.countdown);
            this.tasks.add(task);
        }
    }
    
    // initialisiert die Callables
    public void init() {
        if (!this.executorService.isShutdown()) {
            try {
                futures = executorService.invokeAll(tasks);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
   
}

Danach werden die Tasks gleichzeitig durch Aufruf von executorService.invokeAll(tasks) gestartet:

public void startCountdown(int n, int numThreads) {
         Countdown c = new Countdown(n);
         CountdownService countdown = new CountdownService(c, numThreads);
         countdown.prepare();
         countdown.init();
}

Haskell verfolgt einen anderen Ansatz, indem die erforderlichen Funktionen in einem Modul implementiert werden:

module MyCounter () where
...

import Control.Monad (forever)
import Control.Concurrent.STM
import Control.Concurrent.MVar
import Control.Concurrent (forkIO,threadDelay)
import System.Exit as Exit
import qualified Lib as Lib

-- komplexe Datentypen
data MyCounter = MyCounter Int deriving (Eq)
data Countdown = C MyCounter | M MAX | N MIN deriving (Show,Eq)
data MAX = MAX Int deriving (Eq)
data MIN = MIN Int deriving (Eq)
type CounterVar = TVar Countdown

-- vergleicht den Zähler mit dem Infimum oder Supremum
myCompare :: Countdown -> Countdown -> Ordering
(M (MAX n)) `myCompare` (C (MyCounter i))
    | n == i = EQ
    | n < i = LT
    | otherwise = GT
(N (MIN n)) `myCompare` (C (MyCounter i))
    | n == i = EQ
    | n < i = LT
    | otherwise = GT
c@(C (MyCounter _)) `myCompare` m@(N (MIN _)) = m `myCompare` c
c@(C (MyCounter _)) `myCompare` m@(M (MAX _)) = m `myCompare` c

-- erstellt einen neuen Countdown mit einem Infimum oder Supremum 
resetCounter :: Countdown -> IO CounterVar
resetCounter (M (MAX n)) = do
    tstore <- atomically $ newTVar (C (MyCounter n))
    return tstore   
resetCounter (N (MIN n)) = do
    tstore <- atomically $ newTVar (C (MyCounter n))
    return tstore   

-- reduziert den Countdown um eins
decCounter' :: Countdown -> Countdown
decCounter' (C (MyCounter c)) = C (MyCounter (c - 1))

decCounter :: CounterVar -> Int ->  IO ()
decCounter tstore i =  do
    -- Wert aus der gemeinsam genutzten Variable abrufen
    c <- Lib.getTVar tstore
    let min' = N (MIN 0)
        newCounter = decCounter' c
        res = auxCompare min' c
    -- prüfen ob der Countdown das Infimum oder Supremum erreicht hat
    if  (auxCompare min' c == LT )
        then do
          -- neuen Wert in die TVar ablegen 
          Lib.modifyTVar_ tstore newCounter
   -- rekursiver Aufruf von decCounter
          decCounter tstore (Steps (succ n)) i

     -- Ansonsnte decCounter verlassen
        else (putStrLn $ "Finished Thread Number: " ++ (show i)) >> return ()
    where
        auxCompare x y = x `myCompare` y

Der Countdown wird zusammen mit einem Infimum MIN oder Supremum MAX durch Aufruf der Funktion resetCounter initialisiert. Auch das ermöglicht eine thread-sichere Implementierung des Countdowns, indem der initiale Wert des Countdowns in die weiter oben erwähnte TVar des STM-Moduls abgelegt wird. Jeder Thread führt dann solange die Funktion decCounter aus, bis der Wert 0 erreicht wird. Dabei wird zunächst der Wert aus der TVar abgerufen und durch Aufruf der Hilfsfunktion myCompare überprüft, ob der Countdown dem Grenzwert entspricht.

Falls das nicht der Fall ist, erfolgt ein rekursiver Aufruf von decCounter, andernfalls bricht der Thread die weitere Ausführung von decCoutner ab. Schließlich startet der Countdown, indem wir der Funktion countdowntest die Zahl der Threads t sowie N als Argumente übergeben:

import Control.Concurrent.Async (replicateConcurrently_,async,wait,mapConcurrently_)

countdowntest :: Maybe Int -> Maybe Int -> IO ()
countdowntest mn mt = do
    -- mn: N
    -- mt: Zahl der Threads    
    case (mn,mt) of
        (Just n,Just t) -> do
            tstore <- C.resetCounter (C.M (C.MAX n))
            let nList = [1..t]
            mapConcurrently_ (\i -> C.decCounter tstore (C.Steps 0) i) nList
            c <- Lib.getTVar tstore
            putStrLn $ "Countdown from " ++ (show n) ++ " using " ++ (show t) ++ " cores stopped at: " ++ (show c)
        _ -> putStrLn "The countdown command takes exactly two numbers."

Die Liste nList besteht aus der Ziffernfolge 1 bis t, die der Funktion mapConcurrently_ des Moduls Control.Concurrent.Async übergeben wird, um so viele Threads gleichzeitig zu initialisieren, wie nList an Elementen besitzt.

Testergebnisse des Countdown-Tests bezogen auf beide Testrechner (Abb. 5)

(Bild: Anzela Minosi)

In unserer Testumgebung schnitt Haskell auf beiden Testrechnern am schnellsten ab (siehe Tabelle 3), was nicht zuletzt die gemessenen Durchschnittswerte belegen (Abbildung 5).

Durchschnittliche Ausführungszeit aufgeschlüsselt nach Compilern (Abb. 6)

(Bild: Anzela Minosi)

Dass Python in diesem Test besser als Java rangiert, hat maßgeblich damit zu tun, dass die Implementierung von thread-sicheren Variablen einen gewissen Overhead mit sich bringt. Würden bei unserer Umsetzung der Countdown-Funktion unter Python mehrere Clients gleichzeitig zugreifen, würde die Ausführung instabil. Der Boxplot in Abbildung 6 zeigt überdies, dass die Tests unter Haskell über eine minimale Range verfügen, zu sehen an der Länge der Box. Auch ist die Entfernung zu den absoluten Minima und Maxima (siehe obere und untere horizontale Linien) bei Haskell nicht allzu groß, sodass dies von einer gewissen Zuverlässigkeit des Haskell-Compilers zeugt.

Die grüne Linie zeigt den absoluten Durchschnittswert der Messungen pro Compiler an.

Countdown-Test: Vergleich zwischen ARM- und Intel-CPU (Abb. 7)

(Bild: Anzela Minosi)

Unterschiede in Bezug auf die Messwerte ergeben sich darüber hinaus beim Vergleich zwischen Intel- und ARM-CPU. So fällt die durchschnittliche Ausführungszeit deutlich geringer aus, wenn mehr Threads zum Einsatz kommen (Abbildung 7).

Tabelle 3: Testergebnisse: N = 18
N Durchschnitt (Sekunden) Bestwert Höchstwert Std-Abweichung Compiler Threads CPU
500000 0.34600 0.33470 0.36990 0.01050 ghc-9.6.3 2 ARM
1000000 1.07600 0.65400 4.74400 1.28900 ghc-9.6.3 2 ARM
10000000 4.10800 1.32600 6.58200 2.16600 ghc-9.6.3 2 ARM
500000 0.82900 0.76190 0.89240 0.47800 ghc-9.6.3 8 Intel
1000000 1.60700 1.49300 1.74200 0.08200 ghc-9.6.3 8 Intel
10000000 15.81700 15.38200 16.33400 0.27200 ghc-9.6.3 8 Intel
500000 3.47900 3.41200 3.51800 0.03700 openjdk-20.0.2 2 ARM
1000000 5.43400 5.35200 5.54600 0.06600 openjdk-20.0.2 2 ARM
10000000 40.91400 39.87300 42.07300 0.74700 openjdk-20.0.2 2 ARM
500000 1.90200 1.84800 1.99100 0.03800 openjdk-20.0.2 8 Intel
1000000 3.35700 3.26500 3.61200 0.10500 openjdk-20.0.2 8 Intel
10000000 29.16500 28.20000 30.87900 0.90000 openjdk-20.0.2 8 Intel
500000 2.50500 2.45300 2.60100 0.04300 python-3.11.6 1 ARM
1000000 3.58300 3.51500 3.64400 0.04300 python-3.11.6 1 ARM
10000000 23.62200 22.94400 23.98700 0.31400 python-3.11.6 1 ARM
500000 1.19200 1.15800 1.26900 0.03300 python-3.12.1 1 Intel
1000000 1.87100 1.75700 2.43300 0.20400 python-3.12.1 1 Intel
10000000 13.05300 12.30300 16.41200 1.19000 python-3.12.1 1 Intel