Stage 2: el frame nunca sale de la GPU
Qué fallaba de verdad en el readback
El coste de Stage 1 era estructural, no accidental. read_to_image saca un frame terminado de la memoria de la GPU y lo mete en memoria del sistema; el intercambio rojo/azul recorre cada píxel en la CPU; la subida se lo devuelve a la GPU. Tres pasadas sobre el framebuffer entero, una vez por frame, para mover datos entre dos consumidores que ya estaban sentados en la misma tarjeta.
Ninguna cantidad de ajuste fino cambia esa forma. El único arreglo es dejar de copiar — darle a GPUI una referencia a la memoria en la que Servo ya ha pintado.
En Linux esa referencia tiene un nombre estándar: un dma-buf. Un descriptor de fichero que apunta a un buffer de memoria de la GPU y que otra API puede importar sin copia. Las dos mitades del stack de rumb pueden alcanzarlo: Servo renderiza a través de GL/EGL, el backend de GPUI es wgpu sobre Vulkan, y cada uno tiene un camino de extensión hacia dma-buf. Stage 2 es el trabajo de conectar esos dos caminos y borrar todo lo que había en medio.
El lado exportador
La mitad productora vive en el fork de Servo; el tick() de rumb la llama.
Cuando Servo tiene un frame nuevo, su WebViewDelegate dispara notify_new_frame_ready y el shell levanta un flag de sucio. En el siguiente frame de GPUI, tick() ve el flag, llama a webview.paint() y luego le pide al contexto de render que exporte el framebuffer. Esa exportación hace tres cosas: hace un blit del framebuffer de Servo a una textura intermedia privada — una copia de GPU a GPU, no un readback —, exporta esa textura como dma-buf a través de EGL_MESA_image_dma_buf_export y crea un glFenceSync exportado como sync fd.
Lo que vuelve es un handle que lleva el fd del buffer más todo lo necesario para interpretarlo — fourcc, modifier, stride, offset, ancho, alto — y el fd del fence al lado. Los dos descriptores pasan a ser nuestros: nosotros los poseemos y nosotros los cerramos. El handle va a un slot compartido con el lado de GPUI.
El slot es un Arc<Mutex<Option<DmaBufHandle>>>, y la elección es deliberada, no por pereza. El trait del consumidor exige Send + Sync; el patrón de acceso es un escritor por frame y N lectores por frame, hoy todos en el mismo hilo, pero el bound deja la puerta abierta por si GPUI acaba despachando el dibujado fuera del hilo.
Cuando la exportación no devuelve nada, el shell registra un aviso y el frame reutiliza el handle que ya hubiera en el slot — o se queda en blanco, si nunca ha habido ninguno.
El lado importador
En el otro extremo, ServoDmaBufHandle implementa el ExternalGpuTexture de GPUI. Tres de sus métodos sostienen el diseño.
descriptor() bloquea el slot y duplica el fd antes de entregarlo, de modo que GPUI es dueño de su propia copia y puede cerrarla cuando termina con la textura que importó. El handle en caché del motor se queda exactamente donde estaba — y por eso un frame en el que Servo no pintó nada sigue mostrando el último frame bueno en vez de parpadear a negro.
size() informa de las dimensiones que lleva el propio dma-buf, y no de ningún valor rastreado por separado, porque el buffer manda: es lo que se exportó en el último pintado.
fence() es el raro, y tiene sección propia.
El lado del shell en main.rs perdió su elemento de imagen y ganó external_image(texture_handle). Ese elemento emite una primitiva de escena ExternalSprite; el backend wgpu de GPUI lee el descriptor en draw_external_sprites, importa el dma-buf como una wgpu::Texture a través de wgpu::hal::vulkan y ash, y lo dibuja como un sprite más de la escena. Para el resto de GPUI, una página web es una textura.
El código de stage 1 se borró, no se escondió tras un flag. El slot de frame, el buffer de bytes, la llamada de readback, el intercambio R↔B, la subida de imagen por frame: todo fuera en el mismo commit. No hay ninguna vía alternativa.
Qué compra el fence, y qué no
Un sync fd es, semánticamente, de un solo uso. En cuanto un consumidor ha esperado sobre él, volver a esperar no tiene un significado definido. Así que fence() saca el fence del handle: el primer consumidor del frame se lo lleva, y todos los que vengan detrás no reciben nada y recurren a una barrera más gruesa.
Ese es el diseño. La parte honesta es lo que salió con él. El commit que conectó el puente de punta a punta apunta la espera del fence en el lado de GPUI como todavía pendiente — aplazada para mantener el commit centrado — y en la misma frase deja dicha la consecuencia: sin ella el consumidor muestrea lo que haya en memoria en el momento de dibujar, con el riesgo de leer escrituras de GL a medias en los frames cargados. El productor estaba exportando un fence correcto a un consumidor que todavía no esperaba sobre él. Se veía bien porque una página ligera rara vez pierde esa carrera, no porque la carrera estuviera cerrada.
En el mismo aliento se aplazaron otras tres cosas: un sondeo defensivo del formato de color, un flag para volver a stage 1 y el benchmark.
Esta última merece decirse sin rodeos. Stage 2 nunca se ha medido. El commit se inventó cifras para los dos caminos y ninguna de las dos conjeturas llegó a comprobarse. Lo que esta entrada afirma es estructural — tres pasadas de CPU sobre el framebuffer pasaron a cero — y estructural es todo lo que es. Si quieres una cifra de lo que eso vale en tu hardware, no existe.
Borroso un día
La primera versión visual funcionaba y se veía mal. El texto estaba blando. Todo lo estaba.
Servo renderizaba al tamaño lógico mientras GPUI compone en píxeles físicos — el lógico multiplicado por el factor de escala de la pantalla, normalmente de 1,5 a 2 en un panel moderno. El dma-buf llevaba la imagen pequeña y el sampler de GPUI la estiraba en el momento de muestrear. Sin copias, correcto y borroso.
El arreglo se ejecuta en la construcción del motor: consultar el factor de escala de la ventana, multiplicar por él el tamaño lógico pedido y pasarle a Servo tanto el tamaño físico como el factor de escala. hidpi_scale_factor pasó de un 1.0 hardcodeado al valor real, de modo que las métricas de fuente de Servo, los tamaños em de la maquetación y el DPI del canvas cuadran con lo que GPUI espera componer. Entonces el dma-buf mide exactamente el área de destino y el muestreo es 1:1.
Se dejó abierto un desajuste muy relacionado. Si el gestor de ventanas fuerza un tamaño del que a Servo nunca se le avisó — un WM en mosaico, una configuración con escalado fraccionario —, el quad se vuelve a estirar, y a estas alturas ServoEngine::resize existía pero no se llamaba desde ningún sitio. Eso fue al backlog como «Redimensionado de ventana → redimensionado del WebView de Servo», bloqueado por servo/servo#38369, donde redimensionar el contexto de render hacía panic. Estuvo abierto seis semanas, hasta que las pestañas forzaron una reescritura sobre FBOs offscreen intercambiables y lo cerraron de rebote.
El reenvío de ratón y scroll entró dos horas después del puente, la misma mañana. El teclado no. Las teclas se cayeron al suelo durante otras seis semanas.