Pruebas de velocidad y soporte de los Web Workers

  • Actualización Noviembre 2012: He actualizado este tema agregando código resaltado y revisando algunas cosas.
  • Actualización Junio 2013: Revisión del soporte transferable en Firefox 22, con una nota aclaratoria.

Mejorando la comunicación con el Worker

El Web Worker permite ejecutar tareas de forma concurrente pero tiene algunas limitaciones. La más importante es que no puede acceder a variables globales de JavaScript o al DOM, por lo que no podemos modificar dinámicamente elementos de la página desde el script del Worker. La única forma de comunicarnos es mediante el paso de mensajes. Pero esto tiene un coste como vimos en el tema anterior. Hasta dónde llega ese coste y como podemos solucionarlo es lo que trato de ver en este tema.

En este ejemplo construimos un array muy largo sólo con números enteros iguales al número de su posición en el array, aunque en la posición cero ponemos el número entero 1234567890 para usarlo como prueba de comunicación. Se lo envíamos a un Worker (que hemos llamado worker44) y lo recibimos tras haber cambiado en el Worker la prueba por 1111111111. Veámos cuanto tardan estas tareas:

Ejemplo:

Longitud del array:
Esta longitud tiene un máximo de . Valores superiores serán modificados a este tope. Atención: Safari 5.1 tienen problemas con arrays muy grandes, reduzca este limite a 1,000,000.
Usar arrayBuffer: (Activado usará un ArrayBuffer, desactivado usará un Array convencional)
Usar Transferable (Sólo para cuando se use arrayBuffer y en caso de que soporte Transferable)
Usar Transferir Buffer (Para evitar bug de FIREFOX 22)

Mensajes generados en esta página
Mensajes generados a partir de la recepción del Worker44

Nota sobre soporte transferable en Firefox 18-22 (arreglado en versión 27)

30 junio 2013

Estos temas se redactaron en Abril 2012. Entonces sólo Chrome 18 tenía soporte para transferable. Unos meses más tarde, agosto 2012, Firefox 18 añadió soporte para esta característica. Aunque el test anterior era positivo para transferable, algo no funcionaba bien con este navegador dado que no se producía intercambio de datos con el paso de mensaje con un código como este: worker.postMessage(array, [array.buffer]). He esperado desde la versión 18 hasta la actual Firefox 22 pero sigue pasando lo mismo. Buscando si esto era un error mío o del navegador encontré que es un ¿malfuncionamiento? de Firefox tal como puede observase en la página oficial Bugs Mozilla. La única forma de que se produzca el paso de mensaje es modificando el código así: worker.postMessage(array.buffer, [array.buffer]). Se trata de transferir el buffer en lugar del array. Es un ligero contratiempo pues el array se recibe como buffer, es decir, una serie de bytes sin estructura, lo que conlleva que en el destino hay que darle esa estructura.

Por ejemplo, arrayDatos contiene una vista de un buffer en Int16Array, lo enviamos desde un script en el HTML con

worker.postMessage(arrayDatos, [arrayDatos.buffer])

en el destino (en el script del worker) podemos usar directamente el array con

self.onmessage = function(event){
    //Podemos usar directamente el array que viene en event.data,
    //por ejemplo, en un bucle que itere por el array
    for (var i=0; i<event.data.length; i++){
         ......
    }    
};

Pero esto no funcionará en Firefox 18-22, tal que nos dará un valor undefined en event.data. Se arregla enviando el buffer con

worker.postMessage(arrayDatos.buffer, [arrayDatos.buffer])

Y en el destino hemos de hacer lo siguiente

self.onmessage = function(event){
    //Estructuramos el buffer event.data en un Int16Array
    var arr = new Int16Array(event.data);
    //Ahora procesamos arr en lugar de event.data directamente
    for (var i=0; i<arr.length; i++){
         ......
    }      
};

En el ejemplo anterior he agregado una casilla de verificación para usar Transferir Buffer y evitar este bug, modificado el script para enviar los array de datos como buffer. De todas formas enviando el buffer también funciona en los otros navegadores que soportan transferable.

23 marzo 2014

He consultado esta incidencia de Firefox y ya está resuelta en la versión 27.

El script de Worker lo puede ver ejemplos/worker44.js. El código de este ejemplo es el siguiente:

El envío o recepción de un Array convencional de longitud 5 millones cuyo contenidos son números enteros tarda en torno a los 3.6 segundos en Chrome 18.0. Pero si usamos un ArrayBuffer los tiempos bajan hasta los 0.16 segundos.

Sobre los ArrayBuffer hay que decir que JavaScript ha sido usado tradicionalmente en contextos donde no hay acceso a datos binarios. En los casos en que estos necesitan ser manipulados es interesante usar esta API, que según la especificación Typed Array Specification provee interoperabilidad con datos binarios, definiendo un tipo de buffer genérico de longitud fija así como métodos que permiten acceder a los datos almacenados en el buffer.

El apartado sobre Cloning and Transferring ArrayBuffers and Views expone como clonar y transferir los ArrayBuffers. Esta utilidad es usada por la API Web Worker para transferir una array de este tipo. En el ejemplo anterior enviamos el array convencional arrayDatos al Worker de esta forma:

worker44.postMessage({"cmd":"enviar",
    "dato":arrayDatos, "time":time});

Pero si construimos el arrayDatos como un arrayBuffer, lo que haremos simplemente re-declarando el array:

arrayDatos = new Uint32Array(longitud);

entonces enviamos este array, o mejor dicho, transferimos este arrayBuffer así:

worker44.postMessage({"cmd":"enviar",
    "dato":arrayDatos, "time":time},
    [arrayDatos.buffer]);
En relación con este código, ver en esta página una nota de soporte transferable en Firefox 18-22.

Por último probemos esto en varios navegadores, sólo para el envío al Worker:

NavegadorArray convencionalArrayBuffer sin TransferableArrayBuffer con Transferable
Chrome 18.03600 ms160 ms<1 ms
Firefox 11.01800 ms60 msNo soportado
Safari 5.1 (Ver nota)1000 ms20000 msNo soportado
Opera 11.62 (Ver nota)1700 msNo soportadoNo soportado
Pruebas realizadas en un Intel Pentium 4, dual cuore, CPU 3.2GHz, RAM 1.5GB, Windows XP

Nota para Safari 1.5.1: Para una longitud del array de 5,000,000 de posiciones, este navegador muestra una variabilidad grande cada vez que se ejecuta la prueba. Con un Array convencional el valor mínimo obtenido es de unos 860 ms, el más frecuente es de 1400 ms, aunque a veces se demora a más de 30000 ms. Con un ArrayBuffer a veces se bloquea el navegador y cuando consigue finalizar el test lo hace con valores muy grandes, más de 30000 ms. Para intentar tener un dato fiable, he realizado la prueba con 1,000,000 de posiciones, con lo que los resultados son más estables. Luego he multiplicado los resultados de tiempo por 5 para poder compararlos con los otros navegadores.

Nota para Opera 11.62: Esta versión no soporta el copiado estructurado para pasar los ArrayBuffers, ni tampoco los Transferables. La próxima versión 12 si lo hará, como dice en la página my.opera.com haciendo referencia al cambio CORE-41942 Support structured cloning and Transferables.

La acción de transferir un ArrayBuffer es realmente cambiar el propietario del objeto, por lo que realmente no hay ninguna operación de clonado en esta acción. Es practicamente inmediata, o al menos con el mismo coste que reasignar una variable. Pero el problema es que por ahora sólo está soportado por Chrome, pero seguro que formará parte del resto de navegadores, pues es un aspecto que potencia el Worker para arreglar esa desventaja del coste del paso de mensajes.

El coste del paso de mensajes en el Worker

En el apartado anterior evalué el coste de pasar un único dato muy grande. Ahora el problema es cuando recibimos desde el Worker a la página un elevado número de mensajes, aunque de poco peso cada uno. ¿Cuánto cuesta en tiempo cada mensaje?. En el siguiente ejemplo puede lanzar un bucle y probar distintos valores para que el Worker nos devuelva mensajes:


He modificado esta prueba para tratar de evaluar el coste de pasar mensajes desde el Worker a la página. Con la versión anterior cada vez que se recibía un mensaje en este documento se actualizaba un elemento del DOM. La ejecución de esta tarea influía en la prueba dando resultados no correctos para el navegador Firefox. Ahora lo que hago es ir almacenando los mensajes recibidos en una variable de tipo String. Cuando el bucle finalice y tras tomar el tiempo final verteré el valor de esa variable con todos los mensajes recibidos en ese elemento del DOM.

Ejemplo:

Bucle iteraciones.
Recibir mensajes.

El script de Worker lo puede ver ejemplos/worker55.js. El código de este ejemplo es el siguiente:

Se trata de ver la diferencia entre recibir un único mensaje desde un Worker o recibir un elevado número, en ambos casos mientras se ejecuta la misma tarea larga (un bucle muy grande). Primero probamos con m1=1 mensaje y a continuación con m2=10000. Anotaremos los tiempos t1, t2 y calcularemos el coste con (t2-t1)/(m2-m1) ≅ (t2-t1)/10000. Se obtienen estos resultados comparativos entre los navegadores actuales:

Prueba con un bucle de 100 millones de iteraciones
NavegadorMensajesms/mensaje
110000
Chrome 18.0600 ms2700 ms0.21 ms/mensaje
Firefox 11.03000 ms3400 ms0.04 ms/mensaje
Safari 5.11900 ms4400 ms0.25 ms/mensaje
Opera 11.622900 ms4000 ms0.11 ms/mensaje
Pruebas realizadas en un Intel Pentium 4, dual cuore, CPU 3.2GHz, RAM 1.5GB, Windows XP
NOTA: Con Opera 11.62 en algunas ocasiones se bloquea o envía un único mensaje. Otras veces funciona según lo esperado obteniéndose los resultados mostrados.

Las marcas de tiempo se toman en esta página, no en el Worker. El tiempo inicial lo tomamos cuando se envía el mensaje al Worker para ejecutar el bucle:

iniTime = new Date();
worker5.postMessage({"numItera":numItera, "mensajes":mensajes});

Esto es lo que hacemos en el Worker:

var numItera = 0;
var cadaNum = 0;
var numero = 0;
self.addEventListener("message",
    function(event){
        numero = 1;
        numItera = event.data.numItera;
        cadaNum = parseInt(event.data.numItera / event.data.mensajes);
        bucle();
    },
    false);
function bucle(){
    while(numero&lt;numItera){
        if (numero % cadaNum == 0) self.postMessage(numero);
        numero++;
    }
    self.postMessage(numero)
}

Luego vamos recibiendo mensajes del bucle y gestionándolos en esta página, tomando la lectura final de tiempo cuando se complete el bucle:

worker5.addEventListener("message", manejadorEventoWorker5, false);

.................

function manejadorEventoWorker5(event){
    numMensaje++;
    recogeMensajes += event.data + "\r\n";
    if (event.data == numItera){
        finTime = new Date();
        document.getElementById("mensajeWK5").textContent +=
        "\r\nTiempo total: " + (finTime-iniTime) +
        " ms.\r\nMensajes recibidos " + numMensaje +
        ":\r\n" + recogeMensajes;
    }
}

Las diferencia en términos absolutos están en relación a como cada navegador ejecuta el bucle. Los tiempos con un sólo mensaje son los que se necesitan para enviar el mensaje inicial al Worker y ejecutar el bucle. La diferencia con los 10000 mensajes será el tiempo invertido en enviarlos. Por lo tanto y en términos relativos, que es lo que realmente nos interesa, el mejor resultado es para Firefox con un promedio de 0.04 ms/mensaje. En cualquier caso pasar mensajes tiene un coste. Así que la tarea óptima para ser adjudicada a un Worker sería aquella que necesitara pocos pasos de mensajes. En definitiva el Worker trabaja bien cuando "lo dejan tranquilo".