Zum Hauptinhalt springen

Der Latenzboden: Eine Mikrosekunde Arbeit in zehn Mikrosekunden Installation

· 11 Minuten Lesezeit
Clustron Team
Engineering für verteilte Systeme

Eine Mikrosekunde Arbeit in zehn Mikrosekunden Installation

Hier ist eine Zahl, die verändert, wie man über einen Key/Value-Store denkt. Wenn wir den In-Memory-Store von Zaris isolieren und ihm Operationen zuführen, ohne dass etwas anderes im Weg ist — kein Socket, keine Serialisierung, kein Routing —, bedient ein einzelner Thread mehr als eine Million Lesezugriffe pro Sekunde und skaliert sauber auf mehr als acht Millionen über acht Threads. Der Store erledigt die eigentliche Arbeit eines GET also in unter zwei Mikrosekunden und ist ausdrücklich nicht der Engpass.

Und doch erreicht ein vollständiges GET über eine echte TCP-Verbindung auf derselben Maschine ein Plateau bei einigen Hunderttausend pro Sekunde. Der Store kann die Arbeit fünfmal schneller erledigen, als der Server sie liefern kann. Die interessante Performance-Frage zu Zaris lautet also nicht „Wie schnell ist der Store?" Sondern genau umgekehrt: Wohin geht die ganze Zeit zwischen Socket und Store? Dieser Beitrag ist die Antwort, Operation für Operation nachverfolgt, denn die Lücke zwischen 1,7 Mikrosekunden Logik und 10 bis 13 Mikrosekunden Server-CPU pro Operation ist kein Rauschen — sie ist der Latenzboden, und fast alles davon ist Installation.

Die Messung, mit der die gesamte Untersuchung beginnt​

Man kann einen Pfad nicht optimieren, den man nicht in Schichten getrennt hat. Deshalb haben wir als Erstes eine Reihe von Ablations-Benchmarks gebaut — Harnesses, die das System jeweils auf eine einzige Schicht reduzieren.

  • Nur Store. Den In-Process-Store der Partition direkt aufrufen, im selben Prozess, ohne Netzwerk. Ergebnis: ~1,15 Mio. GET/s und ~0,5 Mio. PUT/s pro Thread, linear skalierend auf ~8,2 Mio. / ~2,0 Mio. bei acht Threads.
  • Reines Socket-Echo. Ein einfacher .NET-Socket, der Frames derselben Form verarbeitet, ohne Zaris-Logik: ~7,2 Mio. Frames/s auf derselben Maschine.
  • Vollständiger nativer TCP-Pfad. Der echte Client spricht mit dem echten Server: ~300–324K Ops/s, wobei auf jeder Seite nur etwa drei von vierzehn logischen Kernen ausgelastet sind.

Betrachtet man diese drei zusammen, fallen sofort zwei übliche Verdächtige weg. Der Store ist nicht die Grenze — er ist 3- bis 4-mal schneller als der vollständige Pfad. Das Betriebssystem und das Loopback sind es auch nicht — ein reiner Socket bewegt Frames zwanzigmal schneller. Was in der Mitte bleibt, zwischen einem Socket, der Millionen schafft, und einem Store, der Millionen schafft, ist die eigene Request-Installation von Zaris, und sie verbrennt etwa 10–13 Mikrosekunden Server-CPU pro Operation, um rund 1,7 Mikrosekunden echter Arbeit zu umhüllen.

Dieses Verhältnis ist das Thema. Folgen wir einem GET durch sie hindurch.

Der Weg eines einzelnen GET​

Nichts in diesem Diagramm ist auf den ersten Blick falsch oder verschwenderisch — es ist die ehrliche Form eines vernetzten Stores. Die Kosten stecken in den Details jedes Pfeils: wie oft ein Thread aufwachen muss, wie oft die Bytes kopiert werden und wie viel feste Arbeit pro Request mitläuft, die nichts mit dem gelesenen Key zu tun hat. In diesen Details leben die elf Mikrosekunden, und sie gruppieren sich in drei Bereiche.

Engpass eins: die Egress-Hop-Kette​

Die größten Einzelkosten entstehen nicht beim Lesen des Wertes — sondern bei seiner Auslieferung. Das Zurücksenden einer einzigen Antwort an den Client durchquert in der aktuellen Struktur drei interne Channels, weckt drei Threads und kopiert die Payload viermal auf ihrem Weg nach draußen:

  1. Die Antwort wird vom Shard-Worker an den Sendepfad der Server-Pipeline übergeben.
  2. Der Sendepfad übergibt sie an eine Schreibschleife pro Verbindung, die sie in einen gepoolten Buffer kodiert.
  3. Die Schreibschleife stellt sie in die Sendewarteschlange der Transportverbindung.
  4. Der Transport führt die eingereihten Buffer zu einem Stream zusammen und schreibt auf den Socket.

Jede Übergabe ist ein Thread-Wake-up, und jedes Wake-up ist ein Scheduling-Ereignis, das das Betriebssystem bedienen muss — einzeln billig, bei Hunderttausenden pro Sekunde verheerend. Und in jeder Stufe werden die Bytes erneut kopiert: gepoolter Encode-Buffer, Sendewarteschlange, Coalescing-Buffer, Socket. Auf der eingehenden Seite gibt es entsprechende Kosten: Jeder Request, der auf einem IOCP-Completion-Thread ankommt, wird an einen Shard-Worker übergeben, bevor irgendeine Arbeit stattfindet — ein weiteres Wake-up und eine weitere Warteschlange.

Der Grund für diese Struktur ist vernünftig — die Entkopplung von Empfang, Dispatch, Encoding und Übertragung macht jede Schicht einfacher nachvollziehbar und lässt langsame Consumer Backpressure ausüben, ohne den Socket zu blockieren. Aber für den Normalfall — einen RF1-Lesezugriff, dessen Antwort bereits im Speicher des Nodes liegt, der den Request empfangen hat — ist das enorm mehr Maschinerie, als die Arbeit verdient. Die Antwort könnte im Prinzip auf dem Empfangspfad selbst berechnet und zurückgeschrieben werden, mit einer Kopie in den Socket-Buffer und ohne jegliche Übergaben.

Das ist das erste und größte Stück struktureller Arbeit, auf das die Messung hinweist: den Egress auf einen Hop und eine Kopie zusammenfassen für den Hot Path und den RF1-Lese-/Schreibzugriff inline auf dem empfangenden Thread ausführen, statt ihn über den Worker-Pool springen zu lassen. Das ist der Unterschied zwischen einem Design, das leicht zu zeichnen ist, und einem Design, das billig zu betreiben ist, und beides ist nicht dasselbe.

Engpass zwei: redundantes Hashing und ein Lock pro Request​

Die zweite Gruppe ist fester Overhead pro Operation — Arbeit, die bei jedem Request anfällt, unabhängig vom berührten Key, und die größtenteils nicht anfallen sollte.

CRC16, dreimal, Bit für Bit. Zaris routet Keys mit einem CRC16-Hash zu Partitionen — derselben Hash-Familie, die Redis Cluster verwendet, so bleibt das RESP-Front-End Slot-kompatibel. Das Problem ist nicht die Wahl des Hashs; es ist, dass die Implementierung bitseriell ist (sie läuft Bit für Bit, statt eine Lookup-Tabelle zu nutzen) und dass ein einzelnes GET sie beim Durchlaufen des Routings dreimal berechnet. Bei rund 260 Nanosekunden pro Berechnung sind das ~784 ns pro GET fürs Hashing — mehr als der gesamte MessagePack-Decode-plus-Encode-Roundtrip, der bei etwa 533 ns liegt. Ein tabellengetriebenes CRC16, einmal berechnet und mitgeführt, würde die größte feste Einzelposition zu einem Rundungsfehler machen.

Eine Lizenzprüfung, die bei jedem Request einen Lock nimmt. Überraschender noch: Der Einstiegspunkt der Requests befragt bei jeder einzelnen Operation den Lizenzdienst, und diese Abfrage nimmt einen prozessweiten Lock und liest die Wanduhr (DateTime.UtcNow), um zu entscheiden, ob die aktuelle Lease noch gültig ist. Ein Lock auf dem Hot Path ist genau die Art von Ding, die in einem Single-Thread-Mikrobenchmark nicht auftaucht und dann unter Nebenläufigkeit stillschweigend alles serialisiert. Das Lizenzurteil ändert sich höchstens ein paar Mal während der Lebensdauer eines Prozesses — wenn eine Lease erteilt, erneuert oder abläuft. Es hat nichts damit zu tun, bei jedem GET unter einem Lock neu berechnet zu werden. Die Lösung ist, das Urteil in einem einzelnen volatile-Feld zu veröffentlichen, das eine Hintergrunderneuerung aktualisiert, und den Hot Path es ohne Lock und ohne Uhr-Aufruf lesen zu lassen. (Die Lease-Mechanik selbst ist Thema des Beitrags zum Lizenz-Lebenszyklus; hier geht es nur darum, dass das Lesen des Urteils kostenlos sein muss.)

Beides ist kein tiefes algorithmisches Problem. Es sind Kosten, wie sie entstehen, wenn jedes Stück für sich korrekt geschrieben wurde und noch niemand gemessen hat, was die Summe auf dem Pfad kostet, der eine Million Mal pro Sekunde läuft.

Engpass drei: Der Client verbraucht so viel wie der Server​

Es wäre bequem, dem Server an allem die Schuld zu geben, aber die Ablation sagt etwas anderes: Lokale Benchmarks sind client-limitiert, und allein das Festnageln des Clients auf Performance-Kerne hob ihn von 169K auf 299K Ops/s. Der Client verbraucht etwa 9 Mikrosekunden und 1,8 KB pro GET, und die Kosten ähneln denen des Servers:

  • Completions springen durch den Thread-Pool. Jeder ausstehende Request wird von einer TaskCompletionSource verfolgt, die so konfiguriert ist, dass ihre Fortsetzung asynchron läuft; wenn die Antwort eintrifft, wird die Completion also in den Thread-Pool gepostet, statt inline fortgesetzt zu werden — ein weiterer Scheduling-Hop bei jeder Operation.
  • Bei jeder einzelnen Operation wird eine Umgebungsvariable gelesen. Der Client ruft Environment.GetEnvironmentVariable("ZARIS_REQUEST_TIMEOUT") pro Request auf, um seine Deadline zu bestimmen. Das Lesen einer Umgebungsvariable durchläuft den Umgebungsblock des Prozesses; eine Million Mal pro Sekunde ist das der größte von Zaris verfasste Frame auf dem Client. Sie sollte einmal beim Start gelesen und gecacht werden.
  • Ein Semaphor und ein Channel-Lock pro Operation, dazu Allokationen pro Request — eine Client-ID, die jedem Request aufgestempelt wird, Eintragsmetadaten, die angefordert und zurückgegeben werden, auch wenn der Aufrufer sie nicht verlangt hat.

Der rote Faden ist derselbe wie beim Server: Die Arbeit eines GET ist winzig und von Scheduling-Hops, Locks und Allokationen umgeben, die jeweils für sich kostenlos aussahen. Der Hot Path des Clients ist bereits auf dem richtigen Weg — der Wechsel zu ValueTask für die Kernoperationen hat eine Allokation pro Aufruf bei synchronem Abschluss beseitigt —, und der Rest der Liste ist dieselbe Idee weitergeführt: die Umgebungsvariable cachen, ohne Thread-Pool-Sprung abschließen, auf dem Socket mit einem ValueTask empfangen und aufhören, Dinge zu allokieren, die niemand verlangt hat.

Was ist der Boden also wirklich?​

Rechnet man alles zusammen, ergibt sich folgendes ehrliche Bild. Auf einem Laptop erreicht der native TCP-Pfad bei etwa 300K Ops/s seine Obergrenze, nicht weil der Store langsam wäre — er schafft Millionen —, sondern weil jede Operation 10–13 Mikrosekunden Server-CPU und ~9 Mikrosekunden Client-CPU durch eine Kette aus Hops, Kopien, redundanten Hashes und Locks schleppt. Der Boden wird von der Installation bestimmt, nicht von der Arbeit.

Das ermutigende Korollar: Der Boden besteht aus behebbaren Dingen, und die Schätzung, was ihre Behebung bringt, ist konkret. Die strukturelle Liste lautet:

  1. Den Egress auf einen Hop und eine Kopie zusammenfassen.
  2. Den RF1-Hot-Path inline auf dem Empfangs-Thread ausführen oder pro Empfang gebündelt.
  3. Tabellengetriebenes CRC16, einmal pro Operation gehasht.
  4. Client-Completions ohne Allokation und ohne Thread-Pool-Sprung.
  5. ValueTask-basierter Socket-Empfang auf dem Client.
  6. Die Umgebungsvariable für den Request-Timeout cachen, statt sie pro Operation zu lesen.
  7. Das Lizenzurteil in einem volatile-Feld veröffentlichen — kein Lock, keine Uhr auf dem Hot Path.
  8. Eine allgemeine Allokationsdiät: gepoolte Antworten, keine Client-ID pro Request, keine unverlangten Metadaten.

Zusammen sollen diese Maßnahmen die Server-CPU pro Operation von ~10 auf ~3–4 Mikrosekunden senken — der Unterschied zwischen einem Dutzend ausgelasteter Kerne für eine Million Ops/s und etwa vier.

Die ehrlichen Einschränkungen​

Zwei Dinge halten diesen Beitrag wahrhaftig statt triumphal.

Erstens, die absoluten Zahlen hier sind Laptop-Zahlen, und sie liegen vermutlich 2- bis 4-mal unter dem, was echte Server-Hardware mit dedizierten Client-Maschinen erreicht. Was sich zwischen Umgebungen überträgt, ist nicht der absolute Durchsatz — sondern die relative Zuordnung: Der Store ist nicht der Engpass, der Egress ist der größte Serverkostenpunkt, und der Client verbraucht so viel wie der Server. Diese Schlussfolgerungen sind maschinenunabhängig; die endgültige Ops/s-Zahl ist es nicht, und die echte Validierung findet im großen Maßstab auf Kubernetes statt, nicht auf einer Workstation.

Zweitens, Replikation verändert die Obergrenze. Alles oben ist die RF1-Geschichte — eine einzelne Kopie, der schnelle Fall. Ein RF2-PUT muss dauerhaft auf ein Backup repliziert werden, bevor er bestätigen kann, und das ist ein grundlegend anderer und teurerer Pfad; ein realistisches Ziel dafür sind 400–500K pro Node, nicht eine Million. Und wenn Sie keine Round-Trips pro Operation brauchen, amortisiert der Batch-Pfad fast diesen gesamten festen Overhead über viele Operationen und kann heute eine Million pro Node erreichen. Die Ein-Million-Ops-Zahl für einzelne Operationen auf einem einzelnen Node ist ein Ziel, das die obige strukturelle Arbeit erreichen soll — keine Zahl, die wir als ausgeliefert nennen.

Diese Unterscheidung ist die gesamte Disziplin vertrauenswürdiger Performance-Arbeit: trennen, was die Messung beweist (der Store ist schnell, die Installation ist der Boden, und hier ist, wohin jede Mikrosekunde geht), von dem, was sie verspricht (eine bestimmte Zahl nach einer bestimmten Liste von Änderungen). Das Erste ist erledigt und solide. Das Zweite ist Ingenieursarbeit, die wir nun mit einer Karte statt einer Vermutung angehen können — und genau deshalb instrumentiert man den Pfad, bevor man ihn anfasst.