← bitàcola

Fase 2: el fotograma no surt mai de la GPU

Què fallava de debò al readback

El cost de la fase 1 era estructural, no accidental. read_to_image treu un fotograma acabat de la memòria de la GPU i el porta a la memòria del sistema; l'intercanvi de vermell i blau recorre tots els píxels a la CPU; la pujada el torna a la GPU. Tres passades sobre tot el framebuffer, un cop per fotograma, per moure dades entre dos consumidors que ja eren a la mateixa targeta.

Cap ajust no canvia aquesta forma. L'única solució és deixar de copiar — donar a GPUI una referència a la memòria on Servo ja ha pintat.

A Linux, aquesta referència té un nom estàndard: un dma-buf. Un descriptor de fitxer que apunta a un buffer de memòria de la GPU i que una altra API pot importar sense copiar-lo. Les dues meitats de la pila de rumb hi poden arribar: Servo renderitza per GL/EGL, el backend de GPUI és wgpu sobre Vulkan, i cadascun té un camí d'extensió cap a dma-buf. La fase 2 és la feina de connectar aquests dos camins i esborrar tot el que hi havia enmig.

El costat de l'exportació

La meitat productora viu al fork de Servo; el tick() de rumb hi crida.

Quan Servo té un fotograma nou, el seu WebViewDelegate dispara notify_new_frame_ready i el shell activa un flag de brut. Al fotograma següent de GPUI, tick() veu el flag, crida webview.paint() i tot seguit demana al context de render que exporti el framebuffer. Aquesta exportació fa tres coses: fa un blit del framebuffer de Servo a una textura intermèdia privada — una còpia de GPU a GPU, no pas un readback —, exporta aquesta textura com a dma-buf mitjançant EGL_MESA_image_dma_buf_export i crea un glFenceSync exportat com a sync fd.

El que en torna és un handle que porta l'fd del buffer i tot el que cal per interpretar-lo — fourcc, modifier, stride, offset, amplada, alçada — i, al costat, l'fd del fence. Tots dos descriptors passen a ser nostres, i som nosaltres qui els hem de tancar. El handle va a parar a una casella compartida amb el costat de GPUI.

La casella és un Arc<Mutex<Option<DmaBufHandle>>>, i l'elecció és deliberada, no pas mandra. El trait del consumidor exigeix Send + Sync; el patró d'accés és un escriptor per fotograma i N lectors per fotograma, tots avui al mateix fil, però el bound deixa la porta oberta per si algun dia GPUI reparteix el dibuix fora del fil.

Quan l'exportació no torna res, el shell registra un avís i el fotograma reaprofita el handle que ja hi ha a la casella — o es queda en blanc, si no n'hi ha hagut mai cap.

El costat de la importació

A l'altre extrem, ServoDmaBufHandle implementa l'ExternalGpuTexture de GPUI. Tres dels seus mètodes sostenen el disseny.

descriptor() bloqueja la casella i duplica l'fd abans de lliurar-lo, de manera que GPUI té la seva pròpia còpia i la pot tancar quan hagi acabat amb la textura que ha importat. El handle que el motor manté a la memòria cau es queda exactament on era — i per això un fotograma en què Servo no ha pintat res encara mostra l'últim fotograma bo en comptes de parpellejar a negre.

size() informa de les dimensions que porta el mateix dma-buf, i no pas d'algun valor que es guardi a part, perquè el buffer és l'autoritat: és el que es va exportar en l'últim pintat.

fence() és el rar, i té secció pròpia.

El costat de shell de main.rs va perdre el seu element d'imatge i va guanyar external_image(texture_handle). Aquest element emet una primitiva d'escena ExternalSprite; el backend de wgpu de GPUI llegeix el descriptor a draw_external_sprites, importa el dma-buf com a wgpu::Texture a través de wgpu::hal::vulkan i d'ash, i el dibuixa com un sprite més de l'escena. Per a la resta de GPUI, una pàgina web és una textura.

El codi de la fase 1 es va esborrar, no es va posar darrere d'un flag. La casella de fotograma, el buffer de bytes, la crida de readback, l'intercanvi R↔B, la pujada d'imatge a cada fotograma: tot fora en el mateix commit. No hi ha cap camí de reserva.

Què compra el fence, i què no

Un sync fd és, semànticament, d'un sol ús. Quan un consumidor ja hi ha esperat, tornar-hi a esperar no té cap significat definit. Per això fence() treu el fence del handle: el primer consumidor del fotograma se'l queda, i tots els que vinguin després no reben res i cauen en una barrera de gra més gruixut.

Aquest és el disseny. La part honesta és què va sortir amb ell. El commit que va connectar el pont de punta a punta apunta l'espera del fence al costat de GPUI com a pendent — ajornada per no dispersar el commit — i diu la conseqüència a la mateixa frase: sense ella, el consumidor mostreja el que hi hagi a la memòria en el moment de dibuixar, amb el risc de llegir escriptures de GL a mitges en els fotogrames carregats. El productor exportava un fence correcte a un consumidor que encara no l'esperava. Semblava que anava bé perquè una pàgina lleugera poques vegades perd aquesta cursa, no pas perquè la cursa estigués tancada.

ONE FRAME Servo paints glFenceSync → sync fd writes have landed first consumer takes the fd, waits any later consumer gets None → coarser barrier wait on neither and you sample a half-written frame

En el mateix alè es van ajornar tres coses més: una comprovació defensiva del format de color, un flag per tornar a la fase 1 i el benchmark.

L'últim s'ha de dir clar. La fase 2 no s'ha mesurat mai. El commit va donar xifres a ull per als dos camins i cap de les dues suposicions no s'ha comprovat mai. El que afirma aquesta entrada és estructural — tres passades de CPU sobre el framebuffer van passar a ser zero — i no és res més que això. Si vols una xifra del que això val al teu maquinari, no existeix.

Borrós durant un dia

La primera versió visual funcionava i es veia malament. El text sortia tou. Tot sortia tou.

Servo renderitzava a la mida lògica mentre que GPUI compon en píxels físics — la mida lògica multiplicada pel factor d'escala de la pantalla, normalment entre 1,5 i 2 en un panell modern. El dma-buf portava la imatge petita i el sampler de GPUI l'estirava en mostrejar-la. Zero còpies, correcte i borrós.

BEFORE Servo 1280 × 800 logical size GPUI target 1792 × 1120 logical × scale factor the sampler stretches one into the other — every glyph soft AFTER Servo 1792 × 1120 GPUI target 1792 × 1120 1 : 1 paint the device pixels the target has

La solució s'executa en construir el motor: consultar el factor d'escala de la finestra, multiplicar-hi la mida lògica demanada i passar a Servo tant la mida física com el factor d'escala. hidpi_scale_factor va passar d'un 1.0 fixat al codi al valor real, de manera que les mètriques de font de Servo, les mides em de maquetació i els DPI del canvas quadren amb el que GPUI espera compondre. Llavors el dma-buf té exactament la mida de l'àrea de destinació i el mostreig és 1:1.

Es va deixar obert un desajust molt relacionat. Si el gestor de finestres imposa una mida de la qual mai no s'ha informat Servo — un WM de mosaic, una configuració d'escalat fraccionari —, el quad torna a estirar-se, i en aquell moment ServoEngine::resize existia però no es cridava des d'enlloc. Això va anar al backlog com a «Redimensionament de finestra → redimensionament del WebView de Servo», bloquejat per servo/servo#38369, on redimensionar el context de render provocava un panic. Va estar obert sis setmanes, fins que les pestanyes van forçar una reescriptura cap a FBO fora de pantalla intercanviables i el va tancar com a efecte secundari.

El reenviament de ratolí i scroll va entrar dues hores després del pont, el mateix matí. El teclat no. Les tecles van quedar a terra sis setmanes més.

GPU SYSTEM MEMORY Servo offscreen framebuffer GPUI paints dma-buf fd + fence nothing crosses here any more
El handle dma-buf i el fence van directes de Servo a GPUI; el carril de memòria del sistema no porta res.