He escrito un código Scala para realizar una operación basada en elementos en una colección. Aquí definí dos métodos que realizan la misma tarea. Un método usa zip
y el otro usa zipped
.
def ES (arr :Array[Double], arr1 :Array[Double]) :Array[Double] = arr.zip(arr1).map(x => x._1 + x._2)
def ES1(arr :Array[Double], arr1 :Array[Double]) :Array[Double] = (arr,arr1).zipped.map((x,y) => x + y)
Para comparar estos dos métodos en términos de velocidad, escribí el siguiente código:
def fun (arr : Array[Double] , arr1 : Array[Double] , f :(Array[Double],Array[Double]) => Array[Double] , itr : Int) ={
val t0 = System.nanoTime()
for (i <- 1 to itr) {
f(arr,arr1)
}
val t1 = System.nanoTime()
println("Total Time Consumed:" + ((t1 - t0).toDouble / 1000000000).toDouble + "Seconds")
}
Llamo al fun
método y paso ES
y ES1
como a continuación:
fun(Array.fill(10000)(math.random), Array.fill(10000)(math.random), ES , 100000)
fun(Array.fill(10000)(math.random), Array.fill(10000)(math.random), ES1, 100000)
Los resultados muestran que el método nombrado ES1
que usa zipped
es más rápido que el método ES
que usa zip
. En base a estas observaciones, tengo dos preguntas.
¿Por qué es zipped
más rápido que zip
?
¿Hay alguna forma aún más rápida de realizar operaciones basadas en elementos en una colección en Scala?
scala
performance
scala-collections
jmh
elementwise-operations
usuario12140540
fuente
fuente
Respuestas:
Para responder a su segunda pregunta:
La triste verdad es que a pesar de su concisión, productividad mejorada y resistencia a los errores, los lenguajes funcionales no son necesariamente los más efectivos: el uso de funciones de orden superior para definir una proyección que se ejecutará en colecciones no gratuitas, y su ciclo cerrado lo resalta. Como otros han señalado, la asignación de almacenamiento adicional para resultados intermedios y finales también tendrá gastos generales.
Si el rendimiento es crítico, aunque de ninguna manera es universal, en casos como el suyo, puede desenrollar las operaciones de Scala en equivalentes imperativos para recuperar un control más directo sobre el uso de la memoria y eliminar las llamadas a funciones.
En su ejemplo específico, las
zipped
sumas se pueden realizar imperativamente asignando previamente una matriz fija y mutable del tamaño correcto (ya que zip se detiene cuando una de las colecciones se queda sin elementos) y luego agrega elementos juntos en el índice apropiado (desde acceder elementos de matriz por índice ordinal es una operación muy rápida).Agregar una tercera función,
ES3
a su conjunto de pruebas:En mi i7 obtengo los siguientes tiempos de respuesta:
Aún más audaz sería hacer una mutación directa en el lugar de las dos matrices más cortas, lo que obviamente corrompería el contenido de una de las matrices, y solo se haría si la matriz original nuevamente no fuera necesaria:
Pero obviamente, la mutación directa de los elementos de la matriz no está en el espíritu de Scala.
fuente
Array.tabulate(minSize)(i => arr(i) + arr1(i))
a unaArray.tabulate
debería ser mucho más rápido que cualquierazip
ozipped
aquí (y está en mis puntos de referencia).for
se desuga a una llamada de función de orden superior (foreach
). La lambda solo se instanciará una vez en ambos casos.Ninguna de las otras respuestas menciona la razón principal de la diferencia de velocidad, que es que la
zipped
versión evita las asignaciones de 10.000 tuplas. Como un par de las otras respuestas hacer nota, lazip
versión implica una serie intermedia, mientras que lazipped
versión no, pero la asignación de un conjunto de 10.000 elementos no es lo que hace que lazip
versión mucho peor-se las 10.000 tuplas de corta duración que se están poniendo en esa matriz. Estos están representados por objetos en la JVM, por lo que está haciendo un montón de asignaciones de objetos para cosas que inmediatamente va a tirar.El resto de esta respuesta solo entra en un poco más de detalles sobre cómo puede confirmar esto.
Mejor benchmarking
Realmente desea utilizar un marco como jmh para realizar cualquier tipo de evaluación comparativa de manera responsable en la JVM, e incluso entonces la parte responsable es difícil, aunque configurar jmh en sí no es tan malo. Si tienes algo
project/plugins.sbt
como esto:Y
build.sbt
así (estoy usando 2.11.8 ya que mencionas que es lo que estás usando):Entonces puede escribir su punto de referencia de esta manera:
Y ejecútalo con
sbt "jmh:run -i 10 -wi 10 -f 2 -t 1 zipped_bench.ZippedBench"
:Lo que muestra que la
zipped
versión obtiene aproximadamente un 80% más de rendimiento, lo que probablemente es más o menos lo mismo que sus mediciones.Medición de asignaciones
También puede pedirle a jmh que mida las asignaciones con
-prof gc
:... donde
gc.alloc.rate.norm
es probablemente la parte más interesante, que muestra que lazip
versión se está asignando más de tres veceszipped
.Implementaciones imperativas
Si supiera que este método se llamaría en contextos extremadamente sensibles al rendimiento, probablemente lo implementaría así:
Tenga en cuenta que, a diferencia de la versión optimizada en una de las otras respuestas, esta utiliza en
while
lugar de unafor
yafor
que todavía se desugará en las operaciones de colecciones Scala. Podemos comparar esta implementación (withWhile
), la implementación optimizada (pero no en el lugar) de la otra respuesta (withFor
) y las dos implementaciones originales:Esa es una gran diferencia entre las versiones imperativas y funcionales, y todas estas firmas de métodos son exactamente idénticas y las implementaciones tienen la misma semántica. No es como si las implementaciones imperativas estuvieran usando un estado global, etc. Si bien las versiones
zip
yzipped
son más legibles, personalmente no creo que tenga sentido que las versiones imperativas estén en contra del "espíritu de Scala", y no dudaría para usarlos yo mismo.Con tabular
Actualización: agregué una
tabulate
implementación al punto de referencia basado en un comentario en otra respuesta:Es mucho más rápido que las
zip
versiones, aunque sigue siendo mucho más lento que los imperativos:Esto es lo que esperaría, ya que no hay nada inherentemente costoso en llamar a una función, y porque acceder a los elementos de la matriz por índice es muy barato.
fuente
Considerar
lazyZip
en vez de
zip
Scala 2.13 añadido
lazyZip
a favor de.zipped
zipped
(y, por lo tantolazyZip
) es más rápido quezip
porque, como lo explicaron Tim y Mike Allen ,zip
seguidomap
resultará en dos transformaciones separadas debido a la rigidez, mientras quezipped
seguidomap
dará como resultado una sola transformación ejecutada de una vez debido a la pereza.zipped
daTuple2Zipped
, y analizandoTuple2Zipped.map
,vemos las dos colecciones
coll1
ycoll2
se repiten una y otra vez en cada iteración, la función que sef
pasamap
se aplica a lo largo del caminosin tener que asignar y transformar estructuras intermedias.
Aplicando el método de evaluación comparativa de Travis, aquí hay una comparación entre nuevos
lazyZip
y obsoletoszipped
dondeda
lazyZip
parece funcionar un poco mejor quezipped
enArraySeq
. Curiosamente, observe el rendimiento degradado significativamente cuando se utilizalazyZip
enArray
.fuente
Siempre debe tener cuidado con la medición del rendimiento debido a la compilación JIT, pero una razón probable es que
zipped
es perezosa y extrae elementos de lasArray
cápsulas originales durante lamap
llamada, mientras quezip
crea un nuevoArray
objeto y luego llamamap
al nuevo objeto.fuente