Math.max (y min) 'Maximum call stack size exceeded' con grandes conjuntos de datos y cómo reimplementarlo para que sea mucho más rápido
Aunque JavaScript no fue creado pensando en la gestión de grandes conjuntos de datos, manipularlos hoy en día es muy común.
La agregación de datos también es una funcionalidad común; calcular el valor máximo en un array es una tarea sencilla, ya que el lenguaje proporciona el método Math.max para hacerlo.
Pero, intenta hacer esto:
const data = Array.from({ length: 1_000_000 }, () => Math.random() * 1000);
console.log(Math.max(...data));
El código es sencillo: genera un array con 1.000.000 de elementos aleatorios e intenta obtener el valor máximo del array y, como Math.max no acepta un array como parámetro, necesitamos usar el operador spread para pasar los valores como argumentos de la función.
Pero si ejecutas eso, obtendrás un error como: RangeError: Maximum call stack size exceeded
Este error se produce porque cuando usas el operador spread en los argumentos de una función (o usas apply), estás haciendo Math.max(data[0], data[1], .... data[999_999]) y los argumentos de la función utilizan la pila de llamadas (call stack), que es una región limitada de memoria para ser almacenada. Este límite depende del motor, por lo que tu código puede funcionar en un navegador y fallar en otro que utilice un motor de JavaScript diferente.
Consulta más detalles en MDN web docs
Las soluciones
Como nos dijo un profesor hace mucho tiempo: si un problema tiene una sola solución no es un problema, es un ejercicio. Tenemos múltiples soluciones para evitar este problema, algunas de ellas incluso mejorando el rendimiento original.
Dividir el array en fragmentos (chunks)
La página de MDN propone utilizar una estrategia híbrida: dividir el array en fragmentos y aplicar la función Math.max a cada uno.
function maxUsingChunks(arr: number[]): number {
let max = -Infinity;
const QUANTUM = 32768;
for (let i = 0; i < arr.length; i += QUANTUM) {
const subMax = Math.max.apply(null, arr.slice(i, Math.max(i + QUANTUM, arr.length)));
max = Math.max(subMax, max);
}
return max;
}
Usando reduce
Podemos recorrer el array y comparar cada valor para saber si es mayor que el valor máximo anterior.
function maxUsingReduce(arr: number[]): number {
return arr.reduce((acc, cur) => Math.max(acc, cur), -Infinity);
}
Usando for
Sí, un simple for para recorrer el array.
function maxUsingFor(arr: number[]): number {
const length = arr.length;
let max = -Infinity;
for (let i = 0; i < length; i++) {
max = Math.max(max, arr[i]);
}
return max;
}
Rendimiento
Hay más soluciones, pero la mayoría son ligeras variaciones de las que he mostrado arriba. Todas son válidas y hacen exactamente lo mismo, pero ¿cuál tiene el mejor rendimiento?
Para medir el rendimiento voy a utilizar la funcionalidad de Vitest benchmarking, que es perfecta para este uso.
Creamos un archivo .bench.ts que incluirá las funciones a comparar.
// max.bench.ts
import { bench } from 'vitest';
const data = Array.from({ length: 100_000 }, () => Math.random() * 1000);
bench('Native Math.max', () => {
Math.max(...data);
});
bench('maxUsingChunks', () => {
maxUsingChunks(data);
});
bench('maxUsingReduce', () => {
maxUsingReduce(data);
});
bench('maxUsingFor', () => {
maxUsingFor(data);
});
Luego ejecutamos el benchmark con vitest bench. Aquí está la salida del comando:
✓ max.bench.ts (4) 2441ms
name hz min max mean p75 p99 p995 p999 rme samples
· Native Math.max 799.83 0.7349 9.2443 1.2503 0.8774 6.3467 7.8521 9.2443 ±9.70% 400
· maxUsingChunks 291.20 2.3094 12.5102 3.4341 3.8463 12.0433 12.5102 12.5102 ±8.72% 146 slowest
· maxUsingReduce 1,033.83 0.8555 1.3126 0.9673 0.9713 1.2611 1.3066 1.3126 ±0.69% 518
· maxUsingFor 11,198.84 0.0872 0.1550 0.0893 0.0887 0.1062 0.1166 0.1391 ±0.13% 5600 fastest
BENCH Summary
maxUsingFor - max.bench.ts
10.83x faster than maxUsingReduce
14.00x faster than Native Math.max
38.46x faster than maxUsingChunks
🤯 Los resultados podrían parecer contraintuitivos: un simple for es mucho más rápido que cualquier otra opción, e incluso la versión con reduce es mucho más rápida que la implementación nativa.
Ron Northcutt escribió un buen artículo que explica en detalle por qué forEach (y otras funciones de bucle de arrays como reduce) son más lentas que un simple for; probablemente la razón más relevante es la sobrecarga de la función (function overhead): forEach() invoca una función de callback para cada elemento del array, y eso es costoso para el motor.
Nota: Si intentamos calcular el valor máximo en un array más grande, el
Math.maxnativo causará el errorMaximum call stack size exceeded, por lo que la versión nativa deMath.maxes peor en todos los aspectos.
Yendo más allá
Podemos intentar optimizar aún más la función con estrategias menos genéricas. Por ejemplo, si conocemos el máximo teórico, podemos reescribir la función de máximo para que deje de iterar si lo alcanzamos y devuelva ese valor. Un caso de uso real que encaja con esto es: tienes un array de temperaturas negativas y quieres obtener la temperatura máxima; podemos establecer el máximo teórico como 0 (ya que hablamos de temperaturas negativas), y si la función encuentra un elemento con el valor 0, estamos seguros de que hemos encontrado el máximo y podemos devolver ese valor sin seguir recorriendo el array.
Reemplazando Math.max
Si quieres solucionar el problema Maximum call stack size exceeded y/o hacer que tus aplicaciones sean más rápidas, puedes simplemente usar el código de la función maxUsingFor (cambiando el nombre de la función) y usarlo en tu código. Recomiendo encarecidamente NO hacer monkey-patch a Math.max. Puede causar efectos secundarios inesperados y errores, hace que el código sea difícil de mantener, etc.
También puedes usar una librería de terceros como fast-max que cubre todos los aspectos que mencionamos:
- Soluciona el problema
Maximum call stack size exceeded - Es rápida
- Implementa estrategias como el máximo teórico
- Puede ignorar valores
Ahora una pregunta para ti: ¿Te has enfrentado al problema del tamaño de la pila de llamadas en tu código? Cuéntamelo en los comentarios.
Sergio Carracedo