Is Nashorn (JVM) faster than Node (V8)?

The answer to the question “Is Nashorn (JVM) faster than Node (V8)?” is in most people’s minds: a foregone conclusion, looking something like this expression.

no-way-9yykt8

It certainly was in my mind, until I actually ran a very simple benchmark that computes the Fibonacci sequence a few times (recursively). It’s a common enough benchmark, one frequently used to test method dispatching, recursion and maths in various languages / virtual machines. For disclosure, the code is listed below:

function fib(n) {
  if (n < 2)
    return 1;
  return fib(n - 2) + fib(n - 1);
}

try {
  print = console.log;
} catch(e) {
}

for(var n = 30; n <= 50; n++) {
  var startTime = Date.now();
  var returned = fib(n);
  var endTime = Date.now();
  print(n + " = " + returned + " in " + (endTime - startTime));
}

The results are, lets just say: “shocking”. The table below is both runs along side each other, the last numbers are the important ones: how many milliseconds each run took.

jason@Bender ~ $ node fib.js 
30 = 1346269 in 12
31 = 2178309 in 17
32 = 3524578 in 27
33 = 5702887 in 44
34 = 9227465 in 69
35 = 14930352 in 113
36 = 24157817 in 181
37 = 39088169 in 294
38 = 63245986 in 474
39 = 102334155 in 766
40 = 165580141 in 1229
41 = 267914296 in 2009
42 = 433494437 in 3241
43 = 701408733 in 5302
44 = 1134903170 in 8671
45 = 1836311903 in 13626
46 = 2971215073 in 22066
47 = 4807526976 in 45589
48 = 7778742049 in 74346
49 = 12586269025 in 120254
50 = 20365011074 in 199417
jason@Bender ~ $ jjs -ot fib.js 
30 = 1346269 in 70
31 = 2178309 in 16
32 = 3524578 in 19
33 = 5702887 in 30
34 = 9227465 in 48
35 = 14930352 in 76
36 = 24157817 in 123
37 = 39088169 in 197
38 = 63245986 in 318
39 = 102334155 in 517
40 = 165580141 in 835
41 = 267914296 in 1351
42 = 433494437 in 2185
43 = 701408733 in 3549
44 = 1134903170 in 5718
45 = 1836311903 in 9306
46 = 2971215073 in 15031
47 = 4807526976 in 34294
48 = 7778742049 in 38446
49 = 12586269025 in 61751
50 = 20365011074 in 100343

So what on earth is going on here? How can it be that the last result is 99 seconds faster on the Java VM than on V8, one of the fastest JavaScript engines on the planet?

The answer is actually hidden at the top of the tables: the -ot option being passed to JJS (Java JavaScript) is the key to this magic sauce. OT stands for Optimistic Typing, which aggressively assumes that variables are all compatible with the Java int type, and then falls back to more permissive types (like Object) when runtime errors happen. Because the code above only ever produces int values, this is a very good assumption to make and allows the Java VM to get on and optimise the code like crazy, where V8 continues to chug along with JavaScript’s Number type (actually it is more intelligent than that, but it’s just not showing in this little benchmark).

The point of this post is: don’t believe everything you read in benchmarks. They are very dependant on the code being run, and micro-benchmarks are especially dangerous things. The work that has been done on Optimistic Typing in Nashorn is amazing, and makes it a very attractive JavaScript environment. But you shouldn’t believe that just because these benchmarks show such a wide gap in performance that your Express application would run faster in Nashorn than under V8… In fact you’d be lucky to get it to run at all.

Feel free to replicate this experiment on your machine. It’s amazing to watch, it surprises me every time I re-run it.

Misleading Microbeanchmarks

You’ll often see people show a micro-benchmark to prove a point. I can think of many examples offhand:

  1. Using a synchronized singleton SimpleDateFormat is slower than tying them to a ThreadLocal or putting them in a pool
  2. Unfair ReadWriteLocks cause write-lock starvation (writeLock.lock() will never return)
  3. Using a HashSet of String’s is much faster than doing an indexOf()

These are all true in the confines of a micro-benchmark. The problem is: micro-benchmarks are misleading. For example, number 2. I recently read a micro-benchmark proving that under high contention situations the writeLock may never be acquired. Firstly the documentation does tell you this:

The constructor for this class accepts an optional fairness parameter. When set true, under contention, locks favor granting access to the longest-waiting thread. Otherwise this lock does not guarantee any particular access order.

Most people assume that the write-lock will have preference over read-locks. However, this is a general-purpose implementation of a ReadWriteLock (ReentrantReadWriteLock). That means it’s designed to work well (and fast) under most situations. The fast is: in most situations, lock contention is relatively low and/or fluctuates over time.

What does this have to do with micro-benchmarking? A micro-benchmark is generally built not to simulate the fluctuation of the systems load. A micro-benchmark is generally designed to simulate a very high load, often where the only thing done is what is being tested (formatting dates, acquiring locks, etc). This results in a very unrealistic picture of whats actually going on.

If we take 3 for example. Looking for a specific substring in a String list (for example “AN” in a comma separated String containing “GH,JK,IH,TA,AN,FR,MN,SA”), there are several different approaches to this:

  • Simply use indexOf to see if the substring exists
  • Use String.split(“,”) and then iterate through the resulting array to find the substring
  • Add the substring tokens to a HashSet and use the contains() method
  • Write a method to iterate through the String and test the substring tokens without using substring (ie: a char[] or some such).

Obviously the fastest (assuming the data can be re-used) will almost always be to add the strings to a HashSet. However if the HashSet is not kept around for re-use later, this is the slowest method. The fastest in this case would be indexOf() or a specialized method. In a micro-benchmark however, it would be easy to prove that a HashSet is by far the fastest, when the String is actually only tested once every day. In which case, whats the point in optimizing?