Tracé de courbes pour les benchmarks avec Matplotlib

Vincent Bernat

La semaine passée, j’ai mené de nombreuses mesures avec un Spirent Avalanche qui est un boîtier permettant de tester les performances d’un équipement réseau (tel qu’un répartiteur de charge ou un serveur web). Le module dédié aux rapports n’est pas très flexible et ne produit pas des courbes de très bonne qualité. Heureusement, tous les résultats sont exportés au format CSV.

Matplotlib est une bibliothèque Python permettant de produire des courbes et des graphiques de manière simple dans la plupart des cas. C’est un bon remplacement à gnuplot et il n’y a pas besoin de connaître énormément de choses sur Python pour l’utiliser. La documentation comprend un guide de l’utilisateur qui permet de se mettre en selle rapidement.

Mise à jour (04.2017)

Cet article est particulièrement vieux et couvre une version antique de Matplotlib.

Introduction rapide#

Matplotlib peut s’utiliser avec IPython :

$ ipython -pylab
Python 2.6.7 (r267:88850, Jul 10 2011, 08:11:54)
Type "copyright", "credits" or "license" for more information.

  Welcome to pylab, a matplotlib-based Python environment.
  For more information, type 'help(pylab)'.

> In [1]: plot([1,1,4,5,10,11])

Une fois que tout est prêt, il est très simple de construire un script équivalent :

#!/usr/bin/env python
from matplotlib.pylab import *
plot([1,1,4,5,10,11])
savefig("my-plot.pdf")

Extraire les résultats du CSV#

Les données sont contenues dans le fichier realtime.csv que l’on renomme et compresse avec gzip. Il contient les données brutes ainsi que quelques indications supplémentaires sur le benchmark (nom, description, paramètres, …). Ces indications doivent être sautées. csv2rec() permet de faire simplement cette opération :

from matplotlib.pylab import *
import sys, os
import gzip

skip = 0
for line in gzip.open(sys.argv[1]):
    if line.startswith("Seconds Elapsed,"):
        break
    skip = skip + 1
ava = csv2rec(gzip.open(sys.argv[1]),skiprows=skip)

Désormais, pour accéder aux mesures correspondant au nombre de secondes écoulées depuis le début du test, il suffit d’utiliser ava['elapsed_seconds'].

Structure générale#

Je veux dessiner quatre courbes :

  • nombre de transactions par seconde (réussies, échouées, tentées),
  • temps de réponse par page (minimum, en moyenne, maximum),
  • charge CPU de l’Avalanche,
  • bande passante utilisée.

La courbe la plus importante est la première. La seconde est un peu moins importante tandis que les deux dernières sont assez annexes et permettent principalement de vérifier qu’il n’y a pas eu un goulot d’étranglement dans la chaîne lors du bench. Les quatre courbes vont être disposées ainsi :

Structure générale
Mise en page de quatre graphiques

Matplotlib permet de placer plusieurs graphiques au sein d’une même figure. Celles-ci partageront le même axe des abscisses. Le résultat sera sauvegardé en PDF.

# Create the figure (A4 format)
figure(num=None, figsize=(8.27, 11.69), dpi=100)

ax1 = subplot2grid((4, 2), (0, 0), rowspan=2, colspan=2)
# […]
ax2 = subplot2grid((4, 2), (2, 0), colspan=2, sharex=ax1)
# […]
ax3 = subplot2grid((4, 2), (3, 0), sharex=ax1)
# […]
ax4 = subplot2grid((4, 2), (3, 1), sharex=ax1)
# […]

# Save to PDF
savefig("%s.pdf" % TITLE)

Courbe de bande passante#

Commençons par le cas le plus simple : l’utilisation de la bande passante.

# Plot 4: Bandwidth
ax4 = subplot2grid((4, 2), (3, 1), sharex=ax1)
plot(ava['seconds_elapsed'], ava['incoming_traffic_kbps']/1000.,
     'b-', label='Incoming traffic')
plot(ava['seconds_elapsed'], -ava['outgoing_traffic_kbps']/1000.,
     'r-', label='Outgoing traffic')
grid(True, which="both", linestyle="dotted")
ylabel("Mbps", fontsize=7)
xticks(fontsize=7)
yticks(fontsize=7)

Dans le graphique positionné en (3,1), nous traçons le trafic entrant (en bleu, b-) et sortant (en rouge, r-) en ordonnée et le nombre de secondes écoulées en abscisse. Voici le résultat :

Usage de la bande passante
Usage de la bande passante

La plupart des fonctions de Matplotlib sont disponibles à la fois comme méthodes d’un objet ou de manière globale. Dans ce dernier cas, elles s’appliquent au dernier objet créé. Dans notre exemple, plot() est appelé après la création de ax4. On aurait pu écrire ax4.plot().

Courbe de CPU#

Les données relatives à l’utilisation CPU ont besoin d’être normalisées. Une fois cette normalisation effectuée, le tracé de la courbe est très similaire au cas précédent.

# CPU
max = np.max(ava['average_cpu_utilization'])
order = 10**np.floor(np.log10(max))
max = np.ceil(max/order)*order
cpu = (max - ava['average_cpu_utilization'])*100/max

# Plot 3: CPU
ax3 = subplot2grid((4, 2), (3, 0), sharex=ax1)
plot(ava['seconds_elapsed'],
     cpu,
     'r-', label="Avalanche CPU")
grid(True, which="both", linestyle="dotted")
ylabel("Avalanche CPU%", fontsize=7)
xticks(fontsize=7)
yticks(fontsize=7)

Temps de réponse#

Nous disposons de trois métriques liées au temps de réponse : minimum, moyenne et maximum. Comme ces métriques peuvent varier rapidement d’ordre de grandeur, une échelle logarithmique est utilisée :

# Plot 2: response time
ax2 = subplot2grid((4, 2), (2, 0), colspan=2, sharex=ax1)
plot(ava['seconds_elapsed'], ava['minimum_response_time_per_page_msec'],
     'b-', label="Minimum response time")
plot(ava['seconds_elapsed'], ava['maximum_response_time_per_page_msec'],
     'r-', label="Maximum response time")
plot(ava['seconds_elapsed'], ava['average_response_time_per_page_msec'],
     'g-', linewidth=2, label="Average response time")
legend(loc='upper left', fancybox=True, shadow=True, prop=dict(size=8))
grid(True, which="major", linestyle="dotted")
yscale("log")
ylabel("Response time (msec)", fontsize=9)
xticks(fontsize=9)
yticks(fontsize=9)

C’est aussi la première courbe avec une légende. Voici le résultat :

Temps de réponse
Graphique des temps de réponse

Transactions par seconde#

Il s’agit de la courbe la plus importante.

# Plot 1: TPS
ax1 = subplot2grid((4, 2), (0, 0), rowspan=2, colspan=2)
plot(ava['seconds_elapsed'], ava['desired_load_transactionssec'],
     '-', color='0.7', label="Desired Load")
plot(ava['seconds_elapsed'], ava['successful_transactionssecond'],
     'g:', label="Successful")
plot(ava['seconds_elapsed'], smooth(ava['successful_transactionssecond']),
     'g-', linewidth=2)
plot(ava['seconds_elapsed'], ava['attempted_transactionssecond'],
     'b-', label="Attempted")
plot(ava['seconds_elapsed'], ava['aborted_transactionssecond'],
     'k-', label="Aborted")
plot(ava['seconds_elapsed'][:-1], ava['unsuccessful_transactionssecond'][:-1],
     'r-', label="Unsuccessful")
legend(loc='upper left', fancybox=True, shadow=True, prop=dict(size=10))
grid(True, which="both", linestyle="dotted")
ylabel("Transactions/s")

Le nombre de transactions réussies est dessiné deux fois : c’est une métrique qui contient pas mal de bruit quand l’équipement commence à saturer et on va donc la lisser avec l’aide de NumPy :

import numpy as np
def smooth(x, win=4):
    s = np.r_[x[win-1:0:-1],x,x[-1:-win:-1]]
    w = np.ones(win, 'd')
    y = np.convolve(w/w.sum(),s,mode='valid')
    return y[(win-1)/2:-(win-1)/2]

Il s’agit d’un lissage linéaire sur une fenêtre fixe réalisé à l’aide d’un produit de convolution. En voici le résultat :

Courbe des transactions
Courbe du nombre de transactions par seconde

Les données d’origine sont tracées en pointillés et en vert tandis que le lissage est tracé avec une ligne verte plus épaisse. Il y a également des annotations qui sont apparues. Voici comment celles-ci sont réalisées :

# Noticeable points
count = 0
def highlight(index, reason):
    global count
    if index and index > 0:
        x,y = (ava['seconds_elapsed'][index],
               smooth(ava['successful_transactionssecond'])[index])
        plot([x], [y], 'ko')
        annotate('%d TPS\n(%s)' % (y,reason), xy=(x,y),
                 xytext=(20, -(count+4.7)*22), textcoords='axes points',
                 arrowprops=dict(arrowstyle="-",
                 connectionstyle="angle,angleA=0,angleB=80,rad=10"),
                 horizontalalignment='left',
                 verticalalignment='bottom',
                 fontsize=8)
        count = count + 1

highlight(np.argmax(smooth(ava['successful_transactionssecond'])), "Max TPS")
highlight(np.argmax(cpu > 99), "CPU>99%")
highlight(np.argmax(ava['average_response_time_per_page_msec'] > 500), ">500ms")
highlight(np.argmax(ava['average_response_time_per_page_msec'] > 100), ">100ms")

np.argmax() retourne le premier indice correspondant au maximum. Un point important à noter est que ava['average_response_time_per_page_msec'] > 100, retourne un tableau avec 1 si la valeur était supérieure à 100 et 0 sinon. Ainsi, np.argmax() retourne le premier indice pour lequel la valeur est supérieure à 100 ms.

La fonction highlight() va ajouter un point (plot([x], [y], 'ko')) sur la courbe lisée des transactions réussies ainsi qu’une annotation textuelle.

Pour voir le résultat complet, jetez un œil sur ce benchmark de nginx en tant que terminaison TLS.