Real-time trading with minimal latency and maximum throughput

In the world of high-frequency trading (HFT), every microsecond counts. The ability to process massive data streams, make a decision, and execute an order ahead of the competition is the key to success. This imposes extreme requirements on software, hardware, and overall system architecture.

The rest of the article is in French…

La programmation concurrente

La programmation concurrente (ou concurrency programming en anglais) est une manière d’écrire des programmes qui peuvent faire plusieurs choses à la fois ou gérer plusieurs tâches simultanément, sans nécessairement les exécuter exactement en parallèle.

Explication simple :

Imaginons que tu cuisines :

  • Tu mets de l’eau à bouillir,
  • Pendant que ça chauffe, tu coupes des légumes,
  • Puis tu reviens à la casserole, etc.

Tu ne fais pas tout en même temps, mais tu gères plusieurs tâches en parallèle en optimisant le temps d’attente.
C’est ça, la concurrence : gérer plusieurs opérations qui se chevauchent dans le temps.


En programmation, ça sert à quoi ?

  • Réagir à plusieurs événements (clics, messages, requêtes réseau…)
  • Optimiser les performances (pendant qu’une tâche attend, une autre avance)
  • Améliorer l’expérience utilisateur (interface fluide, chargement en arrière-plan…)

À ne pas confondre avec :

  • Le parallélisme : exécution de plusieurs tâches exactement en même temps (souvent avec plusieurs cœurs de processeur).
  • La programmation asynchrone : un style particulier de programmation concurrente, souvent utilisé avec des promesses, des callbacks ou async/await.

Exemples de langages/concepts :

  • Java : Thread, ExecutorService
  • Python : threading, asyncio
  • JavaScript : Promise, async/await
  • Go : goroutines
  • Rust : tokio, async

En C/C++, la programmation concurrente peut se faire de plusieurs manières, notamment avec :

  1. Les threads (pthreads en C)

Voici un exemple simple en C avec POSIX threads (pthreads) :

#include <stdio.h>
#include <pthread.h>

void* print_message(void* arg) {
    char* message = (char*) arg;
    printf("%s\n", message);
    return NULL;
}

int main() {
    pthread_t thread1, thread2;
    pthread_create(&thread1, NULL, print_message, "Bonjour depuis le thread 1");
    pthread_create(&thread2, NULL, print_message, "Bonjour depuis le thread 2");
    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);
    return 0;
}

  • pthread_create lance une nouvelle fonction dans un thread séparé.
  • pthread_join attend que le thread se termine.

2. C++11 et plus : std::thread

Depuis C++11, on peut faire plus simple avec la bibliothèque standard :

#include <iostream>
#include <thread>

void say_hello(const std::string& name) {
    std::cout << "Bonjour " << name << " depuis un thread !" << std::endl;
}

int main() {
    std::thread t1(say_hello, "Alice");
    std::thread t2(say_hello, "Bob");
    t1.join();
    t2.join();
    return 0;
}

  • std::thread crée un nouveau thread qui exécute une fonction.
  • .join() bloque le thread principal jusqu’à ce que le thread secondaire soit terminé.

3. Exécution parallèle vs concurrence

  • Les deux exemples ci-dessus montrent de la concurrence.
  • Si ton ordinateur a plusieurs cœurs, il pourrait exécuter les threads en parallèle, sinon, ils seront intercalés (le système d’exploitation change de thread régulièrement).

Points d’attention :

  • Les problèmes de synchronisation (par exemple : deux threads écrivant sur la même variable en même temps)
  • Utiliser des mutex (std::mutex, pthread_mutex_t) pour protéger les ressources partagées

Le throughput

Le throughput (ou débit en français) est un terme technique qui désigne la quantité de travail accomplie dans un certain laps de temps. En informatique, on l’utilise souvent pour mesurer la performance d’un système, comme un programme, un réseau ou un processeur.


Exemples selon le contexte :

1. En réseau :

  • Le throughput est la quantité de données transmises par seconde.
  • Exemple : 100 Mbps = 100 mégabits par seconde.

2. En programmation concurrente :

  • C’est le nombre de tâches traitées par seconde.
  • Exemple : un serveur web peut avoir un throughput de 1000 requêtes HTTP par seconde.

3. En base de données :

  • Nombre de transactions ou de requêtes traitées par seconde.

4. En système (OS) :

  • Combien de processus ou de threads peuvent être exécutés dans un temps donné.

Pourquoi c’est important ?

  • Plus le throughput est élevé, plus le système est performant.
  • Il est souvent en équilibre avec la latence :
    • Latence = temps de réponse pour une requête
    • Throughput = combien de requêtes on peut gérer par seconde

On peut avoir :

  • Une faible latence mais un faible throughput (rapide mais peu de volume)
  • Un haut throughput avec une latence plus grande (beaucoup de volume, mais plus lent par requête)

1. Exemple : mesurer le throughput

Imaginons que tu veux lancer 100 000 tâches, et mesurer combien sont traitées par seconde avec des threads.

Code C++ avec std::thread et std::chrono :

#include <iostream>
#include <thread>
#include <vector>
#include <chrono>
#include <atomic>

void task(std::atomic<int>& counter) {
    // Simule une petite tâche
    counter++;
}

int main() {

    const int num_tasks = 100000;
    std::atomic<int> counter{0};
    auto start = std::chrono::high_resolution_clock::now();
    std::vector<std::thread> threads;
    for (int i = 0; i < num_tasks; ++i) {
        threads.emplace_back(task, std::ref(counter));
    }
    for (auto& t : threads) {
        t.join();
    }
    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> duration = end - start;
    double throughput = num_tasks / duration.count();
    std::cout << "Throughput: " << throughput << " tasks/sec" << std::endl;
    return 0;
}

Ce que fait ce code :

  • Lance 100 000 threads qui incrémentent un compteur.
  • Mesure le temps total.
  • Calcule le throughput = nombre de tâches / temps en secondes.

2. Comment améliorer le throughput ?

Lancer 100 000 threads n’est pas efficace. Voici quelques techniques d’optimisation :

a) Utiliser un thread pool

Au lieu de créer un thread par tâche, on crée un petit nombre de threads qui travaillent en boucle.

Pourquoi ?
Créer/détruire un thread est coûteux. Les thread pools réutilisent les threads.

b) Limiter les accès partagés

Les accès concurrents à des ressources partagées (comme counter) nécessitent des verrous ou des opérations atomiques, ce qui ralentit.

c) Batching (traitement par lots)

Si possible, regrouper plusieurs petites tâches en une plus grosse réduit les appels aux threads.

d) Asynchrone / futures / coroutines

Utiliser des techniques non bloquantes (ex: std::async, std::future, coroutines en C++20) peut améliorer le débit.


En résumé :

  • Throughput = nombre de tâches / temps
  • Pour le mesurer : chronomètre + compteur
  • Pour l’améliorer : éviter la surcharge (création de threads, contention), utiliser des pools, faire du batch, etc.

Exemple : Thread Pool + mesure du throughput

#include <iostream>
#include <thread>
#include <vector>
#include <queue>
#include <functional>
#include <mutex>
#include <condition_variable>
#include <atomic>
#include <chrono>

class ThreadPool {
public:
    ThreadPool(size_t num_threads) {
        for (size_t i = 0; i < num_threads; ++i) {
            workers.emplace_back([this]() {
                while (true) {
                    std::function<void()> task;
                    {   // zone protégée
                        std::unique_lock<std::mutex> lock(queue_mutex);
                        condition.wait(lock, [this]() {
                            return stop || !tasks.empty();
                        });
                        if (stop && tasks.empty()) return;
                        task = std::move(tasks.front());
                        tasks.pop();
                    }
                    task();
                }
            });
        }
    }
    void enqueue(std::function<void()> task) {
        {
            std::lock_guard<std::mutex> lock(queue_mutex);
            tasks.push(task);
        }
        condition.notify_one();
    }
    void shutdown() {
        {
            std::lock_guard<std::mutex> lock(queue_mutex);
            stop = true;
        }
        condition.notify_all();
        for (auto& worker : workers) worker.join();
    }
    ~ThreadPool() {
        if (!stop) shutdown();
    }
private:
    std::vector<std::thread> workers;
    std::queue<std::function<void()>> tasks;
    std::mutex queue_mutex;
    std::condition_variable condition;
    bool stop = false;
};

// Mesure du throughput
int main() {
    const int num_tasks = 100000;
    const int num_threads = std::thread::hardware_concurrency(); // ex: 4, 8, etc.
    ThreadPool pool(num_threads);
    std::atomic<int> counter{0};
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < num_tasks; ++i) {
        pool.enqueue([&counter]() {
            counter++;
        });
    }
    // Attendre que tout soit terminé (simple mais pas parfait)
    while (counter.load() < num_tasks) {
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
    }
    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> duration = end - start;
    double throughput = num_tasks / duration.count();
    std::cout << "Throughput avec thread pool (" << num_threads
              << " threads) : " << throughput << " tasks/sec" << std::endl;
    pool.shutdown();
    return 0;
}


Ce que fait ce programme :

  • Crée un thread pool avec n threads (selon ton CPU).
  • Lance 100 000 tâches dans la file du pool.
  • Attend que toutes soient terminées.
  • Affiche le throughput en tâches/sec.

Comparaison :

MéthodeThreads créés        Performance
1 thread par tâche100 000        Très lent, surcharge
Thread pool4-16        Rapide, scalable

Exemple : Thread Pool + mesure du throughput

#include <iostream>
#include <thread>
#include <vector>
#include <queue>
#include <functional>
#include <mutex>
#include <condition_variable>
#include <atomic>
#include <chrono>
class ThreadPool {
public:
    ThreadPool(size_t num_threads) {
        for (size_t i = 0; i < num_threads; ++i) {
            workers.emplace_back([this]() {
                while (true) {
                    std::function<void()> task;
                    {   // zone protégée
                        std::unique_lock<std::mutex> lock(queue_mutex);
                        condition.wait(lock, [this]() {
                            return stop || !tasks.empty();
                        });
                        if (stop && tasks.empty()) return;
                        task = std::move(tasks.front());
                        tasks.pop();
                    }
                    task();
                }
            });
        }
    }

    void enqueue(std::function<void()> task) {
        {
            std::lock_guard<std::mutex> lock(queue_mutex);
            tasks.push(task);
        }
        condition.notify_one();
    }
    void shutdown() {
        {
            std::lock_guard<std::mutex> lock(queue_mutex);
            stop = true;
        }
        condition.notify_all();
        for (auto& worker : workers) worker.join();
    }

    ~ThreadPool() {
        if (!stop)
            shutdown();
    }

private:
    std::vector<std::thread> workers;
    std::queue<std::function<void()>> tasks;
    std::mutex queue_mutex;
    std::condition_variable condition;
    bool stop = false;
};

// Mesure du throughput
int main() {
    const int num_tasks = 100000;
    const int num_threads = std::thread::hardware_concurrency(); // ex: 4, 8, etc.
    ThreadPool pool(num_threads);
    std::atomic<int> counter{0};
    auto start = std::chrono::high_resolution_clock::now();
    for (int i = 0; i < num_tasks; ++i) {
        pool.enqueue([&counter]() {
            counter++;
        });
    }
    // Attendre que tout soit terminé (simple mais pas parfait)
    while (counter.load() < num_tasks) {
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
    }
    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> duration = end - start;
    double throughput = num_tasks / duration.count();
    std::cout << "Throughput avec thread pool (" << num_threads
              << " threads) : " << throughput << " tasks/sec" << std::endl;
    pool.shutdown();
    return 0;
}

                 

       Ce que fait ce programme :

  • Crée un thread pool avec n threads (selon ton CPU).
  • Lance 100 000 tâches dans la file du pool.
  • Attend que toutes soient terminées.
  • Affiche le throughput en tâches/sec.

Comparaison :

Méthode                    Threads créés          Performance
1 thread par tâche                    100 000          Très lent, surcharge
Thread pool                    4-16          Rapide, scalable

Exemple complet : throughput avec coroutines asynchrones

#include <iostream>
#include <coroutine>
#include <chrono>
#include <thread>
#include <atomic>
#include <vector>

// Awaiter pour simuler un sleep non bloquant
struct SleepAwaiter {
    std::chrono::milliseconds duration;
    bool await_ready() const noexcept { return false; }
    void await_suspend(std::coroutine_handle<> handle) {
        std::jthread([handle, d = duration]() {
            std::this_thread::sleep_for(d);
            handle.resume();
        });
    }
    void await_resume() const noexcept {}
};

// Tâche de base retournée par la coroutine
struct Task {
    struct promise_type {
        Task get_return_object() { return {}; }
        std::suspend_never initial_suspend() noexcept { return {}; }
        std::suspend_never final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::terminate(); }
    };
};

// Coroutine asynchrone qui incrémente un compteur après un délai
Task delayed_task(std::atomic<int>& counter, int delay_ms) {
    co_await SleepAwaiter{std::chrono::milliseconds(delay_ms)};
    counter++;
}

int main() {
    const int num_tasks = 10000; // exemple raisonnable
    std::atomic<int> counter{0};
    auto start = std::chrono::high_resolution_clock::now();
    // Lancer toutes les coroutines
    for (int i = 0; i < num_tasks; ++i) {
        delayed_task(counter, 1);  // délai minimal pour simuler non blocage
    }
    // Attendre que toutes les tâches soient terminées
    while (counter.load() < num_tasks) {
        std::this_thread::sleep_for(std::chrono::milliseconds(1));
    }
    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> duration = end - start;
    double throughput = num_tasks / duration.count();
    std::cout << "Throughput avec coroutines asynchrones : "
              << throughput << " tasks/sec" << std::endl;
    return 0;
}

Ce que tu vas voir :

  • Le programme lance 10 000 coroutines asynchrones.
  • Chacune attend 1 ms (sans bloquer un thread).
  • Le compteur s’incrémente quand la tâche est terminée.
  • Le throughput (tâches par seconde) est affiché.

Notes importantes :

  • Cette simulation utilise std::jthread pour gérer le timer dans SleepAwaiter.
  • En vrai, les coroutines s’intègrent avec des boucles d’événements (event loops) plus efficaces.
  • Ici, on a un très léger délai pour mieux simuler un travail « réel ».

OpenMP

OpenMP peut aider à optimiser la performance, surtout quand tu veux paralléliser des tâches CPU-bound (qui utilisent beaucoup le processeur) de façon simple et efficace.


Qu’est-ce que OpenMP ?

  • C’est une API pour faire de la programmation parallèle sur CPU.
  • Très utilisée en C/C++ (et Fortran).
  • Elle te permet de paralléliser des boucles ou des sections de code avec des directives (#pragma omp), sans gérer explicitement les threads.
  • OpenMP gère la création, la synchronisation et la répartition des threads automatiquement.

Exemple simple en OpenMP pour paralléliser une boucle de tâches :

#include <iostream>
#include <atomic>
#include <omp.h>

int main() {
    const int num_tasks = 100000;
    std::atomic<int> counter{0};
    #pragma omp parallel for
    for (int i = 0; i < num_tasks; ++i) {
        // Tâche CPU-bound simulée
        counter++;
    }
    std::cout << "Counter = " << counter << std::endl;
    return 0;
}

Comment OpenMP peut aider dans ton contexte ?

  • Si tes tâches sont simples et CPU-bound, OpenMP peut paralléliser la boucle rapidement et efficacement.
  • OpenMP peut optimiser l’utilisation des cœurs CPU sans surcharge importante.
  • Pour le throughput, ça peut augmenter le nombre de tâches traitées par seconde.

Limitations / points à garder en tête :

  • OpenMP est surtout adapté à des tâches synchrones et répétitives (boucles).
  • Pour des tâches asynchrones, d’attente ou d’I/O, OpenMP est moins adapté.
  • OpenMP ne gère pas nativement les coroutines ou les mécanismes asynchrones.
  • Si tu utilises des coroutines pour de l’async I/O, OpenMP n’aidera pas directement à optimiser ça.

En résumé :

Cas d’utilisation                                        OpenMP adapté ?
Paralléliser une boucle CPU                                        Oui, ideal
Optimiser un thread pool                                        Possible, mais moins flexible
Gestion d’async I/O/coroutines                                        Non, pas adapté

Combiner architecture, matériel et code efficacement

Les systèmes de trading temps réel ont des exigences extrêmes en termes de latence minimale et throughput maximal. Quelques points à mettre en place, en combinant architecture, hardware, et code.


1. Architecture logicielle

  • Pipeline ultra-optimisé en C++ avec un focus sur la faible latence et haute performance.
  • Utilisation de techniques lock-free et wait-free pour éviter les blocages.
  • Threads affinés (CPU pinning) : chaque thread dédié à une tâche spécifique, lié à un cœur précis.
  • Batching minimal : traiter les données au plus vite, en petites quantités.
  • Communication inter-thread par queues lock-free (ex: boost::lockfree::queue).
  • Utilisation de mémoires préallouées (pas d’allocation dynamique en temps réel).
  • Exploitation des SIMD (instructions vectorielles) pour accélérer les calculs.

2. Code et parallélisme

  • C++ moderne avec optimisations manuelles (inline, pragma vectorization).
  • Thread pools ou pipelines multi-threads spécialisés.
  • Exploiter les coroutines C++20 pour gérer les I/O sans bloquer.
  • Minimiser les appels système, éviter la contention sur les verrous.
  • Profiler pour optimiser les “hot paths”.

3. Hardware CPU & Mémoire

  • CPU : Intel Xeon (gamme haute fréquence, faible latence) ou AMD EPYC selon budget.
  • Prioriser la fréquence (ex: 3.8-4.0 GHz) plus que le nombre de cœurs pour la latence.
  • Hyper-threading désactivé pour éviter la contention.
  • Affinité CPU pour contrôler où s’exécutent les threads.
  • Mémoire DDR4/DDR5 très rapide, souvent avec ECC activé.
  • Préférer des caches L1/L2 larges et basés sur CPU ciblé.

4. Le GPU ?

  • En trading haute fréquence classique (HFT), le GPU est peu utilisé car la latence d’envoi/retour vers le GPU est trop élevée.
  • En revanche, pour certains calculs massivement parallèles (ex: pricing d’options, machine learning), le GPU peut aider.
  • Le GPU est efficace pour des tâches batchées, massivement parallèles, mais pas pour la prise de décision ultra rapide en millisecondes.
  • Certains systèmes hybrides utilisent GPU pour la recherche et CPU pour l’exécution.
  • Dans le chapitre suivant, on verra les avancées en termes d’accès mémoires qui pourrait changer la donne.

5. Autres points clés

  • Réseau ultra basse latence : carte réseau à faible latence (ex: Mellanox), kernel bypass (DPDK).
  • OS minimaliste, souvent Linux custom avec real-time kernel patches.
  • Surveillance hardware (température, fréquence, etc.) pour éviter throttling.
  • Co-localisation géographique proche des serveurs d’échanges.

Résumé rapide

AspectRecommandation
LangageC++ moderne, optimisé, lock-free
CPUXeon haute fréquence, affinité CPU
MémoireDDR4/DDR5 rapide, pré-allocation
ParallélismeThread pinning, queues lock-free
GPUUsage limité, surtout pour calcul batch
RéseauCarte low latency + kerne

Architecture pipeline simplifiée

[Network I/O]

[Message Parser / Validation]  (thread(s) dédiés)

[Market Data Handler / Strategy Logic] (thread(s) dédiés)

[Order Execution / Risk Check] (thread(s) dédiés)

[Network Send]


Points clés à implémenter :

  • Queues lock-free entre chaque étape, pour minimiser la latence.
  • Thread pinning pour fixer les threads sur des cœurs CPU précis.
  • Pré-allocation mémoire des messages.
  • Traitement sans allocations dynamiques pendant la phase critique.
  • Profiling et instrumentation pour mesurer les latences.

Exemple minimal en C++ : pipeline avec lock-free queue et pinning

Je vais utiliser une queue lock-free simple (un buffer circulaire basique) et pthread pour l’affinité CPU. Ce n’est pas complet mais illustre le principe.

#include <iostream>
#include <thread>
#include <atomic>
#include <vector>
#include <chrono>
#include <cstring>
#include <sched.h>
#include <unistd.h>

constexpr int BUFFER_SIZE = 1024;

struct Message {
    int id;
    char data[64];
};
class LockFreeQueue {
    Message buffer[BUFFER_SIZE];
    std::atomic<int> head{0};
    std::atomic<int> tail{0};
public:
    bool push(const Message& msg) {
        int current_tail = tail.load(std::memory_order_relaxed);
        int next_tail = (current_tail + 1) % BUFFER_SIZE;
        if (next_tail == head.load(std::memory_order_acquire)) return false; // queue full
        buffer[current_tail] = msg;
        tail.store(next_tail, std::memory_order_release);
        return true;
    }
    bool pop(Message& msg) {
        int current_head = head.load(std::memory_order_relaxed);
        if (current_head == tail.load(std::memory_order_acquire)) return false; // queue empty
        msg = buffer[current_head];
        head.store((current_head + 1) % BUFFER_SIZE, std::memory_order_release);
        return true;
    }
};
void pinThreadToCore(int core_id) {
    cpu_set_t cpuset;
    CPU_ZERO(&cpuset);
    CPU_SET(core_id, &cpuset);
    pthread_t current_thread = pthread_self();
    if (pthread_setaffinity_np(current_thread, sizeof(cpu_set_t), &cpuset) != 0) {
        std::cerr << "Failed to set thread affinity\n";
    }
}
LockFreeQueue net_to_parser_queue;
LockFreeQueue parser_to_strategy_queue;
void networkThread() {
    pinThreadToCore(0);
    int msg_id = 0;
    while (msg_id < 10000) {
        Message msg;
        msg.id = msg_id++;
        strcpy(msg.data, "MarketData");
        while (!net_to_parser_queue.push(msg)) {
            // queue full, spin or sleep briefly
            std::this_thread::yield();
        }
    }
}
void parserThread() {
    pinThreadToCore(1);
    Message msg;
    while (true) {
        if (net_to_parser_queue.pop(msg)) {
            // Simulate parsing & validation
            // Forward to strategy
            while (!parser_to_strategy_queue.push(msg)) {
                std::this_thread::yield();
            }
            if (msg.id >= 9999) break;
        } else {
            std::this_thread::yield();
        }
    }
}
void strategyThread() {
    pinThreadToCore(2);
    Message msg;
    int processed = 0;
    auto start = std::chrono::high_resolution_clock::now();
    while (true) {
        if (parser_to_strategy_queue.pop(msg)) {
            // Simulate strategy logic
            processed++;
            if (msg.id >= 9999) break;
        } else {
            std::this_thread::yield();
        }
    }
    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> dur = end - start;
    std::cout << "Processed " << processed << " messages in " << dur.count() << " seconds\n";
    std::cout << "Throughput: " << processed / dur.count() << " msgs/sec\n";
}
int main() {
    std::thread net_thread(networkThread);
    std::thread parse_thread(parserThread);
    std::thread strat_thread(strategyThread);
    net_thread.join();
    parse_thread.join();
    strat_thread.join();
    return 0;
}

Ce que montre cet exemple :

  • Chaque étape est un thread dédié avec affinité CPU.
  • Communication via des queues lock-free entre threads.
  • Pas d’allocation dynamique dans la boucle critique.
  • Mesure du throughput final.

Pour aller plus loin, comment intégrer :

  • Du profiling précis (ex: perf, VTune),
  • Des optimisations avec SIMD ou instructions spécifiques CPU,
  • Une architecture réseau ultra basse latence avec DPDK ou kernel bypass.

1. Profiling précis

Pour bien optimiser un système HFT, tu dois mesurer précisément où passent le temps CPU.

Outils recommandés :

  • Linux perf : pour analyser les CPU cycles, cache misses, branch mispredictions, etc.
  • Intel VTune Profiler : analyse détaillée du CPU, hot spots, contention, etc.
  • Google Benchmark ou des timers haute résolution dans le code (ex: std::chrono::high_resolution_clock).

Exemple rapide d’utilisation perf :

perf report

perf record -g ./ton_programme

Cela te montrera quelles fonctions prennent le plus de temps et où optimiser.


2. Optimisations SIMD

Utiliser les instructions vectorielles (AVX, SSE) peut accélérer beaucoup les calculs mathématiques.

Exemple simple avec AVX2 (intrinsics) :

#include <immintrin.h>
void vector_add(const float* a, const float* b, float* result, size_t n) {
    size_t i = 0;
    for (; i + 8 <= n; i += 8) {
        __m256 va = _mm256_loadu_ps(a + i);
        __m256 vb = _mm256_loadu_ps(b + i);
        __m256 vr = _mm256_add_ps(va, vb);
        _mm256_storeu_ps(result + i, vr);
    }
    for (; i < n; ++i) {
        result[i] = a[i] + b[i];
    }
}

3. Réseau ultra basse latence

Pour éviter la latence du kernel Linux, on peut utiliser :

  • DPDK (Data Plane Development Kit) : accès direct à la carte réseau depuis l’espace utilisateur.
  • PF_RING, netmap, ou XDP/eBPF comme alternatives.

Pourquoi ?

  • Kernel bypass : tu évites les appels système et la copie de paquets.
  • Traitement direct, en poll-mode, très rapide.
  • Exige du code spécifique et parfois des cartes réseaux compatibles.

4. Exemple basique de poll avec DPDK (concept)

// Pseudo-code simplifié, DPDK est très volumineux à installer
int main() {
    // Initialiser DPDK et les ports réseau
    // Configurer les RX queues en poll-mode
    while (true) {
        // Poll packets depuis la carte réseau (sans interruption)
        struct rte_mbuf *pkts_burst[32];
        uint16_t nb_rx = rte_eth_rx_burst(port_id, queue_id, pkts_burst, 32);
        for (int i = 0; i < nb_rx; ++i) {
            // Traitement ultra rapide
            // Libérer le buffer après traitement
            rte_pktmbuf_free(pkts_burst[i]);
        }
    }
}

5. Autres bonnes pratiques hardware/OS

  • CPU Turbo Boost désactivé pour fréquence stable.
  • Réduction du bruit sur la machine (processus non essentiels stoppés).
  • Huge pages pour la mémoire (réduit les TLB misses).
  • Temps réel Linux patché (PREEMPT_RT).
  • Isolation CPU via cset ou isolcpus dans le bootloader.

Conclusion

Le vrai défi, c’est de combiner toutes ces couches :

  • Code ultra optimisé,
  • Réseau ultra rapide,
  • Hardware adapté,
  • OS minimaliste configuré pour la latence.

Autres points, les gpu…

Les dernières générations de GPU NVIDIA, notamment avec CUDA, ont considérablement amélioré les capacités de transfert de données, notamment via :

  • GPUDirect RDMA : permet au GPU d’accéder directement à la mémoire des cartes réseau compatibles sans passer par le CPU.
  • GPUDirect Async DMA : transfert asynchrone des données entre GPU et périphériques externes (réseau, stockage) avec un minimum de latence.
  • NVLink et autres interconnexions rapides réduisent aussi la latence entre CPU et GPU.

Alors, pourquoi le GPU reste-t-il rarement utilisé en trading ultra basse latence ?

  1. Latence absolue vs débit :
    Même avec GPUDirect RDMA, la latence totale aller-retour (network → GPU → CPU ou ordre) est souvent plus élevée que ce que peut tolérer un système HFT ultra low latency (quelques microsecondes). Le GPU est plus adapté pour du throughput massif que de la latence minimale extrême.
  2. Complexité logicielle :
    Intégrer GPU dans une chaîne ultra optimisée demande une architecture plus complexe, avec synchronisation et gestion des buffers, ce qui peut introduire des retards.
  3. Nature des calculs :
    Le GPU excelle dans les calculs massivement parallèles (pricing, machine learning, simulation) mais pas dans la prise de décision ultra-rapide ou la gestion d’événements réseau.

Cas où le GPU devient intéressant en finance :

  • Calculs batchés et lourds (Monte-Carlo, pricing d’options, risk analytics)
  • Apprentissage automatique pour la détection de patterns, stratégies hors ligne
  • Pré-traitement massif de données avant de passer à la couche trading rapide

En résumé

  • GPUDirect RDMA et autres avancées réduisent grandement les transferts, mais le GPU reste limité par la latence globale (notamment pour les décisions en temps réel).
  • Pour les tasks ultra low latency, le CPU avec optimisation réseau reste la norme.
  • Le GPU est un excellent complément pour le calcul intensif, mais pas pour l’exécution des ordres en millisecondes.

Choix du hardware…

Pour un système trading ultra low latency & high throughput, le choix hardware est crucial.

Comparons rapidement certaines options :

1. Serveurs Xeon / AMD EPYC haute fréquence

  • Avantages :
    • CPU très performants, cores rapides avec gros caches L1/L2.
    • Fonctionnalités avancées (Intel TSX, AVX512, etc).
    • Support matériel solide pour réseau low-latency (PCIe Gen4, NICs haut de gamme).
    • Facilité d’optimisation (affinité CPU, gestion mémoire, etc).
    • OS et écosystème mature pour software HFT.
  • Inconvénients :
    • Coût élevé.
    • Consommation énergétique importante.
    • Moins facilement scalable horizontalement à très grande échelle.

2. Farms de microserveurs / Raspberry Pi / ARM

  • Avantages :
    • Très faible coût par unité.
    • Faible consommation électrique.
    • Scalabilité horizontale importante.
    • Parfait pour certains calculs parallèles ou systèmes de backtesting.
  • Inconvénients :
    • Fréquence CPU basse (~1.5-2 GHz), donc latence plus élevée.
    • Pas adaptés au temps réel ultra bas (latences réseau + CPU trop grandes).
    • Architecture ARM, parfois moins d’outils d’optimisation.
    • Complexité de synchronisation entre nœuds (réseau plus lent et variable).

3. Rig mining détourné

  • Des rigs mining GPU/ASIC sont pensés pour calculs massivement parallèles (hashing) mais ne sont pas conçus pour faible latence ou I/O rapide.
  • Peu adaptés au trading HFT temps réel.
  • Sont très efficaces pour des workloads batchés (cryptomining, ML offline).

Le choix optimal pour HFT / trading ultra rapide :

  • Serveurs Xeon ou EPYC à haute fréquence, optimisés pour latence, avec :
    • CPU core pinning et hyper-threading désactivé,
    • RAM rapide et pré-allouée,
    • Réseau ultra basse latence (Mellanox + DPDK),
    • OS Linux real-time minimaliste,
    • Logiciel C++ optimisé lock-free, vectorisé, et pipeline multi-thread.
  • Pour le scale out : clusters réduits à quelques serveurs ultra optimisés. Pas de fermes massives de petits nodes.
  • GPUs éventuellement en back-office pour calculs batchés (pricing, ML).

Pourquoi pas des fermes ARM / microserveurs ?

  • Le gain financier est souvent annulé par la latence réseau et la fréquence CPU trop basse.
  • La latence dans le trading se compte en microsecondes, ce qui demande un CPU rapide plus qu’un grand nombre de petits CPU lents.

Conclusion rapide

SolutionLatenceThroughputCoûtScalabilitéUsage recommandé
Serveurs Xeon/EPYCTrès faibleTrès élevéÉlevéMoyenneTrading HFT ultra basse latence
Farms ARM / Raspberry PiMoyenne/hauteMoyenFaibleTrès élevéeCalcul batch, backtesting
Rigs mining GPU/ASICÉlevéeTrès élevéVariableMoyenneCalcul batch, ML offline

Hardware dédié

Réaliser un hardware dédié pour accélérer les chose ? Une carte réseau dédiée,… ou autre ?

Concevoir un hardware dédié pour accélérer un système de trading ultra low latency, c’est la voie vers la perfection.

1. Carte réseau dédiée ultra basse latence

  • FPGA programmable sur la carte réseau (ex: Xilinx, Intel Altera) pour traiter directement les paquets à la volée, sans passer par le CPU.
  • Capable de faire du pré-traitement protocolaire (parsing FIX/FAST) en hardware.
  • Implémenter des règles ultra rapides de filtrage, enrichissement ou validation.
  • Timestamping matériel pour mesurer avec précision la latence.
  • Communication directe avec CPU via PCIe Gen4/Gen5.

2. Accelerators ASIC/FPGA personnalisés

  • Un FPGA ou ASIC dédié pour exécuter la logique trading la plus critique :
    • Décision ultra rapide (ex: algorithmes de market making simples).
    • Calculs fixes, pas de branches complexes.
    • Streaming data processing en temps réel.

3. Mémoire ultra rapide & partagée

  • Intégrer une mémoire HBM (High Bandwidth Memory) directement sur la carte pour stocker les données de marché, sans passer par la RAM du serveur.
  • Accès ultra rapide (microsecondes) à la mémoire par le FPGA/ASIC.

4. Interconnexion CPU-Hardware

  • PCIe Gen5 pour transfert rapide entre CPU et hardware dédié.
  • Possibilité de bypasser le CPU pour certaines décisions (trading sur FPGA seul).
  • Synchronisation via RDMA pour éviter les copies mémoire inutiles.

5. Architecture globale

[Marché] → [Carte réseau FPGA] → [FPGA accélérateur logique] ↔ [Mémoire HBM] → [CPU serveur] → [Exécution Ordres]


6. Exemple de cas d’usage

  • La carte réseau reçoit un ordre du marché → le FPGA analyse, filtre et applique la stratégie ultra rapide → si décision prise, elle est envoyée directement au moteur d’exécution en évitant la latence CPU.
  • Le CPU gère la stratégie complexe, la gestion des risques, l’interface utilisateur.

7. Avantages

  • Latence réduite à quelques centaines de nanosecondes.
  • Déchargement massif du CPU.
  • Fiabilité et répétabilité extrêmes (hardware dédié).

8. Inconvénients

  • Coût de développement très élevé.
  • Complexité du design et maintenance.
  • Difficulté à modifier la stratégie rapidement (FPGA moins flexible que CPU).

Conclusion

Pour le trading haute fréquence extrême, c’est souvent ce type d’architecture FPGA + CPU + carte réseau dédiée qui est utilisé par les plus gros acteurs (ex: Jump Trading, Jane Street).

Il existe déjà des cartes réseau dédiées ultra basse latence conçues spécialement pour les environnements exigeants comme le trading haute fréquence. Voici quelques exemples et caractéristiques clés :

Cartes réseau ultra basse latence existantes

  1. Mellanox (NVIDIA) ConnectX Series
    • Supporte RDMA, GPUDirect, Kernel Bypass (DPDK, RDMA)
    • Très faible latence (de l’ordre de 1-2 microsecondes)
    • Compatible PCIe Gen3/Gen4/Gen5
    • Intègre des fonctionnalités avancées comme le timestamping matériel, le filtrage et la classification des paquets.
  2. Solarflare (maintenant partie de Xilinx/AMD)
    • Spécialisée dans les cartes 10/25/40/100 GbE ultra basse latence
    • Supporte DPDK, kernel bypass, timestamping précis
    • Souvent utilisée en trading haute fréquence.
  3. Intel Ethernet 800 Series
    • Cartes réseau performantes avec prise en charge de fonctionnalités avancées pour la virtualisation et la faible latence.
  4. Netronome Agilio
    • Carte SmartNIC avec processeurs embarqués pour offload programmable des fonctions réseau et applicatives.

Cartes FPGA réseau dédiées

  • NetFPGA : plateforme FPGA open source pour développement d’accélération réseau (éducation et R&D).
  • Xilinx Alveo : cartes FPGA accélératrices qui peuvent être programmées pour du traitement réseau personnalisé (ex: parsing, filtrage ultra rapide).
  • Certaines solutions FPGA + NIC commerciales proposent un pipeline complet programmable en hardware.

Résumé

  • Des cartes réseau dédiées ultra basse latence existent et sont largement utilisées en finance et télécom.
  • Elles combinent souvent hardware programmable (FPGA/SmartNIC) avec support logiciel avancé (DPDK, RDMA).
  • Ces cartes réduisent drastiquement la latence d’entrée/sortie réseau, ce qui est crucial en trading haute fréquence.