
-
Mejora el uso y el rendimiento de la memoria con Swift
Descubre formas de mejorar el rendimiento y la gestión de la memoria de tu código Swift. Exploraremos formas de perfeccionar tu código, desde realizar cambios algorítmicos de alto nivel hasta adoptar InlineArray y Span, para un control preciso de la memoria y las asignaciones.
Capítulos
- 0:00 - Introducción y agenda
- 1:19 - App de formato y parser QOI
- 2:25 - Algoritmos
- 8:17 - Asignaciones
- 16:30 - Exclusividad
- 19:12 - Pila contra montón
- 21:08 - Recuento de referencias
- 29:52 - Biblioteca de análisis binario Swift
- 31:03 - Próximos pasos
Recursos
Videos relacionados
WWDC25
- Crear perfiles y optimizar el uso de energía en tu app
- Novedades de Swift
- Optimiza el rendimiento del CPU con instrumentos
WWDC24
-
Buscar este video…
Hola. Soy Nate Cook y trabajo en la biblioteca estándar de Swift. Hoy hablaremos de cómo comprender y mejorar el rendimiento de tu código, en parte usando algunas de las nuevas incorporaciones del lenguaje y la biblioteca estándar de Swift 6.2. Usaremos los nuevos tipos InlineArray y Span, probaremos valores genéricos y aprenderemos sobre tipos no escapables. Usaremos todo esto para eliminar retenciones y liberaciones, controles de exclusividad y unicidad y otras tareas adicionales. También presentaré una nueva biblioteca de código abierto que usa estas herramientas para crear análisis binarios rápidos y seguros. Se llama biblioteca de análisis binario de Swift. Se centra en la velocidad y brinda herramientas para administrar diferentes tipos de seguridad. Todos queremos que nuestro código sea rápido, y Swift nos ayuda a lograrlo, pero a veces las cosas no son tan rápidas como esperamos. Hoy, descubriremos en qué partes el código invierte más tiempo y luego probaremos varios tipos de optimizaciones de rendimiento... elegir los algoritmos adecuados, eliminar asignaciones adicionales, eliminar controles de exclusividad, pasar de asignaciones de montón a asignaciones de pila y reducir el recuento de referencias. En nuestra exploración, veremos una pequeña app que creé. Sirve para ver el formato de imagen QOI, e incluye un analizador escrito a mano para el formato. QOI es un formato de imagen sin pérdida que es tan simple que su especificación es de una sola página, lo que lo hace útil para probar diferentes enfoques y ver su rendimiento. El QOI usa un lenguaje estándar para formatos binarios con un encabezado de tamaño fijo y una sección de datos con pixeles codificados de varios tamaños. Los pixeles codificados adoptan varias formas. Un pixel puede ser un valor RGB o RGBA, una diferencia con el pixel anterior, una búsqueda en una caché de pixeles vistos anteriormente o... la cantidad de veces que se repetirá el pixel anterior. Bien, probemos mi app.
Abriré este archivo de ícono, que pesa unos pocos kb y se abre de inmediato.
Esta foto de un pájaro es un poco más pesada. Puede tardar unos segundos en cargar… Ahí está. ¿Por qué tarda tanto? Cuando hay una ralentización notable al trabajar con datos reales, se suele deber a un uso incorrecto del algoritmo o la estructura de datos. Usemos Instruments para encontrar y abordar el origen de este problema. En mi biblioteca de análisis, escribí una prueba que analiza la imagen que tardó en cargar. Hago clic en el botón para ejecutar la prueba...
y la pasa tras unos segundos. Además de usar esta prueba para verificar la corrección, puedo crear un perfil para ver su rendimiento en Instruments. Esta vez, hago clic derecho en el botón de ejecución. Hay una opción en el menú para hacer un perfil de la prueba.
Me encanta esta funcionalidad. Al hacer un perfil, puedo centrarme en la parte específica de mi código que me interesa. Seleccionaré esta opción para abrir Instruments.
Instruments se abre con su selector de plantillas, que muestra todas las formas en que te ayuda a comprender el rendimiento del código. Hoy usaremos dos instrumentos diferentes, así que seleccionaré la plantilla en blanco.
Para agregar instrumentos, hago clic en el botón + Instrument. Agregaré el instrumento Allocations para comprender de qué manera mi analizador está asignando memoria. Como me interesa saber en qué partes mi app invierte más tiempo, agregaré el instrumento Time Profiler.
Time Profiler es un buen punto de partida para cuestiones de rendimiento. Ocultemos la barra lateral para dar espacio a los resultados. Luego usaré el botón de grabación para iniciar la prueba.
Podemos ver algunas cosas en la ventana de resultados.
Los instrumentos incluidos en el perfil se muestran en la parte superior. Primero usaremos Time Profiler, así que lo mantendré seleccionado. En la parte inferior está la vista detallada de la herramienta seleccionada. A la izquierda hay una lista de ejecuciones capturadas... y a la derecha está el seguimiento de pila más pesado de la ejecución actual.
Me gustaría ver primero las ejecuciones capturadas con más frecuencia, sin importar cómo se hayan ejecutado. Para ello, hago clic en Call Tree y luego selecciono la casilla Invert Call Tree. Puedo cambiar a una vista gráfica con este botón en la parte superior de la vista detallada. Al hacer clic, la vista cambia para mostrar el perfil como un gráfico de llamas.
Cada una de las barras del gráfico muestra la proporción de veces que se capturó una ejecución en el perfil. En este caso, hay una barra enorme que domina el proceso, platform_memmove. Ese mismo símbolo aparece en el seguimiento de pila. memmove sirve para copiar datos, así que esa barra enorme indica que el analizador pasa la mayor parte de su tiempo copiando datos, en lugar de solo leerlos. Pero eso no debería suceder. Averigüemos qué parte de mi código está causando esto. Quiero ver todos los marcos en el seguimiento de pila, así que haré clic en Show all frames aquí arriba.
En la parte superior están las ejecuciones del sistema, incluida platform_memmove, y algunas versiones especializadas de los métodos proporcionados por el tipo de datos Foundation. Quizás hayas visto métodos especializados como estos en un seguimiento de pila o al depurar. Estos son versiones específicas del tipo de código genérico que el compilador de Swift genera para ti.
Por fin, llegamos a un método que yo definí, readByte.
Dado que esta es la función más cercana al problema de mi código, es el lugar ideal para comenzar. Para ir directo a este método, hago clic derecho y selecciono Reveal in Xcode. Aquí está la declaración del método readByte en Xcode. Instruments me llevó a esta línea, donde omito el primer byte y luego ejecuto el inicializador Data. Con Instruments, identifiqué todas esas ejecuciones de memmove como posibles fuente de lentitud en mi biblioteca y luego pude ir directo a la línea específica de código que causaba todo ese copiado.
Este método auxiliar es muy importante... porque mi código de análisis ejecuta readByte una y otra vez mientras consume los datos binarios sin procesar.
Pensé que esto solo reduciría los datos, devolvería el primer byte y adelantaría el inicio de los datos cada vez que ejecutara readByte. En cambio, copia todo el contenido de los datos en una nueva asignación cada vez que leo un byte. Es mucho más trabajo de lo que esperaba. Arreglemos este error. Volví a Xcode y estoy editando el método readByte.
Dado que el tipo Data está diseñado para reducirse desde ambos extremos, tenemos acceso al método de recopilación popFirst(). popFirst() devuelve el primer byte en data y luego desplaza el inicio de la recopilación hacia adelante, reduciéndola en un byte. Justo lo que queremos.
Con eso solucionado, puedo volver a mi prueba y ejecutar el perfil de nuevo.
Instruments se abre automáticamente, con la prueba en ejecución con la misma configuración de perfil. ¡Excelente! La enorme barra de platform_memmove desapareció del gráfico.
Cuando evalúo mi código, también puedo ver un aumento de velocidad enorme como resultado de ese cambio. Eso es fantástico, pero con un cambio algorítmico como este, el cambio absoluto no es toda la historia. En mi versión original, la relación entre el tamaño de la imagen y el tiempo que tardaba en analizarse era cuadrática. Al aumentar el tamaño de las imágenes, el tiempo de análisis se prolongaba. Con la solución implementada, la relación ahora es lineal. Existe una relación más directa entre el tamaño de la imagen y el tiempo que se tarda en analizarla. Tenemos muchas más mejoras por venir que mejorarán el rendimiento lineal y podremos comparar esas mejoras de forma más directa.
Una vez resuelto ese problema, veamos otro problema de rendimiento común: las asignaciones adicionales. Veamos cuál es el seguimiento de pila más pesado ahora. Estas ejecuciones muestran que vemos mucho tráfico hacia los métodos que asignan y desasignan una matriz Swift. Asignar y desasignar memoria puede ser costoso. Mi analizador será más rápido si puedo averiguar el origen de estas asignaciones y eliminarlas. Para ver las asignaciones que hace mi analizador, puedo usar el instrumento Allocations como hice antes. Hay un par de indicadores distintos de que mi código podría estar causando asignaciones innecesarias. Lo primero es el número gigantesco. ¿Casi un millón de asignaciones para analizar una imagen? Creo que podemos hacerlo mejor. Segundo, podemos ver que casi todas esas asignaciones son transitorias y que están marcadas como de corta duración por Allocations. Para encontrar el origen del problema, cambiaré a la vista Call Tree. Primero, hago clic en el botón emergente Statistics. Luego selecciono Call Trees.
Con el hilo superior, examinaré el seguimiento de pila para encontrar la parte de mi código más cercana al problema. Dado que este seguimiento de pila no está invertido, debo comenzar por la parte inferior. El primer símbolo de mi analizador es este método RGBAPixel.data.
Cuando hago clic en él, se revela en la ventana de detalles del árbol de ejecuciones. Cuando hago clic derecho, puedo seleccionar Reveal in Xcode para ir directo al origen.
Este método parece ser el origen de las asignaciones adicionales. Puedo ver que cada vez que se ejecuta, devuelve una matriz con los valores RGB o los valores RGBA del pixel. Eso significa que creará una matriz y asignará espacio para al menos tres elementos cada vez que se ejecute.
Para saber dónde se usa, hago clic derecho en el nombre de la función y selecciono Show Callers. Se usa en este cierre en la función de análisis principal, que es una parte de esta gran cadena de prefix y flatMap. Para entender por qué este código realiza tantas asignaciones separadas, veamos cómo se acumulan las asignaciones, paso a paso.
Primero, el método readEncodedPixels analiza los datos binarios en pixeles codificados, los diferentes tipos de pixeles que mencioné antes, y necesita asignar suficiente espacio para almacenarlos.
Luego, se ejecuta decodePixels para cada pixel codificado para producir uno o más pixeles RGBA. La mayoría de las codificaciones se convierten en un solo pixel, pero hay una que indica que debemos repetir el pixel anterior una cierta cantidad de veces. Para ello, decodePixels siempre devuelve una matriz. Es necesario asignar cada una de esas matrices.
La parte que “aplana” de flatMap toma todas esas matrices que acabamos de crear y las fusiona en una matriz mucho más grande. Esa es una nueva asignación y todas las pequeñas matrices que acabamos de crear quedan desasignadas.
El método prefix pone un límite a la cantidad de pixeles que podemos producir.
El segundo flatMap inicia ejecutando RGBAPixel.data, el método que marcamos cuando usamos Allocations. Vimos antes que devuelve una matriz con tres o cuatro elementos. Lo que vemos ahora significa que se está creando una de esas matrices de tres o cuatro elementos para cada pixel de la imagen final. A veces, el compilador puede optimizar algunas de estas asignaciones, pero como vimos en el seguimiento, eso no siempre sucederá.
Luego, las matrices se vuelven a aplanar para formar una nueva matriz grande. Por último, esa gran matriz de datos de pixeles RGB o RGBA se copia en una nueva instancia de Data para que pueda devolverse.
Hay una cierta elegancia en estas líneas de código. Concentran una gran potencia en unas pocas ejecuciones de métodos encadenados. Pero el hecho de que sea más corto no significa que sea más rápido. En vez de pasar por todos esos pasos y terminar con una instancia de Data para devolver, ¿y si primero asignamos los datos y luego escribimos cada pixel mientras decodificamos los datos binarios de origen? De esta manera, podemos realizar el mismo procesamiento sin ninguna otra asignación intermedia. Volvamos a mi función de análisis. Reescribamos este método para eliminar todas las asignaciones adicionales.
Lo primero será calcular totalBytes: el tamaño final de los datos resultantes. Luego asignaremos pixelData con la cantidad justa de almacenamiento. La variable offset lleva un registro de la cantidad de datos que escribimos. Esta asignación inicial significa que no tendremos que hacer asignaciones adicionales mientras avanzamos con los datos binarios.
Ahora, analizaremos cada fragmento de datos y lo procesaremos de inmediato. Podemos usar una instrucción switch para procesar el pixel analizado.
Para los pixeles codificados que indican una secuencia, repetiremos el bucle las veces que sea necesario y escribiremos sus datos en cada iteración.
Para cualquier otro tipo de pixel, lo decodificaremos y lo escribiremos directamente en los datos. Esa es la reescritura completa, sin otras asignaciones que no sean para los datos que debemos devolver. Verifiquemos que solucionamos el problema y hagamos un nuevo perfil de la prueba.
Enseguida podemos ver que la cantidad de asignaciones es mucho menor. Para ver la cantidad real de asignaciones en mi código, puedo usar el filtro. Haré clic en el campo de filtro en la parte inferior de la ventana y escribiré QOI.init. Esto filtra los árboles de ejecuciones que no incluyan QOI.init en el seguimiento de pila. Las líneas restantes muestran que ahora el código analizador solo realiza algunas asignaciones, con un total de menos de dos MB. Cuando selecciono la opción y hago clic en el triángulo, el árbol se expande.
El árbol expandido nos muestra lo que queremos.
Lo único que asignamos son los datos que almacenan nuestra imagen resultante.
Si vemos la comparación, se trata de otra gran mejora. Al eliminar las asignaciones adicionales, redujimos el tiempo de ejecución a más de la mitad.
Hasta ahora hicimos dos cambios algorítmicos en nuestro analizador. Eliminamos muchas copias accidentales y redujimos la cantidad de asignaciones. En nuestras próximas mejoras, usaremos algunas técnicas más avanzadas para que el compilador de Swift elimine gran parte del trabajo de administración automática de la memoria en el tiempo de ejecución.
Primero, veamos cómo funcionan las matrices y otros tipos de recopilaciones. El tipo Array de Swift es una de las herramientas más comunes porque es rápida, segura y fácil de usar. Se expande o se reduce según necesites, por lo que no debes saber de antemano con cuántos elementos trabajarás. Swift administra la memoria por ti en segundo plano. Las matrices también son tipos de valores, por lo que los cambios en la copia de una matriz no afectan a otras copias. Si haces una copia y la asignas a una variable diferente o la pasas a una función, Swift no duplica inmediatamente los elementos. En su lugar, usa una optimización denominada copia en escritura, que retrasa esa duplicación hasta que se modifica una de las matrices.
Estas funciones hacen que las matrices sean una gran colección de uso general, pero también tienen algunas desventajas. Para admitir su tamaño dinámico y múltiples referencias, Array almacena su contenido en una asignación separada, a menudo en el montón. El tiempo de ejecución de Swift usa el recuento de referencias para llevar un registro de la cantidad de copias de cada matriz y, ante un cambio, las matrices verifican la unicidad para ver si deben copiar sus elementos. Por último, para garantizar la seguridad de tu código, Swift aplica la exclusividad. Esto significa que dos cosas diferentes no pueden modificar los mismos datos al mismo tiempo. Aunque esta regla se suele aplicar durante la compilación, a veces solo se puede aplicar durante la ejecución. Ahora que conocemos estos conceptos de bajo nivel, veamos cómo aparecen en nuestro perfil. Comenzaremos buscando controles de exclusividad en tiempo de ejecución, que pueden ralentizar tu programa y obstaculizar las optimizaciones. Antes de empezar con esto, tenemos un buen problema. Mejoramos el rendimiento lo suficiente como para que Instruments no tenga tiempo suficiente para inspeccionar el proceso del analizador. Podemos darle un poco más para examinar con un bucle sobre el código de análisis. 50 veces debería ser suficiente.
Echemos un vistazo a este perfil más completo.
Las pruebas de exclusividad aparecen en el seguimiento con los símbolos swift_beginAccess y swiftendAccess. Otra vez hago clic en el campo de filtro en la parte inferior de la ventana e introduzco el nombre del símbolo.
En la parte superior del gráfico, swift_beginAccess aparece varias veces, con los símbolos que requieren esta verificación justo debajo. Esos símbolos son los accesores del pixel anterior y la caché de pixeles, que se almacenan en la clase State de mi analizador. Volveré a Xcode y buscaré esa declaración. Aquí está… State es la clase con las dos propiedades del gráfico. Modificar una instancia de clase es una de las situaciones en las que Swift debe verificar la exclusividad durante la ejecución, por lo que esta declaración es la razón por la que vemos esto. Para eliminar ese control, podemos mover estas propiedades fuera de la clase y colocarlas en el tipo de analizador.
Luego, realizaremos una búsqueda y reemplazo para eliminar los accesos state. para previousPixel y pixelCache.
Cuando desarrollo, el compilador me informa que hay más trabajo por hacer.
Como las propiedades de estado ya no están anidadas en una clase, no puedo modificarlas en un método que no sea mutante.
Aceptaré esta solución para hacer que el método mute.
Hay una cosa más que corregir...
y terminamos. Con ese cambio implementado, volvamos a la prueba...
y volvamos a grabar un perfil para ver el cambio.
Volveré a filtrar por swift_beginAccess.
Está vacío. Eliminamos por completo el control de exclusividad en tiempo de ejecución. Echemos otro vistazo a esas variables de estado. Este es un buen momento para usar una nueva función de Swift para mover datos de la memoria dinámica a la memoria estática y garantizar que esos controles de exclusividad no reaparezcan. La caché de pixeles del analizador es una matriz de RGBAPixels. Se inicializa con 64 elementos y nunca cambia de tamaño. Esta caché es el lugar ideal para usar el nuevo tipo InlineArray. InlineArray es un nuevo tipo de biblioteca estándar en Swift 6.2. Como una matriz regular, almacena múltiples elementos del mismo tipo en una memoria contigua, pero tiene diferencias importantes. Primero, las matrices InlineArray tienen un tamaño fijo que se ajusta al momento de la compilación. A diferencia de las matrices regulares, a las que les puedes agregar o quitar elementos, InlineArray usa la nueva función de genéricos de valores para hacer que su tamaño sea parte de su tipo. Significa que, aunque puedes hacer cambios en los elementos de InlineArray, no puedes agregar ni eliminar, ni asignar una InlineArray a una de tamaño diferente.
Segundo, como su nombre lo indica, cuando usas InlineArray, los elementos siempre se almacenan en línea en lugar de en una asignación separada. InlineArray no comparte almacenamiento entre copias y no usa copia en escritura. En cambio, se copian cada vez que se hace una copia. Esto elimina la necesidad del recuento de referencias y los controles de unicidad y exclusividad que requieren las matrices normales. Este comportamiento de copia diferente de InlineArray es un arma de doble filo. Si el uso de un Array requiere hacer copias o compartir referencias entre diferentes variables o clases, un InlineArray podría no ser la opción correcta. En este caso, sin embargo, la caché de pixeles es una matriz de tamaño fijo que se modifica en el lugar, pero que nunca se copia. Un lugar perfecto para usar InlineArray.
Para nuestra optimización final, usaremos los nuevos tipos de span de la biblioteca estándar para eliminar la mayor parte del recuento de referencias durante el análisis. Volvamos al gráfico de Time Profiler y usemos el filtro nuevamente para ver solo el analizador de QOI. Escribiré QOI.init en el cuadro de filtro.
La vista cambia para centrarse solo en los seguimientos de pila que incluyen el inicializador de análisis. Busquemos los símbolos de retención y liberación. swift_retain es esta barra rosa, aparece en el 7% de las muestras, y swift_release es esta otra, que aparece en otro 7%. El control de unicidad del que hablamos antes también aparece aquí, en otro 3% de las muestras.
Para averiguar el origen, volveré a hacer clic en swift_release y, como antes, escanearé el seguimiento de pila más pesado para buscar el primer método definido por el usuario. Parece que es el mismo método readByte con el que comenzamos.
Esta vez no se trata de un problema algorítmico sino del uso de Data en sí. Al igual que Array, Data suele almacenar su memoria en el montón y requiere un conteo de referencias. Estas operaciones de conteo de referencias, retención y liberación, son muy eficientes, pero pueden sumar una cantidad significativa de tiempo cuando ocurren en un bucle cerrado, como este método. Para solucionar esto, queremos pasar de trabajar con un tipo de colección de alto nivel como Data o Array a un tipo que no provoque esta explosión de conteo de referencias. Hasta Swift 6.2, es posible que hayas usado un método como withUnsafeBufferPointer para acceder al almacenamiento subyacente de una colección. Estos métodos te permiten administrar la memoria manualmente, sin contar referencias, pero introducen riesgos de seguridad en tu código.
Vale la pena preguntar: ¿por qué los punteros no son seguros? Swift los llama poco seguros porque eluden muchas de las garantías de seguridad del lenguaje. Pueden apuntar tanto a memoria inicializada como no inicializada, eliminan algunas garantías de tipo y pueden escapar de su contexto, lo que genera un riesgo de acceder a memoria que ya no está asignada. Cuando usas punteros poco seguros, eres totalmente responsable de mantener intacta la seguridad de tu código. El compilador no puede ayudarte. Esta función processUsingBuffer usa punteros poco seguros de forma correcta. El uso permanece dentro del cierre del puntero de búfer poco seguro, y solo se devuelve el resultado del cálculo al final. Por otro lado, esta función getPointerToBytes() es peligrosa. Contiene dos errores de programación importantes. La función crea una matriz de bytes y ejecuta withUnsafeBufferPointer, pero en lugar de limitar el uso del puntero al cierre, devuelve el puntero al ámbito externo. Primer error. Peor aún, el código luego devuelve ese puntero que ya no es válido desde la propia función. Segundo error. Ambos errores extienden la vida del puntero más allá de la vida útil de aquello a lo que apunta, y crea una peligrosa referencia residual a la memoria reubicada o desasignada. Para ayudar con esto, Swift 6.2 presenta un nuevo grupo de tipos llamados Span. Los spans son una nueva forma de trabajar con la memoria contigua de una colección. Es importante destacar que los spans usan la nueva funcionalidad de lenguaje no escapable que permite al compilador vincular su tiempo de vida a la colección que los proporciona. La memoria a la que un span da acceso tendrá la misma duración que el span, sin posibilidad de que queden referencias residuales. Como cada tipo de span se declara como no escapable, el compilador impide que liberes o devuelvas un span fuera del contexto donde lo recuperaste. Este método processUsingSpan muestra cómo se puede usar un span para escribir un código más simple y seguro que el que permiten los punteros. Para obtener un span sobre los elementos de la matriz, usa la propiedad span. Sin usar un cierre, tenemos acceso al almacenamiento de la matriz que es tan eficiente como los punteros poco seguros, pero sin las inseguridades. Podemos ver la funcionalidad de lenguaje no escapable en acción si intentamos reescribir la función peligrosa anterior. Lo primero que encontraremos es que ni siquiera podemos escribir esta misma firma de función con Span. Dado que la vida útil de un span está vinculada a la colección que lo proporciona, sin ninguna colección o span que se pase, no hay ningún lugar de donde obtener la vida útil del span que se pasa.
¿Qué pasa si intentamos ocultar el span del compilador, capturándolo en un cierre? En esta función, crearé una matriz, accederé a su span y luego intentaré devolver un cierre que capture ese span. Pero ni siquiera eso funciona. El compilador reconoce que capturar el span le permite escapar y señala que su vida útil depende de la matriz local. Este requisito verificado por el compilador de que un span no escape de su ámbito significa que las retenciones y las liberaciones no son necesarias. Obtenemos el rendimiento de usar un búfer poco seguro sin ningún problema de seguridad. La familia Span incluye versiones tipificadas y sin procesar de spans de solo lectura y mutables para trabajar con colecciones existentes, así como un span de salida que puedes usar para inicializar una nueva colección. También se incluye UTF8Span, un nuevo tipo diseñado para un procesamiento Unicode seguro y eficiente.
De vuelta al código, implementemos este mismo método readByte para RawSpan.
Comenzamos agregando una extensión RawSpan...
y definiendo el método readByte.
La API de RawSpan es un poco diferente de Data, pero hace lo mismo que nuestra implementación anterior. Carga el primer byte, reduce el RawSpan y luego devuelve el valor cargado. Recuerda que este método unsafeLoad se denomina así solo porque puede ser poco seguro cargar ciertos tipos de tipos. Cargar un tipo entero integrado, como hacemos aquí, siempre es seguro.
Luego, actualizaremos nuestros métodos de análisis.
Estos dos métodos de análisis deben usar RawSpan en lugar de Data como parámetro.
También necesitaré hacer un cambio en el sitio de ejecución.
En lugar de pasar los datos en sí, obtendremos el RawSpan de los datos y lo pasaremos al método de análisis. Accederé al RawSpan de los datos con la propiedad bytes. Este valor rawBytes es no escapable. No podría devolverlo desde esta función, pero puedo pasarlo al método de análisis sin ningún problema.
Con ese cambio, terminé con la actualización para usar RawSpan. Para ahorrar aún más trabajo de bajo nivel, también podemos adoptar el nuevo OutputSpan en nuestro método de análisis.
En lugar de crear un Data inicializado en cero, usaremos el nuevo inicializador rawCapacity, que ofrece un OutputSpan para completar gradualmente los datos no inicializados.
OutputSpan lleva un registro de la cantidad de datos que escribí, y podemos usar su propiedad count en lugar de la variable offset separada.
Usaremos una variación diferente de nuestro método de escritura que escribe en outputSpan en lugar de en una instancia de Data.
Veamos cómo se implementa este método.
El método write(to:) puede ejecutar el método append de OutputSpan para cada canal en el pixel. Dado que OutputSpan es un tipo no escapable que está diseñado para este tipo de uso, es más simple y eficiente que escribir en la instancia de Data, y más seguro que bajar a un puntero de búfer poco seguro. Con esos cambios terminados, volvamos a la prueba y grabemos un nuevo perfil.
Filtraré por QOI.init.
En el gráfico de llamas podemos ver que los bloques swift_retain y swift_release desaparecieron. Se ve genial. Detengámonos ahí y veamos los resultados de adoptar InlineArray y RawSpan.
Con estos últimos cambios, nuestro trabajo de administración de memoria hizo que nuestro análisis sea seis veces más rápido, sin código poco seguro. Es 16 veces más rápido de lo que era después de deshacernos del algoritmo cuadrático, y más de 700 veces más rápido que con lo que comenzamos. Ya vimos mucho en esta sesión. Al revisar esta biblioteca de análisis de imágenes, realizamos dos cambios algorítmicos para operar de manera más eficiente y reducir las asignaciones. Usamos nuevos tipos de biblioteca estándar, InlineArray y RawSpan, para eliminar la administración de memoria en tiempo de ejecución y aprendimos sobre la nueva funcionalidad de lenguaje no escapable. La nueva biblioteca de análisis binario de Swift se basa en las mismas funcionalidades. La biblioteca está diseñada para crear analizadores seguros y eficientes de formatos binarios, y ayuda a los desarrolladores a abordar distintos tipos de seguridad. La biblioteca ofrece un conjunto completo de inicializadores de análisis y otras herramientas que te guían para consumir de manera segura datos binarios sin procesar.
Este es un analizador para el encabezado QOI, escrito con la nueva biblioteca. Esto muestra varias de sus funcionalidades, como ParserSpan, un tipo de span sin procesar personalizado para analizar datos binarios e inicializadores de análisis que evitan el desbordamiento de enteros y te permiten especificar la propiedad de signo, el ancho de bits y el orden de bytes. La biblioteca también ofrece analizadores de validación para tus propios tipos personalizados representables sin procesar y operadores de producción opcional para hacer cálculos de forma segura con valores recién analizados y no confiables.
Ya estamos usando la biblioteca de análisis binario en Apple y ya está disponible para el público. Te invitamos a que le eches un vistazo y la pruebes. Para unirte a la comunidad, puedes publicar en los foros de Swift o abrir incidencias o solicitudes de incorporación de cambios en GitHub. Muchas gracias por acompañarme en este proceso de optimización de nuestro código Swift. Intenta usar Xcode e Instruments para crear un perfil de prueba de las partes críticas del rendimiento de tu app. Puede explorar los nuevos tipos InlineArray y Span en la documentación o descargando la nueva versión de Xcode. ¡Qué disfrutes la WWDC!
-
-
7:01 - Corrected Data.readByte() method
import Foundation extension Data { /// Consume a single byte from the start of this data. mutating func readByte() -> UInt8? { guard !isEmpty else { return nil } return self.popFirst() } }
-
9:56 - RGBAPixel.data(channels:) method
extension RGBAPixel { /// Returns the RGB or RGBA values for this pixel, as specified /// by the given channels information. func data(channels: QOI.Channels) -> some Collection<UInt8> { switch channels { case .rgb: [r, g, b] case .rgba: [r, g, b, a] } } }
-
10:21 - Original QOIParser.parseQOI(from:) method
extension QOIParser { /// Parses an image from the given QOI data. func parseQOI(from input: inout Data) -> QOI? { guard let header = QOI.Header(parsing: &input) else { return nil } let pixels = readEncodedPixels(from: &input) .flatMap { decodePixels(from: $0) } .prefix(header.pixelCount) .flatMap { $0.data(channels: header.channels) } return QOI(header: header, data: Data(pixels)) } }
-
12:53 - Revised QOIParser.parseQOI(from:) method
extension QOIParser { /// Parses an image from the given QOI data. func parseQOI(from input: inout Data) -> QOI? { guard let header = QOI.Header(parsing: &input) else { return nil } let totalBytes = header.pixelCount * Int(header.channels.rawValue) var pixelData = Data(repeating: 0, count: totalBytes) var offset = 0 while offset < totalBytes { guard let nextPixel = parsePixel(from: &input) else { break } switch nextPixel { case .run(let count): for _ in 0..<count { state.previousPixel .write(to: &pixelData, at: &offset, channels: header.channels) } default: decodeSinglePixel(from: nextPixel) .write(to: &pixelData, at: &offset, channels: header.channels) } } return QOI(header: header, data: pixelData) } }
-
15:07 - Array behavior
var array = [1, 2, 3] array.append(4) array.removeFirst() // array == [2, 3, 4] var copy = array copy[0] = 10 // copy happens on mutation // array == [2, 3, 4] // copy == [10, 3, 4]
-
19:47 - InlineArray behavior (part 1)
var array: InlineArray<3, Int> = [1, 2, 3] array[0] = 4 // array == [4, 2, 3] // Can't append or remove elements array.append(4) // error: Value of type 'InlineArray<3, Int>' has no member 'append' // Can only assign to a same-sized inline array let bigger: InlineArray<6, Int> = array // error: Cannot assign value of type 'InlineArray<3, Int>' to type 'InlineArray<6, Int>'
-
20:23 - InlineArray behavior (part 2)
var array: InlineArray<3, Int> = [1, 2, 3] array[0] = 4 // array == [4, 2, 3] var copy = array // copy happens on assignment for i in copy.indices { copy[i] += 10 } // array == [4, 2, 3] // copy == [14, 12, 13]
-
23:13 - processUsingBuffer() function
// Safe usage of a buffer pointer func processUsingBuffer(_ array: [Int]) -> Int { array.withUnsafeBufferPointer { buffer in var result = 0 for i in 0..<buffer.count { result += calculate(using: buffer, at: i) } return result } }
-
23:34 - Dangerous getPointerToBytes() function
// Dangerous - DO NOT USE! func getPointerToBytes() -> UnsafePointer<UInt8> { let array: [UInt8] = Array(repeating: 0, count: 128) // DANGER: The next line escapes a pointer let pointer = array.withUnsafeBufferPointer { $0.baseAddress! } // DANGER: The next line returns the escaped pointer return pointer }
-
24:46 - processUsingSpan() function
// Safe usage of a span @available(macOS 16.0, *) func processUsingSpan(_ array: [Int]) -> Int { let intSpan = array.span var result = 0 for i in 0..<intSpan.count { result += calculate(using: intSpan, at: i) } return result }
-
25:07 - getHiddenSpanOfBytes() function (attempt 1)
@available(macOS 16.0, *) func getHiddenSpanOfBytes() -> Span<UInt8> { } // error: Cannot infer lifetime dependence...
-
25:28 - getHiddenSpanOfBytes() function (attempt 2)
@available(macOS 16.0, *) func getHiddenSpanOfBytes() -> () -> Int { let array: [UInt8] = Array(repeating: 0, count: 128) let span = array.span return { span.count } }
-
26:27 - RawSpan.readByte() method
@available(macOS 16.0, *) extension RawSpan { mutating func readByte() -> UInt8? { guard !isEmpty else { return nil } let value = unsafeLoadUnaligned(as: UInt8.self) self = self._extracting(droppingFirst: 1) return value } }
-
28:02 - Final QOIParser.parseQOI(from:) method
/// Parses an image from the given QOI data. mutating func parseQOI(from input: inout RawSpan) -> QOI? { guard let header = QOI.Header(parsing: &input) else { return nil } let totalBytes = header.pixelCount * Int(header.channels.rawValue) let pixelData = Data(rawCapacity: totalBytes) { outputSpan in while outputSpan.count < totalBytes { guard let nextPixel = parsePixel(from: &input) else { break } switch nextPixel { case .run(let count): for _ in 0..<count { previousPixel .write(to: &outputSpan, channels: header.channels) } default: decodeSinglePixel(from: nextPixel) .write(to: &outputSpan, channels: header.channels) } } } return QOI(header: header, data: pixelData) }
-
28:31 - RGBAPixel.write(to:channels:) method
@available(macOS 16.0, *) extension RGBAPixel { /// Writes this pixel's RGB or RGBA data into the given output span. @lifetime(&output) func write(to output: inout OutputRawSpan, channels: QOI.Channels) { output.append(r) output.append(g) output.append(b) if channels == .rgba { output.append(a) } } }
-
-
- 0:00 - Introducción y agenda
Obtenga información sobre cómo optimizar el rendimiento de las apps y bibliotecas de código Swift utilizando Swift 6.2. Los nuevos tipos 'InlineArray' y 'Span' reducen las asignaciones, las comprobaciones de exclusividad y el recuento de referencias. Se presenta una nueva biblioteca Swift de código abierto, Binary Parsing, para un análisis binario rápido y seguro.
- 1:19 - App de formato y parser QOI
La app de esta sesión de la WWDC25 carga imágenes en formato QOI, un formato simple y sin pérdidas con una especificación de una sola página. El analizador de imágenes de la app gestiona varios métodos de codificación de píxeles. Luego, la app carga instantáneamente un pequeño archivo de ícono, pero tarda unos segundos en cargar una foto más grande de un pájaro.
- 2:25 - Algoritmos
Cuando las apps trabajan con datos del mundo real, a menudo pueden surgir problemas de rendimiento debido al uso incorrecto de algoritmos o estructuras de datos. Para identificar y abordar estos problemas, puede utilizar Instrumentos, que tiene plantillas de instrumentos para analizar asignaciones y liberaciones e identificar código ineficiente con perfiladores. La herramienta Time Profiler es especialmente útil para problemas de rendimiento. Al analizar las llamadas capturadas y los seguimientos de pila, puede identificar las áreas donde las apps pasan la mayor parte del tiempo. En el ejemplo, se gastó una cantidad significativa de tiempo en una llamada del sistema para copiar datos, 'platform_memmove'. Mediante el uso de instrumentos, este ejemplo analiza un método personalizado llamado "readByte". Este método se agregó a una extensión del tipo 'Datos', lo que provocó una copia excesiva de datos binarios. El ejemplo reemplaza el método con el método 'popFirst()' más eficiente, que reduce los datos del frente de una secuencia sin copiar. Este cambio resolvió el problema de rendimiento en el método 'readByte'. Después de realizar el cambio, el ejemplo ejecutó el perfil nuevamente y la barra significativa 'platform_memmove' desapareció del gráfico de llama. La evaluación comparativa mostró una aceleración sustancial y la relación entre el tamaño de la imagen y el tiempo de análisis cambió de cuadrática a lineal, lo que indica un algoritmo más eficiente.
- 8:17 - Asignaciones
Se vuelve a perfilar la app para descubrir que el analizador de imágenes genera asignaciones y desasignaciones de memoria excesivas, en particular cuando involucran matrices. La gran cantidad de asignaciones, casi un millón para analizar una sola imagen, indica un problema crítico. La mayoría de estas asignaciones son transitorias y de corta duración, lo que sugiere que pueden optimizarse. Para identificar la fuente de estas asignaciones innecesarias, el ejemplo utiliza el instrumento Asignaciones en Instrumentos. El análisis revela que un método llamado 'RGBAPixel.data(channels:)' es el principal culpable. Este método crea una matriz cada vez que se llama, lo que genera una cantidad sustancial de asignaciones. La estructura del código, que implica una cadena compleja de métodos 'flatMap' y 'prefix', contribuye al problema. Cada paso de esta cadena genera nuevas asignaciones a medida que las matrices se crean, se aplanan y se copian repetidamente. Si bien este enfoque es conciso, no es eficiente en el uso de la memoria. Para solucionar este problema, el ejemplo reescribe la función de análisis. En lugar de depender de asignaciones intermedias, calcula de antemano el tamaño total de los datos del resultado y asigna un único búfer. Este enfoque elimina la necesidad de asignaciones repetidas durante el proceso de decodificación.
- 16:30 - Exclusividad
El rendimiento de la app mejoró tanto que los instrumentos de creación de perfiles necesitaban más datos. Después de repetir el código de análisis 50 veces, los resultados mostraron los símbolos 'swift_beginAccess' y 'swift_endAccess' que indican pruebas de exclusividad. Estas pruebas de exclusividad fueron causadas por propiedades en la clase 'State' anidadas dentro de la estructura 'QOIParser', que el ejemplo luego mueve directamente al tipo de analizador padre para eliminar las verificaciones de exclusividad. Después de algunos ajustes del compilador, la verificación de exclusividad se eliminó por completo, como lo verificó una nueva ejecución del perfil.
- 19:12 - Pila contra montón
El ejemplo reemplaza el uso de 'Array' en la app con 'InlineArray', una colección de tamaño fijo almacenada en línea, que optimiza el uso de la memoria al eliminar el conteo de referencias y las verificaciones de exclusividad. Es ideal para el caché de píxeles ・・ una matriz de 64 elementos que nunca cambia de tamaño y se modifica en el lugar, lo que mejora el rendimiento sin la necesidad de copiar o compartir referencias.
- 21:08 - Recuento de referencias
En el ejemplo de optimización final de la app, el ejemplo utiliza los nuevos tipos "Span" para mejorar el rendimiento y mejorar la seguridad de la memoria. En Instrumentos, se utiliza el gráfico de llama del análisis del Perfilador de tiempo. Los datos perfilados se centran en 'QOIParser' y encuentran que se dedica un tiempo significativo a operaciones de conteo de referencias, particularmente con el tipo 'Datos' debido a su semántica de copia en escritura. 'Span' y sus tipos relacionados son una nueva forma de trabajar con memoria contigua en una colección. Utilizan la característica no escapable de Swift ('~Escapable'), que vincula sus duraciones de vida a la colección, lo que garantiza la seguridad de la memoria y elimina la necesidad de una gestión manual de la memoria. Esto permite un acceso eficiente a la memoria sin los riesgos asociados con los punteros inseguros. El ejemplo demuestra cómo utilizar los tipos 'Span' para reescribir métodos existentes, haciéndolos más simples, más seguros y con mayor rendimiento. En los métodos de análisis de imágenes, 'Datos' se reemplaza por 'RawSpan' y la sobrecarga del recuento de referencias se reduce enormemente. Además, se adopta 'OutputSpan' en el proceso de análisis para una mayor optimización, lo que hace que la operación de análisis sea seis veces más rápida que antes sin recurrir a punteros inseguros.
- 29:52 - Biblioteca de análisis binario Swift
Swift Binary Parsing le permite crear analizadores seguros y eficientes para formatos binarios. Ofrece herramientas para gestionar diversos aspectos de seguridad, incluida la prevención del desbordamiento de enteros, la especificación del signo, el ancho de bits y el orden de bytes y la validación de tipos personalizados. La biblioteca ya se encuentra en uso en Apple y está disponible públicamente para que puedas probarla y contribuir a través de los foros de Swift y GitHub.
- 31:03 - Próximos pasos
Las conclusiones clave incluyen: Uso de Xcode e instrumentos para crear perfiles de apps. Analizar el rendimiento de los algoritmos para identificar cuellos de botella. Explorando soluciones a lo anterior con los nuevos tipos 'InlineArray' y 'Span' introducidos en Swift 6.2.