Estructuras vs. Clases

Desde el principio, cuando comencé a programar, surgió la pregunta de qué usar para mejorar el rendimiento: estructura o clase; qué matrices son mejores para usar y cómo. En cuanto a las estructuras, Apple agradece su uso, explicando que son mejores en la optimización, y toda la esencia del lenguaje Swift son las estructuras. Pero hay quienes no están de acuerdo con esto, porque puedes simplificar maravillosamente el código heredando una clase de otra y trabajando con esa clase. Para acelerar el trabajo con las clases, creamos diferentes modificadores y objetos que fueron optimizados específicamente para las clases, y ya es difícil decir qué será más rápido y en qué caso.

Para organizar todos los puntos en la "e", escribí varias pruebas que utilizan los enfoques habituales para el procesamiento de datos: pasar a un método, copiar, trabajar con matrices, etc. Decidí no sacar grandes conclusiones, todos decidirán por sí mismos si vale la pena creer en las pruebas, podrán descargar el proyecto y verán cómo funcionará para usted, y tratarán de optimizar el funcionamiento de una prueba en particular. Quizás incluso saldrán nuevos chips que no mencioné, o se usan tan raramente que simplemente no he oído hablar de ellos.

PD: comencé a trabajar en un artículo sobre Xcode 10.3 y pensé en tratar de comparar su velocidad con Xcode 11, pero aún así el artículo no se trata de comparar dos aplicaciones, sino de la velocidad de nuestras aplicaciones. No tengo dudas de que el tiempo de ejecución de las funciones disminuirá, y lo que ha sido mal optimizado se volverá más rápido. Como resultado, esperé el nuevo Swift 5.1 y decidí probar las hipótesis en la práctica. Que tengas una buena lectura.

Prueba 1: Comparar matrices en estructuras y clases


Supongamos que tenemos una clase y queremos colocar los objetos de esta clase en una matriz, la acción habitual en una matriz es recorrerla.

En una matriz, cuando se usan clases en él y se trata de recorrerlo, el número de enlaces aumenta, una vez completado, el número de enlaces al objeto disminuirá.

Si pasamos por la estructura, en el momento en que el objeto es llamado por índice, se creará una copia del objeto, mirando la misma área de memoria, pero marcada como inmutable. Es difícil decir qué es más rápido: aumentar el número de enlaces a un objeto o crear un enlace a un área en la memoria con la falta de la capacidad de cambiarlo. Vamos a verlo en la práctica:


Fig. 1: Comparación de obtener una variable de matrices basadas en estructuras y clases

Prueba 2. Comparar ContiguousArray versus Array


Lo que es más interesante es comparar el rendimiento de una matriz (Array) con una matriz de referencia (ContiguousArray), que es necesaria específicamente para trabajar con clases almacenadas en la matriz.

Verifiquemos el rendimiento para los siguientes casos:

Matriz contigua que almacena una estructura con tipo de valor
Estructura de almacenamiento de matriz contigua con String
Clase de almacenamiento ContiguousArray con tipo de valor
Clase de almacenamiento ContiguousArray con String
Estructura de almacenamiento de matriz con tipo de valor
Estructura de almacenamiento de matriz con String
Clase de almacenamiento de matriz con tipo de valor
Clase de almacenamiento de matriz con String

Dado que los resultados de la prueba (pruebas: pasar a una función con la optimización en línea desactivada, pasar a una función con la optimización en línea activada, eliminar elementos, agregar elementos, acceso secuencial a un elemento en un bucle) incluirá una gran cantidad de pruebas (para 8 matrices de 5 pruebas cada una) , Daré los resultados más significativos:

  1. Si llama a una función y le pasa una matriz, apagando en línea, entonces dicha llamada será muy costosa (para las clases basadas en la Cadena de referencia, es 20,000 veces más lenta, para las clases basadas en Valor, el tipo es 60,000 veces peor con el optimizador en línea apagado) .
  2. Si la optimización (en línea) funciona para usted, entonces se debe esperar la degradación solo 2 veces, dependiendo de qué tipo de datos se agregue a qué matriz. La única excepción fue el tipo de valor, envuelto en una estructura que se encuentra en la matriz contigua, sin degradación del tiempo.
  3. Eliminación: la extensión entre la matriz de referencia y la habitual era de aproximadamente el 20% (a favor de la matriz habitual).
  4. Anexar: al usar objetos envueltos en clases, ContiguousArray tenía una velocidad aproximadamente un 20% más rápida que Array con los mismos objetos, mientras que Array era más rápido al trabajar con estructuras que ContiguousArray con estructuras.
  5. El acceso a los elementos de la matriz cuando se usan contenedores de estructuras resultó ser más rápido que cualquier contenedor en las clases, incluido ContiguousArray (aproximadamente 500 veces más rápido).

En la mayoría de los casos, usar matrices regulares para trabajar con objetos es más eficiente. Utilizado antes, utilizamos más.

La optimización de bucle para matrices es servida por el inicializador de colección diferida, que le permite recorrer toda la matriz solo una vez, incluso si usa varios filtros o mapas sobre los elementos de la matriz.

Al utilizar las estructuras como una herramienta de optimización, existen dificultades, como el uso de tipos a los que se hace referencia internamente en la naturaleza: cadenas, diccionarios, matrices de referencia. Luego, cuando una variable que almacena un tipo de referencia en sí mismo se ingresa a una función, se crea una referencia adicional para cada elemento que es una clase. Esto tiene otro lado, un poco más al respecto. Podría intentar usar una clase contenedora sobre una variable. Entonces, el número de enlaces al pasar a la función aumentará solo para ella, y el número de enlaces a los valores dentro de la estructura seguirá siendo el mismo. En general, quiero ver cuántas variables de un tipo de referencia deben estar en la estructura para que su rendimiento disminuya por debajo del rendimiento de las clases con los mismos parámetros. Hay un artículo en la web llamado "¡Deja de usar estructuras!" Que hace la misma pregunta y la responde. Descargué el proyecto y decidí averiguar qué sucede dónde y en qué casos obtenemos estructuras lentas. El autor muestra el bajo rendimiento de las estructuras en comparación con las clases, argumentando que crear un nuevo objeto es mucho más lento que aumentar la referencia al objeto es absurdo (por lo que siempre eliminé la línea donde se crea un nuevo objeto en el bucle). Pero si no creamos un enlace al objeto, sino que simplemente lo pasamos a una función para trabajar con él, entonces la diferencia en el rendimiento será muy insignificante. Cada vez que ponemos en línea (nunca) en una función, nuestra aplicación debe ejecutarla y no crear código en una cadena. A juzgar por las pruebas, Apple logró que el objeto pasado a la función se modificara ligeramente, para las estructuras el compilador cambia la mutabilidad y hace que el acceso a las propiedades no mutables del objeto sea lento. Algo similar sucede en la clase, pero al mismo tiempo aumenta el número de referencias al objeto. Y ahora tenemos un objeto perezoso, todos sus campos también son perezosos, y cada vez que llamamos a una variable de objeto, la inicializa. En esto, las estructuras no tienen igual: cuando una función llama a dos variables, la estructura del objeto es solo ligeramente inferior a la clase en velocidad; cuando llamas a tres o más, la estructura siempre será más rápida.

Prueba 3: compare el rendimiento de estructuras y clases que almacenan clases grandes


También cambié ligeramente el método en sí, que se llamó cuando se agregó otra variable (de esta manera, se inicializaron tres variables en el método, y no dos, como en el artículo), y para que no haya un desbordamiento Int, reemplacé las operaciones en las variables con la suma y la resta. Se agregaron métricas de tiempo más claras (en la captura de pantalla son segundos, pero no es tan importante para nosotros, entender las proporciones resultantes es importante), eliminar el marco Darwin (no lo uso en proyectos, tal vez en vano, no hay diferencias en las pruebas antes / después de agregar el marco en mi prueba), la inclusión de la máxima optimización y compilación en la compilación de lanzamiento (parece que esto será más honesto), y aquí está el resultado:


Fig. 2: Rendimiento de estructuras y clases del artículo "Dejar de usar estructuras"

Las diferencias en los resultados de la prueba son insignificantes.

Prueba 4: Función que acepta genérico, protocolo y función sin genérico


Si tomamos una función genérica y pasamos dos valores allí, unidos solo por la capacidad de comparar estos valores (func min), entonces el código de tres líneas se convertirá en un código de ocho (como dice Apple). Pero esto no siempre sucede, Xcode tiene métodos de optimización en los que si ve que se le pasan dos valores estructurales cuando llama a la función, genera automáticamente una función que toma dos estructuras y ya no copia los valores.


Fig. 3: Función genérica típica

Decidí probar dos funciones: en la primera, se declara el tipo de datos Genérico, la segunda acepta solo el Protocolo. En la nueva versión del protocolo Swift 5.1, es incluso un poco más rápido que el genérico (antes de Swift 5.1 los protocolos eran 2 veces más lentos), aunque según Apple debería ser al revés, pero cuando se trata de pasar por una matriz, ya tenemos que escribir, lo que se ralentiza Genérico (pero siguen siendo geniales, porque son más rápidos que los protocolos):


Fig. 4: Comparación de funciones de host genéricas y de protocolo.

Prueba 5: compare la llamada del método principal y el método nativo y, al mismo tiempo, verifique la clase final para dicha llamada


Lo que siempre me interesó es cuán lentamente funcionan las clases con un gran número de padres, qué tan rápido una clase activa sus funciones y las de un padre. En los casos en que estamos tratando de llamar a un método que toma una clase, el despacho dinámico entra en juego. Que es esto Cada vez que se llama a un método o variable dentro de nuestra función, se genera un mensaje pidiéndole al objeto esta variable o método. El objeto, al recibir dicha solicitud, comienza a buscar el método en la tabla de despacho de su clase, y si se llamó a una anulación del método o variable, lo toma y lo devuelve, o llega recursivamente a la clase base.


Fig. 5: llamadas a métodos de clase, para pruebas de despacho

Se pueden sacar varias conclusiones de la prueba anterior: cuanto mayor sea la clase de clases primarias, más lenta funcionará, y que la diferencia de velocidad es tan pequeña que puede descuidarse con seguridad, lo más probable es que la optimización del código haga que no haya diferencia en la velocidad. En este ejemplo, el modificador de clase final no tiene una ventaja, por el contrario, el trabajo de la clase es aún más lento, posiblemente debido al hecho de que no se convierte en una función realmente rápida.

Prueba 6: Llamar una variable con modificador final contra una variable de clase regular


También resultados muy interesantes al asignar el modificador final a una variable, puede usarlo cuando sepa con certeza que la variable no se reescribirá en ningún lugar de los herederos de la clase. Intentemos poner el modificador final a una variable. Si en nuestra prueba creamos solo una variable y llamamos una propiedad sobre ella, entonces se inicializaría una vez (el resultado es de abajo). Si creamos honestamente cada vez que un nuevo objeto y solicitamos su variable, la velocidad disminuirá notablemente (el resultado está arriba):


Fig. 6: Llamar variable final

Obviamente, el modificador no fue en beneficio de la variable, y siempre es más lento que su competidor.

Prueba 7: Problema de polimorfismo y protocolos para estructuras. O el rendimiento de un contenedor existencial


Problema: si tomamos un protocolo que admita un determinado método y varias estructuras heredadas de este protocolo, ¿qué pensará nuestro compilador cuando coloquemos estructuras con diferentes volúmenes de valores almacenados en una matriz, unidos por el protocolo original?

Para resolver el problema de llamar a un método predefinido en los herederos, se utiliza el mecanismo de Tabla de Testigos de Protocolo. Crea estructuras de shell que hacen referencia a los métodos necesarios.

Para resolver el problema del almacenamiento de datos, se utiliza un contenedor Existencial. Almacena en sí mismo 5 celdas de información, cada una de 8 bytes. En los primeros tres, se asigna espacio para los datos almacenados en la estructura (si no encajan, crea un enlace al montón en el que se almacenan los datos), el cuarto almacena información sobre los tipos de datos que se utilizan en la estructura y nos dice cómo administrar estos datos. , el quinto contiene referencias a los métodos del objeto.


Figura 7. Comparación del rendimiento de una matriz que crea un enlace a un objeto y que lo contiene

Entre el primer y el segundo resultado, el número de variables se triplicó. En teoría, deben colocarse en un contenedor, se almacenan en este contenedor y la diferencia de velocidad se debe al volumen de la estructura. Curiosamente, si reduce el número de variables en la segunda estructura, el tiempo de operación no cambiará, es decir, el contenedor realmente almacena 3 o 2 variables, pero aparentemente, hay condiciones especiales para una variable que aumentan significativamente la velocidad. La segunda estructura encaja perfectamente en el contenedor y difiere en volumen del tercero a la mitad, lo que da una fuerte degradación en tiempo de ejecución, en comparación con otras estructuras.

Un poco de teoría para optimizar tus proyectos


Los siguientes factores pueden influir en el rendimiento de las estructuras:

  • donde se almacenan sus variables (montón / pila);
  • la necesidad de contar las referencias para propiedades;
  • métodos de programación (estático / dinámico);
  • Copy-On-Write se usa solo por estructuras de datos que son tipos de referencia que fingen ser estructuras (String, Array, Set, Dictionary) debajo del capó.

Vale la pena aclarar de inmediato que el más rápido de todos será aquellos objetos que almacenan propiedades en la pila, no use el conteo de referencias con el método estático de examen médico.

Que las clases son malas y peligrosas en comparación con las estructuras



No siempre controlamos la copia de nuestros objetos, y si lo hacemos, podemos obtener demasiadas copias que serán difíciles de administrar (creamos objetos en el proyecto que son responsables de formar la vista, por ejemplo).

No son tan rápidos como las estructuras.

Si tenemos un enlace a un objeto y estamos tratando de controlar nuestra aplicación en un estilo de subprocesos múltiples, podemos obtener la condición de carrera cuando nuestro objeto se usa desde dos lugares diferentes (y esto no es tan difícil, porque un proyecto construido con Xcode siempre es un poco más lento) que la versión de la tienda).

Si tratamos de evitar la Condición de carrera, gastamos muchos recursos en Lock y nuestros datos, que comienzan a consumir recursos y perder tiempo en lugar de un procesamiento rápido y obtenemos objetos aún más lentos que los mismos construidos en las estructuras.

Si hacemos todas las acciones anteriores en nuestros objetos (enlaces), entonces la probabilidad de puntos muertos imprevistos es alta.

La complejidad del código está aumentando debido a esto.

Más código = más errores, ¡siempre!

Conclusiones


Pensé que las conclusiones de este artículo son simplemente necesarias, porque no quiero leer el artículo de vez en cuando, y una lista consolidada de puntos es simplemente necesaria. Resumiendo las líneas bajo las pruebas, quiero resaltar lo siguiente:

  1. Las matrices se colocan mejor en una matriz.
  2. Si desea crear una matriz a partir de clases, es mejor elegir una matriz regular, ya que ContiguousArray rara vez ofrece ventajas y no son muy altas.
  3. La optimización en línea acelera el trabajo, no lo apague.
  4. El acceso a los elementos de la matriz siempre es más rápido que el acceso a los elementos de la matriz contigua.
  5. Las estructuras son siempre más rápidas que las clases (a menos, por supuesto, que habilite la optimización de todo el módulo u otra optimización similar).
  6. Al pasar un objeto a una función y llamar a sus propiedades, a partir del tercero, la estructura es más rápida que las clases.
  7. Cuando pasa un valor a una función escrita para Genérico y Protocolo, Genérico será más rápido.
  8. Con la herencia de clases múltiples, la velocidad de la llamada a la función se degrada.
  9. Las variables marcaron el trabajo final más lentamente que los pimientos regulares.
  10. Si una función acepta un objeto que combina varios objetos con el protocolo, funcionará rápidamente si solo se almacena una propiedad en él, y se degradará en gran medida al agregar más propiedades.

Referencias
medium.com/@vhart/protocols-generics-and-existential-containers-wait-what-e2e698262ab1
developer.apple.com/videos/play/wwdc2016/416
developer.apple.com/videos/play/wwdc2015/409
developer.apple.com/videos/play/wwdc2016/419
medium.com/commencis/stop-using-structs-e1be9a86376f
Probar el código fuente

Source: https://habr.com/ru/post/472290/


All Articles