dominio: reglas de baja latencia
Una medición indica si se ejecutó en caliente o en frío, y coincide con la producción [R:una-medición-coincide-con-el-régimen-que-reporta]
Escrito para las audiencias de Rust y Java.
Escrito a partir de JMH y sus ejemplos (Aleksey Shipilev), cuya existencia es la afirmación de que un microbenchmark hecho a mano en la JVM es incorrecto por defecto.
Indica en qué régimen se realizó la medición y verifica que sea el régimen en el que se ejecuta la producción. Aquí residen dos errores y solo uno de ellos tiene reputación.
Medir en frío cuando la producción está caliente. Una primera ejecución se ejecuta interpretada o desde una caché de instrucciones fría, con un predictor de saltos no entrenado, tablas de páginas no pobladas y, en un runtime administrado, sin código compilado. La cifra puede ser de uno a dos órdenes de magnitud peor que el estado estable, y describe el calentamiento más que el trabajo. En la JVM, la escalada es por etapas: intérprete, luego C1, luego C2, con reemplazo en pila para bucles largos, y requiere miles de iteraciones, por lo que un bucle de temporización hecho a mano es incorrecto por defecto y por lo que existe un harness.
Medir en caliente cuando la producción está fría, que es la mitad que no recibe atención porque parece rigor. Una ruta que se ejecuta una vez por minuto nunca está caliente. Un pico de apertura de mercado llega a código frío. Un manejador de errores se ejecuta por primera vez en el día en el peor momento posible. Para cada uno de ellos, la cifra en caliente es un número que ningún usuario verá nunca, y el calentamiento que se descartó cuidadosamente es el comportamiento de producción. Descartarlo no es higiene allí; es eliminar la medición.
Por lo tanto, la regla es una coincidencia de alcance más que un procedimiento: di qué régimen se midió, di en qué régimen está la producción, y muestra que son iguales. Cuando la producción es genuinamente ambas (un flujo constante con ráfagas frías), eso son dos mediciones y dos números reportados, no uno promediado hasta la falta de significado.
Esto es [R:mide-la-afirmación-no-un-subconjunto] aplicado al tiempo en lugar de a un conjunto: las muestras de calentamiento son un subconjunto, descartarlas es una decisión de alcance, y una decisión de alcance tomada por reflejo es la que fabrica un número incorrecto con la autoridad de haber sido medido.
Verificación de modo de fallo: ¿la ruta de producción está caliente o fría cuando importa, y cuál medí? Si el benchmark descartó las primeras N iteraciones, nombra qué tiene la producción que corresponde a esas iteraciones. Si nada lo hace, la parte descartada era la respuesta.
Dos vecinos, deliberadamente separados. [R:sin-omisión-coordinada] es el instrumento que no toma muestras durante los eventos que importan; este es el instrumento tomándolas en el régimen equivocado. Y [R:verifica-que-la-abstracción-no-se-haya-compilado] cubre el caso en que el optimizador eliminó el trabajo que el benchmark estaba midiendo: una medición en caliente de nada en absoluto.
Una cola sin límite convierte la sobrecarga en latencia en lugar de rechazo [R:una-cola-sin-límite-no-tiene-comportamiento-de-sobrecarga]
Escrito para las audiencias de Rust y Java.
Escrito a partir de la Ley de Little (J. D. C. Little, 1961): L = lambda W, por lo que para una cola que nunca rechaza, la espera crece sin límite una vez que la tasa de llegada supera la tasa de servicio.
Decide qué hace la cola cuando el consumo se queda atrás de la producción. Crecer no es una decisión.
La profundidad de la cola es latencia (eso es la Ley de Little, no una heurística): con llegadas a una tasa lambda y una longitud media de cola L, la espera media es L/lambda, por lo que una L sin límite es una espera sin límite. Por lo tanto, una cola sin límite de capacidad no tiene límite de latencia, y un requisito de latencia declarado en cualquier lugar aguas arriba de ella no se sostiene por nada.
El fallo tiene una forma característica que lo hace tarde para diagnosticar. Los productores nunca se bloquean, por lo que nada aguas arriba reporta un problema; el rendimiento parece correcto, porque cada elemento se procesa eventualmente; y el único síntoma es que la latencia aumenta monótonamente. Cuando es visible, el trabajo acumulado está haciendo trabajo que nadie quiere: cada elemento consumido ya está obsoleto, por lo que la capacidad se está gastando en entregar respuestas a preguntas cuyo momento ha pasado, que es la razón por la que el sistema no se recupera cuando la carga vuelve a la normalidad. Tiene que drenar primero, a la misma tasa que se quedó atrás.
Así que acota la cola y elige el comportamiento en el límite. Cuatro son legítimos y la elección es una decisión de dominio, no técnica:
- Bloquear al productor. Contrapresión real: el consumidor más lento establece la tasa para todos, y la presión se propaga a quien realmente pueda liberarla. Incorrecto cuando el productor no debe ser detenido, que es el régimen del que trata
[R:sin-bloqueo-dentro-de-una-ventana-de-publicación]. - Descartar el más antiguo. Correcto para datos de mercado, lecturas de sensores, telemetría: un tick obsoleto tiene valor negativo y el más nuevo es el único que vale la pena tener.
- Descartar el más nuevo. Correcto cuando el acumulado es una cola justa y los recién llegados no han recibido ninguna promesa.
- Rechazar y decirlo. Correcto para la entrada de órdenes y cualquier cosa que un llamante deba saber el destino. Una orden descartada en silencio es peor que una rechazada por todo el ancho de la confianza que el llamante depositó en la llamada.
No elegir selecciona un quinto comportamiento que nadie elegiría deliberadamente: absorber todo, degradar la latencia de cada elemento por igual y fallar eventualmente al agotar la memoria o, en un runtime administrado, por una pausa de recolección causada por el propio acumulado, que ralentiza aún más al consumidor y es lo más parecido en producción a un bucle de retroalimentación con el signo equivocado.
La estructura no acotada sigue siendo la opción correcta donde el límite realmente reside en otro lugar: una cola cuyos productores tienen limitación de velocidad aguas arriba, o cuyo consumidor es demostrablemente más rápido que cualquier tasa de llegada posible. Dilo, en el código, junto a la cola. Una cola no acotada con una razón declarada es una decisión; una sin ella es la ausencia de una decisión que tiene la misma forma.
Verificación de modo de fallo: si el consumo se queda atrás de la producción durante diez segundos, ¿qué hace esta cola y quién se entera? Si la respuesta es "crece" y "nadie", no hay comportamiento de sobrecarga aquí, solo un síntoma de sobrecarga en espera.
Una estructura es correcta solo bajo el régimen de concurrencia que su prueba asumió [R:una-estructura-mantiene-el-régimen-bajo-el-que-fue-probada]
Escrito para las audiencias de Rust y Java.
Escrito a partir de Dmitry Vyukov, Cola MPSC intrusiva basada en nodos, 1024cores, donde el régimen está declarado en el nombre del algoritmo y en ninguna parte de su código.
Lee el régimen del argumento de corrección de la estructura, luego nombra qué en el código impide violarlo. "La documentación dice de un solo consumidor" no es un mecanismo.
MPSC significa multiproductor, un solo consumidor, y el "único" es una condición previa más que una descripción. mpscq_pop de Vyukov lee self->tail, camina desde él y lo escribe de vuelta: una carga simple y un almacenamiento simple, sin sincronización, porque se asumió exactamente un hilo ejecutándolos. Dos consumidores se intercalan allí y ambos pueden devolver el mismo nodo, o uno puede perder un nodo por completo. Ninguno de los resultados genera nada. No hay aserción, no hay candado que contender, no hay ruta de error: la cola devuelve valores plausibles y el programa continúa con un elemento duplicado o desaparecido.
Ese silencio es todo el problema, y se ve agravado por la carga. Dos consumidores ejecutando cien operaciones pueden nunca intercalarse en la ventana que importa; el conjunto de pruebas pasa, la prueba de resistencia en staging pasa, y la intercalación llega con el tráfico de producción. Un defecto que solo aparece bajo las condiciones que no puedes reproducir es indistinguible, desde el exterior, de un defecto en otro lugar completamente diferente.
Así que pon el régimen en el tipo, donde el lenguaje lo permita. Entrega exactamente un manejador de consumidor en la construcción; hazlo ni Clone ni Sync; usa &mut self en pop para que el borrow checker imponga la exclusividad. Entonces, "dos consumidores" no es un error a encontrar, sino un programa que no compila: [R:haz-que-los-estados-ilegales-sean-irrepresentables], y [R:tipos-de-estado-para-protocolos] donde el régimen cambia a lo largo de la vida de una estructura. Este es el caso de mayor apalancamiento de esa disciplina en todo el dominio, porque el control alternativo es una oración en un comentario de cabecera y el fallo que previene es no atribuible.
Cuando el lenguaje no lo soporte (Java no tiene tal manejador), el control tiene que ser algo que realmente se ejecute: registrar el hilo propietario en el primer uso y afirmarlo en cada llamada posterior en una compilación de depuración, o enrutar todo el consumo a través de un solo objeto cuya construcción es el único lugar donde se declara el invariante. Un comentario no es un control ([R:garantía-necesita-un-lector]).
La clase es más amplia que las colas, y los otros miembros fallan de la misma manera. Un anillo SPSC con dos productores. Una estructura documentada como no reentrante, invocada desde un manejador de señales o un callback. Un HashMap alcanzado desde dos hilos porque el campo que lo contenía se volvió compartido tres refactorizaciones después. Un iterador mantenido a través de una mutación. En cada caso, el régimen era una hipótesis del argumento de corrección y nada en el artefacto lo registra.
Verificación de modo de fallo: ¿qué régimen asume el argumento de corrección de esta estructura, y qué fallaría si lo violara ahora mismo? Si la respuesta honesta es "el nombre lo dice" o "somos cuidadosos", la estructura no está protegida y la próxima persona que la use no lo sabrá.
La memoria que nunca ha sido tocada aún no es memoria [R:asignado-no-es-residente]
Escrito para las audiencias de Rust y Java.
Escrito a partir de Ulrich Drepper, "Lo que todo programador debería saber sobre la memoria" (2007), sobre el paginado bajo demanda y el costo del primer toque.
Preasignar no es preparar. Una asignación exitosa compra espacio de direcciones; la página llega en el primer toque, y el toque es lo que cuesta.
malloc y mmap devuelven direcciones virtuales. Las páginas físicas se suministran de forma diferida: la primera escritura en cada página atrapa en el kernel, que encuentra un marco, lo pone a cero (porque los datos del inquilino anterior no deben filtrarse) y lo asigna. Por lo tanto, un búfer de un gigabyte asignado al inicio y escrito por primera vez en la ruta crítica contiene aproximadamente un cuarto de millón de fallos de página, que llegan uno por página, repartidos en la primera pasada sobre él. Cada aserción de "sin asignación" en esa ruta se cumple, y cada una de ellas está midiendo lo incorrecto: nada llamó al asignador.
La residencia tampoco es permanente una vez lograda. Las páginas pueden ser reclamadas bajo presión de memoria o intercambiadas, por lo que un proceso de larga duración que tocó sus búferes al inicio y luego los dejó inactivos puede volver a fallar en una ruta que ha estado libre de fallos durante una semana. Es por eso que el remedio tiene dos mitades y por qué hacer solo la primera es un error común y costoso.
Por lo tanto, para cualquier cosa en una ruta con un plazo: escribe en cada página que la ruta tocará, al inicio, antes de que llegue cualquier trabajo real; el toque debe ser una escritura, ya que una lectura de una página no tocada puede ser servida por la página cero compartida y no probar nada. Luego fíjala: mlockall(MCL_CURRENT | MCL_FUTURE), y sin intercambio en la máquina. En la JVM, -XX:+AlwaysPreTouch hace la primera mitad para el heap y vale la pena el inicio más lento precisamente porque el costo se traslada fuera de la ruta que tiene un límite.
Las páginas grandes cambian la aritmética en ambas direcciones y merecen su propia decisión en lugar de un valor predeterminado. Menos páginas, más grandes, significan menos fallos y menos presión de TLB; cada fallo es más grande, y las páginas grandes transparentes traen una ruta de desfragmentación que puede detener un hilo durante milisegundos exactamente en el momento equivocado. Las páginas grandes explícitas reservadas en el arranque evitan eso; dejar THP en always y esperar es la forma que este dominio sigue teniendo que nombrar.
Toca desde el hilo que será el propietario de la memoria, no desde el hilo que hace la configuración. En una máquina multi-socket, la primera escritura también decide la ubicación: una página se asigna en el nodo de quien la falla, por lo que la forma obvia de satisfacer esta regla (un bucle en el hilo de inicialización que recorre cada búfer) pone cada página en el nodo de ese hilo, y un trabajador fijado al otro socket lee todo a través de la interconexión con aproximadamente el doble de latencia local. La ruta ahora está libre de fallos y completamente remota: un estado estable más lento comprado con un primer mensaje más rápido. Fija primero los hilos propietarios, luego deja que cada uno toque lo que usará.
Verificación de modo de fallo: ¿cada página que esta ruta tocará ha sido escrita, por el hilo que la leerá, y qué impide que se reclame? Tres preguntas, porque responder solo la primera deja un sistema que es rápido hasta que se vuelve inactivo, y responder solo las dos primeras deja uno que es rápido en un solo socket.
[R:sin-asignación-en-la-ruta-crítica] es el hermano y ninguno subsume al otro: ese trata sobre entrar al asignador, este sobre la memoria que ya devolvió. [R:responde-al-requisito-en-su-capa] es por qué los remedios son todos una política de memoria, un bloqueo o un parámetro de arranque: ninguna elección de contenedor o crate llega a esto. [R:sin-bloqueo-dentro-de-una-ventana-de-publicación] es donde muerde más fuerte, porque un fallo de página tomado entre dos almacenamientos de un escritor mantiene la ventana abierta durante toda su duración.
Responde a un requisito en la capa en la que vive [R:responde-al-requisito-en-su-capa]
Escrito para las audiencias de Rust y Java.
Indica en qué capa vive un requisito antes de elegir cualquier cosa, y satisfácelo allí.
Un requisito sobre la máquina (no perturbar un núcleo aislado vecino, no fallar una página en la ruta de envío, no tomar un bloqueo que un hilo de tiempo real contiende) no se responde eligiendo un crate más rápido, un transporte de mejor reputación o una API asíncrona. Esas son decisiones a nivel de biblioteca. Pueden ser correctas e incluso pueden ayudar, pero no mantienen una restricción a nivel de sistema, y tratarlas como si lo hicieran convierte una pregunta abierta en una cerrada sin nada detrás.
El fallo no es la elección incorrecta; es la entrada de contabilidad incorrecta. Una restricción marcada como satisfecha deja de ser examinada. Una restricción abierta todavía...