¿Qué estructura de datos debo usar para esta estrategia de almacenamiento en caché?

11

Estoy trabajando en una aplicación .NET 4.0, que realiza un cálculo bastante costoso en dos dobles que devuelven un doble. Este cálculo se realiza para cada uno de varios miles de artículos . Estos cálculos se realizan en un Tasksubproceso en un subproceso de grupo.

Algunas pruebas preliminares han demostrado que los mismos cálculos se realizan una y otra vez, por lo que me gustaría caché n resultados. Cuando la caché está llena, me gustaría echar a los menos frecuentemente elemento utilizado recientemente. ( Editar: me di cuenta de que con menos frecuencia no tiene sentido, porque cuando el caché está lleno y reemplazaría un resultado por uno recién calculado, ese sería el que se usa con menos frecuencia y se reemplazará de inmediato la próxima vez que se calcule un nuevo resultado y agregado a la caché)

Para implementar esto, estaba pensando en usar un Dictionary<Input, double>(donde Inputsería una mini-clase que almacena los dos valores dobles de entrada) para almacenar las entradas y los resultados almacenados en caché. Sin embargo, también necesitaría hacer un seguimiento de cuándo se utilizó un resultado la última vez. Para esto, creo que necesitaría una segunda colección que almacenara la información que necesitaría para eliminar un resultado del diccionario cuando el caché se estaba llenando. Me preocupa que mantener esta lista ordenada constantemente impacte negativamente el rendimiento.

¿Hay una manera mejor (es decir, más eficiente) de hacer esto, o tal vez incluso una estructura de datos común que desconozco? ¿Qué tipo de cosas debo perfilar / medir para determinar la optimización de mi solución?

PersonalNexus
fuente

Respuestas:

12

Si desea utilizar un caché de desalojo LRU (desalojo menos utilizado recientemente), entonces probablemente una buena combinación de estructuras de datos para usar es:

  • Lista circular vinculada (como cola prioritaria)
  • Diccionario

Esta es la razón por:

  • La lista vinculada tiene un tiempo de inserción y eliminación de O (1)
  • Los nodos de la lista se pueden reutilizar cuando la lista está llena y no es necesario realizar asignaciones adicionales.

Así es como debería funcionar el algoritmo básico:

Las estructuras de datos.

LinkedList<Node<KeyValuePair<Input,Double>>> list; Dictionary<Input,Node<KeyValuePair<Input,Double>>> dict;

  1. Entrada es recibida
  2. Si el diccionario contiene la clave
    • devuelve el valor almacenado en el nodo y mueve el nodo al comienzo de la lista
  3. Si el diccionario no contiene la clave
    • calcular el valor
    • almacenar el valor en el último nodo de la lista
    • si el último no tiene un valor, elimine la clave anterior del diccionario
    • mueve el último nodo a la primera posición.
    • almacenar en el diccionario el par de valores clave (entrada, nodo).

Algunos de los beneficios de este enfoque son: leer y establecer un valor de diccionario se acerca a O (1), insertar y eliminar un nodo en una lista vinculada es O (1), lo que significa que el algoritmo se acerca a O (1) para leer y escribir valores al caché, y evita las asignaciones de memoria y bloquea las operaciones de copia de memoria, lo que lo hace estable desde el punto de vista de la memoria.

Pop Catalin
fuente
Buenos puntos, la mejor idea hasta ahora, en mi humilde opinión. Implementé un caché basado en esto hoy y tendré que perfilar y ver qué tan bien funciona mañana.
PersonalNexus
3

Esto parece un gran esfuerzo para realizar un solo cálculo dada la potencia de procesamiento que tiene a su disposición en la PC promedio. Además, seguirá teniendo el gasto de la primera llamada a su cálculo para cada par único de valores, por lo que 100,000 pares de valores únicos aún le costarán Tiempo n * 100,000 como mínimo. Tenga en cuenta que el acceso a los valores en su diccionario probablemente será más lento a medida que el diccionario crezca. ¿Puede garantizar que la velocidad de acceso a su diccionario compensará lo suficiente como para proporcionar un rendimiento razonable frente a la velocidad de su cálculo?

De todos modos, parece que probablemente deba considerar encontrar un medio para optimizar su algoritmo. Para esto, necesitará una herramienta de creación de perfiles, como Redgate Ants, para ver dónde están los cuellos de botella y para ayudarlo a determinar si hay formas de reducir algunos de los gastos generales que podría tener en relación con las instancias de clase, los recorridos de listas, la base de datos accesos, o lo que sea que te esté costando tanto tiempo.

S.Robins
fuente
1
Desafortunadamente, por el momento, el algoritmo de cálculo no se puede cambiar, ya que es una biblioteca de terceros que utiliza algunas matemáticas avanzadas que naturalmente requieren un uso intensivo de la CPU. Si en un momento posterior se volverá a trabajar, definitivamente revisaré las herramientas de creación de perfiles sugeridas. Además, el cálculo se realizará con bastante frecuencia, a veces con entradas idénticas, por lo que el perfil preliminar ha mostrado un beneficio claro incluso con una estrategia de almacenamiento en caché muy ingenua.
PersonalNexus
0

Un pensamiento es por qué solo caché n resultados? Incluso si n es 300,000, solo usaría 7.2MB de memoria (más cualquier extra para la estructura de la tabla). Eso supone tres dobles de 64 bits, por supuesto. Simplemente puede aplicar la memorización a la compleja rutina de cálculo en sí si no le preocupa quedarse sin espacio en la memoria.

Peter Smith
fuente
No habrá solo un caché, sino uno por "elemento" que estoy analizando, y puede haber varios cientos de miles de estos elementos.
PersonalNexus
¿De qué manera importa de qué 'Elemento' proviene la entrada? ¿hay efectos secundarios?
jk.
@jk. Diferentes elementos producirán entradas muy diferentes para el cálculo. Como esto significa que habrá poca superposición, no creo que tenga sentido mantenerlos en un solo caché. Además, diferentes elementos podrían vivir en diferentes subprocesos, por lo que para evitar el estado compartido, me gustaría mantener las cachés separadas.
PersonalNexus
@PersonalNexus Supongo que esto implica que hay más de 2 parámetros involucrados en el cálculo. De lo contrario, básicamente tienes f (x, y) = hacer algunas cosas. ¿Además parece que el estado compartido ayudaría al rendimiento en lugar de obstaculizar?
Peter Smith
@PeterSmith Los dos parámetros son las entradas principales. Hay otros, pero rara vez cambian. Si lo hacen, tiraría todo el caché. Por "estado compartido" me refería a un caché compartido para todos o un grupo de elementos. Dado que esto debería bloquearse o sincronizarse de alguna otra manera, obstaculizaría el rendimiento. Más sobre las implicaciones de rendimiento del estado compartido .
PersonalNexus
0

El enfoque con la segunda colección está bien. Debe ser una cola prioritaria que permita encontrar / eliminar valores mínimos rápidamente y también cambiar (aumentar) las prioridades dentro de la cola (la última parte es la difícil, no es compatible con la mayoría de las implementaciones simples de colas prio). La biblioteca C5 tiene tal colección, se llama IntervalHeap.

O, por supuesto, puedes intentar crear tu propia colección, algo así como a SortedDictionary<int, List<InputCount>>. ( InputCountdebe ser una clase que combine sus Inputdatos con su Countvalor)

La actualización de esa colección al cambiar el valor de conteo se puede implementar al eliminar y volver a insertar un elemento.

Doc Brown
fuente
0

Como se señaló en la respuesta de Peter Smith, el patrón que está tratando de implementar se llama memorización . En C # es bastante difícil implementar la memorización de manera transparente sin efectos secundarios. El libro de Oliver Sturm sobre programación funcional en C # ofrece una solución (el código está disponible para descargar, capítulo 10).

En F # sería mucho más fácil. Por supuesto, es una gran decisión comenzar a usar otro lenguaje de programación, pero vale la pena considerarlo. Especialmente en cálculos complejos, es probable que haga más cosas más fáciles de programar que la memorización.

Gert Arnold
fuente