I wondered if transmission to or from a web worker can be a bottleneck. Should we post message just as we trigger any kind of events, or should we take care and try to limit as much as possible the communication between the two ?
Let's have an example. If I have a huge array that is dynamically constructed (e.g. an array of contact points coming from mousemove
or touchmove
for a gesture recogniser), is it more efficient to transfer the data iteratively – i.e. send each element as soon as we receive it and let the worker store them on its side – or is it better to store them on the main thread and send all the data at once at the end, in particular when one cannot use a transferable object?
Well you can buffer the data in Uint16Array
1. You can then do a little trick and move the data instead of copying. See this demo on MDN for an introduction.
1: should be enough for screens smaller than 16x16 meters at pixel density 0.25 pixels per milimeter, which I believe is most screens on the world
First to your question, let's test the web workers speed.
I created this test snippet that attempts to measure actual speed of workers. But attempts is important here. Truly I figured out that only reliable way of measuring the time will affect the time, much like what we experience in modern physic theories.
What the code definitely can tell us is that buffering is a good idea. First textbox sets the total amount of data to be sent. Second sets the number of samples to divide the data in. You'll soon find out that overhead with samples is notable. Checkbox allows you to chose whether to transfer data or not. This starts to matter with bigger amount of data, just as anticipated.
Please forgive the messy code, I can't force myself to behave when writing exciting test snippets. I created this tjes
function WorkerFN() {
console.log('WORKER: Worker ready for data.');
// Amount of data expected
var expectedData = 0;
// Amount of data received
var receivedData = 0;
self.onmessage = function(e) {
var type = e.data.type;
if(type=="data") {
receivedData+=e.data.data.byteLength;
self.postMessage({type: "timeResponse", timeStart: e.data.time, timeHere: performance.now(), bytes: e.data.data.byteLength, all:expectedData<=receivedData});
}
else if(type=="expectData") {
if(receivedData>0 && receivedData<expectedData) {
console.warn("There is transmission in progress already!");
}
console.log("Expecting ", e.data.bytes, " bytes of data.");
expectedData = e.data.bytes;
receivedData = 0;
}
}
}
var worker = new Worker(URL.createObjectURL(new Blob(["("+WorkerFN.toString()+")()"], {type: 'text/javascript'})));
/** SPEED CALCULATION IN THIS BLOCK **/
var results = {
transfered: 0,
timeIntegral: 0 //Total time between sending data and receiving confirmation
}
// I just love getters and setters. They are so irresistably confusing :)
// ... little bit like women. You think you're just changing a value and whoops - a function triggers
Object.defineProperty(results, "speed", {get: function() {
if(this.timeIntegral>0)
return (this.transfered/this.timeIntegral)*1000;
else
return this.transfered==0?0:Infinity;
}
});
// Worker sends times he received the messages with data, we can compare them with sent time
worker.addEventListener("message", function(e) {
var type = e.data.type;
if(type=="timeResponse") {
results.transfered+=e.data.bytes;
results.timeIntegral+=e.data.timeHere-e.data.timeStart;
// Display finish message if allowed
if(e.data.all) {
status("Done. Approx speed: "+humanFileSize(Math.round(results.speed/100)/10, true)+"/s");
addRecentResult();
}
}
});
/** GUI CRAP HERE **/
// Firefox caches disabled values after page reload, which makes testing a pain
$(".disableIfWorking").attr("disabled", false);
$("#start_measure").click(startMeasure);
$("#bytes").on("input", function() {
$("#readableBytes").text(humanFileSize(this.value, true));
});
$("#readableBytes").text(humanFileSize($("#bytes").val()*1||0, true));
function addRecentResult() {
var bytes = $("#bytes").val()*1;
var chunks = $("#chunks").val()*1;
var bpch = Math.ceil(bytes/chunks);
var string = '<tr><td class="transfer '+($("#transfer")[0].checked)+'"> </td><td class="speed">'+humanFileSize(results.speed, true)+'/s</td><td class="bytes">'+humanFileSize(bytes, true)+'</td><td class="bpch">'+humanFileSize(bpch, true)+'</td><td class="time">'+results.timeIntegral+'</td></tr>';
if($("#results td.transfer").length==0)
$("#results").append(string);
else
$(string).insertBefore($($("#results td.transfer")[0].parentNode));
}
function status(text, className) {
$("#status_value").text(text);
if(typeof className=="string")
$("#status")[0].className = className;
else
$("#status")[0].className = "";
}
window.addEventListener("error",function(e) {
status(e.message, "error");
// Enable buttons again
$(".disableIfWorking").attr("disabled", false);
});
function startMeasure() {
if(Number.isNaN(1*$("#bytes").val()) || Number.isNaN(1*$("#chunks").val()))
return status("Fill the damn fields!", "error");
$(".disableIfWorking").attr("disabled", "disabled");
DataFabricator(1*$("#bytes").val(), 1*$("#chunks").val(), sendData);
}
/** SENDING DATA HERE **/
function sendData(dataArray, bytes, bytesPerChunk, transfer, currentOffset) {
// Initialisation before async recursion
if(typeof currentOffset!="number") {
worker.postMessage({type:"expectData", bytes: bytesPerChunk*dataArray.length});
// Reset results
results.timeIntegral = 0;
results.transfered = 0;
results.finish = false;
setTimeout(sendData, 500, dataArray, bytes, bytesPerChunk, $("#transfer")[0].checked, 0);
}
else {
var param1 = {
type:"data",
time: performance.now(),
data: dataArray[currentOffset]
};
// I decided it's optimal to write code twice and use if
if(transfer)
worker.postMessage(param1, [dataArray[currentOffset]]);
else
worker.postMessage(param1);
// Allow GC
dataArray[currentOffset] = undefined;
// Increment offset
currentOffset++;
// Continue or re-enable controls
if(currentOffset<dataArray.length) {
// Update status
status("Sending data... "+Math.round((currentOffset/dataArray.length)*100)+"% at "+humanFileSize(Math.round(results.speed/100)/10, true)+"/s");
setTimeout(sendData, 100, dataArray, bytes, bytesPerChunk, transfer, currentOffset);
}
else {
//status("Done. Approx speed: "+humanFileSize(Math.round(results.speed/100)/10, true)+"/s");
$(".disableIfWorking").attr("disabled", false);
results.finish = true;
}
}
}
/** CREATING DATA HERE **/
function DataFabricator(bytes, chunks, callback) {
var loop;
var args = [
chunks, // How many chunks to create
bytes, // How many bytes to transfer total
Math.ceil(bytes/chunks), // How many bytes per chunk, byt min 1 byte per chunk
0, // Which offset of current chunk are we filling
[], // Array of existing chunks
null, // Currently created chunk
];
// Yeah this is so damn evil it randomly turns bytes in your memory to 666
// ... yes I said BYTES
(loop=function(chunks, bytes, bytesPerChunk, chunkOffset, chunkArray, currentChunk) {
var time = performance.now();
// Runs for max 40ms
while(performance.now()-time<40) {
if(currentChunk==null) {
currentChunk = new Uint8Array(bytesPerChunk);
chunkOffset = 0;
chunkArray.push(currentChunk.buffer);
}
if(chunkOffset>=currentChunk.length) {
// This means the array is full
if(chunkArray.length>=chunks)
break;
else {
currentChunk = null;
// Back to the top
continue;
}
}
currentChunk[chunkOffset] = Math.floor(Math.random()*256);
// No need to change every value in array
chunkOffset+=Math.floor(bytesPerChunk/5)||1;
}
// Calculate progress in bytes
var progress = (chunkArray.length-1)*bytesPerChunk+chunkOffset;
status("Generating data - "+(Math.round((progress/(bytesPerChunk*chunks))*1000)/10)+"%");
if(chunkArray.length<chunks || chunkOffset<currentChunk.length) {
// NOTE: MODIFYING arguments IS PERFORMANCE KILLER!
Array.prototype.unshift.call(arguments, loop, 5);
setTimeout.apply(null, arguments);
}
else {
callback(chunkArray, bytes, bytesPerChunk);
Array.splice.call(arguments, 0);
}
}).apply(this, args);
}
/** HELPER FUNCTIONS **/
// Thanks: http://stackoverflow.com/a/14919494/607407
function humanFileSize(bytes, si) {
var thresh = si ? 1000 : 1024;
if(Math.abs(bytes) < thresh) {
return bytes + ' B';
}
var units = si
? ['kB','MB','GB','TB','PB','EB','ZB','YB']
: ['KiB','MiB','GiB','TiB','PiB','EiB','ZiB','YiB'];
var u = -1;
do {
bytes /= thresh;
++u;
} while(Math.abs(bytes) >= thresh && u < units.length - 1);
return bytes.toFixed(1)+' '+units[u];
}
* {margin:0;padding:0}
#start_measure {
border: 1px solid black;
background-color:orange;
}
button#start_measure[disabled] {
border: 1px solid #333;
font-style: italic;
background-color:#AAA;
width: 100%;
}
.buttontd {
text-align: center;
}
#status {
margin-top: 3px;
border: 1px solid black;
}
#status.error {
color: yellow;
font-weight: bold;
background-color: #FF3214;
}
#status.error div.status_text {
text-decoration: underline;
background-color: red;
}
#status_value {
display: inline-block;
border-left: 1px dotted black;
padding-left: 1em;
}
div.status_text {
display: inline-block;
background-color: #EEE;
}
#results {
width: 100%
}
#results th {
padding: 3px;
border-top:1px solid black;
}
#results td, #results th {
border-right: 1px dotted black;
}
#results td::first-child, #results th::first-child {
border-left: 1px dotted black;
}
#results td.transfer.false {
background-color: red;
}
#results td.transfer.true {
background-color: green;
}
<script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>
<table>
<tr><td>Bytes to send total: </td><td><input class="disableIfWorking" id="bytes" type="text" pattern="\d*" placeholder="1024"/></td><td id="readableBytes"></td></tr>
<tr><td>Divide in chunks: </td><td><input class="disableIfWorking" id="chunks" type="text" pattern="\d*" placeholder="number of chunks"/></td><td></td></tr>
<tr><td>Use transfer: </td><td> <input class="disableIfWorking" id="transfer" type="checkbox" checked /></td><td></td></tr>
<tr><td colspan="2" class="buttontd"><button id="start_measure" class="disableIfWorking">Start measuring speed</button></td><td></td></tr>
</table>
<div id="status"><div class="status_text">Status </div><span id="status_value">idle</span></div>
<h2>Recent results:</h2>
<table id="results" cellpading="0" cellspacing="0">
<tr><th>transfer</th><th>Speed</th><th>Volume</th><th>Per chunk</th><th>Time (only transfer)</th></tr>
</table>
I'll stick to the mouse pointer example, because it's easy to simulate. We'll make a program that calculates mouse pointer path distance using web worker.
What we're gonna do is real, old school buffering. We make a fixed size array (only those allow transferring to workers) and fill it while remembering last point we filled. When we're at the end, we can send the array and create another.
// Creating a buffer
this.buffer = new Uint16Array(256);
this.bufferOffset = 0;
We can save coordinates easily then, as long as we do not let bufferOffset
overflow the buffer
:
if(this.bufferOffset>=this.buffer.length)
this.sendAndResetBuffer();
this.buffer[this.bufferOffset++] = X;
this.buffer[this.bufferOffset++] = Y;
You've already seen the example on MDN (right...?) so just a quick recapitulation:
worker.postMessage(myTypedArray.buffer, [myTypedArray.buffer]);
// The buffer must be empty now!
console.assert(myTypedArray.buffer.byteLength==0)
Here's what I came with for the buffering and sending data. The class is created with desired max buffer length. It then stores data (pointer locations in this case) and dispatches to the Worker.
/** MousePointerBuffer saves mouse locations and when it's buffer is full,
sends them as array to the web worker.
* worker - valid worker object ready to accept messages
* buffer_size - size of the buffer, in BYTES, not numbers or points
**/
function MousePointerBuffer(worker, buffer_size) {
this.worker = worker;
if(buffer_size%4!=0)
throw new Error("MousePointerBuffer requires complement of 4 bytes number, because 1 mouse point is 2 shorts which is 4 bytes!");
this.buffer_size = buffer_size/2;
// Make buffer lazy
this.buffer = null;
this.bufferOffset = 0;
// This will print the aproximate time taken to send data + all of the overheads
worker.addEventListener("message", function(e) {
if(e.data.type=="timer")
console.log("Approximate time: ", e.data.time-this.lastSentTime);
}.bind(this));
}
MousePointerBuffer.prototype.makeBuffer = function() {
if(this.buffer!=null) {
// Buffer created and not full
if(this.bufferOffset<this.buffer_size)
return;
// Buffer full, send it then re-create
else
this.sendBuffer();
}
this.buffer = new Uint16Array(this.buffer_size);
this.bufferOffset = 0;
}
/** Sends current buffer, even if not full. Data is sent as array
[ArrayBuffer buffer, Number bufferLength] where buffer length means
occupied bytes. **/
MousePointerBuffer.prototype.sendBuffer = function() {
this.lastSentTime = performance.now();
console.log("Sending ",this.buffer.buffer.byteLength," bytes at: ",this.lastSentTime);
this.worker.postMessage([this.buffer.buffer, this.bufferOffset]
, [this.buffer.buffer] // Comment this line out to see
// How fast is it without transfer
);
// See? Bytes are gone.
console.log("Bytes in buffer after sending: ",this.buffer.buffer.byteLength);
this.buffer = null;
this.bufferOffset = 0;
}
/* Creates event callback for mouse move events. Callback is stored in
.listener property for later removal **/
MousePointerBuffer.prototype.startRecording = function() {
// The || expression alows to use cached listener from the past
this.listener = this.listener||this.recordPointerEvent.bind(this);
window.addEventListener("mousemove", this.listener);
}
/* Can be used to stop any time, doesn't send buffer though! **/
MousePointerBuffer.prototype.stopRecording = function() {
window.removeEventListener("mousemove", this.listener);
}
MousePointerBuffer.prototype.recordPointerEvent = function(event) {
// This is probably not very efficient but makes code shorter
// Of course 90% time that function call just returns immediatelly
this.makeBuffer();
// Save numbers - remember that ++ first returns then increments
this.buffer[this.bufferOffset++] = event.clientX;
this.buffer[this.bufferOffset++] = event.clientY;
}
function WorkerFN() {
console.log('WORKER: Worker ready for data.');
// Variable to store mouse pointer path distance
var dist = 0;
// Last coordinates from last iteration - filled by first iteration
var last_x = null,
last_y = null;
// Sums pythagorian distances between points
function calcPath(array, lastPoint) {
var i=0;
// If first iteration, first point is the inital one
if(last_x==null||last_y==null) {
last_x = array[0];
last_y = array[1];
// So first point is already skipped
i+=2;
}
// We're iterating by 2 so redyce final length by 1
var l=lastPoint-1
// Now loop trough points and calculate distances
for(; i<l; i+=2) {
console.log(dist,last_x, last_y);
dist+=Math.sqrt((last_x-array[i]) * (last_x-array[i])+
(last_y-array[i+1])*(last_y-array[i+1])
);
last_x = array[i];
last_y = array[i+1];
}
// Tell the browser about the distance
self.postMessage({type:"dist", dist: dist});
}
self.onmessage = function(e) {
if(e.data instanceof Array) {
self.postMessage({type:'timer', time:performance.now()});
setTimeout(calcPath, 0, new Uint16Array(e.data[0]), e.data[1]);
}
else if(e.data.type=="reset") {
self.postMessage({type:"dist", dist: dist=0});
}
}
}
var worker = new Worker(URL.createObjectURL(new Blob(["("+WorkerFN.toString()+")()"], {type: 'text/javascript'})));
/** MousePointerBuffer saves mouse locations and when it's buffer is full,
sends them as array to the web worker.
* worker - valid worker object ready to accept messages
* buffer_size - size of the buffer, in BYTES, not numbers or points
**/
function MousePointerBuffer(worker, buffer_size) {
this.worker = worker;
if(buffer_size%4!=0)
throw new Error("MousePointerBuffer requires complement of 4 bytes number, because 1 mouse point is 2 shorts which is 4 bytes!");
this.buffer_size = buffer_size/2;
// Make buffer lazy
this.buffer = null;
this.bufferOffset = 0;
// This will print the aproximate time taken to send data + all of the overheads
worker.addEventListener("message", function(e) {
if(e.data.type=="timer")
console.log("Approximate time: ", e.data.time-this.lastSentTime);
}.bind(this));
}
MousePointerBuffer.prototype.makeBuffer = function() {
if(this.buffer!=null) {
// Buffer created and not full
if(this.bufferOffset<this.buffer_size)
return;
// Buffer full, send it then re-create
else
this.sendBuffer();
}
this.buffer = new Uint16Array(this.buffer_size);
this.bufferOffset = 0;
}
/** Sends current buffer, even if not full. Data is sent as array
[ArrayBuffer buffer, Number bufferLength] where buffer length means
occupied bytes. **/
MousePointerBuffer.prototype.sendBuffer = function() {
this.lastSentTime = performance.now();
console.log("Sending ",this.buffer.buffer.byteLength," bytes at: ",this.lastSentTime);
this.worker.postMessage([this.buffer.buffer, this.bufferOffset]
, [this.buffer.buffer] // Comment this line out to see
// How fast is it without transfer
);
// See? Bytes are gone.
console.log("Bytes in buffer after sending: ",this.buffer.buffer.byteLength);
this.buffer = null;
this.bufferOffset = 0;
}
/* Creates event callback for mouse move events. Callback is stored in
.listener property for later removal **/
MousePointerBuffer.prototype.startRecording = function() {
// The || expression alows to use cached listener from the past
this.listener = this.listener||this.recordPointerEvent.bind(this);
window.addEventListener("mousemove", this.listener);
}
/* Can be used to stop any time, doesn't send buffer though! **/
MousePointerBuffer.prototype.stopRecording = function() {
window.removeEventListener("mousemove", this.listener);
}
MousePointerBuffer.prototype.recordPointerEvent = function(event) {
// This is probably not very efficient but makes code shorter
// Of course 90% time that function call just returns immediatelly
this.makeBuffer();
// Save numbers - remember that ++ first returns then increments
this.buffer[this.bufferOffset++] = event.clientX;
this.buffer[this.bufferOffset++] = event.clientY;
}
var buffer = new MousePointerBuffer(worker, 400);
buffer.startRecording();
// Cache text node reffernce here
var textNode = document.getElementById("px").childNodes[0];
worker.addEventListener("message", function(e) {
if(e.data.type=="dist") {
textNode.data=Math.round(e.data.dist);
}
});
// The reset button
document.getElementById("reset").addEventListener("click", function() {
worker.postMessage({type:"reset"});
buffer.buffer = new Uint16Array(buffer.buffer_size);
buffer.bufferOffset = 0;
});
* {margin:0;padding:0;}
#px {
font-family: "Courier new", monospace;
min-width:100px;
display: inline-block;
text-align: right;
}
#square {
width: 200px;
height: 200px;
border: 1px dashed red;
display:table-cell;
text-align: center;
vertical-align: middle;
}
Distance traveled: <span id="px">0</span> pixels<br />
<button id="reset">Reset</button>
Try this, if you hve steady hand, you will make it 800px around:
<div id="square">200x200 pixels</div>
This demo is printing into normal browser console, so take a look there.
On line 110 class is initialized, so you can change buffer length:
var buffer = new MousePointerBuffer(worker, 400);
On line 83, you can comment out transfer command to simulate normal copy operation. It seems to me that the difference is really insignificant in this case:
, [this.buffer.buffer] // Comment this line out to see
// How fast is it without transfer
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With