Mapas con clave compuesta

En mi último post hablé de los problemas de usar una función hash incorrecta cuando guardas un objeto con clave compuesta en un HashMap de Java, pero me quedé con la duda: ¿qué estructura de datos es mejor a la hora de indexar esos objetos?

Siguiendo con el mismo ejemplo hablaré de productos y tiendas, y usaré sus identificadores para formar la clave del mapa. Las estructuras de datos propuestas son:

  • Un único mapa con una clave que contenga sus índices: HashMap<Tuple<Integer, Integer>, MyObject>, al que llamaré TupleMap.
  • Un mapa anidado: HashMap<Integer, HashMap<Integer, MyObject>>, al que llamaré DoubleMap.

Para salir de dudas y sacar conclusiones mediré:

  • La memoria consumida al indexar una colección de objetos
  • El tiempo necesario para guardar de forma aleatoria esa colección de objetos
  • El tiempo necesario para recuperar, también de forma aleatoria, todos los elementos de la colección

TL;DR: este post es más aburrido, así que os ahorraré el trabajo de leerlo entero:

  • DoubleMap es más eficiente en memoria y consume un 30% menos que TupleMap
  • En colecciones grandes, DoubleMap es un 30% más rápido indexando, mientras que en colecciones pequeñas es bastante más rápido
  • En colecciones grandes, DoubleMap y TupleMap tienen un rendimiento parecido consultando, mientras que en colecciones pequeñas DoubleMap es sensiblemente más rápido

Todo el código fuente necesario para reproducir las pruebas está en este repositorio de GitHub: https://github.com/jerolba/hashcode-map.

En este caso aplicaré la función hash que genera menor número de colisiones y minimiza el consumo de memoria, por lo que no me tendré que preocupar de esa parte en mis benchmarks y estaré en un caso optimo (y optimista) en la versión de TupleMap de no tener que lidiar con las colisiones.

Consumo de memoria

Si usamos un objeto como clave primaria, ¿cuánta memoria consumirán esas instancias de la clave primaria? Si usamos HashMaps anidados, ¿penalizará el overhead de esos objetos?

Si rellenamos de forma aleatoria un mapa con 10.000 productos y 500 tiendas, obtenemos la siguiente gráfica de consumo de memoria, teniendo en cuenta sólo las clases involucradas en los mapas:

De media, el mapa con un objeto clave (Tuple) consume un 50% más de memoria, ocupando finalmente 299 MB frente a los 193 MB del DoubleMap.

Mirando el histograma de los objetos en memoria vemos que las instancias de la clase Tuple están ocupando 114 MB y no se están produciendo colisiones al no aparecer instancias del tipo TreeMap:

Classinstancessize
java.util.HashMap$Node5.000.000160.000.000
com.jerolba.bikey.Tuple5.000.000120.000.000
java.util.HashMap$Node[]133.554.448
java.util.HashMap148
com.jerolba.bikey.TupleMap116

mientras que en la versión de DoubleMap las instancias de HashMap extra están ocupando apenas medio megabyte, y la mayor diferencia radica en el espacio empleado en los arrays de nodos:

Classinstancessize
java.util.HashMap$Node5.010.000160.320.000
java.util.HashMap$Node[]10.00141.185.552
java.util.HashMap10.001480.048
com.jerolba.bikey.DoubleMap116

Por tanto, si al usar este tipo de mapas necesitas crear nuevas instancias del objeto que representa la clave primaria, yo optaría por usar una estructura de datos del tipo HashMap<A, HashMap<B, MyObject>>.

Rendimiento en indexación

¿Cuánto se tarda en crear una colección grande en cada caso? ¿Influye mucho el número de elementos de cada tipo?

Para no aburriros con los detalles del benchmark lo resumo en una única gráfica donde se muestra el tiempo (en milisegundos) necesario para insertar una colección aleatoria de productos y tiendas según diferentes números totales de productos y tiendas:

De media, mantener toda la información en un único mapa tiene una penalización de al menos un 40% de tiempo.

Aunque no muestre gráficas (tienes los datos al final del post), el aumento del número de datos en cualquiera de las dos variables (productos o tiendas) aumenta el tiempo de ejecución de forma lineal.

Rendimiento en consulta

¿Cuánto se tarda en acceder al valor asociado a una clave compuesta? ¿Penalizará el tener que consultar en dos mapas? En la versión de TupleMap, ¿Se tarda más si por cada consulta tengo que instanciar un objeto Tuple?

Al igual que antes, resumiré en una única gráfica el benchmark de consultar en una colección grande todos sus valores de forma aleatoria, según diferentes números totales de productos y tiendas:

Aunque el tiempo de ejecución de DoubleMap está siempre ligeramente por debajo de el de TupleMap podemos considerar que tienen un rendimiento muy similar, y por tanto el tiempo de acceso no debería condicionarnos la elección de una estructura de datos u otra.

Sorprendentemente tener que crear una instancia de Tuple por cada consulta no penaliza en el rendimiento, e incluso lo mejora ligeramente en colecciones grandes (las optimizaciones de la JVM son inescrutables)

Colecciones pequeñas

Los problemas a los que me enfrento normalmente usan colecciones grandes, pero en los resultados de los benchmarks podemos ver que en colecciones pequeñas la implementación de DoubleMap tiene bastante mejor rendimiento.

Indexación

Para visualizarlo mejor mostraré dos gráficas: cuando la colección tiene 1.000 productos y cuando tiene 2.000.

Los tiempos de la versión TupleMap son entre 2 y 6 veces peores. Sin haber analizado el comportamiento interno de la JVM/CPU/Memoria, intuyo que el menor tamaño en datos influye en la localidad de la información y dará menos problema con las línea de caché.

Consulta

Igualmente lo analizamos viendo dos gráficas para distinto número de productos:

Los tiempos de la versión TupleMap son entre un 50% y un 150% peores. Tampoco me atrevo a asegurar a qué se debe, pero sigo creyendo que los tiros van por problemas con la caché.

Conclusiones

A pesar de los resultados obtenidos, en este caso considero que es difícil sacar unas conclusiones claras realizando microbenchmarking. El comportamiento de las estructuras de datos pueden variar entre el código de producción y el del benchmark.

En código de producción, entre acceso y acceso al mapa, tu aplicación puede hacer muchas cosas que influyan en la disponibilidad de la información en las cachés, generando un patrón de acceso complentamente distinto al del benchmark.

Personalmente me quedo con la idea de consumir menos memoria no instanciando la clase Tuple y usar directamente HashMaps anidados. Para evitar el código feo de tanto generico, puedes abstraer y encapsular ese código en una clase.

Tener que insertar/consultar en dos HashMaps parece que no supone un problema de rendimiento, e incluso es más rápido, sobre todo en colecciones relativamente pequeñas.

Usando DoubleMap nos olvidamos del problema de tener que elegir una función hash que minimice las colisiones, ya que la clave estaría distribuida entre los dos niveles de HashMaps.

Resultados de los benchmarks

El código fuente para ejecutar los benchmarks están en el repositorio de GitHub, pero para que podáis ver los datos de las gráficas en crudo os copio los resultados. Así además podéis hacer vuestros análisis y sacar vuestras conclusiones.

Indexación

Nº ProductosNº TiendasTotalTupleMap
(ms)
DoubleMap
(ms)
1.000100100.00033,15,61
2.500100250.000103,3824,78
5.000100500.000177,71116,43
7.500100750.000272,02160,95
10.0001001.000.000314,94241,86
1.000250250.000129,1819,82
2.500250625.000292,97114,85
5.0002501.250.000644,29421,45
7.5002501.875.0001.061,19631,18
10.0002502.500.0001.432,111.102,94
1.000500500.000326,4955,98
2.5005001.250.000805,16368,4
5.0005002.500.0001.503,63994,39
7.5005003.750.0002.601,491.687,45
10.0005005.000.0003.158,442.601,42
1.000750750.000450,9882,68
2.5007501.875.0001.427,11569,73
5.0007503.750.0002.531,311.347,68
7.5007505.625.0003.730,372.436,08
10.0007507.500.0005.108,523.753,73
1.0001.0001.000.000790,76272,73
2.5001.0002.500.0001.833,54905,38
5.0001.0005.000.0003.487,372.360,07
7.5001.0007.500.0005.550,263.886,36
10.0001.00010.000.0007.763,965.728,61

Consulta

Nº ProductosNº TiendasTotalTupleMap
(ms)
Tuple new
(ms)
DoubleMap
(ms)
1.000100100.00012,0812,553,81
2.500100250.00037,1138,2114,35
5.000100500.00077,4077,3946,84
7.500100750.000125,96126,3568,53
10.0001001.000.000148,55141,47163,37
1.000250250.00048,9650,4118,30
2.500250625.000140,25144,7070,04
5.0002501.250.000332,23344,52242,27
7.5002501.875.000533,58498,65428,10
10.0002502.500.000689,37656,38640,08
1.000500500.000112,44115,9167,37
2.5005001.250.000426,38436,60236,71
5.0005002.500.000838,11827,97721,81
7.5005003.750.0001.092,231.032,171.146,23
10.0005005.000.0001.659,281.671,451.445,54
1.000750750.000220,95228,46104,46
2.5007501.875.000690,21694,86493,78
5.0007503.750.0001.224,581.185,301.052,61
7.5007505.625.0001.950,892.206,491.735,02
10.0007507.500.0002.750,522.567,102.750,77
1.0001.0001.000.000342,89351,78216,07
2.5001.0002.500.000973,661.019,16677,97
5.0001.0005.000.0001.838,451.968,091.618,03
7.5001.0007.500.0002.789,492.598,462.448,23
10.0001.00010.000.0004.468,784.318,663.970,30