Beneficio de evitar múltiples traversals de lista


He visto muchos ejemplos en lenguajes funcionales sobre el procesamiento de una lista y la construcción de una función para hacer algo con sus elementos después de recibir algún valor adicional (generalmente no presente en el momento en que se generó la función), tales como:

En todos estos ejemplos, los autores generalmente señalan el beneficio de recorrer la lista original solo una vez. Pero no puedo evitar pensar " seguro, en lugar de atravesar una lista de N elementos, estás atravesando una cadena de N evaluaciones, ¿y qué?". Sé que debe haber algún beneficio, ¿podría alguien explicarlo, por favor?


Edit: Gracias a ambos por las respuestas. Desafortunadamente, eso no es lo que quería saber. Trataré de aclarar mi pregunta, para que no se confunda con la (más común) sobre la creación de listas intermedias (de la que ya he leído en varios lugares). También gracias por corregir el formato de mi publicación.

Estoy interesado en los casos en los que se construye una función para ser aplicado a una lista, donde todavía no tiene el valor necesario para evaluar el resultado (sea una lista o no). Entonces no puede evitar generar referencias a cada elemento de la lista (incluso si la estructura de la lista ya no se hace referencia). Y tienes los mismos accesos a la memoria que antes, pero no tienes que deconstruir la lista (coincidencia de patrones).

Por ejemplo, vea el capítulo "puesta en escena" en el libro ML mencionado. Lo he probado en ML y Raqueta, más específicamente la versión por etapas de "append" que atraviesa la primera lista y devuelve una función para insertar la segunda lista en la cola, sin atravesar la primera lista muchas veces. Sorprendentemente para mí, fue mucho más rápido incluso teniendo en cuenta que todavía tenía que copiar la estructura de la lista como el último puntero era diferente en cada caso.

La siguiente es una variante de map que después de aplicada a una lista, debería ser más rápida al cambiar la función. Como Haskell no es estricto, tendría que forzar la evaluación de listMap [1..100000] en cachedList (o tal vez no, ya que después de la primera aplicación todavía debería estar en memoria).

listMap = foldr comb (const [])
  where comb x rest = \f -> f x : rest f

cachedList = listMap [1..100000]
doubles = cachedList (2*)
squares = cachedList (\x -> x*x)

-- print doubles and squares
-- ...

Sé que en Haskell no hace ninguna diferencia (por favor corríjame si me equivoco) usando comb x rest f = ... vs comb x rest = \f -> ..., pero elegí esta versión para enfatizar la idea.

Update: después de algunas pruebas simples, no pude encontrar ninguna diferencia en los tiempos de ejecución en Haskell. La pregunta entonces es solo acerca de lenguajes estrictos como Scheme (al menos la implementación de Racket, donde lo probé) y ML.

Author: Will Ness, 2012-12-03

4 answers

Así que la respuesta a tu pregunta es, compilación parcial. Hecho con anticipación, hace que no sea necesario recorrer la lista para llegar a los elementos individuales: todas las referencias se encuentran con anticipación y se almacenan dentro de la función precompilada.

En cuanto a su preocupación acerca de la necesidad de que esa función sea atravesada también, sería cierto en lenguajes interpretados. Pero la compilación elimina este problema.

En presencia de la pereza este truco de codificación puede conducir a los resultados opuestos. Teniendo ecuaciones completas, por ejemplo, Haskell GHC compiler es capaz de realizar todo tipo de optimizaciones, que esencialmente eliminan las listas por completo y convierten el código en un equivalente de bucles. Esto sucede cuando compilamos el código con, por ejemplo, -O2 switch.

Escribir las ecuaciones parciales puede impedir esta optimización del compilador y forzar la creación real de funciones, con una desaceleración drástica del código resultante. Probé su código cachedList y vi un 0.01 s tiempo de ejecución se convierten en 0.20 s (no recuerdo en este momento la prueba exacta que hice).

 2
Author: Will Ness,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-12-14 19:07:58

Ejecutar unas cuantas instrucciones aritméticas adicionales en el cuerpo del bucle es más barato que ejecutar unas cuantas recuperaciones de memoria adicionales, básicamente.

Traversals significa hacer mucho acceso a la memoria, así que cuanto menos lo hagas, mejor. La fusión de traversals reduce el tráfico de memoria y aumenta la carga de cómputo en línea recta, por lo que obtiene un mejor rendimiento.

En concreto, considere este programa para calcular algunas matemáticas en una lista:

go :: [Int] -> [Int]
go = map (+2) . map (^3)

Claramente, lo diseñamos con dos traversals de la lista. Entre el primer y el segundo recorrido, un resultado se almacena en una estructura de datos intermedia. Sin embargo, es una estructura perezosa, por lo que solo cuesta O(1) memoria.

Ahora, el compilador de Haskell fusiona inmediatamente los dos bucles en:

go = map ((+2) . (^3))

¿Por qué es eso? Después de todo, ambos son O(n) complejidad, ¿verdad? La diferencia está en los factores constantes.

Considerando esta abstracción: para cada paso de la primera tubería hacemos:

  i <- read memory          -- cost M
  j = i ^ 3                 -- cost A
  write memory j            -- cost M
  k <- read memory          -- cost M
  l = k + 2                 -- cost A
  write memory l            -- cost M

Así que pagamos 4 accesos a memoria, y 2 operaciones aritméticas.

Para el resultado fusionado tenemos:

  i <- read memory          -- cost M
  j = (i ^ 3) + 2           -- cost 2A
  write memory j            -- cost M

Donde A y M son los factores constantes para hacer matemáticas en el acceso ALU y a la memoria.

También hay otros factores constantes (dos ramas de bucle) en lugar de uno.

Entonces, a menos que el acceso a la memoria sea libre (no lo es, por mucho tiempo), la segunda versión siempre es más rápida.

Tenga en cuenta que los compiladores que operan en secuencias inmutables pueden implementar la fusión de matrices, la transformación esto es para ti. GHC es un compilador.

 27
Author: Don Stewart,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-12-03 19:10:53

Hay otra razón muy importante. Si recorre una lista solo una vez, y no tiene otra referencia a ella, el GC puede liberar la memoria reclamada por los elementos de la lista a medida que los atraviesa. Además, si la lista se genera perezosamente, siempre tiene solo un consumo de memoria constante. Por ejemplo

import Data.List

main = do
    let xs = [1..10000000]
        sum = foldl' (+) 0 xs
        len = foldl' (\_ -> (+ 1)) 0 xs
    print (sum / len)

Calcula sum, pero necesita mantener la referencia a xs y la memoria que ocupa no se puede liberar, porque es necesaria para calcular len más tarde. (O viceversa.) Tan el programa consume una cantidad considerable de memoria, cuanto mayor xs más memoria necesita.

Sin embargo, si recorremos la lista solo una vez, se crea perezosamente y los elementos pueden ser GC inmediatamente, por lo que no importa cuán grande sea la lista, el programa solo toma O(1) memoria.

{-# LANGUAGE BangPatterns #-}
import Data.List

main = do
    let xs = [1..10000000]
        (sum, len) = foldl' (\(!s,!l) x -> (s + x, l + 1)) (0, 0) xs
    print (sum / len)
 16
Author: Petr Pudlák,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-12-03 20:37:33

Lo siento de antemano por una respuesta de estilo parlanchín.

Eso es probablemente obvio, pero si estamos hablando del rendimiento, siempre debe verificar las hipótesis midiendo.

Hace un par de años estaba pensando en la semántica operativa de GHC, la máquina STG. Y me hice la misma pregunta - seguramente los famosos algoritmos de "one-traversal" no son tan grandes? Solo parece un recorrido en la superficie, pero debajo del capó también tienes esta cadena de golpes estructura que suele ser bastante similar a la lista original.

Escribí algunas versiones (que varían en rigor) del famoso problema RepMin - dado un árbol lleno de números, generar el árbol de la misma forma, pero reemplazar cada número con el mínimo de todos los números. Si mi memoria es correcta (recuerde-siempre verifique las cosas usted mismo!), el algoritmo ingenuo de dos travesías se realizó mucho más rápido que varios algoritmos inteligentes de un solo recorrido.

También compartí mis observaciones con Simon Marlow (ambos estábamos en una escuela de verano de FP durante ese tiempo), y dijo que usan este enfoque en GHC. Pero no para mejorar el rendimiento, como podrías haber pensado. En cambio, dijo, para un gran AST (como el de Haskell) escribir todos los constructores toma mucho espacio (en términos de líneas de código), y así reducen la cantidad de código escribiendo solo un recorrido (sintáctico).

Personalmente evito este truco porque si cometes un error, obtienes un bucle que es una cosa muy desagradable para depurar.

 3
Author: Roman Cheplyaka,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2012-12-09 17:51:07