Performance et tuning#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.colors as mcolors
import numpy as np
import seaborn as sns
import pandas as pd
import psutil
import os
import re

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

Méthodologie de diagnostic — méthode USE#

Avant de toucher à un seul paramètre noyau, il faut mesurer. La méthode USE (Utilization, Saturation, Errors), formalisée par Brendan Gregg, fournit un cadre systématique applicable à chaque ressource physique :

Ressource

Utilisation

Saturation

Erreurs

CPU

sar -u (% user+sys)

Load avg > nb_cœurs, runqueue r dans vmstat

perf stat hardware errors

Mémoire

free -h (% used)

Swap actif (si/so vmstat), OOM killer

Erreurs ECC mémoire

Disque

iostat %util

avgqu-sz > 1, await élevé

dmesg erreurs I/O

Réseau

sar -n DEV (% bande passante)

Drops (netstat -s)

Erreurs de trame

L’application systématique de la méthode USE évite le cargo-cult tuning : modifier des paramètres sans mesure préalable conduit presque toujours à des régressions non anticipées.

Charge moyenne — interprétation correcte#

La charge moyenne (load average) mesure le nombre moyen de processus en état R (runnable) ou D (uninterruptible sleep I/O). Elle est souvent mal interprétée :

load average: 3.20, 2.95, 2.81
             1 min  5 min 15 min
  • Sur un système à 4 cœurs : charge 3.2 à 1 min → 80 % de saturation, acceptable

  • Sur un système à 2 cœurs : charge 3.2 → saturation à 160 %, files d’attente

La tendance temporelle est aussi informative que la valeur absolue : une charge montant de 0.5 à 4.0 sur 15 minutes signale un problème émergent.

iowait dans le load average

Les processus en état D (uninterruptible I/O wait) contribuent au load average mais n’utilisent pas la CPU. Un load average élevé avec une CPU idle à 70 % et iowait à 25 % indique une saturation I/O, pas une saturation CPU — deux problèmes très différents avec des solutions différentes.


Profiling CPU#

perf stat — compteurs matériels#

perf stat interroge les Performance Monitoring Counters (PMC) du processeur, exposés par le noyau via le sous-système perf_events :

perf stat -e cycles,instructions,cache-misses,branch-misses ./mon-programme
 Performance counter stats for './mon-programme':

     2,345,678,901   cycles                    #    3.42 GHz
     1,890,234,567   instructions              #    0.81  insn per cycle
        45,234,100   cache-misses              #    2.45% of all cache refs
         3,456,789   branch-misses             #    1.23% of all branches

       0.686321123 seconds time elapsed

Un ratio instructions per cycle (IPC) < 1 indique des bulles de pipeline fréquentes, souvent dues à des cache-misses. Un taux de branch-misses > 5 % indique des prédictions de branchement défaillantes.

perf record/report — profiling par échantillonnage#

# Enregistrer un profil CPU pendant 30 secondes
perf record -g -F 1000 -p $(pgrep mon-app) -- sleep 30

# Analyser le profil
perf report --sort=symbol,dso

-g capture les call stacks, -F 1000 échantillonne à 1000 Hz. Le rapport montre les fonctions consommant le plus de cycles CPU, avec leur call graph complet.

Flamegraphs — visualisation des call stacks#

Un flamegraph représente les call stacks de manière hiérarchique : la largeur de chaque bloc est proportionnelle au temps CPU passé dans cette fonction. Les fonctions les plus larges en haut de la flamme sont les hotspots à optimiser.

# Avec les scripts de Brendan Gregg
perf record -F 99 -ag -- sleep 30
perf script | stackcollapse-perf.pl | flamegraph.pl > flamegraph.svg

strace et ltrace — traçage des appels système#

# Tracer les appels système d'un processus
strace -c -p 1234

# Résumé statistique
strace -c ./mon-programme 2>&1 | head -20
% time     seconds  usecs/call     calls    errors syscall
 45.23    0.001234          12       100           read
 23.11    0.000631           6       105           write
 15.44    0.000421         421         1           execve
  8.90    0.000243           4        60           mmap

ltrace fait de même pour les appels aux bibliothèques dynamiques (libssl, libc…).


I/O et stockage#

iotop — processus et I/O#

# Afficher les processus avec le plus d'I/O en temps réel
iotop -o -d 2

# Mode non-interactif pour la journalisation
iotop -b -o -n 5 -d 2
Total DISK READ:       12.34 M/s | Total DISK WRITE:      45.67 M/s
    TID  PRIO  USER  DISK READ  DISK WRITE  SWAPIN     IO>    COMMAND
   1234  be/4  mysql  10.23 M/s   2.34 M/s   0.00 %  78.23 % mysqld
   5678  be/4  www    0.00 B/s   43.33 M/s   0.00 %  12.45 % php-fpm

Schedulers I/O — choisir selon le workload#

Le noyau Linux propose plusieurs ordonnanceurs d’I/O pour la file de requêtes des blocs :

Scheduler

Description

Workload adapté

mq-deadline

Garantit des délais maximaux, faible latence

SSD, bases de données OLTP

kyber

Faible overhead, optimisé multi-queue NVMe

NVMe haute performance

bfq (Budget Fair Queueing)

Équité entre processus, latence interactive

Desktop, médias, HDD

none

Pas d’ordonnancement (FIFO)

Hyperviseurs (l’hôte ordonnance)

# Voir le scheduler actuel
cat /sys/block/sda/queue/scheduler

# Changer le scheduler
echo "mq-deadline" > /sys/block/nvme0n1/queue/scheduler

# Persistant via udev
cat /etc/udev/rules.d/60-scheduler.rules
# ACTION=="add|change", KERNEL=="nvme[0-9]*", ATTR{queue/scheduler}="kyber"

fio — benchmark I/O#

# Test de lecture séquentielle
fio --name=lecture_seq --filename=/tmp/test.fio \
    --rw=read --bs=1M --size=2G --numjobs=1 --runtime=30 \
    --ioengine=libaio --direct=1 --iodepth=32

# Test d'écriture aléatoire (4K)
fio --name=ecriture_rand --filename=/tmp/test.fio \
    --rw=randwrite --bs=4K --size=1G --numjobs=4 --runtime=30 \
    --ioengine=libaio --direct=1 --iodepth=64 --group_reporting
READ: bw=1241MiB/s (1301MB/s), io=2048MiB, run=1650msec
  iops        : min=1200, max=1280, avg=1241.34
  lat (msec)  : min=0.82, max=2.34, avg=0.97

Réseau — diagnostic et tuning#

ss — états TCP et buffers#

ss remplace netstat avec de meilleures performances (il lit directement les sockets du noyau via Netlink au lieu de /proc/net/tcp) :

# Toutes les sockets TCP établies avec informations étendues
ss -tipn

# Sockets en écoute avec les buffers
ss -tlnp

# Statistiques par état TCP
ss -s
Netid  State   Recv-Q  Send-Q   Local Address:Port    Peer Address:Port
tcp    ESTAB   0       0        10.0.0.1:443          203.0.113.5:52341 users:(("nginx",pid=1234))
       cubic wscale:7,7 rto:204 rtt:3.421/1.2 ato:40 mss:1448 rcvmss:1448 advmss:1448
       cwnd:10 bytes_acked:45234 bytes_received:1234 segs_out:456 segs_in:234

Les champs Recv-Q et Send-Q non nuls sur les sockets en écoute signalent que l’application ne consomme pas les connexions assez vite — un backlog saturé.

tcpdump — capture de paquets#

# Capturer le trafic HTTP sur eth0
tcpdump -i eth0 -w /tmp/capture.pcap port 80 or port 443

# Filtre BPF avancé : uniquement les SYN (nouvelles connexions)
tcpdump -i any 'tcp[tcpflags] & tcp-syn != 0 and tcp[tcpflags] & tcp-ack == 0'

# Afficher le contenu HTTP
tcpdump -i eth0 -A -s 0 port 80 | grep -E "Host:|GET |POST "

ethtool — configuration de l’interface#

# Voir les offloads actifs
ethtool -k eth0 | grep -E "scatter-gather|tcp-segmentation|generic-segmentation"

# Désactiver le GRO (Generic Receive Offload) — utile pour analyser des paquets
ethtool -K eth0 gro off

# Statistiques du pilote réseau
ethtool -S eth0 | grep -E "rx_missed|tx_dropped|rx_errors"

iperf3 — mesure de bande passante#

# Serveur
iperf3 -s

# Client — test TCP bidirectionnel sur 30 secondes
iperf3 -c 192.168.1.100 -t 30 --bidir

# Test UDP (mesure du jitter)
iperf3 -c 192.168.1.100 -u -b 100M

Mémoire — pages, NUMA et OOM killer#

Transparent HugePages (THP)#

Par défaut, Linux alloue la mémoire par pages de 4 KiB. Les Transparent HugePages (THP) regroupent 512 pages consécutives en une seule page de 2 MiB, réduisant la pression sur le TLB (Translation Lookaside Buffer).

# Voir l'état THP
cat /sys/kernel/mm/transparent_hugepage/enabled
# [always] madvise never

# Désactiver pour les bases de données (Oracle, MongoDB recommandent never)
echo never > /sys/kernel/mm/transparent_hugepage/enabled
echo never > /sys/kernel/mm/transparent_hugepage/defrag

THP et bases de données

Oracle, MongoDB, Redis et plusieurs autres bases de données recommandent de désactiver THP. Le compactage de mémoire déclenché par THP introduit des latences imprévisibles (pauses de 100ms+) incompatibles avec les SLAs OLTP. Pour les applications analytiques en mémoire (Spark, Hadoop), THP peut au contraire améliorer les performances.

NUMA — Non-Uniform Memory Access#

Sur les serveurs multi-sockets, chaque processeur a accès rapide à sa propre banque mémoire (NUMA node local) et accès plus lent à celle des autres processeurs :

# Voir la topologie NUMA
numactl --hardware

# Lier un processus à un nœud NUMA
numactl --cpunodebind=0 --membind=0 ./mon-app

# Statistiques NUMA
numastat -m

Un processus qui alloue de la mémoire sur un nœud NUMA distant peut subir des latences mémoire 2 à 3 fois supérieures.

OOM Killer — score et configuration#

Quand la mémoire physique est épuisée, le noyau active l”OOM Killer qui sélectionne et tue le processus avec le score le plus élevé :

# Voir le score OOM d'un processus
cat /proc/$(pgrep mon-app)/oom_score

# Protéger un processus critique (score ajustement -1000 = jamais tuer)
echo -1000 > /proc/$(pgrep sshd)/oom_score_adj

# Forcer le kill d'un processus par l'OOM (score +1000)
echo 1000 > /proc/$(pgrep gros-process)/oom_score_adj
# OOM killer dans les logs
dmesg | grep -i "oom\|killed process"
journalctl -k | grep "Out of memory"
[1234567.890] Out of memory: Kill process 4567 (java) score 892 or sacrifice child
[1234567.891] Killed process 4567 (java) total-vm:4096000kB, anon-rss:3800000kB

vm.swappiness#

# Valeur actuelle (défaut : 60)
sysctl vm.swappiness

# Réduire le swap pour les serveurs (10 = utiliser swap uniquement en dernier recours)
sysctl -w vm.swappiness=10

# Persistant
echo "vm.swappiness=10" >> /etc/sysctl.d/99-performance.conf

Tuning noyau — paramètres sysctl#

Paramètres réseau critiques#

# /etc/sysctl.d/99-network-tuning.conf

# Buffers TCP : [min défaut max] en octets
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216

# Backlog des sockets en écoute (SYN queue)
net.ipv4.tcp_max_syn_backlog = 8192

# File d'attente des connexions entrantes (SOMAXCONN)
net.core.somaxconn = 65535

# Réutilisation des sockets TIME_WAIT (serveurs à fort trafic)
net.ipv4.tcp_tw_reuse = 1

# Protection SYN flood
net.ipv4.tcp_syncookies = 1

# Nombre de ports éphémères disponibles
net.ipv4.ip_local_port_range = 1024 65535

Paramètres I/O et VM#

# Taille de la fenêtre de lecture anticipée (Ko) — adapté aux lectures séquentielles
blockdev --setra 4096 /dev/sda

# Via sysfs (persistant avec udev)
echo 4096 > /sys/block/sda/queue/read_ahead_kb

# Ratio de pages sales avant flush (défaut : 20%)
sysctl -w vm.dirty_ratio=15

# Ratio déclenchant le flush en arrière-plan (défaut : 10%)
sysctl -w vm.dirty_background_ratio=5

Appliquer les changements sysctl

sysctl -p /etc/sysctl.d/99-performance.conf applique immédiatement un fichier de configuration sans redémarrage. Pour valider sans appliquer, utiliser sysctl -n <paramètre> pour lire la valeur actuelle.


Courbe de charge simulée sur 24h#

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

np.random.seed(42)

# Simulation de charge sur 24h (mesures toutes les 5 minutes → 288 points)
t = np.linspace(0, 24, 288)

# Profil de base : creux la nuit, pics en journée
charge_base = (
    0.3 + 0.5 * np.sin(np.pi * (t - 6) / 12) * (t > 6) * (t < 22) +
    0.8 * np.exp(-0.5 * ((t - 10) / 1.5) ** 2) +
    1.2 * np.exp(-0.5 * ((t - 15) / 2.0) ** 2) +
    0.3 * np.random.normal(0, 0.1, len(t))
)
charge_base = np.clip(charge_base, 0.1, None)

# Ajouter un pic d'incident entre 16h30 et 17h30
incident_start, incident_end = int(16.5 * 12), int(17.5 * 12)
charge_base[incident_start:incident_end] += (
    3.5 * np.exp(-0.5 * ((np.linspace(0, 1, incident_end - incident_start) - 0.5) / 0.2) ** 2)
)

nb_coeurs = 4

fig, ax = plt.subplots(figsize=(14, 5))

# Zones colorées
ax.axhspan(0, nb_coeurs * 0.7, alpha=0.07, color="green",  label="Normal (< 70% capacité)")
ax.axhspan(nb_coeurs * 0.7, nb_coeurs, alpha=0.07, color="orange", label="Élevé (70-100%)")
ax.axhspan(nb_coeurs, nb_coeurs * 1.5, alpha=0.07, color="red",    label="Critique (> 100%)")
ax.axhspan(nb_coeurs * 1.5, 10, alpha=0.07, color="darkred")

ax.plot(t, charge_base, color=sns.color_palette("muted")[0], linewidth=1.8, label="Load average")
ax.axhline(y=nb_coeurs, color="red", linestyle="--", linewidth=1.5,
           label=f"Saturation CPU ({nb_coeurs} cœurs)")
ax.axhline(y=nb_coeurs * 0.7, color="orange", linestyle="--", linewidth=1.2,
           label="Seuil d'alerte (70%)")

# Annoter le pic d'incident
pic_idx = int(16.9 * 12)
ax.annotate("Incident\n16h54",
            xy=(t[pic_idx], charge_base[pic_idx]),
            xytext=(17.5, charge_base[pic_idx] + 0.8),
            arrowprops=dict(arrowstyle="->", color="red"),
            fontsize=10, color="red")

ax.set_xlim(0, 24)
ax.set_ylim(0, max(charge_base) * 1.15)
ax.set_xticks(range(0, 25, 2))
ax.set_xticklabels([f"{h:02d}h" for h in range(0, 25, 2)])
ax.set_xlabel("Heure de la journée")
ax.set_ylabel("Load average (1 min)")
ax.set_title(f"Charge système simulée sur 24h — serveur {nb_coeurs} cœurs")
ax.legend(loc="upper left", fontsize=9)
plt.show()
_images/143bcb403429310e6e85bee76162f2bdcdfe5fce347faea71201b4478c2d9934.png

Flamegraph simplifié#

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

# Simulation d'un flamegraph (barh empilé par frame de call stack)
# Inspiré de la structure réelle perf report

fonctions = [
    # (nom, parent_offset, durée_relative, profondeur)
    ("main()",            0.00, 1.00, 0),
    ("  http_serve()",    0.00, 0.82, 1),
    ("    parse_request()", 0.00, 0.35, 2),
    ("      malloc()",    0.00, 0.08, 3),
    ("      memcpy()",    0.08, 0.10, 3),
    ("      regex_match()", 0.18, 0.17, 3),
    ("    db_query()",    0.35, 0.40, 2),
    ("      pg_exec()",   0.35, 0.28, 3),
    ("        read()",    0.35, 0.14, 4),
    ("        write()",   0.49, 0.07, 4),
    ("      json_encode()", 0.63, 0.12, 3),
    ("  log_write()",     0.82, 0.10, 1),
    ("    write()",       0.82, 0.10, 2),
]

palette = sns.color_palette("muted", 5)
couleurs_profondeur = {0: palette[0], 1: palette[1], 2: palette[2],
                       3: palette[3], 4: palette[4]}

fig, ax = plt.subplots(figsize=(13, 6))

for i, (nom, debut, duree, profondeur) in enumerate(fonctions):
    barre = ax.barh(
        profondeur,
        duree,
        left=debut,
        height=0.7,
        color=couleurs_profondeur[profondeur],
        edgecolor="white",
        linewidth=0.8,
        align="center",
    )
    if duree > 0.05:
        nom_court = nom.strip()
        ax.text(debut + duree / 2, profondeur, nom_court,
                ha="center", va="center", fontsize=8.5,
                color="white" if profondeur < 3 else "black",
                fontweight="bold")

ax.set_yticks(range(5))
ax.set_yticklabels(["Niveau 0\n(main)", "Niveau 1", "Niveau 2",
                    "Niveau 3", "Niveau 4"])
ax.set_xlim(0, 1)
ax.set_xlabel("Fraction du temps CPU (largeur ∝ temps passé)")
ax.set_title("Flamegraph simplifié — profil CPU d'un serveur HTTP")

# Légende des hotspots
ax.annotate("Hotspot : db_query (40%)",
            xy=(0.55, 2), xytext=(0.70, 3.5),
            arrowprops=dict(arrowstyle="->"),
            fontsize=9, color="red")
plt.show()
_images/93a3bce75cea3242a47f97cecd8cd79daa1c2dce6406f44456102d761dedefd0.png

Mémoire — parse de /proc/meminfo#

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

def lire_meminfo():
    meminfo = {}
    try:
        with open("/proc/meminfo") as f:
            for ligne in f:
                m = re.match(r"^(\w+):\s+(\d+)", ligne)
                if m:
                    meminfo[m.group(1)] = int(m.group(2))  # valeur en kB
    except FileNotFoundError:
        pass
    return meminfo

mem = lire_meminfo()

if mem:
    total     = mem.get("MemTotal", 0)
    free      = mem.get("MemFree", 0)
    available = mem.get("MemAvailable", 0)
    buffers   = mem.get("Buffers", 0)
    cached    = mem.get("Cached", 0)
    shmem     = mem.get("Shmem", 0)
    slab_rec  = mem.get("SReclaimable", 0)
    slab_unr  = mem.get("SUnreclaim", 0)
    anon      = mem.get("AnonPages", 0)
    mapped    = mem.get("Mapped", 0)
    swap_total= mem.get("SwapTotal", 0)
    swap_free = mem.get("SwapFree", 0)
    swap_used = swap_total - swap_free
    dirty     = mem.get("Dirty", 0)

    def ko_vers_gib(v):
        return v / 1024**2

    categories = {
        "Anon (app)"  : anon,
        "Cache page"  : cached,
        "Buffers"     : buffers,
        "Slab récup." : slab_rec,
        "Slab irréc." : slab_unr,
        "Partagée"    : shmem,
        "Libre"       : free,
        "Autre"       : max(0, total - anon - cached - buffers - slab_rec - slab_unr - shmem - free),
    }

    fig, axes = plt.subplots(1, 2, figsize=(13, 5))

    # Graphe en barres empilées horizontal
    couleurs = sns.color_palette("muted", len(categories))
    valeurs_gib = [ko_vers_gib(v) for v in categories.values()]
    labels_cat  = list(categories.keys())

    gauche = 0
    for val, nom, coul in zip(valeurs_gib, labels_cat, couleurs):
        if val > 0:
            barre = axes[0].barh(0, val, left=gauche, height=0.5, color=coul, label=nom)
            if val > 0.2:
                axes[0].text(gauche + val / 2, 0, f"{val:.1f}G",
                             ha="center", va="center", fontsize=8, color="white", fontweight="bold")
            gauche += val

    axes[0].set_xlim(0, ko_vers_gib(total))
    axes[0].set_yticks([])
    axes[0].set_xlabel("GiB")
    axes[0].set_title(f"Répartition RAM — {ko_vers_gib(total):.1f} GiB total")
    axes[0].legend(loc="upper right", bbox_to_anchor=(1, 1.3),
                   ncol=2, fontsize=8)

    # Indicateurs clés
    indicateurs = {
        "Total" : ko_vers_gib(total),
        "Disponible" : ko_vers_gib(available),
        "Utilisée (app)" : ko_vers_gib(anon + shmem),
        "Cache+Buf" : ko_vers_gib(cached + buffers + slab_rec),
        "Swap utilisé" : ko_vers_gib(swap_used),
        "Pages sales" : ko_vers_gib(dirty),
    }

    noms_ind = list(indicateurs.keys())
    vals_ind  = list(indicateurs.values())
    barres_ind = axes[1].bar(range(len(noms_ind)), vals_ind,
                              color=sns.color_palette("muted", len(noms_ind)))
    axes[1].set_xticks(range(len(noms_ind)))
    axes[1].set_xticklabels(noms_ind, rotation=25, ha="right", fontsize=9)
    axes[1].set_ylabel("GiB")
    axes[1].set_title("Indicateurs mémoire clés")
    for b, v in zip(barres_ind, vals_ind):
        if v > 0.01:
            axes[1].text(b.get_x() + b.get_width()/2, v + 0.02,
                         f"{v:.2f}G", ha="center", va="bottom", fontsize=8)

    plt.suptitle("Hiérarchie mémoire — /proc/meminfo", fontsize=13, fontweight="bold")
    plt.show()
    print(f"MemAvailable : {ko_vers_gib(available):.2f} GiB  |  Swap utilisé : {ko_vers_gib(swap_used):.2f} GiB")
else:
    print("/proc/meminfo non disponible sur ce système.")
_images/079033475a52b4490fb3ca764d2d9e110ca066ec6a2678231fdfb548ce21817c.png
MemAvailable : 2.46 GiB  |  Swap utilisé : 2.45 GiB

Modélisation — Little’s Law et schedulers I/O#

Little’s Law pour le dimensionnement#

La loi de Little est un résultat fondamental de la théorie des files d’attente :

N = λ × W

  • N : nombre moyen de requêtes dans le système (en cours de traitement + en attente)

  • λ : débit d’arrivée (requêtes par seconde)

  • W : temps moyen de séjour dans le système (traitement + attente)

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

# Modélisation Little's Law : N = λ × W
# Scénario : serveur web avec temps de traitement moyen W(λ)
# W augmente avec la charge (contention des ressources)

lambda_max = 1000  # requêtes/s maximales avant saturation
lambda_arr = np.linspace(1, lambda_max, 300)

# Temps de réponse W(λ) selon le modèle M/M/1 : W = 1/(μ-λ) avec μ=taux de service
mu = 1100  # capacité de service (req/s)
W_mm1 = 1 / (mu - lambda_arr)  # modèle M/M/1 pur

# Modèle plus réaliste avec saturation douce
W_reel = 0.002 + 0.001 * (lambda_arr / 400) ** 2.5

N_mm1 = lambda_arr * W_mm1
N_reel = lambda_arr * W_reel

fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# Temps de réponse W vs λ
axes[0].plot(lambda_arr, W_mm1 * 1000, label="M/M/1 théorique",
             color=sns.color_palette("muted")[0], linewidth=2)
axes[0].plot(lambda_arr, W_reel * 1000, label="Modèle réel (saturation douce)",
             color=sns.color_palette("muted")[1], linewidth=2)
axes[0].axvline(x=lambda_max * 0.7, color="orange", linestyle="--",
                label="Seuil alerte (70% capacité)")
axes[0].axvline(x=mu * 0.95, color="red", linestyle="--",
                label="Saturation (95% capacité)")
axes[0].set_xlim(0, lambda_max)
axes[0].set_ylim(0, 80)
axes[0].set_xlabel("Débit d'arrivée λ (req/s)")
axes[0].set_ylabel("Temps de réponse W (ms)")
axes[0].set_title("Temps de réponse vs charge")
axes[0].legend(fontsize=8)

# N = λ × W (concurrence)
axes[1].plot(lambda_arr, N_mm1, label="M/M/1 : N = λ × W",
             color=sns.color_palette("muted")[0], linewidth=2)
axes[1].plot(lambda_arr, N_reel, label="Modèle réel",
             color=sns.color_palette("muted")[1], linewidth=2)
axes[1].fill_between(lambda_arr, N_reel, alpha=0.15,
                      color=sns.color_palette("muted")[1])
axes[1].set_xlim(0, lambda_max)
axes[1].set_ylim(0, min(200, max(N_reel) * 1.2))
axes[1].set_xlabel("Débit d'arrivée λ (req/s)")
axes[1].set_ylabel("Requêtes simultanées N")
axes[1].set_title("Little's Law : N = λ × W")
axes[1].legend(fontsize=8)

plt.suptitle("Modélisation des performances — Loi de Little", fontsize=13, fontweight="bold")
plt.show()
_images/e5becbfac06e9f805644fe8f99c53440a28b9cb93b21cfa3b4e2e672ee0a6344.png

Comparaison des schedulers I/O#

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

schedulers = ["mq-deadline", "kyber", "bfq", "none (passthrough)"]

# Données simulées issues de benchmarks typiques (fio sur NVMe/SSD/HDD)
# Latence en µs pour requêtes 4K aléatoires
latence_nvme = [95,  80,  150, 70]
latence_ssd  = [180, 220, 200, 300]
latence_hdd  = [8000, 12000, 5500, 15000]

# Débit séquentiel relatif (% du max)
debit_nvme = [98, 100, 90, 100]
debit_ssd  = [97, 95,  88, 100]
debit_hdd  = [99, 92,  85, 100]

x = np.arange(len(schedulers))
largeur = 0.25

fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# Latence 4K random (NVMe et SSD — HDD sur axe secondaire séparé)
barres1 = axes[0].bar(x - largeur, latence_nvme, largeur,
                       label="NVMe (µs)", color=sns.color_palette("muted")[0])
barres2 = axes[0].bar(x,            latence_ssd,  largeur,
                       label="SSD (µs)",  color=sns.color_palette("muted")[1])
axes[0].set_xticks(x)
axes[0].set_xticklabels(schedulers, rotation=15, ha="right", fontsize=9)
axes[0].set_ylabel("Latence 4K random (µs)")
axes[0].set_title("Latence I/O — requêtes 4K aléatoires")
axes[0].legend()
for b, v in zip(barres1, latence_nvme):
    axes[0].text(b.get_x() + b.get_width()/2, v + 1, f"{v}", ha="center", va="bottom", fontsize=8)
for b, v in zip(barres2, latence_ssd):
    axes[0].text(b.get_x() + b.get_width()/2, v + 2, f"{v}", ha="center", va="bottom", fontsize=8)

# Débit séquentiel relatif
barres3 = axes[1].bar(x - largeur, debit_nvme, largeur,
                       label="NVMe", color=sns.color_palette("muted")[0])
barres4 = axes[1].bar(x,            debit_ssd,  largeur,
                       label="SSD",  color=sns.color_palette("muted")[1])
barres5 = axes[1].bar(x + largeur, debit_hdd,  largeur,
                       label="HDD",  color=sns.color_palette("muted")[2])
axes[1].set_xticks(x)
axes[1].set_xticklabels(schedulers, rotation=15, ha="right", fontsize=9)
axes[1].set_ylabel("Débit séquentiel (% du maximum)")
axes[1].set_ylim(0, 115)
axes[1].set_title("Débit séquentiel relatif")
axes[1].legend()

plt.suptitle("Comparaison des schedulers I/O Linux", fontsize=13, fontweight="bold")
plt.show()

# Tableau synthétique
df_sched = pd.DataFrame({
    "Scheduler"   : schedulers,
    "Latence NVMe (µs)" : latence_nvme,
    "Latence SSD (µs)"  : latence_ssd,
    "Latence HDD (µs)"  : latence_hdd,
    "Workload optimal"  : [
        "OLTP, bases de données",
        "NVMe haute performance",
        "Desktop, usage mixte",
        "VM (délégation à l'hôte)",
    ],
})
print(df_sched.to_string(index=False))
_images/edb4e82af12fe9e93c8ee01c714e063e5b5f05ddd6471e4964628ff1409ab916.png
         Scheduler  Latence NVMe (µs)  Latence SSD (µs)  Latence HDD (µs)         Workload optimal
       mq-deadline                 95               180              8000   OLTP, bases de données
             kyber                 80               220             12000   NVMe haute performance
               bfq                150               200              5500     Desktop, usage mixte
none (passthrough)                 70               300             15000 VM (délégation à l'hôte)

Profiling applicatif et benchmarking#

/usr/bin/time -v — profil d’exécution complet#

/usr/bin/time -v ./mon-programme argument1
        Command being timed: "./mon-programme argument1"
        User time (seconds): 4.23
        System time (seconds): 0.18
        Percent of CPU this job got: 98%
        Elapsed (wall clock) time: 4.52
        Maximum resident set size (kbytes): 524288
        Major (requiring I/O) page faults: 0
        Minor (reclaiming a frame) page faults: 12345
        Voluntary context switches: 234
        Involuntary context switches: 89
        File system inputs: 0
        File system outputs: 8192

Les major page faults (> 0) indiquent que le programme a dû charger des pages depuis le disque — un signe que son working set dépasse la RAM disponible.

cProfile Python vs profiling système#

# Profiler un script Python avec cProfile
python3 -m cProfile -s cumulative mon_script.py | head -20

# Avec sortie vers fichier pour analyse avec snakeviz
python3 -m cProfile -o profile.pstats mon_script.py
snakeviz profile.pstats

La différence entre cProfile (profiling au niveau Python, bytecode) et perf (profiling noyau, instructions machine) est fondamentale : cProfile ne voit pas le temps passé dans les extensions C (numpy, lxml, cryptographie) tandis que perf peut capturer le profil complet incluant les bibliothèques natives.

sysbench et stress-ng — benchmarking#

# Benchmark CPU (calcul de nombres premiers)
sysbench cpu --cpu-max-prime=20000 --threads=4 run

# Benchmark mémoire (débit de lecture/écriture)
sysbench memory --memory-block-size=1K --memory-total-size=10G run

# Benchmark I/O
sysbench fileio --file-total-size=2G prepare
sysbench fileio --file-total-size=2G --file-test-mode=rndrw --threads=4 run
sysbench fileio --file-total-size=2G cleanup

# stress-ng : générer une charge contrôlée pour tester la stabilité
stress-ng --cpu 4 --vm 2 --vm-bytes 1G --io 2 --timeout 60s --metrics

Protocole de mesure rigoureux#

Éviter les pièges de mesure

Répétitions : effectuer au minimum 5 à 10 mesures et utiliser la médiane (pas la moyenne) pour éliminer les valeurs aberrantes liées à des interruptions système.

Isolation : désactiver le scaling de fréquence CPU (cpupower frequency-set -g performance), s’assurer qu’aucun autre workload intensif ne tourne en parallèle, vider les caches disque (echo 3 > /proc/sys/vm/drop_caches) avant les benchmarks I/O.

Warmup : inclure une phase d’échauffement (1-2 itérations non mesurées) pour que les caches TLB, CPU et disque soient dans un état stable représentatif des conditions de production.

Réplicabilité : noter l’état complet du système (version noyau, paramètres sysctl, schedulers, topologie NUMA, gouverneur CPU) pour que les résultats puissent être reproduits.


Résumé#

Le tuning Linux est une discipline empirique : chaque optimisation doit être précédée d’une mesure, suivie d’une validation, et documentée. La méthode USE fournit le cadre systématique pour éviter de se concentrer sur la mauvaise ressource.

Points à retenir :

  • La méthode USE (Utilisation/Saturation/Erreurs) s’applique à chaque ressource physique et évite le diagnostic par intuition.

  • perf est l’outil de référence pour le profiling CPU à bas niveau ; les flamegraphs rendent les résultats lisibles par tous.

  • Les schedulers I/O doivent être sélectionnés selon le type de stockage et le profil de charge : mq-deadline pour les bases de données, bfq pour les usages interactifs.

  • Les paramètres sysctl réseau (tcp_rmem/wmem, somaxconn) sont les plus impactants sur des serveurs à fort trafic HTTP.

  • THP améliore les performances analytiques mais dégrade les bases de données transactionnelles — configurer explicitement selon le workload.

  • La loi de Little (N = λ × W) permet de dimensionner correctement les ressources sans sur-provisionner.

  • Tout benchmark sans protocole rigoureux (répétitions, isolation, warmup) produit des résultats non reproductibles et donc inexploitables.

Axe de tuning

Outil de mesure

Levier principal

CPU

perf stat, sar -u

Affinité, priorité nice/RT

Mémoire

/proc/meminfo, numastat

swappiness, NUMA binding, THP

I/O disque

iostat -x, fio

Scheduler I/O, read_ahead, direct I/O

Réseau

ss, iperf3, ethtool

tcp_rmem/wmem, somaxconn, offloads

Applicatif

perf record, cProfile

Algorithmes, pool de connexions