Journal Le TapTempo du web, mais plus rapide

Posté par  .
Étiquettes :
15
19
juin
2022

spacefox a lancé dans ce journal un "concours" d'implémentation d'un programme dans divers langages. Le but est d'écrire un serveur HTTP qui retourne une redirection vers une page aléatoire : https://avatar.spacefox.fr/Renard-$random.png.

Pas mal d'implémentations ont déjà été proposées, je vais essayer à mon tour en essayer 4 :

  • Deno, pour découvrir
  • Node.js, pour avoir une référence par rapport à Deno
  • nginx (ngx_http_lua_module) et Varnish, car ce sont deux outils qu'on utiliserait dans la vraie vie pour implémenter le programme.

Outil de benchmark

Pour commencer, au lieu d'utiliser ab, je vais utiliser wrk. En effet, je n'arrive pas à obtenir plus de 19 000 req/s avec ab sur ma machine, alors que, avec wrk (spoiler alert), on mesure jusqu'à 250 000 req/s.

Méthodologie

J'ai fait les tests sur ma machine, un desktop sous ArchLinux, avec un core i5-8400 (6 cœurs), avec wrk -d10s -t4 -c 100.

J'ai essayé d'obtenir des résultats représentatifs, mais sans y passer trop de temps et sans chercher une rigueur excessive. Notamment, wrk et le programme sont exécutés sur la même machine, ce qui peut fausser les résultats. Également, je n'ai pas fait spécialement attention aux versions et aux optimisations, si ce n'est d'avoir des versions récentes et de ne pas avoir des désoptimisations flagrantes (compilation en mode debug, logs sur stdout, parallélisme insuffisant…).

Résultats

Java

Pour avoir une baseline, l'original de spacefox :

Requests/sec: 20 267.84
Transfer/sec: 2.75MB

Pas de surprise, on est sur le même ordre de grandeur que spacefox (17000 avec ab sur sa machine perso).

rust + hyper

Maintenant la version rust/hyper de abriotde (qui est parallélisée) :

Requests/sec: 232 214.97
Transfer/sec: 31.34MB

Sans surprise, c'est beaucoup plus rapide. Rust et hyper sont très rapides, et devraient être la borne haute de ce qu'on peut atteindre sans optimisations complexes.

Deno

Requests/sec: 42 960.47
Transfer/sec: 6.23MB

Là c'est une surprise pour moi. En général Java est un peu plus rapide que Node/Deno, mais sur ce benchmark Deno est 2x plus rapide. Après je connais mal Java, et c'est possible que j'ai raté des flags ad hoc. A noter que le JavaScript est exécuté sur un thread, comme pour la version Java.

node.js

Requests/sec:  33 400.03
Transfer/sec:      6.18MB

Un peu plus lent que deno. En effet, deno est censé être le successeur de node.js, et il a des optimisations qui n'existent pas sur node.js.

Varnish

Varnish est un caching reverse proxy, qui peut être programmé dans un langage spécifique, le VCL, qui, à l'exécution, est transformé en C puis compilé. Il est donc rapide:

Requests/sec: 159 889.33
Transfer/sec:     28.16MB

nginx + ngx_http_lua_module

Requests/sec: 243841.01
Transfer/sec: 83.70MB

nginx est réputé pour être rapide, mais, avec le module lua, je m'attendais à ce qu'il soit plus lent que l'implémentation rust. En fait il est systématiquement plus rapide, et ce malgré le double de données transféré (il répond une petite page html, alors que les autres implémentations renvoient un body vide).

Conclusion

nginx est à la fois le plus rapide et le plus simple à configurer (surtout si on l'utilise déjà pour d'autres services).

Coté langages de programmation, il y a de grosses différences de performances, mais tous sont largement assez rapides sauf cas très particuliers, surtout si, pour ceux qui ne sont pas parallèles, on exécute plusieurs instances derrière un reverse proxy.

Code source

Deno

import { serve } from "https://deno.land/std@0.144.0/http/server.ts";

serve((request) => {
  const url = new URL(request.url);

  if (url.pathname !== "/") return new Response("nope", { status: 404 });

  return new Response(null, {
    status: 302,
    headers: {
      location: `https://avatar.spacefox.fr/Renard-${Math.round(
        Math.random() * 10
      )}.png`,
    },
  });
});

Node.js

const http = require("node:http");

const server = http.createServer((req, res) => {
  if (req.path !== "/") res.writeHead(404);

  res.writeHead(302, {
    location: `https://avatar.spacefox.fr/Renard-${Math.round(
      Math.random() * 10
    )}.png`,
  });

  res.end();
});

server.listen(8000);

Varnish

vcl 4.0;
import std;

backend default none;

sub vcl_recv {
  if (req.url != "/") {
    return(synth(404, "Not Found"));
  }

  return(synth(302, ""));
}

sub vcl_synth {
  if (resp.status == 302) {
    set resp.http.location = "https://avatar.spacefox.fr/Renard-" +
     std.real2integer(std.random(0,10),0) + 
     ".png";
  }

  return(deliver);
}

nginx

events {
    worker_connections 2048;
}

worker_processes 6;

http {
    access_log /dev/null;

    server {
        listen 8080;

        location / {
            set_by_lua_block $random {
                return math.random(1, 10)
            }
            return 302 https://avatar.spacefox.fr/Renard-$random.png;
        }
    }
}
  • # 1 worker process pour nginx ?

    Posté par  (site web personnel, Mastodon) . Évalué à 4.

    Merci pour ces exemples :)

    Si je comprends bien le code de nginx utilise 6 processus différents pour traiter les requêtes.

    Je pense que pour pouvoir comparer avec Node.js, il faudrait évaluer avec un seul worker process.

    En effet, Node.js ne démarre qu'un seul processus avec un seul thread par défaut.

    • [^] # Re: 1 worker process pour nginx ?

      Posté par  . Évalué à 3.

      Vu que dans les programmes déjà existants Java était single-threadé et Rust multi-threadé la question se posait, et j'ai préféré prendre ce qui était le plus performant dans une configuration relativement standard.

      Avec node.js en mode cluster on arrive à :

      Requests/sec: 140514.83
      Transfer/sec: 26.00MB
      

      soit 4.5x la perf. monothreadée, donc un scaling quasi-linéaire (sur les 6 CPUs, 1 CPU était pris par wrk, et 0.7 par d'autres process).

      const http = require("node:http");
      const { cpus } = require("node:os");
      const cluster = require("node:cluster");
      
      const numCPUs = cpus().length;
      
      if (cluster.isPrimary)
        for (let i = 0; i < numCPUs; i++) {
          cluster.fork();
        }
      else {
        const server = http.createServer((req, res) => {
          if (req.path !== "/") res.writeHead(404);
      
          res.writeHead(302, {
            location: `https://avatar.spacefox.fr/Renard-${Math.round(
              Math.random() * 10
            )}.png`,
          });
      
          res.end();
        });
      
        server.listen(8000);
      }
      • [^] # Re: 1 worker process pour nginx ?

        Posté par  (site web personnel) . Évalué à 1.

        Et sur ta machine, quel est le temps CPU en mono-thread des processus Java, wrk avec Java, puis Rust, et enfin wrk avec Rust ?

        Le test avec ab me donne quasi es mêmes résultats.

        Lorsque je fais un vmstat (je sais c'est grossier), voici en gros le temps cpu avec la version Java multi-thread:

        procs ----------mémoire---------- -échange- -----io---- -système- ------cpu-----
         r  b   swpd  libre tampon  cache   si   so    bi    bo   in   cs us sy id wa st
         0  0      0 22876588 169192 3754992    0    0     0   120 2752 3430  0  0 100  0  0
         5  0      0 22853456 169192 3755752    0    0     0     0 31732 282378  5  8 87  0  0
         4  0      0 22855772 169192 3755272    0    0     0     0 25935 356583  3  9 88  0  0
         4  0      0 22850504 169192 3755536    0    0     0     0 27205 375689  3  9 88  0  0
         4  0      0 22847880 169200 3755536    0    0     0   128 26307 392497  3  9 88  0  0
         4  0      0 22854760 169200 3755696    0    0     0     0 26645 387530  3  9 88  0  0
         5  0      0 22858052 169208 3755152    0    0     0    48 26583 382465  3  9 87  0  0
         3  0      0 22861584 169208 3756016    0    0     0     0 29299 367593  3 10 87  0  0
         4  0      0 22865088 169208 3755664    0    0     0     0 26797 309219  4  9 87  0  0
         4  0      0 22857348 169208 3755216    0    0     0    76 27135 322763  4  9 87  0  0
         4  0      0 22866680 169208 3755856    0    0     0   100 25179 384999  3  9 87  0  0
         0  0      0 22867748 169208 3754832    0    0     0     0 9974 98889  1  2 97  0  0

        Le CPU n'est occupé qu'a 12 %, et wrk prend autant de temps CPU que le processus Java…

        • [^] # Re: 1 worker process pour nginx ?

          Posté par  . Évalué à 1.

          Et sur ta machine, quel est le temps CPU en mono-thread des processus Java, wrk avec Java, puis Rust, et enfin wrk avec Rust ?

          Selon vmstat, avec varnish je suis à 100%, en rust à 90%, java à 50% (avec 6 cœurs).

          wrk utilise entre 0.7 et 1.5 cœurs selon les benchs, et j'ai des trucs divers qui utilise 0.7 cœurs.

          Le CPU n'est occupé qu'a 12 %, et wrk prend autant de temps CPU que le processus Java…

          Tu as 16 cœurs ?

  • # Varnish vs nginx et biais de configuration

    Posté par  (site web personnel) . Évalué à 4.

    Je suis étonné que nginx gagne contre Varnish car Varnish compile et donc rn théorie devrait bien plus optimiser.
    Ne serait-ce pas un problème de configuration et un biais du fait que tu configures l'un et pas l'autre?

    Un rapide coup d'oeil sur la doc Varnish dit qu'il y a 2 epoll/kqueue et que c'est configurable, je ne suis pas expert mais peut-être qu'à la vue de la "difficulté" de la tâche tu en es plutôt à tester une config d'outil contre une autre et que tu as configuré un des outils mais pas l'autre… Peut-être tester la configuration par défaut de chaque outil et donc tester nginx sans "worker_connections 2048;" ni "worker_processes 6;" pour éviter un biais, ou trouver un équivalent chez Varnish (thread_pool_max et thread_pools?).

    • [^] # Re: Varnish vs nginx et biais de configuration

      Posté par  . Évalué à 2.

      nginx aussi compile, il utilise LuaJIT :)

      Concernant Varnish, ton lien indique :

      Note

      If you run across tuning advice that suggests running one thread pool for each CPU core, rest assured that this is old advice. Experiments and data from production environments have revealed that as long as you have two thread pools (which is the default), there is nothing to gain by increasing the number of thread pools.

      Cela dit c'est complètement possible que j'aie raté des choses.

      • [^] # Re: Varnish vs nginx et biais de configuration

        Posté par  (site web personnel) . Évalué à 2. Dernière modification le 19 juin 2022 à 20:03.

        Je ne connais pas assez (pas loin de pas du tout :-p), juste que Varnish a quand même bonne réputation et du coup je m'attendais à ce qu'il soit dans le même style que rust+hyper et nginx+lua, mais il peut aussi faire plus de choses qui ralentissent, ou conf par défaut moins prévue pour de petits codes comme le test.

        Comme rust+hyper et nginx+lua sont en pratique quasi à égalité, je trouve bizarre que Varnish soit (relativement) si loin alors que les mêmes principes et les mêmes recherches d'optimisations sont sur les 3.

        PS : et sinon, il faut le dire, les autres c'est du bloat pas écologique :).

    • [^] # Re: Varnish vs nginx et biais de configuration

      Posté par  (site web personnel) . Évalué à 4.

      Sur plusieurs projets, on a remplacé Varnish en reverse proxy cache par le microcache de nginx, et au delà de la simplification de l'infra (parce qu'on a souvent nginx derrière Varnish pour la couche applicative, en CGI pour PHP ou autre) nginx semble gagner la partie en ce qui concerne les performances brutes. Après, avec nginx on l'utilise en "microcache" (donc on cache moins agressivement, moins longtemps qu'avec Varnish, qui lui est conçu pour cacher longtemps et beaucoup).

  • # Version Java multi-thread

    Posté par  (site web personnel) . Évalué à 1.

    Chez moi, wrk prend 30 % de temps CPU de plus que le processus Java. Difficile donc de déduire quoi que ce soit, quelque soit le nombre de thread que je choisisse, la limite semble identique et le temps CPU pratiquement constant.

    Ce que l'on peut dire, c'est que ce bench est mal parallélisé en Java, avec ce code.

    En version mono-thread:

    Requests/sec:  43544.29
    Transfer/sec:      5.92MB

    En multi-thread:

    Requests/sec:  76680.75
    Transfer/sec:     10.43MB

    J'utilise les paramètres par défaut pour la JVM. Pour passer en version multi-thread, voici le code:

    package fr.spacefox.avatar;
    
    import com.sun.net.httpserver.HttpServer;
    import java.io.IOException;
    import java.net.InetSocketAddress;
    import java.util.Random;
    
    public class AvatarHttpServer {
    
        private final Random random = new Random();
        private final int port;
        private final int imgCount;
    
        public AvatarHttpServer(final int port, final int imgCount) {
            this.port = port;
            this.imgCount = imgCount;
        }
    
        public void run() throws IOException {
            var server = HttpServer.create(new InetSocketAddress(port), 0);
            server.createContext("/", exchange -> {
                exchange.getResponseHeaders().add(
                        "Location",
                        "https://avatar.spacefox.fr/Renard-" + (random.nextInt(imgCount) + 1) + ".png");
                exchange.sendResponseHeaders(302, -1);
            });
            server.setExecutor(java.util.concurrent.Executors.newCachedThreadPool());
            //server.setExecutor(java.util.concurrent.Executors.newFixedThreadPool(16));
            server.start();
        }
    
        public static void main(String[] args) throws IOException {
            new AvatarHttpServer(Integer.parseInt(args[0]), Integer.parseInt(args[1])).run();
        }
    }

    que j'ai repris du précédant journal.

    • [^] # Re: Version Java multi-thread

      Posté par  . Évalué à 1.

      Tel que je comprends la doc, il n'y a que les handlers de requête qui sont parallélisés. Du coup à partir du moment où tout ce qui n'est pas handler utilise 100% de CPU, ça ne sert à rien d'ajouter davantage de parallélisme.

  • # C

    Posté par  (site web personnel) . Évalué à 4.

    Avec libmicrohttpd (installé depuis un package debian sur Ubuntu 20.04):

    Avec ./wrk -d10s -t4:

    Requests/sec: 217045.72
    Transfer/sec: 31.69MB
    Code:

    #include <stdio.h>
    #include <string.h>
    #include <stdlib.h>
    #include <time.h>
    #include <unistd.h>
    #include <microhttpd.h>
    
    static int avatar_location(void * cls, struct MHD_Connection * connection,
            const char * url, const char * method,
            const char * version, const char * upload_data,
            size_t * upload_data_size, void ** ptr) {
        struct MHD_Response * response;
        char location[] = "https://avatar.spacefox.fr/Renard-XXXX.png";
        int ret;
    
        if (0 != strcmp(method, "GET"))
            return MHD_NO; /* unexpected method */
    
        response = MHD_create_response_from_buffer(0, NULL, MHD_RESPMEM_PERSISTENT);
        snprintf(&location[34], sizeof(location)-34, "%d.png", rand() % 11);
    
        MHD_add_response_header(response, "Location", location);
        ret = MHD_queue_response(connection, MHD_HTTP_FOUND, response);
        MHD_destroy_response(response);
    
        return ret;
    }
    
    int main(int argc, char ** argv) {
        struct MHD_Daemon * d;
    
        if (argc != 2) {
            printf("%s PORT\n", argv[0]);
            return 1;
        }
    
        srand(time(NULL));
    
        d = MHD_start_daemon(MHD_USE_THREAD_PER_CONNECTION, atoi(argv[1]),
                NULL, NULL, &avatar_location, NULL, MHD_OPTION_END);
        if (d == NULL)
            return 1;
    
        (void) getc (stdin);
    
        MHD_stop_daemon(d);
    
        return 0;
    }

    et

    gcc main.c -lmicrohttpd -O3 -o ./taptempoweb
    • [^] # HAProxy, vive le C, vive la France

      Posté par  . Évalué à 2.

      HAproxy fait beaucoup mieux!

      global
              maxconn         200
      defaults
              mode    http
              timeout connect 5000
              timeout client  50000
              timeout server  50000
      frontend http
        bind :::8080 v4v6
        http-request redirect location https://avatar.spacefox.fr/Renard-%[rand(23)]
      
      Running 10s test @ http://localhost:8080/
        4 threads and 10 connections
        Thread Stats   Avg      Stdev     Max   +/- Stdev
          Latency    62.45us  163.53us  15.92ms   99.96%
          Req/Sec    32.25k     1.61k   35.73k    63.37%
        1296464 requests in 10.10s, 140.41MB read
      Requests/sec: 128360.73
      Transfer/sec:     13.90MB
      wrk -d10s -t4 http://localhost:8080/  2.41s user 12.31s system 145% cpu 10.106 total
      

      avec ton code j'obtiens

      Running 10s test @ http://localhost:8080/
        4 threads and 10 connections
        Thread Stats   Avg      Stdev     Max   +/- Stdev
          Latency   169.41us   48.95us   1.82ms   89.27%
          Req/Sec     3.31k     2.90k    6.80k    39.39%
        13415 requests in 10.04s, 1.89MB read
      Requests/sec:   1336.17
      Transfer/sec:    193.23KB
      wrk -d10s -t4 http://localhost:8080/  0.06s user 0.46s system 5% cpu 10.045 tota
      
      • [^] # Re: HAProxy, vive le C, vive la France

        Posté par  . Évalué à 1.

        Et curieusement l'empreinte disque (approximation ~mémoire) est similaire. Je crois que l'on peut prudemment déclarer une victoire écrasante du C. :P

        $ ldd `which haproxy` |grep lib | awk '{print $3}'  |xargs ls -Ll  `which haproxy` | awk '{sum = sum + $5} END {print sum}'
        9131472
        $ ldd /tmp/a.out |grep lib | awk '{print $3}'  |xargs ls -Ll /tmp/a.out | awk '{sum = sum + $5} END {print sum}'
        9154048
        
      • [^] # Re: HAProxy, vive le C, vive la France

        Posté par  . Évalué à 1.

        Tests faits sur FreeBSD current GENERIC (donc pas du tout optimisé pour le benchmark), ce qui doit aussi expliquer les mauvais résultats que j'obtiens avec ton programme, je t'invite à tester haproxy pour pouvoir comparer correctement.

Suivre le flux des commentaires

Note : les commentaires appartiennent à celles et ceux qui les ont postés. Nous n’en sommes pas responsables.