Dimensionnez un système à plusieurs serveurs
Le trafic de votre API a triplé : elle reçoit maintenant 24 requêtes par seconde. Un serveur ne suffit plus, il en faut plusieurs. Mais combien ? Et vaut-il mieux trois serveurs indépendants avec chacun sa file, trois serveurs qui partagent une file commune, ou une seule machine trois fois plus puissante ?
Ce chapitre étend la file M/M/1 à plusieurs serveurs. Vous y trouverez les deux formules que tout ingénieur réseau connaît, Erlang C pour les systèmes où l’on attend et Erlang B pour ceux où l’on refuse les clients, et vous apprendrez à choisir un nombre de serveurs. Nous suivrons l’API à trois répliques, puis un cluster de GPU qui refuse les tâches quand il est plein.
La file M/M/s : plusieurs serveurs, une file commune
Section intitulée « La file M/M/s : plusieurs serveurs, une file commune »Dans une file M/M/s, les clients arrivent selon un Poisson de taux et patientent dans une file unique ; dès qu’un des serveurs se libère, il prend le premier client en attente. Chaque service dure un temps exponentiel de taux .
Le nombre de clients présents est encore un processus de naissance et de mort. Les arrivées ont toujours lieu au taux . Pour les départs, tout dépend du nombre de serveurs occupés : avec clients présents, serveurs travaillent, et le premier service se termine au taux
Deux notations reviennent sans cesse :
- est le trafic offert, exprimé en erlangs : le nombre moyen de serveurs qu’il faudrait occuper en permanence pour absorber la demande ;
- est l’intensité de trafic par serveur, c’est-à-dire le taux d’occupation moyen de chaque serveur.
La formule du chapitre 6 donne les :
Tant que , chaque nouveau client trouve un serveur libre ; au-delà, on retombe sur une géométrique de raison . La série converge si et seulement si , et la normalisation donne
Erlang C : la probabilité d’attendre
Section intitulée « Erlang C : la probabilité d’attendre »La question pratique est : quelle proportion de clients doit attendre ? Un client attend s’il trouve les serveurs occupés, c’est-à-dire au moins clients présents. En sommant la géométrique, on obtient la formule d’Erlang C :
On en déduit tous les indicateurs :
- nombre moyen de clients en attente : ;
- nombre moyen de clients en service : (autant de serveurs occupés, en moyenne, que le trafic offert) ;
- temps moyen d’attente, par la loi de Little : ;
- temps moyen de séjour : .
Mutualiser ou grossir ? Trois architectures comparées
Section intitulée « Mutualiser ou grossir ? Trois architectures comparées »Revenons à la question de l’introduction. Pour le même trafic () et la même capacité totale (30 requêtes par seconde), comparons trois architectures, toutes chargées à 80 % :
| Architecture | Modèle | Attente moyenne | Séjour moyen |
|---|---|---|---|
| 3 serveurs, chacun sa file (trafic réparti au hasard) | 3 × M/M/1 (, ) | 0,400 s | 0,500 s |
| 3 serveurs, une file commune | M/M/3 (, ) | 0,108 s | 0,208 s |
| 1 serveur trois fois plus rapide | M/M/1 (, ) | 0,133 s | 0,167 s |
Pour la première ligne, la décomposition d’un Poisson (chapitre 7) donne à chaque serveur un Poisson de taux 8, et on retrouve l’API du chapitre 8.
Deux leçons en ressortent :
- Mutualiser la file est un gain énorme : le temps de séjour est divisé par plus de deux. Avec des files séparées, un serveur peut rester inoccupé pendant qu’une requête attend devant son voisin ; avec une file commune, jamais. C’est pour cela que les bureaux de poste et les répartiteurs de charge (load balancers) utilisent une file unique, ou au moins envoient la requête au serveur le moins chargé.
- Un serveur rapide bat plusieurs serveurs lents en temps de séjour, parce que chaque service est trois fois plus court. En revanche, on y attend un peu plus, et une panne arrête tout. Le choix final dépend donc aussi de la tolérance aux pannes.
M/M/∞ : chacun son serveur
Section intitulée « M/M/∞ : chacun son serveur »À la limite , il y a toujours un serveur libre : personne n’attend jamais. On a et , et le chapitre 6 nous a déjà donné la réponse :
Le nombre de clients présents suit une loi de Poisson de paramètre , et le régime stationnaire existe toujours. Ce modèle décrit les situations où chacun « se sert lui-même » : les sessions ouvertes sur un site web, les voitures sur une portion d’autoroute fluide, les utilisateurs connectés à un service de visioconférence. Si 2 utilisateurs se connectent par minute et restent en moyenne 15 minutes, le nombre de sessions simultanées suit une loi de Poisson de moyenne 30 et d’écart-type ; la probabilité de dépasser 40 sessions simultanées vaut environ 3 %.
M/M/s/s : le système à perte et la formule d’Erlang B
Section intitulée « M/M/s/s : le système à perte et la formule d’Erlang B »Changeons de décor. Un cluster de calcul possède GPU. Chaque tâche en occupe un ; si tous sont occupés, la tâche est refusée (l’utilisateur devra la resoumettre plus tard). Il n’y a aucune file d’attente : la capacité totale est égale au nombre de serveurs. C’est la file M/M/s/s, dite système à perte. C’est aussi le modèle historique des centraux téléphoniques : quand toutes les lignes sont prises, l’appel est rejeté.
Le nombre de tâches en cours est un processus de naissance et de mort sur , avec pour et . C’est la M/M/∞ tronquée à :
L’espace étant fini, le régime stationnaire existe toujours, même si . La grandeur clé est la probabilité de perte, la probabilité qu’une tâche arrive quand les GPU sont occupés. C’est la formule d’Erlang B :
En pratique, on calcule Erlang B par une récurrence numériquement stable, qui évite les grandes factorielles :
import math
def erlang_b(s, u): """Probabilité de perte d'une M/M/s/s de trafic offert u (récurrence stable).""" b = 1.0 for k in range(1, s + 1): b = u * b / (k + u * b) return b
def erlang_c(s, u): """Probabilité d'attendre dans une M/M/s (exige u < s).""" rho = u / s b = erlang_b(s, u) return b / (1 - rho + rho * b) # lien classique entre Erlang B et C
def mms(lam, mu, s): """Indicateurs d'une M/M/s : P(attendre), attente et séjour moyens.""" u = lam / mu c = erlang_c(s, u) nq = (u / s) * c / (1 - u / s) wq = nq / lam # loi de Little sur la salle d'attente return c, wq, wq + 1 / mu
print(mms(24, 10, 3)) # ≈ (0.647, 0.108, 0.208)print([round(erlang_b(s, 2.4), 3) for s in range(3, 8)])# [0.268, 0.139, 0.062, 0.024, 0.008]
# Plus petit nombre de GPU pour moins de 1 % de refuss = 1while erlang_b(s, 2.4) > 0.01: s += 1print(s) # 7En résumé
Section intitulée « En résumé »- M/M/s : , ; trafic offert , intensité ; stable si .
- Erlang C : est la probabilité d’attendre ; , serveurs occupés en moyenne, puis Little pour et .
- À capacité égale, une file commune à serveurs est bien meilleure que files séparées ; un serveur unique plus rapide donne un séjour plus court mais concentre le risque de panne.
- M/M/∞ : personne n’attend, le nombre de clients suit une loi de Poisson de paramètre .
- M/M/s/s (système à perte, sans attente) : la probabilité de refus est donnée par Erlang B, , qu’on calcule par récurrence.
Exercices
Section intitulée « Exercices »Exercice · Un standard téléphonique
facileUn petit standard dispose de 3 lignes. Les appels arrivent au rythme de 4 par heure et durent en moyenne 30 minutes. Un appel qui arrive quand les trois lignes sont occupées est perdu. Quelle proportion d’appels est perdue ? Combien de lignes sont occupées en moyenne ?
Voir la solution
C’est une M/M/3/3 de trafic offert erlangs. Par la formule d’Erlang B :
Environ 21 % des appels sont perdus. Les appels acceptés arrivent au taux par heure et durent 0,5 h : par Little, le nombre moyen de lignes occupées vaut .
En code
def erlang_b(s, u): b = 1.0 for k in range(1, s + 1): b = u * b / (k + u * b) return b
lam, duree = 4, 0.5 # appels par heure, durée moyenne (h)u = lam * dureeB = erlang_b(3, u)print(B, 4 / 19) # 0.2105 0.2105 : appels perdusprint(lam * (1 - B) * duree) # 1.579 lignes occupées (loi de Little)erlang_b <- function(s, u) { b <- 1 for (k in seq_len(s)) b <- u * b / (k + u * b) b}lam <- 4; duree <- 0.5 # appels par heure, durée moyenne (h)u <- lam * dureeB <- erlang_b(3, u)c(B, 4 / 19) # 0.2105 0.2105 : appels perduslam * (1 - B) * duree # 1.579 lignes occupées (loi de Little)Exercice · Deux serveurs lents ou un rapide ?
moyenUn service reçoit requêtes par seconde. On hésite entre deux serveurs de taux partageant une file commune (M/M/2) et un seul serveur de taux (M/M/1).
- Vérifiez que les deux configurations ont la même charge.
- Pour la M/M/2, calculez , la probabilité d’attendre, l’attente moyenne et le séjour moyen.
- Comparez avec la M/M/1 et concluez.
Voir la solution
- M/M/2 : . M/M/1 : . Même charge.
- . . Erlang C : . , donc s et s.
- La M/M/1 donne s et s. On attend un peu moins avec deux serveurs lents, mais le séjour total est plus long, car chaque service dure 0,2 s au lieu de 0,1 s. Si seul le temps de réponse compte, le serveur rapide l’emporte ; si la disponibilité en cas de panne compte, deux serveurs sont plus sûrs.
En code
import math
def mms(lam, mu, s): """P0, P(attendre), attente et séjour moyens d'une M/M/s.""" u, rho = lam / mu, lam / (s * mu) queue = u**s / (math.factorial(s) * (1 - rho)) p0 = 1 / (sum(u**n / math.factorial(n) for n in range(s)) + queue) c = queue * p0 # Erlang C wq = rho * c / (1 - rho) / lam # Little sur la salle d'attente return p0, c, wq, wq + 1 / mu
print([round(x, 3) for x in mms(8, 5, 2)]) # [0.111, 0.711, 0.356, 0.556]print([round(x, 3) for x in mms(8, 10, 1)]) # [0.2, 0.8, 0.4, 0.5]mms <- function(lam, mu, s) { # P0, P(attendre), attente et séjour moyens u <- lam / mu; rho <- lam / (s * mu) file <- u^s / (factorial(s) * (1 - rho)) p0 <- 1 / (sum(u^(0:(s - 1)) / factorial(0:(s - 1))) + file) c_attente <- file * p0 # Erlang C wq <- rho * c_attente / (1 - rho) / lam # Little sur la salle d'attente round(c(p0 = p0, C = c_attente, Wq = wq, W = wq + 1 / mu), 3)}mms(8, 5, 2) # p0 0.111 C 0.711 Wq 0.356 W 0.556mms(8, 10, 1) # p0 0.2 C 0.8 Wq 0.4 W 0.5Exercice · Un tampon vidé d'un seul coup
difficileUn capteur envoie des paquets vers un tampon de capacité paquets, selon un Poisson de taux paquets par seconde. Le tampon est vidé entièrement, d’un seul coup, à des instants qui forment un Poisson de taux . Un paquet qui arrive quand le tampon est plein est perdu.
- Le nombre de paquets dans le tampon est-il un processus de naissance et de mort ? Écrivez les taux de transition.
- Montrez que pour et .
- Quel taux de vidage faut-il pour perdre moins d’un paquet sur mille ?
- Si le tampon était vidé périodiquement, toutes les secondes exactement, le modèle resterait-il markovien ?
Voir la solution
- Non : un vidage fait passer de à 0 d’un coup, pas à . C’est bien une chaîne de Markov à temps continu, mais pas un processus de naissance et de mort. Les taux sont au taux (pour ) et au taux (pour ).
- Balance globale de l’état avec : on en sort au taux et on y entre seulement depuis , donc , et . Pour l’état : . Pour l’état 0 : (tous les autres états se vident vers 0), donc . On obtient la formule annoncée pour , et .
- Par les arrivées de Poisson, la proportion de paquets perdus est . On veut , soit , donc vidages par seconde. Avec , on perd des paquets.
- Non : l’instant du prochain vidage dépendrait du temps écoulé depuis le précédent, une information qui ne figure pas dans l’état « nombre de paquets ». Le futur ne dépendrait plus seulement du présent. Pour retrouver un modèle markovien, il faudrait ajouter à l’état le temps écoulé depuis le dernier vidage (on sort alors du cadre de ce cours).
En code
import numpy as np
lam, mu, K = 100.0, 100.0, 10A = np.zeros((K + 1, K + 1))for n in range(K + 1): if n < K: A[n, n + 1] = lam # arrivée d'un paquet if n >= 1: A[n, 0] = mu # vidage complet A[n, n] = -A[n].sum()
M = A.T.copy(); M[-1, :] = 1pi = np.linalg.solve(M, np.r_[np.zeros(K), 1])x = lam / (lam + mu)formule = np.r_[(1 - x) * x ** np.arange(K), x**K]print(np.abs(pi - formule).max()) # 1e-19 : la formule est exacteprint(pi[K]) # 0.000977 de paquets perdus
print(lam * (1 - 10**-0.3) / 10**-0.3) # 99.53 vidages/s au minimumlam <- 100; mu <- 100; K <- 10A <- matrix(0, K + 1, K + 1) # états 0, ..., K en positions 1, ..., K+1for (n in 0:K) { if (n < K) A[n + 1, n + 2] <- lam # arrivée d'un paquet if (n >= 1) A[n + 1, 1] <- mu # vidage complet A[n + 1, n + 1] <- -sum(A[n + 1, ])}M <- t(A); M[K + 1, ] <- 1pi_ <- solve(M, c(rep(0, K), 1))x <- lam / (lam + mu)formule <- c((1 - x) * x^(0:(K - 1)), x^K)max(abs(pi_ - formule)) # 2e-19 : la formule est exactepi_[K + 1] # 0.000977 de paquets perdus
lam * (1 - 10^-0.3) / 10^-0.3 # 99.53 vidages/s au minimum