Saltar al contenido principal

El piso de latencia: un microsegundo de trabajo dentro de diez de plomería

· 12 min de lectura
Clustron Team
Ingeniería de Sistemas Distribuidos

Un microsegundo de trabajo dentro de diez microsegundos de plomería

Aquí hay un número que reencuadra cómo piensas sobre un almacén clave/valor. Cuando aislamos el almacén en memoria de Zaris y le damos operaciones sin nada más en el camino —sin socket, sin serialización, sin enrutamiento— un solo hilo sirve lecturas a más de un millón por segundo, y escala limpiamente a más de ocho millones a lo largo de ocho hilos. El almacén, en otras palabras, hace el trabajo real de un GET en menos de dos microsegundos, y enfáticamente no es el cuello de botella.

Y sin embargo un GET completo sobre una conexión TCP real, en la misma máquina, se estanca en unos pocos cientos de miles por segundo. El almacén puede hacer el trabajo cinco veces más rápido de lo que el servidor puede entregarlo. Así que la pregunta de rendimiento interesante sobre Zaris no es "¿qué tan rápido es el almacén?". Es lo contrario: ¿a dónde va todo el tiempo entre el socket y el almacén? Este artículo es la respuesta, trazada operación por operación, porque la brecha entre 1,7 microsegundos de lógica y 10 a 13 microsegundos de CPU de servidor por operación no es ruido —es el piso de latencia, y casi todo él es plomería.

La medición que inicia toda la investigación​

No puedes optimizar una ruta que no has separado en capas, así que lo primero que construimos fue un conjunto de benchmarks de ablación —arneses que despojan al sistema hasta una capa a la vez.

  • Solo almacén. Llama directamente al almacén en proceso de la partición, en proceso, sin red. Resultado: ~1,15M GET/s y ~0,5M PUT/s por hilo, escalando linealmente a ~8,2M / ~2,0M a ocho hilos.
  • Eco de socket puro. Un socket .NET simple haciendo frames de la misma forma, sin lógica de Zaris: ~7,2M frames/s en la misma caja.
  • Ruta TCP nativa completa. El cliente real hablando con el servidor real: ~300-324K ops/s, con solo unos tres de catorce núcleos lógicos ocupados en cada lado.

Lee esos tres juntos y dos sospechosos habituales caen de inmediato. El almacén no es el límite —es 3-4x más rápido que la ruta completa. El sistema operativo y el loopback tampoco son el límite —un socket puro mueve frames veinte veces más rápido. Lo que queda en el medio, entre un socket que puede hacer millones y un almacén que puede hacer millones, es la propia plomería de solicitudes de Zaris, y está gastando aproximadamente 10-13 microsegundos de CPU de servidor por operación para envolver unos 1,7 microsegundos de trabajo real.

Esa proporción es el tema. Sigamos un GET a través de ella.

La ruta de un solo GET​

Nada en ese diagrama está mal o es derrochador a primera vista —es la forma honesta de un almacén en red. El costo está en los detalles de cada flecha: cuántas veces tiene que despertar un hilo, cuántas veces se copian los bytes, y cuánto trabajo fijo por solicitud viaja junto que no tiene nada que ver con la clave que se lee. Esos detalles son donde viven los once microsegundos, y se agrupan en tres conjuntos.

Cuello de botella uno: la cadena de saltos de egreso​

El mayor costo individual no es leer el valor —es entregarlo. Enviar una respuesta de vuelta al cliente, tal como está estructurado actualmente, cruza tres canales internos, despierta tres hilos y copia la carga útil cuatro veces en su camino de salida:

  1. La respuesta se entrega del shard worker a la ruta de envío del pipeline del servidor.
  2. La ruta de envío la entrega a un bucle de escritura por conexión, que la codifica en un buffer agrupado (pooled).
  3. El bucle de escritura la encola en la cola de envío de la conexión de transporte.
  4. El transporte fusiona (coalesces) los buffers encolados en un stream y escribe al socket.

Cada entrega es un despertar de hilo, y cada despertar es un evento de planificación que el SO tiene que atender —barato individualmente, ruinoso a cientos de miles por segundo. Y en cada etapa los bytes se copian de nuevo: buffer de codificación agrupado, cola de envío, buffer de fusión, socket. En el lado entrante hay un costo equivalente: cada solicitud que llega en un hilo de completación IOCP se entrega a un shard worker antes de que ocurra cualquier trabajo, lo que es otro despertar y otra cola.

La razón por la que existe esta estructura es razonable —desacoplar recepción, despacho, codificación y transmisión hace que cada capa sea más simple de razonar y permite que los consumidores lentos apliquen backpressure sin bloquear el socket. Pero para el caso común —una lectura RF1 cuya respuesta ya está en memoria en el nodo que recibió la solicitud— es enormemente más maquinaria de la que el trabajo merece. La respuesta podría, en principio, computarse y escribirse de vuelta en la propia ruta de recepción, con una copia al buffer del socket y sin entregas en absoluto.

Ese es el primer y mayor trozo de trabajo estructural al que apunta la medición: colapsar el egreso a un salto y una copia para la ruta caliente, y ejecutar la lectura/escritura RF1 en línea en el hilo receptor en lugar de rebotarla a través del pool de workers. Esta es la diferencia entre un diseño que es fácil de dibujar y un diseño que es barato de ejecutar, y los dos no son lo mismo.

Cuello de botella dos: hashing redundante y un lock por solicitud​

El segundo grupo es sobrecarga fija por operación —trabajo que ocurre en cada solicitud sin importar qué clave toque, y que en su mayoría no debería.

CRC16, tres veces, bit a bit. Zaris enruta claves a particiones con un hash CRC16 —la misma familia de hash que usa Redis Cluster, que es cómo el front-end RESP se mantiene compatible con slots. El problema no es la elección del hash; es que la implementación es bit-serial (iterando bit por bit en lugar de usar una tabla de búsqueda) y que un solo GET lo computa tres veces mientras la solicitud pasa por el enrutamiento. A unos 260 nanosegundos por computación, eso son ~784 ns por GET gastados en hashing —más que todo el viaje de ida y vuelta de decodificación-más-codificación de MessagePack, que ronda los 533 ns. Un CRC16 basado en tabla computado una vez y transportado junto convertiría el mayor costo fijo individual en un error de redondeo.

Una comprobación de licencia que toma un lock, en cada solicitud. Más sorprendente: el punto de entrada de la solicitud consulta el servicio de licencias en cada operación individual, y esa consulta toma un lock de todo el proceso y lee el reloj de pared (DateTime.UtcNow) para decidir si el arrendamiento actual sigue siendo válido. Un lock en la ruta caliente es exactamente el tipo de cosa que no aparece en un microbenchmark de un solo hilo y luego serializa silenciosamente todo bajo concurrencia. El veredicto de la licencia cambia como mucho unas pocas veces a lo largo de la vida de un proceso —cuando un arrendamiento se concede, renueva o expira. No tiene por qué recomputarse bajo un lock en cada GET. El arreglo es publicar el veredicto en un único campo volatile que una renovación en segundo plano actualiza, y dejar que la ruta caliente lo lea sin lock y sin llamada al reloj. (La maquinaria de arrendamiento en sí es el tema del artículo sobre el ciclo de vida de licencias; el punto aquí es puramente que leer el veredicto debe ser gratis.)

Ninguno de estos es un problema algorítmico profundo. Son el tipo de costo que se acumula cuando cada pieza se escribe correctamente de forma aislada y nadie ha medido todavía cuánto cuesta la suma en la ruta que corre un millón de veces por segundo.

Cuello de botella tres: el cliente gasta tanto como el servidor​

Sería conveniente culpar al servidor de todo, pero la ablación dice otra cosa: los benchmarks locales están limitados por el cliente (client-bound), y fijar el cliente solo a núcleos de rendimiento lo llevó de 169K a 299K ops/s. El cliente gasta aproximadamente 9 microsegundos y 1,8 KB por GET, y los costos riman con los del servidor:

  • Las completaciones rebotan a través del thread pool. Cada solicitud pendiente se rastrea mediante un TaskCompletionSource configurado para ejecutar su continuación de forma asíncrona, así que cuando llega la respuesta la completación se publica al thread pool en lugar de reanudarse en línea —otro salto de planificación, en cada operación.
  • Se lee una variable de entorno en cada operación individual. El cliente llama a Environment.GetEnvironmentVariable("ZARIS_REQUEST_TIMEOUT") por solicitud para decidir su deadline. Leer una variable de entorno recorre el bloque de entorno del proceso; hacerlo un millón de veces por segundo es el mayor frame autoría-de-Zaris en el cliente. Debería leerse una vez al arranque y cachearse.
  • Un semáforo y un lock de canal por operación, más asignaciones por solicitud —un id de cliente estampado en cada solicitud, metadatos de entrada solicitados y devueltos incluso cuando el llamador no los pidió.

El hilo conductor con el servidor es idéntico: el trabajo de un GET es diminuto, y está rodeado de saltos de planificación, locks y asignaciones que cada uno parecía gratis de forma aislada. La ruta caliente del cliente ya ha empezado a andar por el buen camino —el paso a ValueTask para las operaciones principales eliminó una asignación por llamada en la completación síncrona— y el resto de la lista es la misma idea aplicada más a fondo: cachea la variable de entorno, completa sin el rebote al thread pool, recibe en el socket con un ValueTask, y deja de asignar cosas que nadie pidió.

Entonces, ¿cuál es el piso, realmente?​

Súmalo y la imagen honesta es esta. En un portátil, la ruta TCP nativa tope alrededor de 300K ops/s no porque el almacén sea lento —puede hacer millones— sino porque cada operación arrastra 10-13 microsegundos de CPU de servidor y ~9 microsegundos de CPU de cliente a través de una cadena de saltos, copias, hashes redundantes y locks. El piso lo fija la plomería, no el trabajo.

El corolario alentador es que el piso está hecho de cosas arreglables, y la estimación de lo que arreglarlas compra es concreta. La lista estructural es:

  1. Colapsar el egreso a un salto y una copia.
  2. Ejecutar la ruta caliente RF1 en línea en el hilo de recepción, o por lotes por recepción.
  3. CRC16 basado en tabla, hasheado una vez por operación.
  4. Completaciones del cliente con cero asignación y sin rebote al thread pool.
  5. Recepción de socket basada en ValueTask en el cliente.
  6. Cachear la variable de entorno de timeout de solicitud en lugar de leerla por op.
  7. Publicar el veredicto de licencia en un campo volatile —sin lock, sin reloj, en la ruta caliente.
  8. Una dieta general de asignaciones: respuestas agrupadas, sin id de cliente por solicitud, sin metadatos no solicitados.

Juntas se estima que llevan la CPU de servidor por operación de ~10 a ~3-4 microsegundos —que es la diferencia entre necesitar una docena de núcleos ocupados para un millón de ops/seg y necesitar unos cuatro.

Las advertencias honestas​

Dos cosas mantienen este artículo veraz en lugar de triunfalista.

Primero, los números absolutos aquí son números de portátil, y probablemente son 2-4x bajos frente a hardware de servidor real con máquinas cliente dedicadas. Lo que se transfiere entre entornos no es el throughput absoluto —es la atribución relativa: el almacén no es el cuello de botella, el egreso es el mayor costo del servidor, y el cliente gasta tanto como el servidor. Esas conclusiones son independientes de la máquina; la cifra final de ops/seg no lo es, y la validación real está a escala en Kubernetes, no en una estación de trabajo.

Segundo, la replicación cambia el techo. Todo lo anterior es la historia de RF1 —una sola copia, el caso rápido. Un PUT RF2 tiene que replicar de forma durable a un backup antes de poder confirmar, y esa es una ruta genuinamente distinta y más cara; un objetivo realista para ella es 400-500K por nodo, no un millón. Y cuando no necesitas viajes de ida y vuelta por operación en absoluto, la ruta por lotes amortiza casi todo este costo fijo a lo largo de muchas operaciones y puede alcanzar un millón por nodo hoy. La cifra de un millón de ops de un solo nodo para operaciones individuales es un objetivo que el trabajo estructural anterior pretende alcanzar —no un número que estemos citando como entregado.

Esa distinción es toda la disciplina del trabajo de rendimiento en el que vale la pena confiar: separar lo que la medición prueba (el almacén es rápido, la plomería es el piso, y aquí es a dónde va cada microsegundo) de lo que promete (un número específico tras una lista específica de cambios). Lo primero está hecho y sólido. Lo segundo es ingeniería que ahora podemos hacer con un mapa en lugar de una conjetura —que es la razón entera por la que instrumentas la ruta antes de tocarla.