• 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.

Soporte Web Worker, TypedArray y Transferable

html5test.com En html5test.com se puede comprobar el soporte de las características de HTML5 en los navegadores, observándose que los actuales soportan los Web Workers, aunque con distinto grado en la adopción de las particularidades de esta característica. En este tema intento aplicar lo aprendido en un caso de uso concreto: mejorar los tiempos de proceso en el elemento Canvas del Generador de fractales. En lugar de hacer cambios en esa aplicación sin saber como resultará será mejor hacer unas pruebas previas con algo más sencillo. Esta página contiene siete ejemplos que generan un mismo fractal Julia. Inicialmente he tomado la configuración que aparece en el Trazador de gráficas matemáticas para el ejemplo expuesto en la sección Fractales de Julia en c=(-0.75, 0), aunque aquí también pueden modificarse los parámetros para generar otros ejemplos.

Dado el diferente nivel de soporte de los navegadores con los Workers, es necesario detectarlo previamente y actuar en su caso. Hay scripts que pueden ayudar en esto de la detección de características HTML5, como modernizr.com. Pero prefiero hacerme mi propio evaluador test-worker.js. El test se realiza llamando a la función var t = testWorker(). Luego accedemos a cada soporte, por ejemplo, con t.testArray que contiene un booleano para ver si pasó el test de enviar un array convencional. Al arrancar la página se ejecutará este test, cuyo resultado mostramos a continuación:

Ejemplo:

Test de soporte worker y prueba de envío y transferencia de un arrayBuffer usando el módulo test-worker.js con la función testWorker():
 

Código completo de este ejemplo

Se evalúa el soporte Worker y de arrayBuffer. Éste último permite una mayor velocidad en la transferencia de datos, pues éstos se envían como datos binarios, algo que hasta ahora JavaScript no tenía. Esta técnica se conoce como Typed Array (arrays tipados o algo así). Un arrayBuffer es una sucesión de bytes que toman un sentido cuando se obtiene una vista de los mismos. Así Uint8Array es una vista de enteros de 8 bits sin signo, lo que en el lenguaje C se conoce como unsigned char. La vista Uint8ClampedArray es especial para usar con <canvas>, como veremos en los últimos ejemplos de esta página.

Se realiza un testArray para verificar que se puede enviar un array convencional de longitud 1, es decir, declarado como new Array(1). Si esta prueba es correcta significa que se ha verificado la comunicación con el Worker. Si hay soporte arrayBuffer se realiza la prueba testBuffer y testTransferable enviando las vistas Uint8ClampedArray o Uint8Array si no soporta la anterior.

El concepto de Transferable se explica en Cloning and Transferring ArrayBuffers and Views. Pues no debemos olvidar que el Worker no tiene acceso a variables globales (ni al DOM). Todos los datos a comunicar con el Worker debe ser copiados (clonados) antes de ser enviados o recibidos. Si los datos son binarios el clonado es más rápido. Pero lo que resulta (casi) inmediato es la transferencia del dato, o mejor dicho, de la propiedad del dato. Lo veremos en ejecución en los ejemplos, aunque también puede consultar el artículo html5rocks.com: TRANSFERABLE OBJECTS: LIGHTNING FAST! que expone un test sobre la transferencia de datos y algunas notas interesantes. Por ahora esto de Transferable sólo lo ejecuta Chrome, pero será un soporte general en el futuro dado que arregla el problema del coste del paso de datos en los Workers.

Este test, que servirá para gestionar los ejemplos de esta página, ha dado los siguientes resultados con los navegadores actuales:

NavegadorworkerarrayBufferUint8ArrayUint8ClampedArraytestArraytestBuffertestTransferable
Chrome 18.0OKOKOKOKOKOKOK
Firefox 11.0OKOKOKOKOKOKNO
Safari 5.1OKOKOKNOOKOKNO
Opera 11.62OKOKOKNOOKNONO

El test nos permite saber el soporte y actuar en consecuencia. Por ejemplo, Opera 11.62 permite Worker pero no los arrayBuffer, por lo que la ejecución deberá realizarse clonando un array convencional (testArray es OK para este navegador).

Nota soporte transferable en Firefox 18-22

30 junio 2013

Firefox 18 añadió soporte para transferable en agosto 2012, pero incluso hasta la actual Firefox 22 (Junio 2013) hay un malfuncionamiento que se detalla en un nota aclaratoria de un tema anterior. En este tema he modificado ahora los ejemplos 5, 6 y 7 que utilizan el soporte transferable para que funcionen también en Firefox 22. En el código de cada ejemplo se comentan los cambios realizados.

En este tema desarrollaré una serie de ejemplos mientras intento aplicar el uso de Worker para mejorar la respuesta. Como aplicación práctica voy a usar el tema de Fractales. Es en principio un caso de uso que se presta a utilizar Workers, pues se trata de un bucle que realiza muchos cálculos y genera un volumen importante de datos. Mientras se ejecuta no es necesario interaccionar con el bucle y así nos queda la interfaz de usuario sin bloquear.

Configurar parámetros para generar Fractal

Estos campos configuran la ejecución para todos los ejemplos de esta página. Con estos valores iniciales he realizado el estudio comparativo para tratar de mejorar los tiempos de ejecución.

Ejemplo:

Configuración para generar el fractal en todos los ejemplos de esta página:

Código completo de este ejemplo

Ejemplo 1: Fractal con canvas

Ejemplo:

Elemento canvas no soportado.
 

Código completo de este ejemplo

La ejecución del fractal se basa en iterar por todos los puntos del plano. Con un plano de 200×200 serán 40000 iteraciones. Como mínimo, pues si recordamos el algoritmo para seleccionar puntos que expuse en el tema Fractales con el trazador de gráficas:

Declarar plano XY [x1,x2]×[y1,y2]
Bucle x=x1 hasta x=x2
    Bucle y=y1 hasta y=y2
        Bucle seleccionador de puntos
            Si (x,y) cumple la condición de selección
                Salir de este bucle
            Fin si
        Fin bucle
        Si completamos este bucle
            Pintar punto (x,y)
        Fin si        
    Fin bucle
Fin bucle    
    

Para cada una de esas 40000 iteraciones se producirán más en el bucle seleccionador de puntos. Los ejemplo de partida en esta página se corresponde con un Fractal de Julia con el parámetro (-0.75,0), y un valor de escape de 30. Por lo tanto el máximo de iteraciones serían 200×200×30 = 1200000, pero como todos los puntos no escapan, al final se ejecuta con 346264 iteraciones.

En este primer ejemplo se aplica ese algoritmo directamente para verter los puntos seleccionados en el elemento Canvas, SIN WORKER. En Chrome 18.0 esto se ejecuta en unos 340 ms. Voy a seguir poniendo estas mediciones siempre referidas a este navegador, pero entendiendo que son relativas a los medios empleados. Pues lo que interesa son los comparativos entre los distintos ejemplos más que los valores absolutos.

Ejemplo 2: Fractal con canvas y setTimeout

Ejemplo:

Elemento canvas no soportado.
 

Código completo de este ejemplo

Dado que no estamos usando Worker, la ejecución anterior bloquea el resto de ejecuciones del navegador. Una forma de evitarlo sin usar Worker es mediante setTimeout(). Esto lo expliqué en un tema anterior Simulando concurrencia en JavaScript. Además el efecto visual de ir presentando lo que se va generando es una medida que ayuda al usuario a saber que algo se está ejecutando. Puede ser incluso preferible aunque la ejecución se demore hasta en torno a los 1000 ms (Chrome 18.0).

Ejemplo 3: Fractal con canvas y un worker con array convencional

Ejemplo:

Elemento canvas no soportado.
 

Código completo de este ejemplo

El script de este Worker lo puede ver en worker-fractal.js.

¿Y qué pasa con los Workers? ¿Pueden mejorar lo anterior?. Este ejemplo usa un único Worker y un array convencional para pasar los datos hacia y desde el Worker. Pero el tiempo de 360 ms. (Chrome 18.0) no mejora con respecto al primer ejemplo. En forma resumida el script en esta página hace lo siguiente:

La función ejecutarFractal3() envía los datos con postMessage() al Worker. Son los parámetros necesarios para ejecutar el bucle seleccionador de puntos en el script worker-fractal.js. De forma resumida hace lo siguiente:

Se devuelve un array convencional con los valores k de escape. Las posiciones (x,y) se deducirán de la ordenación de los puntos en el bucle. De nuevo en la página principal el manejador del evento message recibe el objeto event.data. Iterará por el array de valores k, tantos como puntos hay en el plano, dando un color a cada punto en función de ese valor de escape. El punto (x,y) se obtiene de la ordenación del bucle, pintándose con fillRect().

Ejemplo 4: Fractal con canvas y tres workers con array convencional

Ejemplo:

Elemento canvas no soportado.
 

Código completo de este ejemplo

El script de este Worker lo puede ver en worker-fractal.js (el mismo del ejemplo anterior).

¿Y si dividimos la ejecución entre varios Workers?. En este ejemplo usamos tres Workes. Cada uno se encarga de un tercio del fractal usando el mismo script vinculado worker-fractal.js con el array convencional como antes. No hay mejora, pues el tiempo es ahora de 370 ms (Chrome 18.0).

Ejemplo 5: Fractal con canvas, worker,arrayBuffer y transferable

Ejemplo:

Elemento canvas no soportado.
 

Código completo de este ejemplo

El script de este Worker lo puede ver en worker-fractal5.js.

Ya uno empieza a intuir que el problema no está tanto en el coste de ejecución del script del Worker como en el trasvase de datos. Estos ejemplos necesitan un total de 200×200=40000 puntos. A la vuelta del Worker gestionamos el pintado de puntos en el Canvas usando el valor de escape para dotar de diferentes colores al punto (x,y), obtenidos por el orden que ocupan según fueron generados en el bucle:

y = n%alto;
x = Math.floor(n/alto);

Recordemos que los datos que se comunican con el Worker deben ser clonados antes de ser enviados. Y esto es lo que aporta mayor coste. Una forma para evitarlo es usar los TypedArray. El tipo ArrayBuffer nos permite manipular los datos como binarios, pues se trata de un buffer de bytes sin ninguna estructura que pueda entorpecer el clonado. En el script del Worker hacemos esto:

if (testBuffer==1){
    arrayPuntos = new Uint16Array(tamanyo);
} else {
    arrayPuntos = new Array(tamanyo);
}
...BUCLE EJECUCIÓN...
if (testTransferable){
    self.postMessage(arrayPuntos, [arrayPuntos.buffer]);
} else {
    self.postMessage(arrayPuntos);
}

Si el navegador soporta ArrayBuffer usamos el tipo Uint16Array para crear un array pero basado en un ArrayBuffer. Ese array se comporta exactamente igual que uno convencional, pero la clonación es más rápida. En el script contemplo la posibilidad de que no se soporte ArrayBuffer, usando entonces un array convencional con new Array().

Tras finalizar la ejecución en el Worker devolvemos los datos simplemente con self.postMessage(arrayPuntos), pudiendo ser ese array uno convencional o un ArrayBuffer. Pero si el navegador soporta la transferencia (Transferable) de la propiedad del ArrayBuffer (ver Transferring ArrayBuffers) habremos conseguido eliminar el clonado. Porque lo que realmente se está haciendo es transferir la propiedad de la variable. Esto se hace poniendo el buffer como segundo argumento en self.postMessage(arrayPuntos, [arrayPuntos.buffer]).

Sólo Chrome 18.0 a fecha de esta prueba soporta transferable (y obviamente ArrayBuffer). En el ejemplo he desglosado los tiempos de cada parte, ocupando sólo unos 25 ms la ejecución en el Worker (midiendo desde antes de enviar los datos de partida hasta después de recibir los datos de resultado). Mientras que el volcado de puntos en el canvas, que se ejecuta en esta página principal, ocupa unos 313 ms. Pero aún así el tiempo total de 338 ms no se ha reducido mucho con respecto a ejemplos anteriores.

Ejemplo 6: Fractal con canvas, worker,arrayBuffer, transferable y putImageData

Ejemplo:

Elemento canvas no soportado.
 

Código completo de este ejemplo

El script de este Worker lo puede ver en worker-fractal6.js.

Con el ejemplo del apartado anterior no rebajamos mucho el tiempo. Pero al menos se dividió el problema en dos partes. Hemos resuelto lo del Worker, al menos si hay soporte de Transferable. Ahora nos queda ver que pasa con el volcado de puntos en el elemento Canvas. ¿Podemos rebajar esos 313 ms del Canvas que es un valor grande comparado con los 25 ms del Worker?

El elemento <canvas> permite la manipulación a nivel de píxeles (Ver Pixel manipulation). Podemos crear un objeto ImageData con context.createImageData(sw,sh). Se trata de un objeto con las dimensiones dadas sw,sh en píxeles que nos servirá para gestionar el contenido del Canvas. Así con getImageData(sx, sy, sw, sh) recuperamos el contenido de los píxeles del Canvas y con putImageData(imagedata, dx, dy) ponemos un contenido. Los datos que representan una imagen son una serie continua de valores red,green,blue,alfa de tipo byte, es decir, enteros en el rango [0..255]. No hay ninguna estructura subyacente, es decir, se trata de un buffer de datos. JavaScript genera la imagen extrayendo los bytes de forma consecutiva en grupos de cuatro y aplicándolos a cada punto del Canvas, empezando en la esquina superior izquierda y en sentido horizontal-vertical.

Esta forma es más rápida que ir llenando píxeles de 1×1 con fillRect(x,y,1,1), como hacia en los ejemplos anteriores. Al igual que antes, la ejecución sigue desglosada en dos partes, pero ahora usamos putImageData() para volcar el Fractal en el Canvas. Veámos todo el proceso de forma resumida:

  1. Desde la página ejecutamos lo necesario para generar y pintar un Fractal, enviando los datos necesarios al Worker:

    Según admita o no el arrayBuffer hemos preparado un array de salida que también podrá enviarse con transferencia si es soportado. Esta parte nada tiene que ver con la generación en el Worker, pero así los datos le llegan antes.

  2. En el Worker se reciben los datos donde se ejecutará el script que genera el Fractal y también en este caso adjudica los colores a cada punto. Esto antes no se hacia en el Worker sino en la página principal. En pseudo-código es algo así:

    Declarar un arrayBuffer Uint8ClampedArray
    Bucle x<ancho
        Bucle y<alto
            Bucle k<escape
                Si no escapa salir
            Fin bucle
            Si k<escape
                red = funcion(k)
                green = funcion(k)
                blue = funcion(k)
                alfa = 255
            En otro caso
                red = un valor red 
                green = un valor green
                blue = un valor blue
                alfa = 255
            Fin si
            Poner red,green,blue,alfa en el arrayBuffer
        Fin bucle
    Fin bucle
    Devolver el arrayBuffer

    Necesitamos pasar el grupo red, green, blue, alfa que son valores enteros [0..255] que representan un byte para el color y opacidad de cada punto. La opacidad que en CSS es opacity, es un valor en el rango [0..1] pero que se aplica al rango [0..255], siendo el máximo el de mayor opacidad.

    Hay que observar que el arrayBuffer declarado debe ser Uint8ClampedArray que es especial para luego ser volcado en el Canvas con putImageData(). En todo caso y aunque en este pseudo-código no aparece, en el script del ejemplo se observa la posibilidad de que este tipo no se soporte, en cuyo caso se usarán otros tipos pero a costa de empeorar los tiempos.

  3. De vuelta en la página con los datos del Worker volcamos los puntos en el Canvas:

    Los datos vienen en el arrayBuffer event.data pudiéndose acceder a ellos como si fuera un array convencional. Veáse que no tenemos que preocuparnos de como vienen los datos en el arrayBuffer, sino simplemente traspasarlo uno tras otro al ImageData.

Esta acción de manipulación de píxeles en el Canvas con putImageData() es más rápida que el uso de fillRect() para pintar cada punto. El tiempo total baja hasta los 25 ms (en segunda ejecución), que comparado con los 338 ms del ejemplo anterior es un ahorro muy importante. El tiempo de generación es similar, por lo que todo el ahorro se consigue en el vertido en el Canvas. Por supuesto, siempre que se soporte lo necesario: Transferable, Uint8ClampedArray y putImageData.

Ejemplo 7: Fractal con canvas, worker, arrayBuffer, transferable y putImageData, gestionado en secciones

Ejemplo:

Elemento canvas no soportado.
 

Código completo de este ejemplo

El script de este Worker lo puede ver en worker-fractal7.js.

Por último y como una mejora en la presentación, podemos aplicar un efecto de "cortinilla" para que el fractal vaya presentándose a medida que se genera. Algo como el segundo ejemplo donde se usaba setTimeout. Lo que hacemos es ejecutar el fractal de forma seccionada, generando columnas de 1×alto puntos en el Worker e irlas enviando al Canvas para que las vaya pintando a medida que las reciba. El tiempo final no se incrementa demasiado, pues pasa de 25 ms a unos 50 ms, pero el efecto final para el usuario es más satisfactorio. Esto apenas se aprecia con generaciones que ocupan tan poco tiempo como en este ejemplo, pero con otras más pesadas si es de agradecer que se vayan presentando los datos a medida que se generan.

Resumen de ejecución con Web Workers en navegadores

Por último ejecutaré los ejemplos en varios de los navegadores actuales. Anotaré los tiempos más frecuentes tras ejecutar repetidas veces el mismo ejemplo. No pretendo hacer un registro estadístico con estos datos, sino más bien mostrar el nivel de soporte pues los valores absolutos dependen del entorno donde se esté ejecutando. Los tiempos se dan en milisegundos, midiendo desde antes de enviar los datos al Worker hasta que se recibe el último píxel y se pone en el Canvas.

Navegador1) sólo canvas2) setTimeout3) worker + array convencional4) tres workers + array convencional5) worker + arrayBuffer + transferable6) ... + putImageData7) ... + seccionadoNo soporta
Chrome 18.034010003603703382550
Firefox 11.09001800920920910150240Transferable
Safari 5.1320990340320520820425Uint8ClampedArray, Transferable
Opera 11.62360500350370370330460Uint8ClampedArray, testBuffer, Transferable

En resumen, los Web Workers pueden resultar apropiados para ejecutar algoritmos que manejen puntos para un Canvas, pero cuando la característica de la transferencia (transferable) está soportada se consigue el mejor rendimiento.