Étiquette : cache

  • Conception orientée données et caches du processeur

    Conception orientée données et caches du processeur

    La plus grande leçon de performance que j’aie apprise, c’est que les processeurs modernes ne sont pas lents à calculer, ils sont lents à attendre la mémoire. Un défaut de cache vers la mémoire principale peut coûter quelques centaines de cycles. Pendant ce temps, le processeur aurait pu faire des centaines d’additions. Une fois cet écart intériorisé, on commence à concevoir les programmes autour de la façon dont les données circulent, pas seulement des opérations qu’on exécute dessus. C’est le cœur de la conception orientée données.

    La hiérarchie mémoire est la vraie machine

    Entre le processeur et la mémoire principale s’intercalent plusieurs niveaux de cache : L1 est minuscule et presque aussi rapide que les registres, L2 est plus grand et plus lent, L3 plus grand et plus lent encore. Quand le processeur a besoin d’un octet, il n’en cherche pas un seul. Il récupère une ligne de cache entière, typiquement 64 octets, et la range en cache. Si votre accès suivant est dans cette ligne, il est quasi gratuit. S’il est ailleurs au loin, vous payez la pénalité complète du défaut.

    Cela veut dire que la disposition de vos données en mémoire contrôle directement votre performance. Deux programmes faisant l’arithmétique identique peuvent différer d’un ordre de grandeur uniquement à cause des schémas d’accès.

    Tableau de structures contre structure de tableaux

    L’exemple classique est la façon de stocker une collection d’enregistrements. La disposition intuitive, orientée objet, est un tableau de structures :

    struct Particle {
        float x, y, z;     // position
        float vx, vy, vz;  // vitesse
        float mass;
        char name[32];
    };
    struct Particle particles[100000];
    
    // mise à jour des positions
    for (int i = 0; i < 100000; i++) {
        particles[i].x += particles[i].vx;
    }

    Cette boucle ne touche que x et vx, mais chaque ligne de cache chargée est pleine de mass, name et des autres champs dont vous n'avez pas besoin. Vous traînez des données froides à travers le cache pour rien. La disposition orientée données scinde les champs en tableaux parallèles, une structure de tableaux :

    struct Particles {
        float x[100000], y[100000], z[100000];
        float vx[100000], vy[100000], vz[100000];
        float mass[100000];
    };
    
    for (int i = 0; i < 100000; i++) {
        p.x[i] += p.vx[i];
    }

    Maintenant le tableau x et le tableau vx sont chacun densément empaquetés. Chaque octet tiré en cache est un octet utilisé. Sur du vrai matériel, ce genre de changement donne couramment des accélérations de 3x à 10x sur les boucles chaudes, sans aucun changement d'algorithme.

    Pourquoi la poursuite de pointeurs fait mal

    Les listes chaînées et les arbres à nœuds sont l'opposé d'amicaux pour le cache. Chaque nœud est une allocation de tas séparée qui peut vivre n'importe où, donc parcourir la liste saute partout en mémoire et rate le cache à presque chaque étape. Si vous comprenez pile contre tas et comment les allocations de tas s'éparpillent, cela découle naturellement. Un tableau plat parcouru linéairement est nettement plus rapide qu'une liste chaînée du même nombre d'éléments, même si les deux sont en O(n), car le préchargeur matériel peut prédire et précharger un accès séquentiel.

    • Préférez les tableaux contigus aux structures à nœuds quand vous itérez souvent.
    • Regroupez les champs auxquels vous accédez ensemble, et séparez ceux que vous n'utilisez pas.
    • Traitez les données dans l'ordre où elles sont en mémoire dès que possible.
    • Gardez les données chaudes petites pour qu'il en tienne plus en cache à la fois.

    C'est la même idée qu'un bon allocateur

    Voilà pourquoi la stratégie d'allocation compte autant. Si vous allouez dix mille objets un par un, ils finissent éparpillés. Si vous les allouez en un seul bloc, ils sont voisins et itèrent vite. L'allocateur sur mesure de écrire un allocateur mémoire simple vous donne exactement ce contrôle : vous décidez la disposition au lieu de la laisser au hasard.

    Quand y recourir

    Je ne restructure pas tout autour du cache. Pour du code qui tourne une fois ou rarement, la clarté l'emporte. Mais pour les boucles chaudes, les noyaux internes qui tournent des millions de fois, la disposition des données est généralement la première chose que je règle et souvent le meilleur retour. Profilez d'abord, trouvez où sont les défauts de cache, puis disposez les données comme le matériel veut les lire. La machine est ravie d'être rapide si vous cessez de la faire attendre.

  • Les patterns de scalabilite backend qui tiennent vraiment sous la charge

    Les patterns de scalabilite backend qui tiennent vraiment sous la charge

    La plupart des backends ne s effondrent pas à cause d un seul gros problème. Ils s effondrent à cause d une douzaine de petites hypothèses qui tenaient très bien à mille requêtes par jour et qui cessent discrètement de tenir à un million. J ai passé une bonne partie de ma carrière à traquer ces hypothèses, souvent à trois heures du matin, et les patterns ci dessous sont ceux vers lesquels je reviens toujours. Aucun n est exotique. La compétence, c est de savoir lequel appliquer et quand s arrêter.

    Scaler verticalement avant de scaler horizontalement

    La première question que je pose quand un service souffre, c est de savoir si je peux simplement lui donner une machine plus grosse. La scalabilité verticale a mauvaise réputation parce qu elle a un plafond, mais ce plafond est bien plus haut qu on ne le croit. Une instance moderne avec 64 cœurs et 256 gigaoctets de mémoire encaisse une quantité énorme de trafic, et vous l obtenez sans toucher à votre code, à votre déploiement ni à votre modèle mental du système.

    La scalabilité horizontale, c est quand vous ajoutez des machines et répartissez le travail entre elles. Elle va plus loin, mais elle vous impose des décisions. L état doit vivre quelque part de partagé. Les requêtes doivent être routées. Les pannes se multiplient parce que vous avez désormais dix choses qui peuvent casser au lieu d une. Ma règle est simple. Je scale verticalement jusqu à ce que ça devienne cher ou que j atteigne le plafond de l instance, et seulement alors je scale horizontalement. Déployer une flotte de minuscules nœuds dès le premier jour, c est la meilleure façon de déboguer des problèmes de systèmes distribués que vous n aviez pas encore besoin d avoir.

    La statelessness rend tout le reste possible

    Vous ne pouvez pas répartir le trafic sur plusieurs serveurs si l un d eux est secrètement spécial. Dès qu une requête ne fonctionne que parce qu elle est tombée sur la même machine que la requête précédente, la scalabilité horizontale est morte. C est pour ça que je traite la statelessness comme une fondation plutôt qu une optimisation.

    En pratique, cela veut dire pas de données de session en mémoire locale, pas de fichiers uploadés posés sur le disque local, pas de compteurs en mémoire qui comptent. Poussez l état de session dans Redis ou dans un token signé. Poussez les fichiers dans du stockage objet. Poussez tout ce qui est durable dans une base de données. Quand chaque nœud applicatif est interchangeable, un répartiteur de charge peut envoyer une requête à n importe lequel, vous pouvez ajouter et retirer des nœuds librement, et un nœud planté ne vous coûte que les requêtes en cours qu il portait.

    Si vous voulez le contexte plus large de la place de tout ça dans un système, j en ai parlé dans l architecture fullstack moderne, où la statelessness réapparaît comme une condition pour des déploiements propres.

    La répartition de charge et comment une requête trouve un foyer

    Une fois que vous avez plusieurs nœuds interchangeables, quelque chose doit décider où va chaque requête. Un répartiteur de charge se place devant et distribue le trafic. Le round robin est le point de départ évident et il convient pour des charges uniformes, mais il devient vite bête quand le coût des requêtes varie. Least connections est généralement un meilleur choix par défaut parce qu il envoie le nouveau travail au nœud le moins occupé à l instant présent.

    Ce que les gens oublient, ce sont les health checks. Un répartiteur ne vaut que par sa capacité à repérer un nœud malade et à cesser de lui envoyer du trafic. Je configure toujours des health checks actifs avec un vrai endpoint qui touche les dépendances critiques, pas une route qui renvoie 200 quoi qu il arrive.

    upstream api_backend {
        least_conn;
        server 10.0.1.10:8080 max_fails=3 fail_timeout=30s;
        server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
        server 10.0.1.12:8080 max_fails=3 fail_timeout=30s;
    }
    
    server {
        listen 443 ssl;
        location / {
            proxy_pass http://api_backend;
            proxy_next_upstream error timeout http_502 http_503;
        }
        location /healthz {
            access_log off;
            proxy_pass http://api_backend;
        }
    }

    Le cache, le levier au plus fort rendement

    Rien ne vous offre de la marge plus vite que de ne pas faire le travail deux fois. Le cache est l outil au plus fort rendement de toute cette liste, et il opère à plusieurs niveaux. Il y a le CDN en bordure pour les ressources statiques et les réponses cacheables. Il y a un cache applicatif comme Redis ou Memcached pour les résultats calculés et les lignes chaudes. Il y a le cache de requêtes de la base et le cache de pages du système d exploitation en dessous. Chaque niveau depuis lequel vous pouvez servir est un niveau de travail que les niveaux inférieurs ne voient jamais.

    Le difficile n est jamais de lire depuis un cache. C est l invalidation. Un cache périmé est pire que pas de cache parce qu il ment avec aplomb. Je m appuie beaucoup sur l expiration par durée parce qu elle est prévisible, et je ne passe à l invalidation par événement que quand la péremption fait vraiment mal. Le cache-aside est mon pattern par défaut : on vérifie le cache, en cas de miss on va à la source, puis on réécrit le résultat avec un TTL.

    def get_user(user_id):
        key = "user:" + str(user_id)
        cached = redis.get(key)
        if cached is not None:
            return deserialize(cached)
        user = db.query("SELECT * FROM users WHERE id = %s", user_id)
        redis.setex(key, 300, serialize(user))
        return user

    Deux modes de défaillance méritent d être nommés. Une ruée sur le cache survient quand une clé populaire expire et que mille requêtes ratent en même temps et frappent la base ensemble. On la combat avec des TTL jitterés ou un court verrou pour qu une seule requête reconstruise la valeur. Le second, c est la croissance non bornée, que l on gère avec une politique d éviction comme LRU et un plafond mémoire.

    Les files de messages, ou comment arrêter le travail lent dans le chemin de la requête

    Une grande partie de ce qui rend les requêtes lentes n a pas besoin de se produire pendant que l utilisateur attend. Envoyer un email, redimensionner une image, générer un rapport, synchroniser avec un tiers. Si l appelant n a pas besoin du résultat tout de suite, sortez le du chemin de la requête et mettez le dans une file. Le tier web accepte le job, le confie à un broker comme RabbitMQ ou SQS, répond immédiatement, et un pool de workers avale le backlog à son propre rythme.

    Cela fait deux choses. Ça rend votre latence prévisible parce que la requête n attend plus le travail lent en aval. Et ça absorbe les pics, parce qu une file est un tampon. Quand le trafic triple pendant une heure, la file grossit et les workers rattrapent ensuite au lieu que tout le système fonde. Ce qu il faut absolument réussir, c est l idempotence. Les files redélivrent. Un message finira par être traité plus d une fois, donc chaque worker doit pouvoir tourner deux fois sur la même entrée sans danger.

    • Rendez les handlers idempotents avec une clé de déduplication ou un upsert pour qu une redélivrance soit sans effet.
    • Configurez une dead letter queue pour que les messages empoisonnés cessent de bloquer la file et atterrissent quelque part où vous pouvez les inspecter.
    • Surveillez la profondeur de la file comme une métrique de premier plan. Une file qui grossit est le tout premier signe que les workers ne suivent plus.

    Backpressure et dégradation gracieuse

    Scaler, ce n est pas seulement encaisser plus. C est échouer correctement quand on ne peut pas. Un système en vraie surcharge devrait délester volontairement plutôt que s effondrer. Je place des limites de débit aux bords, des timeouts sur chaque appel sortant, et des circuit breakers autour des dépendances instables pour qu un service aval lent n empile pas les threads et n emporte pas tout le processus avec lui.

    La dégradation gracieuse, c est décider à l avance de ce qu on lâche en premier. Si le service de recommandation est en panne, montrez une liste générique au lieu d une page d erreur. Si le cache est froid, servez des données un peu périmées plutôt que rien. Les utilisateurs qui ont une expérience dégradée sont bien plus contents que ceux qui reçoivent une 500.

    Où se place la base de données

    Tout ce qui précède achète du temps, mais finit par buter sur la base de données, qui est presque toujours le vrai goulot. Le cache réduit les lectures, les files lissent les écritures, mais passé un certain point la couche de données elle même doit scaler. C est un sujet assez vaste pour que je lui aie consacré un article dédié sur scaler les bases de données avec la réplication et le sharding, et si votre schéma vous résiste avant même d en arriver là, corrigez ça d abord avec une conception de schéma solide.

    L ordre dans lequel je les applique vraiment

    Si je devais compresser tout ça en une séquence, ce serait celle ci. Rendre le service stateless pour qu il puisse scaler tout court. Scaler verticalement jusqu à ce que ce ne soit plus rentable. Mettre un répartiteur de charge et plus de nœuds devant. Ajouter du cache aux niveaux qui font le plus mal. Déplacer le travail lent vers des files. Ajouter du backpressure pour que la surcharge dégrade au lieu de planter. Et ensuite, en général seulement, faire le travail plus dur de scaler la base de données. Mesurez à chaque étape, parce que le goulot est rarement là où votre intuition le situe, et ajouter de la capacité à la mauvaise couche ne fait que déplacer la file.