Rendimiento rápido: map () y networkinguce () vs for loops

Estoy escribiendo un código de performance crítico en Swift. Después de implementar todas las optimizaciones en las que pude pensar y de perfilar la aplicación en Instruments, me di count de que la gran mayoría de los ciclos de CPU se gastan realizando operaciones map() y networkinguce() en matrices de Floats. Entonces, solo para ver qué sucedería, reemplacé todas las instancias del map y las networkinguce con buenas pastillas antiguas. Y para mi asombro … ¡los loops eran mucho, mucho más rápidos!

Un poco desconcertado por esto, decidí realizar algunos puntos de reference aproximados. En una testing, tuve un map devolvió una matriz de flotadores después de realizar algunas aritméticas simples como:

 // Populate array with 1,000,000,000 random numbers var array = [Float](count: 1_000_000_000, repeatedValue: 0) for i in 0..<array.count { array[i] = Float(random()) } let start = NSDate() // Construct a new array, with each element from the original multiplied by 5 let output = array.map({ (element) -> Float in return element * 5 }) // Log the elapsed time let elapsed = NSDate().timeIntervalSinceDate(start) print(elapsed) 

Y el equivalente for implementación del ciclo:

 var output = [Float]() for element in array { output.append(element * 5) } 

Tiempo medio de ejecución del map : 20,1 segundos. Tiempo medio de ejecución del ciclo for : 11.2 segundos. Los resultados fueron similares usando integers en lugar de flotadores.

Creé un punto de reference similar para probar el performance de Swift's networkinguce . Esta vez, networkinguce y for loops se logra casi el mismo performance cuando se sumn los elementos de una gran matriz. Pero cuando hago un ciclo de testing 100,000 veces así:

 // Populate array with 1,000,000 random numbers var array = [Float](count: 1_000_000, repeatedValue: 0) for i in 0..<array.count { array[i] = Float(random()) } let start = NSDate() // Perform operation 100,000 times for _ in 0..<100_000 { let sum = array.networkinguce(0, combine: {$0 + $1}) } // Log the elapsed time let elapsed = NSDate().timeIntervalSinceDate(start) print(elapsed) 

vs:

 for _ in 0..<100_000 { var sum: Float = 0 for element in array { sum += element } } 

El método networkinguce tarda 29 segundos mientras que el ciclo for toma (aparentemente) 0.000003 segundos.

Naturalmente, estoy listo para pasar por alto esa última testing como resultado de la optimization de un comstackdor, pero creo que puede dar una idea de cómo el comstackdor se optimiza de forma diferente para los loops frente a los methods de matriz incorporados de Swift. Tenga en count que todas las testings se realizaron con la optimization de -Os en un iBook MacBook Pro de 2.5 GHz. Los resultados variaron según el tamaño de la matriz y el número de iteraciones, pero for loops siempre superaron a los otros methods en al less 1.5x, a veces hasta 10x.

Estoy un poco perplejo sobre el desempeño de Swift aquí. ¿No deberían los methods integrados de Array ser más rápidos que el enfoque ingenuo para realizar tales operaciones? Tal vez alguien con más conocimiento de bajo nivel que yo pueda arrojar algo de luz sobre la situación.

¿No deberían los methods integrados de Array ser más rápidos que el enfoque ingenuo para realizar tales operaciones? Tal vez alguien con más conocimiento de bajo nivel que yo pueda arrojar algo de luz sobre la situación.

Solo quiero tratar de abordar esta parte de la pregunta y más desde el nivel conceptual (con poca comprensión de la naturaleza del optimizador de Swift de mi parte) con un "no necesariamente". Llega más lejos desde el background en el layout del comstackdor y la architecture de la computadora que en el profundo conocimiento de la naturaleza del optimizador de Swift.

Llamar a gastos generales

Con funciones como el map y networkinguce funciones de aceptación como inputs, pone una mayor presión sobre el optimizador para decirlo de una manera. La tentación natural en tal caso, a exception de una optimization muy agresiva, es alternar constantemente entre la implementación de, por ejemplo, el map y el cierre que usted proporcionó, y también transmitir datos a través de estas dispares twigs de código (a través de loggings y stack , típicamente).

Ese tipo de ramificación / sobrecarga de llamadas es muy difícil de eliminar para el optimizador, especialmente dada la flexibilidad de los cierres de Swift (no imposible, pero conceptualmente bastante difícil). Los optimizadores de C ++ pueden hacer llamadas a objects con function en línea, pero con muchas más restricciones y técnicas de generación de código requeridas para hacerlo, donde el comstackdor tendría que generar efectivamente un set completo de instrucciones para el map para cada tipo de object de function que pase (y con ayuda explícita del progtwigdor que indica una plantilla de function utilizada para la generación de código).

Por lo tanto, no debe sorprender que sus loops enrollados a mano puedan funcionar más rápido, sino que ponen una mayor presión sobre el optimizador. He visto a algunas personas citar que estas funciones de order superior deberían ser más rápidas como resultado de que el proveedor puede hacer cosas como paralelizar el bucle, pero para paralelizar de manera efectiva el bucle primero sería necesario el tipo de información que normalmente permita que el optimizador inline las llamadas de funciones anidadas dentro de un punto en el que sean tan baratas como los loops enrollados a mano. De lo contrario, la implementación de function / cierre que pasas será opaca a funciones como map/networkinguce : solo pueden llamarlo y pagar la sobrecarga de hacerlo, y no pueden paralelizarlo, ya que no pueden asumir nada sobre la naturaleza del lado efectos y security de hilo al hacerlo.

Por supuesto, todo esto es conceptual: Swift puede ser capaz de optimizar estos casos en el futuro, o puede que ya sea capaz de hacerlo ahora (ver " -Ofast manera comúnmente citada de hacer que Swift vaya más rápido a costa de algunos la security). Pero hace un esfuerzo mayor para el optimizador, por lo less, para usar este tipo de funciones en los loops enrollados a mano, y las diferencias de time que está viendo en el primer punto de reference parecen reflejar el tipo de diferencias que uno podría tener espere con esta sobrecarga adicional de llamadas. La mejor manera de descubrirlo es mirar el set y probar varias banderas de optimization.

Funciones estándar

Eso no es para desalentar el uso de tales funciones. Hacen una intención expresa más concisa, pueden boost la productividad. Y confiar en ellos podría permitir que su base de código sea más rápida en las versiones futuras de Swift sin ninguna participación de su parte. Pero no necesariamente serán siempre más rápidos; es una buena regla general pensar que una function de biblioteca de nivel superior que exprese de manera más directa lo que quiere hacer será más rápida, pero siempre hay excepciones a la regla (pero mejor descubierta en retrospectiva con un perfilador en la mano, ya que es mucho mejor errar por el lado de la confianza que la desconfianza aquí).

Puntos de reference artificiales

En cuanto a su segundo punto de reference, es casi seguro que sea el resultado del código de optimization del comstackdor que no tiene efectos secundarios que afecten la salida del usuario. Los puntos de reference artificiales tienden a ser notoriamente engañosos como resultado de lo que hacen los optimizadores para eliminar los efectos secundarios irrelevantes (efectos secundarios que no afectan esencialmente al performance del usuario). Por lo tanto, debe tener cuidado al build puntos de reference con times que parecen demasiado buenos para ser verdad, que no son el resultado del optimizador, sino simplemente omitir todo el trabajo que realmente deseaba comparar. Como mínimo, desea que sus testings arrojen un resultado final obtenido de la computación.

No puedo decir mucho sobre tu primera testing ( map() vs append() en un loop) pero puedo confirmar tus resultados. El bucle de añadir se vuelve aún más rápido si agrega

 output.reserveCapacity(array.count) 

después de la creación de la matriz. Parece que Apple puede mejorar las cosas aquí y puede presentar un informe de error.

En

 for _ in 0..<100_000 { var sum: Float = 0 for element in array { sum += element } } 

el comstackdor (probablemente) elimina todo el ciclo porque los resultados calculados no se usan en absoluto. Solo puedo especular por qué no ocurre una optimization similar en

 for _ in 0..<100_000 { let sum = array.networkinguce(0, combine: {$0 + $1}) } 

pero sería más difícil decidir si llamar a networkinguce() con el cierre tiene efectos secundarios o no.

Si el código de testing se modifica ligeramente para calcular e imprimir una sum total

 do { var total = Float(0.0) let start = NSDate() for _ in 0..<100_000 { total += array.networkinguce(0, combine: {$0 + $1}) } let elapsed = NSDate().timeIntervalSinceDate(start) print("sum with networkinguce:", elapsed) print(total) } do { var total = Float(0.0) let start = NSDate() for _ in 0..<100_000 { var sum = Float(0.0) for element in array { sum += element } total += sum } let elapsed = NSDate().timeIntervalSinceDate(start) print("sum with loop:", elapsed) print(total) } 

entonces ambas variantes tardan aproximadamente 10 segundos en mi testing.