web-dev-qa-db-fra.com

Pourquoi est-ce TCP écrire une latence pire lorsque le travail est entrelacé?

J'ai profilé la latence TCP (en particulier, la variable write de l'espace utilisateur à l'espace noyau d'un petit message) afin d'obtenir une certaine intuition pour la latence d'une write (en reconnaissant que cela peut être spécifique au contexte). J'ai remarqué des incohérences substantielles entre les tests qui me semblent similaires, et je suis très curieux de savoir d'où provient la différence. Je comprends que les micro-repères puissent être problématiques, mais j’ai toujours l’impression que je manque une compréhension fondamentale (car les différences de latence sont de l’ordre de 10x).

La configuration est que j'ai un serveur C++ TCP qui accepte une connexion client (d'un autre processus sur le même CPU), et lors de la connexion avec le client effectue 20 appels système à write au socket, envoyant un octet à un temps. Le code complet du serveur est copié à la fin de cet article. Voici la sortie qui multiplie chaque write à l'aide de boost/timer (qui ajoute un bruit d'environ 1 micron):

$ clang++ -std=c++11 -stdlib=libc++ tcpServerStove.cpp -O3; ./a.out
18 mics
3 mics
3 mics
4 mics
3 mics
3 mics
4 mics
3 mics
5 mics
3 mics
...

Je trouve de manière fiable que la première write est nettement plus lente que les autres. Si j'emballe 10 000 appels write dans une minuterie, la moyenne est de 2 microsecondes par write, mais le premier appel est toujours composé de 15 micros ou plus. Pourquoi ce phénomène de "réchauffement"?

Dans le même ordre d’idées, j’ai lancé une expérience dans laquelle, entre chaque appel write, j’effectuais un travail de blocage de la CPU (calcul d’un nombre premier élevé). Cela provoque tous les appels write à être lent:

$ clang++ -std=c++11 -stdlib=libc++ tcpServerStove.cpp -O3; ./a.out
20 mics
23 mics
23 mics
30 mics
23 mics
21 mics
21 mics
22 mics
22 mics
...

Compte tenu de ces résultats, je me demande s’il existe une sorte de traitement par lots pendant le processus de copie d’octets du tampon utilisateur vers le tampon du noyau. Si plusieurs appels write se succèdent rapidement, sont-ils regroupés en une seule interruption du noyau?

En particulier, je cherche une idée du temps que write prend pour copier les tampons de l’espace utilisateur vers l’espace noyau. S'il y a un effet de coalescence qui permet à la moyenne write de ne prendre que 2 micros successivement, alors il serait injustement optimiste de conclure que la latence write est de 2 micros; Il semble que mon intuition devrait être que chaque write prend 20 microsecondes. Cela semble étonnamment lent pour la latence la plus faible que vous puissiez obtenir (appel write brut sur un octet) sans contournement par le noyau.

Une dernière donnée est que lorsque je configure un test ping-pong entre deux processus de mon ordinateur (un serveur TCP et un client TCP), la moyenne de mes 6 micros par aller-retour (qui inclut read, write, ainsi que par le biais du réseau localhost). Cela semble en contradiction avec les latences de 20 microns pour une seule écriture vue ci-dessus.

Code complet pour le serveur TCP:

// Server side C/C++ program to demonstrate Socket programming
// #include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <boost/timer.hpp>
#include <unistd.h>

// Set up some blocking work.
bool isPrime(int n) {
    if (n < 2) {
        return false;
    }

    for (int i = 2; i < n; i++) {
        if (n % i == 0) {
            return false;
        }
    }

    return true;
}

// Compute the nth largest prime. Takes ~1 sec for n = 10,000
int getPrime(int n) {
    int numPrimes = 0;
    int i = 0;
    while (true) {
        if (isPrime(i)) {
            numPrimes++;
            if (numPrimes >= n) {
                return i;
            }
        }
        i++;
    }
}

int main(int argc, char const *argv[])
{
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);

    // Create socket for TCP server
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    // Prevent writes from being batched
    setsockopt(server_fd, SOL_SOCKET, TCP_NODELAY, &opt, sizeof(opt));
    setsockopt(server_fd, SOL_SOCKET, TCP_NOPUSH, &opt, sizeof(opt));
    setsockopt(server_fd, SOL_SOCKET, SO_SNDBUF, &opt, sizeof(opt));
    setsockopt(server_fd, SOL_SOCKET, SO_SNDLOWAT, &opt, sizeof(opt));

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    bind(server_fd, (struct sockaddr *)&address, sizeof(address));

    listen(server_fd, 3);

    // Accept one client connection
    new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);

    char sendBuffer[1] = {0};
    int primes[20] = {0};
    // Make 20 sequential writes to kernel buffer.
    for (int i = 0; i < 20; i++) {
        sendBuffer[0] = i;
        boost::timer t;
        write(new_socket, sendBuffer, 1);
        printf("%d mics\n", int(1e6 * t.elapsed()));

        // For some reason, doing some blocking work between the writes
        // The following work slows down the writes by a factor of 10.
        // primes[i] = getPrime(10000 + i);
    }

    // Print a prime to make sure the compiler doesn't optimize
    // away the computations.
    printf("prime: %d\n", primes[8]);

}

Code client TCP:

// Server side C/C++ program to demonstrate Socket programming
// #include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char const *argv[])
{
    int sock, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);

    // We'll be passing uint32's back and forth
    unsigned char recv_buffer[1024] = {0};

    // Create socket for TCP server
    sock = socket(AF_INET, SOCK_STREAM, 0);

    setsockopt(sock, SOL_SOCKET, TCP_NODELAY, &opt, sizeof(opt));

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    // Accept one client connection
    if (connect(sock, (struct sockaddr *)&address, (socklen_t)addrlen) != 0) {
        throw("connect failed");
    }

    read(sock, buffer_pointer, num_left);

    for (int i = 0; i < 10; i++) {
        printf("%d\n", recv_buffer[i]);
    }
}

J'ai essayé avec et sans les drapeaux TCP_NODELAY, TCP_NOPUSH, SO_SNDBUF et SO_SNDLOWAT, avec l'idée que cela pourrait empêcher le traitement par lots (mais je comprends que ce traitement se produit entre le tampon du noyau et le réseau, pas entre le tampon de l'utilisateur et le tampon du noyau) .

Voici le code serveur pour le test de ping-pong:

// Server side C/C++ program to demonstrate Socket programming
// #include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <boost/timer.hpp>
#include <unistd.h>

 __inline__ uint64_t rdtsc(void)
   {
uint32_t lo, hi;
__asm__ __volatile__ (
        "xorl %%eax,%%eax \n        cpuid"
        ::: "%rax", "%rbx", "%rcx", "%rdx");
__asm__ __volatile__ ("rdtsc" : "=a" (lo), "=d" (hi));
return (uint64_t)hi << 32 | lo;
 }

// Big Endian (network order)
unsigned int fromBytes(unsigned char b[4]) {
    return b[3] | b[2]<<8 | b[1]<<16 | b[0]<<24;
}

void toBytes(unsigned int x, unsigned char (&b)[4]) {
    b[3] = x;
    b[2] = x>>8;
    b[1] = x>>16;
    b[0] = x>>24;
}

int main(int argc, char const *argv[])
{
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    unsigned char recv_buffer[4] = {0};
    unsigned char send_buffer[4] = {0};

    // Create socket for TCP server
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    bind(server_fd, (struct sockaddr *)&address, sizeof(address));

    listen(server_fd, 3);

    // Accept one client connection
    new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
    printf("Connected with client!\n");

    int counter = 0;
    unsigned int x = 0;
    auto start = rdtsc();
    boost::timer t;

    int n = 10000;
    while (counter < n) {
        valread = read(new_socket, recv_buffer, 4);
        x = fromBytes(recv_buffer);
        toBytes(x+1, send_buffer);
        write(new_socket, send_buffer, 4);
        ++counter;
    }

    printf("%f clock cycles per round trip (rdtsc)\n",  (rdtsc() - start) / double(n));
    printf("%f mics per round trip (boost timer)\n", 1e6 * t.elapsed() / n);
}

Voici le code client pour le test de ping-pong:

// #include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <boost/timer.hpp>
#include <unistd.h>

// Big Endian (network order)
unsigned int fromBytes(unsigned char b[4]) {
    return b[3] | b[2]<<8 | b[1]<<16 | b[0]<<24;
}

void toBytes(unsigned int x, unsigned char (&b)[4]) {
    b[3] = x;
    b[2] = x>>8;
    b[1] = x>>16;
    b[0] = x>>24;
}

int main(int argc, char const *argv[])
{
    int sock, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);

    // We'll be passing uint32's back and forth
    unsigned char recv_buffer[4] = {0};
    unsigned char send_buffer[4] = {0};

    // Create socket for TCP server
    sock = socket(AF_INET, SOCK_STREAM, 0);

    // Set TCP_NODELAY so that writes won't be batched
    setsockopt(sock, SOL_SOCKET, TCP_NODELAY, &opt, sizeof(opt));

    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    // Accept one client connection
    if (connect(sock, (struct sockaddr *)&address, (socklen_t)addrlen) != 0) {
        throw("connect failed");
    }

    unsigned int lastReceived = 0;
    while (true) {
        toBytes(++lastReceived, send_buffer);
        write(sock, send_buffer, 4);
        valread = read(sock, recv_buffer, 4);
        lastReceived = fromBytes(recv_buffer);
    }
}
14
rampatowl

Quelques problèmes se posent ici.

Pour vous rapprocher de la réponse, votre côté client doit faire deux choses: 1. recevoir toutes les données. 2. Gardez une trace de la taille de chaque lecture. Je l'ai fait par:

  int loc[N+1];
int nloc, curloc;
for (nloc = curloc = 0; curloc < N; nloc++) {
    int n = read(sock, recv_buffer + curloc, sizeof recv_buffer-curloc);
    if (n <= 0) {
            break;
    }
    curloc += n;
    loc[nloc] = curloc;
}
int last = 0;
for (int i = 0; i < nloc; i++) {
    printf("%*.*s ", loc[i] - last, loc[i] - last, recv_buffer + last);
    last = loc[i];
}
printf("\n");

et en définissant N à 20 (pardon, éducation), et en changeant votre serveur pour écrire un octet à la fois. Maintenant, quand cela affiche quelque chose comme:

 a b c d e f g h i j k l m n o p q r s 

nous savons que le serveur envoie des paquets de 1 octet; Cependant, quand il imprime quelque chose comme:

 a bcde fghi jklm nop qrs 

nous pensons que le serveur envoie principalement des paquets de 4 octets.

Le problème fondamental est que TCP_NODELAY ne fait pas ce que vous soupçonnez. L'algorithme de Nagle, accumule la sortie quand il y a un paquet envoyé non acquitté; TCP_NODELAY contrôle si cela est appliqué.

Indépendamment de TCP_NODELAY, vous êtes toujours un STREAM_SOCKET, ce qui signifie que N-write peut être combiné en un seul. La prise alimente l'appareil, mais simultanément vous alimentez la prise. Une fois qu'un paquet [mbuf, skbuff, ...] a été validé sur le périphérique, le socket doit créer un nouveau paquet sur le prochain write () s. Dès que le périphérique est prêt pour un nouveau paquet, le socket peut le fournir, mais jusque-là, le paquet servira de tampon. En mode tampon, l'écriture est très rapide car toutes les structures de données nécessaires sont disponibles [comme indiqué dans les commentaires et autres réponses].

Vous pouvez contrôler cette mise en mémoire tampon en ajustant les options de socket SO_SNDBUF et SO_SNDLOWAT. Notez cependant que le tampon renvoyé par accept n'hérite pas des tailles de tampon du socket fourni. En réduisant le SNDBUF à 1

La sortie ci-dessous:

abcdefghijklmnopqrst 
a bcdefgh ijkl mno pqrst 
a b cdefg hij klm nop qrst 
a b c d e f g h i j k l m n o p q r s t 

correspond commence par défaut, puis ajoute successivement: TCP_NODELAY, TCP_NOPUSH, SO_SNDBUF (= 1), SO_SNDLOWAT (= 1) côté serveur lors des connexions suivantes. Chaque itération a un delta de temps plus plat que la précédente.

Votre kilométrage variera probablement. C’était sur MacOS 10.12; et j'ai changé vos programmes en chose C++ avec rdtsc () parce que j'ai des problèmes de confiance.

/* srv.c */
// Server side C/C++ program to demonstrate Socket programming
// #include <iostream>
#include <unistd.h>
#include <stdio.h>
#include <sys/socket.h>
#include <stdbool.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <unistd.h>

#ifndef N
#define N 20
#endif
int nap = 0;
int step = 0;
extern long rdtsc(void);

void xerror(char *f) {
    perror(f);
    exit(1);
}
#define Z(x)   if ((x) == -1) { xerror(#x); }

void sopt(int fd, int opt, int val) {
    Z(setsockopt(fd, SOL_SOCKET, opt, &val, sizeof(val)));
}
int gopt(int fd, int opt) {
    int val;
    socklen_t r = sizeof(val);
    Z(getsockopt(fd, SOL_SOCKET, opt, &val, &r));
    return val;
}

#define POPT(fd, x)  printf("%s %d ", #x, gopt(fd, x))
void popts(char *tag, int fd) {
    printf("%s: ", tag);
    POPT(fd, SO_SNDBUF);
    POPT(fd, SO_SNDLOWAT);
    POPT(fd, TCP_NODELAY);
    POPT(fd, TCP_NOPUSH);
    printf("\n");
}

void stepsock(int fd) {
     switch (step++) {
     case 7:
    step = 2;
     case 6:
         sopt(fd, SO_SNDLOWAT, 1);
     case 5:
         sopt(fd, SO_SNDBUF, 1);
     case 4:
         sopt(fd, TCP_NOPUSH, 1);
     case 3:
         sopt(fd, TCP_NODELAY, 1);
     case 2:
     break;
     }
}

int main(int argc, char const *argv[])
{
    int server_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);



    // Create socket for TCP server
    server_fd = socket(AF_INET, SOCK_STREAM, 0);

    popts("original", server_fd);
    // Set TCP_NODELAY so that writes won't be batched
    while ((opt = getopt(argc, argv, "sn:o:")) != -1) {
    switch (opt) {
    case 's': step = ! step; break;
    case 'n': nap = strtol(optarg, NULL, 0); break;
    case 'o':
        for (int i = 0; optarg[i]; i++) {
            switch (optarg[i]) {
            case 't': sopt(server_fd, TCP_NODELAY, 1); break;
            case 'p': sopt(server_fd, TCP_NOPUSH, 0); break;
            case 's': sopt(server_fd, SO_SNDBUF, 1); break;
            case 'l': sopt(server_fd, SO_SNDLOWAT, 1); break;
            default:
                exit(1);
            }
        }
    }
    }
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) == -1) {
    xerror("bind");
    }
    popts("ready", server_fd);
    while (1) {
        if (listen(server_fd, 3) == -1) {
        xerror("listen");
        }

        // Accept one client connection
        new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);
        if (new_socket == -1) {
        xerror("accept");
        }
            popts("accepted: ", new_socket);
        sopt(new_socket, SO_SNDBUF, gopt(server_fd, SO_SNDBUF));
        sopt(new_socket, SO_SNDLOWAT, gopt(server_fd, SO_SNDLOWAT));
        if (step) {
                stepsock(new_socket);
            }
        long tick[21];
        tick[0] = rdtsc();
        // Make N sequential writes to kernel buffer.
        for (int i = 0; i < N; i++) {
                char ch = 'a' + i;

        write(new_socket, &ch, 1);
        tick[i+1] = rdtsc();

        // For some reason, doing some blocking work between the writes
        // The following work slows down the writes by a factor of 10.
        if (nap) {
           sleep(nap);
        }
        }
        for (int i = 1; i < N+1; i++) {
        printf("%ld\n", tick[i] - tick[i-1]);
        }
        printf("_\n");

        // Print a prime to make sure the compiler doesn't optimize
        // away the computations.
        close(new_socket);
    }
}

clnt.c:

#include <stdio.h>
#include <stdlib.h>
#include <netinet/in.h>
#include <netinet/tcp.h>
#include <string.h>
#include <unistd.h>

#ifndef N
#define N 20
#endif
int nap = 0;

int main(int argc, char const *argv[])
{
    int sock, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);

    // We'll be passing uint32's back and forth
    unsigned char recv_buffer[1024] = {0};

    // Create socket for TCP server
    sock = socket(AF_INET, SOCK_STREAM, 0);

    // Set TCP_NODELAY so that writes won't be batched
    setsockopt(sock, SOL_SOCKET, TCP_NODELAY, &opt, sizeof(opt));

    while ((opt = getopt(argc,argv,"n:")) != -1) {
        switch (opt) {
        case 'n': nap = strtol(optarg, NULL, 0); break;
        default:
            exit(1);
        }
    }
    opt = 1;
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(8080);

    // Accept one client connection
    if (connect(sock, (struct sockaddr *)&address, (socklen_t)addrlen) != 0) {
        perror("connect failed");
    exit(1);
    }
    if (nap) {
    sleep(nap);
    }
    int loc[N+1];
    int nloc, curloc; 
    for (nloc = curloc = 0; curloc < N; nloc++) {
    int n = read(sock, recv_buffer + curloc, sizeof recv_buffer-curloc);
        if (n <= 0) {
        perror("read");
        break;
    }
    curloc += n;
    loc[nloc] = curloc;
    }
    int last = 0;
    for (int i = 0; i < nloc; i++) {
    int t = loc[i] - last;
    printf("%*.*s ", t, t, recv_buffer + last);
    last = loc[i];
    }
    printf("\n");
    return 0;
}

rdtsc.s:

.globl _rdtsc
_rdtsc:
    rdtsc
    shl $32, %rdx
    or  %rdx,%rax
    ret
3
mevets

(Pas tout à fait une réponse, mais il me fallait un peu plus de place qu'un commentaire ...)

Cela ressemble à l'algorithme de Nagle , ou une variante de celui-ci, contrôlant quand les paquets TCP sont réellement envoyés.

Pour la première écriture, lorsqu'il n'y a pas de données non confirmées dans le "canal", elles seront envoyées immédiatement, ce qui prend un moment. Peu de temps après, il y aura toujours des données non confirmées dans le canal pour les écritures ultérieures. Une petite quantité de données peut donc être mise en file d'attente dans le tampon d'envoi, ce qui est plus rapide.

Après une interruption des transmissions, lorsque tous les envois auront eu la chance de se rattraper, le tuyau sera prêt à être envoyé immédiatement.

Vous pouvez le confirmer en utilisant quelque chose comme Wireshark pour examiner les paquets réels TCP - cela montrera comment les demandes write() sont regroupées.

Pour être juste, je m'attendrais à ce que le drapeau TCP_NODELAY contourne ce problème, ce qui se traduira par une répartition plus uniforme des timings, comme vous le dites. Si vous pouvez vérifier les paquets TCP, il serait également intéressant de regarder s'ils affichent le drapeau PSH, pour forcer un envoi immédiat.

4
df778899

(je ne sais pas si cela peut aider, mais je n'ai pas assez de réputation pour poster un commentaire)

Le microbenchmarking est délicat, en particulier avec les appels de système d'exploitation - selon mon expérience, peu de facteurs doivent être pris en compte et filtrés ou mesurés avant de prendre des chiffres de manière concluante. 

Certains de ces facteurs sont:

  1. cache hits/misses

  2. préemption multitâche

  3. Système d'exploitation allouant de la mémoire à certains moments d'appels API (cette allocation de mémoire peut facilement entraîner des retards en microsecondes)

  4. chargement paresseux (certaines API peuvent ne pas faire grand chose pendant l'appel connect, par exemple, jusqu'à ce que des données réelles soient entrées)

  5. la vitesse d'horloge réelle de la CPU en ce moment (la mise à l'échelle dynamique de l'horloge se produit tout le temps)

  6. commandes récemment exécutées sur ce cœur ou sur des cœurs adjacents (par exemple, de lourdes instructions AVX512 peuvent faire basculer la CPU en mode L2 (licence 2), ce qui ralentit l’horloge pour éviter une surchauffe).

  7. avec la virtualisation, tout ce qui peut s'exécuter sur le même processeur physique.

Vous pouvez essayer d'atténuer l'influence des facteurs 1, 2, 6 et 7 en exécutant la même commande à plusieurs reprises au cours d'un cycle. Mais, dans votre cas, cela peut signifier que vous devez ouvrir plusieurs sockets à la fois et mesurer la 1re écriture sur chacune d’elles dans un cycle. De cette façon, votre cache pour aller au noyau sera préchauffé au premier appel, et les appels suivants auront un temps "plus propre". Vous pouvez faire la moyenne.

Pour vous aider avec 5, vous pouvez essayer de "préchauffer" l'horloge du processeur - exécutez un long cycle de blocage juste avant votre test et à l'intérieur de votre boucle de test, mais ne faites rien d'extraordinaire dans ce cycle pour éviter une surchauffe - le plus sûr est d'appeler __asm("nop") à l'intérieur de ce cycle. 

Au début, je n’avais pas remarqué que vous n’envoyiez qu’un seul octet, et je pensais que cela pouvait être dû à TCP début lent . Mais aussi votre deuxième test avec nombre premier ne le supporte pas. Cela ressemble donc plus aux facteurs 1, 5 ou 6 de ma liste.

1
john316