7

I've found what appears to be an interesting anomaly in JavaScript. Which centres upon my attempts to speed up trigonometric transformation calculations by precomputing sin(x) and cos(x), and simply referencing the precomputed values.

Intuitively, one would expect pre-computation to be faster than evaluating the Math.sin() and Math.cos() functions each time. Especially if your application design is going to use only a restricted set of values for the argument of the trig functions (in my case, integer degrees in the interval [0°, 360°), which is sufficient for my purposes here).

So, I ran a little test. After pre-computing the values of sin(x) and cos(x), storing them in 360-element arrays, I wrote a short test function, activated by a button in a simple test HTML page, to compare the speed of the two approaches. One loop simply multiplies a value by the pre-computed array element value, whilst the other loop multiplies a value by Math.sin().

My expectation was that the pre-computed loop would be noticeably faster than the loop involving a function call to a trig function. To my surprise, the pre-computed loop was slower.

Here's the test function I wrote:

function MyTest()
{
var ITERATION_COUNT = 1000000;

var angle = Math.floor(Math.random() * 360);

var test1 = 200 * sinArray[angle];

var test2 = 200 * cosArray[angle];

var ref = document.getElementById("Output1");

var outData = "Test 1 : " + test1.toString().trim() + "<br><br>";
outData += "Test 2 : "+test2.toString().trim() + "<br><br>";

var time1 = new Date();     //Time at the start of the test

for (var i=0; i<ITERATION_COUNT; i++)
{
    var angle = Math.floor(Math.random() * 360);
    var test3 = (200 * sinArray[angle]);

//End i loop
}

var time2 = new Date();

//This somewhat unwieldy procedure is how we find out the elapsed time ...

var msec1 = (time1.getUTCSeconds() * 1000) + time1.getUTCMilliseconds();
var msec2 = (time2.getUTCSeconds() * 1000) + time2.getUTCMilliseconds();

var elapsed1 = msec2 - msec1;

outData += "Test 3 : Elapsed time is " + elapsed1.toString().trim() + " milliseconds<br><br>";

//Now comparison test with the same number of sin() calls ...

var time1 = new Date();

for (var i=0; i<ITERATION_COUNT; i++)
{
    var angle = Math.floor(Math.random() * 360);
    var test3 = (200 * Math.sin((Math.PI * angle) / 180));

//End i loop
}

var time2 = new Date();

var msec1 = (time1.getUTCSeconds() * 1000) + time1.getUTCMilliseconds();
var msec2 = (time2.getUTCSeconds() * 1000) + time2.getUTCMilliseconds();

var elapsed2 = msec2 - msec1;

outData += "Test 4 : Elapsed time is " + elapsed2.toString().trim() + " milliseconds<br><br>";

ref.innerHTML = outData;

//End function
}

My motivation for the above, was that multiplying by a pre-computed value fetched from an array would be faster than invoking a function call to a trig function, but the results I obtain are interestingly anomalous.

Some sample runs yield the following results (Test 3 is the pre-computed elapsed time, Test 4 the Math.sin() elapsed time):

Run 1:

Test 3 : Elapsed time is 153 milliseconds

Test 4 : Elapsed time is 67 milliseconds

Run 2:

Test 3 : Elapsed time is 167 milliseconds

Test 4 : Elapsed time is 69 milliseconds

Run 3 :

Test 3 : Elapsed time is 265 milliseconds

Test 4 : Elapsed time is 107 milliseconds

Run 4:

Test 3 : Elapsed time is 162 milliseconds

Test 4 : Elapsed time is 69 milliseconds

Why is invoking a trig function twice as fast as referencing a precomputed value from an array, when the precomputed approach, intuitively at least, should be the faster by an appreciable margin? All the more so because I'm using integer arguments to index the array in the precomputed loop, whilst the function call loop also includes an extra calculation to convert from degrees to radians?

There's something interesting happening here, but at the moment, I'm not sure what. Usually, array accesses to precomputed data are a lot faster than calling intricate trig functions (or at least, they were back in the days when I coded similar code in assembler!), but JavaScript seems to turn this on its head. The only reason I can think of, is that JavaScript adds a lot of overhead to array accesses behind the scenes, but if this were so, this would impact upon a lot of other code, that appears to run at perfectly reasonable speed.

So, what exactly is going on here?

I'm running this code in Google Chrome:

Version 60.0.3112.101 (Official Build) (64-bit)

running on Windows 7 64-bit. I haven't yet tried it in Firefox, to see if the same anomalous results appear there, but that's next on the to-do list.

Anyone with a deep understanding of the inner workings of JavaScript engines, please help!

David Edwards
  • 794
  • 8
  • 13
  • 3
    i didn't read the wall of text but did you consider `Math.sin` already does a table lookup plus optimized interpolation towards the desired value? – ASDFGerte Aug 26 '17 at 15:01
  • 1
    Do you know this for sure? Only if correct, this would explain a LOT. – David Edwards Aug 26 '17 at 15:03
  • I'm not quite sure I get it? Firstly, you have `var angle` like four times? Secondly, you don't account for "warm-up", where you keep the tests in different scopes, with tear-down etc? And what is `sinArray`, and how does it equate to `Math.PI * angle` ? Set up a proper test on https://jsperf.com/ – adeneo Aug 26 '17 at 15:09
  • Closest indication that my assumption is not too far off (first viable related text from a google search): [`V8 roll with the new sin/cos implementation using table lookup and interpolation`](https://bugs.chromium.org/p/v8/issues/detail?id=3006). I only know that's a common way to implement it. Also note the function will be running native code, giving it yet another advantage. – ASDFGerte Aug 26 '17 at 15:13
  • 3
    I'm not entirely following the somewhat twisty Chromium source code, but it looks like [it's implemented quite efficiently, and in C](https://github.com/v8/v8/blob/d721a9d40627a80acc27a0b856e82a2b20d8d834/src/base/ieee754.cc#L701), so I doubt you'll manage to beat it by doing anything clever at the much, much higher level of JavaScript. – Matt Gibson Aug 26 '17 at 15:17
  • @ASDFGerte this is the best answer one can give... Add it! – Jonas Wilms Aug 26 '17 at 15:25
  • Protip regarding the "*somewhat unwieldy procedure*": just `const elapsed = date2 - date1`. Or `date.valueOf()`, `+date` or `date.getTime()` if you want to explicitly convert to a number before subtracting. – Bergi Aug 26 '17 at 15:32
  • 2
    Can you please post a [mcve] that we can try out, please? In particular it would be important to see how you precalculated that array. – Bergi Aug 26 '17 at 15:34
  • 1
    Some quick guesses: [the optimiser eats your microbenchmark for breakfast](http://mrale.ph/talks/goto2015/#/71) because it can prove all the called functions are pure; [accessing global variables is slow](https://stackoverflow.com/q/35811885/1048572), and (as established in the other comments) JS Math is faster than you might think (though possibly at the expanse of accuracy). – Bergi Aug 26 '17 at 15:51
  • For a side note, Firefox v 55.0.2 computes sines like 10x faster than Chrome v 60.0.3112.101 – Redu Aug 26 '17 at 17:11

3 Answers3

4

Optimiser has skewed the results.

Two identical test functions, well almost.
Run them in a benchmark and the results are surprising.

{

    func : function (){
        var i,a,b;
        D2R = 180 / Math.PI
        b = 0;
        for (i = 0; i < count; i++ ) {
            // single test start
            a = (Math.random() * 360) | 0;
            b += Math.sin(a * D2R);
            // single test end
        }
    },
    name : "summed",
},{
    func : function (){
        var i,a,b;
        D2R = 180 / Math.PI;
        b = 0;
        for (i = 0; i < count; i++ ) {
            // single test start
            a = (Math.random() * 360) | 0;
            b = Math.sin(a * D2R);
            // single test end
        }
    },
    name : "unsummed",
},

The results

=======================================
Performance test. : Optimiser check.
Use strict....... : false
Duplicates....... : 4
Samples per cycle : 100
Tests per Sample. : 10000
---------------------------------------------
Test : 'summed'
Calibrated Mean : 173µs ±1µs (*1) 11160 samples 57,803,468 TPS
---------------------------------------------
Test : 'unsummed'
Calibrated Mean : 0µs ±1µs (*1) 11063 samples Invalid TPS
----------------------------------------
Calibration zero : 140µs ±0µs (*)
(*) Error rate approximation does not represent the variance.
(*1) For calibrated results Error rate is Test Error + Calibration Error.
TPS is Tests per second as a calculated value not actual test per second.

The benchmarker barely picked up any time for the un-summed test (Had to force it to complete).

The optimiser knows that only the last result of the loop for the unsummed test is needed. It only does for the last iteration all the other results are not used so why do them.

Benchmarking in javascript is full of catches. Use a quality benchmarker, and know what the optimiser can do.

Sin and lookup test.

Testing array and sin. To be fair to sin I do not do a deg to radians conversion.

tests : [{
        func : function (){
            var i,a,b;
            b=0;
            for (i = 0; i < count; i++ ) {
                a = (Math.random() * 360) | 0;
                b += a;
            }
        },
        name : "Calibration",
    },{
        func : function (){
            var i,a,b;
            b = 0;
            for (i = 0; i < count; i++ ) {
                a = (Math.random() * 360) | 0;
                b += array[a];
                
            }
        },
        name : "lookup",
    },{
        func : function (){
            var i,a,b;
            b = 0;
            for (i = 0; i < count; i++ ) {
                a = (Math.random() * 360) | 0;
                b += Math.sin(a);
            }
        },
        name : "Sin",
    }
],

And the results

=======================================
Performance test. : Lookup compare to calculate sin.
Use strict....... : false
Data view........ : false
Duplicates....... : 4
Cycles........... : 1055
Samples per cycle : 100
Tests per Sample. : 10000
---------------------------------------------
Test : 'Calibration'
Calibrator Mean : 107µs ±1µs (*) 34921 samples
---------------------------------------------
Test : 'lookup'
Calibrated Mean : 6µs ±1µs (*1) 35342 samples 1,666,666,667TPS
---------------------------------------------
Test : 'Sin'
Calibrated Mean : 169µs ±1µs (*1) 35237 samples 59,171,598TPS
-All ----------------------------------------
Mean : 0.166ms Totals time : 17481.165ms 105500 samples
Calibration zero : 107µs ±1µs (*);
(*) Error rate approximation does not represent the variance.
(*1) For calibrated results Error rate is Test Error + Calibration Error.
TPS is Tests per second as a calculated value not actual test per second.

Again had the force completions as the lookup was too close to the error rate. But the calibrated lookup is almost a perfect match to the clock speed ??? coincidence.. I am not sure.

Community
  • 1
  • 1
Blindman67
  • 51,134
  • 11
  • 73
  • 136
1

I believe this to be a benchmark issue on your side.

var countElement = document.getElementById('count');
var result1Element = document.getElementById('result1');
var result2Element = document.getElementById('result2');
var result3Element = document.getElementById('result3');

var floatArray = new Array(360);
var typedArray = new Float64Array(360);
var typedArray2 = new Float32Array(360);

function init() {
  for (var i = 0; i < 360; i++) {
    floatArray[i] = typedArray[i] = Math.sin(i * Math.PI / 180);
  }
  countElement.addEventListener('change', reset);
  document.querySelector('form').addEventListener('submit', run);
}

function test1(count) {
  var start = Date.now();
  var sum = 0;
  for (var i = 0; i < count; i++) {
    for (var j = 0; j < 360; j++) {
      sum += Math.sin(j * Math.PI / 180);
    }
  }
  var end = Date.now();
  var result1 = "sum=" + sum + "; time=" + (end - start);
  result1Element.textContent = result1;
}

function test2(count) {
  var start = Date.now();
  var sum = 0;
  for (var i = 0; i < count; i++) {
    for (var j = 0; j < 360; j++) {
      sum += floatArray[j];
    }
  }
  var end = Date.now();
  var result2 = "sum=" + sum + "; time=" + (end - start);
  result2Element.textContent = result2;
}

function test3(count) {
  var start = Date.now();
  var sum = 0;
  for (var i = 0; i < count; i++) {
    for (var j = 0; j < 360; j++) {
      sum += typedArray[j];
    }
  }
  var end = Date.now();
  var result3 = "sum=" + sum + "; time=" + (end - start);
  result3Element.textContent = result3;
}

function reset() {
  result1Element.textContent = '';
  result2Element.textContent = '';
  result3Element.textContent = '';
}

function run(ev) {
  ev.preventDefault();
  reset();
  var count = countElement.valueAsNumber;
  setTimeout(test1, 0, count);
  setTimeout(test2, 0, count);
  setTimeout(test3, 0, count);
}

init();
<form>
  <input id="count" type="number" min="1" value="100000">
  <input id="run" type="submit" value="Run">
</form>
<dl>
  <dt><tt>Math.sin()</tt></dt>
  <dd>Result: <span id="result1"></span></dd>
  <dt><tt>Array()</tt></dt>
  <dd>Result: <span id="result2"></span></dd>
  <dt><tt>Float64Array()</tt></dt>
  <dd>Result: <span id="result3"></span></dd>
</dl>

In my testing, an array is unquestionably faster than an uncached loop, and a typed array is marginally faster than that. Typed arrays avoid the need for boxing and unboxing the number between the array and the computation. The results I see are:

Math.sin(): 652ms
Array(): 41ms
Float64Array(): 37ms

Note that I am summing and including the results, to prevent the JIT from optimizing out the unused pure function. Also, Date.now() instead of creating seconds+millis yourself.

ephemient
  • 198,619
  • 38
  • 280
  • 391
1

I agree with that the issue may be down to how you have initialised the pre-computed array

Jsbench shows the precomputed array to be 13% faster than using Math.sin()

  • Precomputed array: 86% (fastest 1480 ms)
  • Math.sin(): 100% (1718 ms)
  • Precomputed Typed Array: 87% (1493 ms)

Hope this helps!

Community
  • 1
  • 1
Alessi 42
  • 1,112
  • 11
  • 26