3 años de Metal

Hace tres años, portamos nuestro renderizador a Metal. No nos llevó mucho tiempo, fue muy divertido y funcionó realmente bien en iOS. Así que escribimos un artículo en el que explicábamos cómo tomamos la decisión y cómo acabó todo (spoiler: ¡muy bien!). La mayor parte de esa retrospectiva original sigue siendo válida, pero hoy Metal está en mejor forma que nunca, así que hemos decidido volver a publicarla con nuestra actualización de tres años.
Así que retrocedamos en el tiempo, imaginemos que estamos en diciembre de 2016 y acabamos de lanzar una versión de nuestro renderizador Metal en iOS.
¿Por qué Metal?
Cuando Apple anunció Metal en la WWDC de 2014, mi reacción inicial fue ignorarlo. Solo estaba disponible en el hardware más nuevo, que la mayoría de nuestros usuarios no tenían, y aunque Apple decía que resolvía los problemas de rendimiento de la CPU, optimizar para el mercado más reducido significaría que la brecha entre los dispositivos más rápidos y los más lentos se ampliaría aún más. En aquel momento solo utilizábamos OpenGL ES 2 en Apple, y también estábamos empezando a portarlo a Android.
Dos años y medio después, así es como se presenta la cuota de mercado de Metal para nuestros usuarios:

Esto resulta mucho más atractivo de lo que solía ser. Sigue siendo cierto que implementar Metal no ayuda a los dispositivos más antiguos, pero el mercado de GL en iOS sigue reduciéndose, y el contenido que ejecutamos en estos dispositivos antiguos suele ser diferente del que se ejecuta en los dispositivos más nuevos, por lo que tiene sentido dedicar algo de esfuerzo a hacerlo más rápido. Dado que tu código Metal para iOS se ejecutará en Mac con muy pocos cambios, podría tener sentido utilizarlo también en Mac, incluso si te centras en dispositivos móviles (actualmente solo distribuimos compilaciones Metal en iOS).
Creo que vale la pena analizar la cuota de mercado con un poco más de detalle. En iOS, admitimos Metal para iOS 8.3 y versiones posteriores; aunque hay algunos usuarios que no pueden ejecutar Metal debido a restricciones de la versión del sistema operativo, la mayor parte del 25 % que sigue ejecutando GL simplemente utiliza dispositivos más antiguos que cuentan con hardware SGX. Además, no cuentan con ninguna de las características de OpenGL ES 3, y nos conformamos con ejecutar una ruta de renderizado de gama baja en esos casos (aunque nos encantaría que todos los dispositivos pasaran a Metal; afortunadamente, la división entre GL y Metal solo irá a mejor). En Mac, la API de Metal es más reciente y el sistema operativo juega un papel bastante importante: hay que usar OS X 10.11+ para utilizar Metal y la mitad de nuestros usuarios simplemente tienen un sistema operativo más antiguo; no se trata tanto del hardware como del software (el 95 % de nuestros usuarios de Mac ejecutan OpenGL 3.2+).
Así que, dada la cuota de mercado, todavía tenemos opciones que no implican portar a Metal. Una de ellas es simplemente usar MoltenGL, que utilizaría el código OpenGL que ya tenemos, pero supuestamente sería más rápido; otra es portar a Vulkan (para obtener un mejor rendimiento en PC y, eventualmente, en Android) y usar MoltenVK. He evaluado brevemente MoltenGL y los resultados no me han entusiasmado demasiado: me costó bastante conseguir que nuestro código funcionara, y aunque el rendimiento era un poco mejor en comparación con el OpenGL estándar, esperaba más. En cuanto a MoltenVK, creo que es un error intentar implementar una API de bajo nivel como una capa sobre otra: es inevitable que se produzca un desajuste de impedancia que dará lugar a un rendimiento subóptimo; quizá sea mejor que la API de alto nivel que solías usar, pero es poco probable que sea tan rápida como podría ser, ¡que supuestamente es la razón por la que estás eligiendo una API de bajo nivel para empezar! Otro aspecto importante es que la implementación de Metal es mucho más sencilla que la de Vulkan —hablaremos de ello más adelante—, así que, en cierto sentido, preferiría un envoltorio de Metal a Vulkan en lugar de uno de Vulkan a Metal.
También cabe destacar que, al parecer, en iOS 10 y en los iPhone más recientes no hay controlador GL: GL se implementa sobre Metal. Lo que significa que usar OpenGL solo te ahorra un poco de esfuerzo de desarrollo —no tanto, teniendo en cuenta que la promesa de «escribe una vez, ejecuta en cualquier lugar» que tiene OpenGL no funciona realmente en dispositivos móviles.
Portabilidad
En general, la portabilidad a Metal fue pan comido. Tenemos mucha experiencia trabajando con diferentes API gráficas, que van desde API de alto nivel como Direct3D 9/11 hasta API de bajo nivel como PS4 GNM. Esto nos da la ventaja única de poder utilizar cómodamente una API como Metal, que es a la vez razonablemente de alto nivel, pero que también deja algunas tareas, como la sincronización CPU-GPU, en manos del desarrollador de la aplicación.
El único obstáculo fue realmente conseguir que nuestros shaders se compilaran; una vez hecho esto y llegado el momento de escribir el código, quedó claro que la API es tan sencilla y intuitiva que el código prácticamente se escribía solo. Conseguí que la versión que renderizaba la mayoría de las cosas de forma subóptima funcionara en unas 10 horas en un solo día, y pasé dos semanas más limpiando el código, corrigiendo problemas de validación, perfilando y optimizando, y realizando el pulido general. Conseguir una implementación de la API en este plazo dice mucho de la calidad de la API y del conjunto de herramientas. Creo que hay varios aspectos que contribuyen a ello:
- Puedes desarrollar el código de forma incremental, con buena retroalimentación en cada etapa. Nuestro código comenzó ignorando toda sincronización entre CPU y GPU, siendo realmente subóptimo en ciertas partes de la configuración de estado, utilizando el seguimiento de referencias integrado para los recursos y sin ejecutar nunca la CPU y la GPU en paralelo para evitar problemas; la fase de optimización y pulido convirtió esto en algo que podíamos lanzar, sin perder nunca la capacidad de renderizar en el proceso.
- Las herramientas están ahí para ti, funcionan y funcionan bien. Esto no es tan sorprendente para quienes están acostumbrados a Direct3D 11, pero es la primera vez en dispositivos móviles que he tenido un perfilador de CPU, un perfilador de GPU, un depurador de GPU y una capa de validación de la API de GPU que funcionaban bien en conjunto, detectando la mayoría de los problemas durante el desarrollo y ayudando a optimizar el código.
- Aunque la API es de un nivel algo inferior al de Direct3D 11, y deja algunas decisiones clave de bajo nivel en manos del desarrollador (como la configuración de la pasada de renderizado o la sincronización), sigue utilizando un modelo de recursos tradicional en el que cada recurso tiene ciertas «banderas de uso» con las que se ha creado, pero no requiere barreras de pipeline ni transiciones de diseño, y un modelo de enlace tradicional en el que cada etapa de sombreado tiene varias ranuras a las que puedes asignar recursos libremente. Ambos son familiares, fáciles de entender y requieren una cantidad muy limitada de código para ponerse en marcha rápidamente.
Otra cosa que ayudó es que nuestra interfaz API estaba preparada para API similares a Metal: es muy sencilla, pero expone suficientes detalles (como las pasadas de renderizado) para poder escribir fácilmente una implementación de alto rendimiento. En ningún momento de nuestra implementación tuve que guardar/restaurar el estado (muchas interfaces API adolecen de esto, sobre todo porque tratan la configuración del destino de renderizado como cambios de estado y la vinculación de recursos/estado persiste a través de ello) ni tomar decisiones complicadas sobre la vida útil/sincronización de los recursos. Casi el único fragmento de código «complicado» necesario para el renderizado es el que crea el estado del pipeline de renderizado mediante el hash de los bits necesarios para crearlo; los objetos de estado del pipeline no forman parte de nuestra abstracción de la API. Incluso eso es bastante sencillo y rápido. Escribiré más sobre nuestra interfaz API en una entrada aparte.

Así pues, una semana para compilar los shaders, dos semanas para conseguir una implementación pulida y optimizada¹: ¿cuáles son los resultados? Los resultados son excelentes: Metal cumple totalmente con lo prometido en cuanto a rendimiento. Por un lado, el rendimiento del envío en un solo subproceso es notablemente mejor que con OpenGL (reduciendo la parte de envío de dibujo de nuestro fotograma de renderizado entre 2 y 3 veces, dependiendo de la carga de trabajo), y esto es así teniendo en cuenta que nuestra implementación de OpenGL está bastante bien ajustada en cuanto a la reducción de la configuración de estado redundante y a la buena interacción con el controlador mediante el uso de rutas rápidas. Pero no se queda ahí: el multihilo en Metal es muy fácil de utilizar, siempre que tu código de renderizado esté preparado para ello. Aún no hemos cambiado al envío de dibujos multihilo, pero ya estamos convirtiendo otras partes que preparan recursos para que se ejecuten fuera del hilo de renderizado, lo cual, a diferencia de OpenGL, es bastante sencillo.
Más allá de eso, Metal nos permite solucionar otros problemas de rendimiento al proporcionarnos herramientas fiables y de fácil acceso. Una de las partes centrales de nuestro código de renderizado es el sistema que calcula los datos de iluminación en la CPU en el espacio mundial y los carga en regiones de una textura 3D (que tenemos que emular en hardware OpenGL ES 2). Las actualizaciones son parciales, por lo que no podemos duplicar toda la textura y tenemos que depender de cómo implemente el controlador glTexSubImage3D. En un momento dado, intentamos usar PBO para mejorar el rendimiento de las actualizaciones, pero nos encontramos con importantes problemas de estabilidad en todos los frentes, tanto en Android como en iOS. En Metal hay dos formas integradas de cargar una región: MTLTexture.replaceRegion, que se puede utilizar si la GPU no está leyendo la textura en ese momento, o MTLBlitCommandEncoder (copyFromBufferToTexture o copyFromTextureToTexture), que puede cargar la región de forma asíncrona justo a tiempo para que la GPU comience a utilizar la textura.


Como comentario general final, el mantenimiento del código Metal también es bastante sencillo: todas las funciones adicionales que hemos tenido que añadir hasta ahora han sido más fáciles de implementar allí que en cualquier otra API que admitimos, y espero que esta tendencia continúe. Existía cierta preocupación de que añadir una API más requiriera un mantenimiento constante, pero en comparación con OpenGL esto realmente no supone mucho trabajo; de hecho, dado que ya no tendremos que dar soporte a OpenGL ES 3 en iOS, esto significa que también podemos simplificar parte del código de OpenGL que tenemos.
Estabilidad
Hoy en día, Metal se siente muy estable en iOS. No estoy seguro de cómo era la situación en el lanzamiento en 2014, ni de cómo es hoy en día en Mac, pero tanto los controladores como las herramientas para iOS parecen bastante sólidos.
Tuvimos un problema con el controlador en iOS 10 relacionado con la carga de shaders compilados con Xcode 7 (que solucionamos cambiando a Xcode 8), y un fallo del controlador en iOS 9 que resultó ser consecuencia de un uso incorrecto de la API nextDrawable. Aparte de eso, no hemos visto ningún error de comportamiento ni ningún fallo; para ser una API relativamente nueva, Metal se ha mostrado muy sólida en todos los aspectos.
Además, las herramientas que ofrece Metal son variadas y completas; concretamente, puedes utilizar:
- Una capa de validación bastante completa que identifica problemas comunes en el uso de la API. Es básicamente como la depuración de Direct3D, algo familiar para Direct3D pero prácticamente desconocido en el mundo de OpenGL (en teoría, ARB_debug_callback debería resolver esto, pero en la práctica casi nunca está disponible y, cuando lo está, no resulta muy útil).
- Un depurador de GPU operativo que muestra todos los comandos que has enviado junto con su estado, el contenido del destino de renderizado, el contenido de las texturas, etc. No sé si tiene un depurador de shaders que funcione porque nunca lo he necesitado, y la inspección del búfer podría ser un poco más sencilla, pero en general cumple su función.
- Un perfilador de GPU que funcione y que muestre estadísticas de rendimiento por pasada (tiempo, ancho de banda) y también el tiempo de ejecución por shader. Dado que la GPU es un tiler, no se puede esperar realmente tiempos por llamada de dibujo, por supuesto. Tener este nivel de visibilidad —especialmente teniendo en cuenta la ausencia total de información de tiempos de la GPU en las API gráficas de iOS— es genial.
- Un rastreo de la línea de tiempo de CPU/GPU (Metal System Trace) que muestra la programación de la carga de trabajo de renderizado de la CPU y la GPU, similar a GPUView pero realmente fácil de usar, salvo algunas peculiaridades de la interfaz de usuario.
- Un compilador de shaders offline que valida la sintaxis de tus shaders, te ofrece ocasionalmente advertencias útiles, convierte tus shaders en un blob binario que se carga bastante rápido en tiempo de ejecución y, además, está razonablemente bien optimizado de antemano, lo que reduce los tiempos de carga, ya que el compilador del controlador puede ser más rápido.
Si vienes del mundo de Direct3D o de las consolas, es posible que des por sentado cada uno de estos elementos; créeme, en OpenGL cada uno de estos aspectos es inusual y se recibe con entusiasmo, especialmente en dispositivos móviles, donde estás acostumbrado a lidiar con controladores que a veces fallan, sin validación, sin depurador de GPU, sin un perfilador de GPU que sea útil, sin capacidad para recopilar datos de programación de la GPU y viéndote obligado a trabajar con un lenguaje de shaders basado en texto para el que cada proveedor tiene un analizador sintáctico ligeramente diferente.
Metal es una API excelente tanto para escribir código como para lanzar aplicaciones. Es fácil de usar, tiene un rendimiento predecible, cuenta con controladores robustos y un conjunto de herramientas sólido. Supera a OpenGL en todos y cada uno de los aspectos, excepto en la portabilidad, pero la realidad con OpenGL es que realmente solo deberías haberlo usado en tres plataformas (iOS, Android y Mac), y dos de ellas ahora son compatibles con Metal; además, la promesa de portabilidad de OpenGL no se cumple en gran medida, ya que el código que escribes en una plataforma muy a menudo acaba sin funcionar en otra por diferentes razones.
Si utilizas un motor de terceros como Unity o UE4, Metal ya es compatible con ellos; si no es así y te gusta la programación gráfica o te importa mucho el rendimiento y te tomas en serio iOS o Mac, te recomiendo encarecidamente que pruebes Metal. No te decepcionará.
Metal ahora
Los mayores cambios que ha experimentado Metal desde nuestro punto de vista en los últimos tres años tienen que ver con su adopción a gran escala.
Hace tres años, una cuarta parte de los dispositivos tenía que usar OpenGL. Hoy en día, para nuestro público, esta cifra es de aproximadamente el 2 %, lo que significa que nuestro backend de OpenGL ya casi no tiene importancia. Todavía lo mantenemos, pero esto no durará mucho tiempo.
Los controladores también son mejores que nunca; en general, no vemos problemas de controladores en iOS, y cuando los hay, suelen darse en prototipos iniciales, y para cuando los prototipos llegan a producción, los problemas suelen estar solucionados.
También hemos dedicado tiempo a mejorar nuestro backend Metal, centrándonos en tres áreas:
Reestructuración de la cadena de herramientas de compilación de shaders
Otra cosa que ha ocurrido en los últimos tres años es el lanzamiento y el desarrollo de Vulkan. Aunque podría parecer que las API son completamente diferentes (y lo son), el ecosistema de Vulkan ha proporcionado a la comunidad de renderizado un fantástico conjunto de herramientas de código abierto que, al combinarse, dan como resultado un conjunto de herramientas de compilación de calidad de producción y fácil de usar.
Utilizamos las bibliotecas para crear una cadena de herramientas de compilación capaz de tomar código fuente HLSL (utilizando diversas características de DX11, incluidos los shaders de cómputo), compilarlo a SPIRV, optimizar dicho SPIRV y convertir el SPIRV resultante a MSL (Metal Shading Language). Sustituye a nuestra cadena de herramientas anterior, que solo podía utilizar código fuente HLSL de DX9 como entrada y presentaba diversos problemas de corrección con shaders complicados.
Resulta un tanto irónico que Apple no haya tenido nada que ver con esto, pero aquí estamos. Muchísimas gracias a los colaboradores y mantenedores de glslang (https://github.com/KhronosGroup/glslang), spirv-opt (https://github.com/KhronosGroup/SPIRV-Tools) y SPIRV-Cross (https://github.com/KhronosGroup/SPIRV-Cross). Hemos aportado un conjunto de parches a estas bibliotecas para que nos ayuden a lanzar también la nueva cadena de herramientas, y la utilizamos para reorientar nuestros sombreadores hacia las API de Vulkan, Metal y OpenGL.
Compatibilidad con macOS
La adaptación a macOS siempre fue una posibilidad, pero no era una prioridad para nosotros hasta que empezamos a echar en falta algunas funciones. Así que decidimos que debíamos invertir en Metal en macOS para conseguir un renderizado más rápido y abrir nuevas posibilidades de cara al futuro.
Desde el punto de vista de la implementación, esto no fue nada difícil. La mayor parte de la API es exactamente la misma; aparte de la gestión de ventanas, la única área que requirió ajustes sustanciales fue la asignación de memoria. En dispositivos móviles, hay un espacio de memoria compartida para buffers y texturas, mientras que en ordenadores de sobremesa, la API asume una GPU dedicada con su propia memoria de vídeo.
Es posible solucionar esto rápidamente utilizando recursos gestionados, donde el tiempo de ejecución de Metal se encarga de copiar los datos por ti. Así es como lanzamos nuestra primera versión, pero más tarde reelaboramos la implementación para copiar de forma más explícita los datos de los recursos utilizando búferes temporales, de modo que pudiéramos minimizar la sobrecarga de memoria del sistema.
La mayor diferencia entre macOS e iOS fue la estabilidad. En iOS solo teníamos que lidiar con un proveedor de controladores en una arquitectura, mientras que en macOS teníamos que dar soporte a los tres proveedores (Intel, AMD, NVidia). Además, en iOS —¡por suerte!— nos saltamos la *primera* versión de iOS en la que Metal estaba disponible, iOS 8, y en macOS esto no era viable porque en aquel momento habríamos tenido muy pocos usuarios que utilizaran Metal. Debido a la combinación de estos problemas, nos hemos topado con muchos más problemas de controladores tanto en áreas relativamente inocuas como en áreas relativamente oscuras de la API en macOS.
Seguimos admitiendo todas las versiones de macOS Metal (10.11+), aunque hemos empezado a eliminar la compatibilidad y a cambiar al backend OpenGL heredado para algunas versiones con errores conocidos del compilador de shaders que nos resultan difíciles de solucionar; por ejemplo, en 10.11 ahora requerimos macOS 10.11.6 para que Metal funcione.
Las mejoras de rendimiento se ajustaron a nuestras expectativas; en términos de cuota de mercado, hoy en día tenemos aproximadamente un 25 % de usuarios de OpenGL y un 75 % de Metal en la plataforma macOS, lo cual es una distribución bastante saludable. Esto significa que, en algún momento en el futuro, podría ser práctico para nosotros dejar de dar soporte a OpenGL de escritorio por completo, ya que ninguna otra plataforma que soportamos lo utiliza, lo cual es estupendo para poder centrarnos en API que son más fáciles de mantener y con las que se obtiene un buen rendimiento.
Iteraciones sobre el rendimiento y el consumo de memoria
Históricamente hemos sido bastante conservadores con las características de las API gráficas que utilizamos, y Metal no es una excepción. Hay varias actualizaciones importantes de características que Metal ha incorporado a lo largo de los años, incluyendo API de asignación de recursos mejoradas con montones explícitos, sombreadores de mosaicos con Metal 2, búferes de argumentos y generación de comandos del lado de la GPU, etc.
En su mayoría, no utilizamos ninguna de las características más recientes. Hasta ahora, el rendimiento ha sido razonable, y nos gustaría centrarnos en mejoras que se apliquen de forma generalizada, por lo que algo como los shaders de mosaicos, que nos obliga a implementar un soporte muy específico para ello en todo el renderizador y solo es accesible en hardware más nuevo, resulta menos interesante.
Dicho esto, dedicamos cierto tiempo a ajustar varias partes del backend para que simplemente funcione *más rápido*: utilizando cargas de texturas completamente asíncronas para reducir los tirones durante la carga de niveles, lo cual fue muy sencillo; realizando las optimizaciones de memoria mencionadas anteriormente en macOS; optimizando el envío de la CPU en varios puntos del backend mediante la reducción de fallos de caché, etc., y —una de las pocas características nuevas para las que tenemos soporte explícito— utilizando el almacenamiento de texturas sin memoria cuando está disponible para reducir significativamente la memoria requerida para nuestro nuevo sistema de sombras.
El futuro
En general, el hecho de que no hayamos tenido que dedicar demasiado tiempo a las mejoras de Metal es, en realidad, algo positivo: el código que se escribió hace tres años, en términos generales, funciona y es rápido y estable, lo cual es una gran señal de una API madura. La migración a Metal fue una gran inversión, dada la cantidad de tiempo que llevó y los beneficios continuos que nos aporta a nosotros y a nuestros usuarios.
Reevaluamos constantemente el equilibrio entre la cantidad de trabajo que dedicamos a las diferentes API; es muy probable que tengamos que profundizar en las partes más modernas de la API de Metal para algunos de los futuros proyectos de renderizado; si eso ocurre, ¡nos aseguraremos de escribir otra entrada al respecto!
- Sí, vale, y quizá una semana para corregir algunos errores descubiertos durante las pruebas ↩
- Las cifras corresponden a 128 KB de datos actualizados por fotograma (dos regiones de 32x16x32 RGBA8) en A10 ↩
Ni Roblox Corporation ni este blog respaldan ni apoyan a ninguna empresa o servicio. Además, no se ofrecen garantías ni promesas respecto a la exactitud, fiabilidad o exhaustividad de la información contenida en este blog.
Esta entrada del blog se publicó originalmente en el blog técnico de Roblox.


