Ivan Malopinsky

Compiling 11 languages to JavaScript


I’ve been curious about compiling various languages to JavaScript for a while so I wrote the same short program in Clojure, Dart, Go, Haxe, Java, Kotlin, Nim, Python, Ruby, Scala, and Scheme, then compiled it to JS. Here’s what I learned.

Setup

I was interested in a couple of scenarios: writing client-side applications in a language other than JavaScript and enabling existing code to run in the browser or on Node.js. The best choice depends on application, since on the client side file size is more important than speed, whereas on the server side it’s the other way around.

The benchmark used for all languages is a program that finds a few thousand primes using trial division. This program is deliberately unoptimized so as to run sufficiently slowly while benchmarking and, where applicable, deliberately non-idiomatic so that the generated code is roughly the same. hyperfine was used to average multiple runs of the generated code.

All code for this benchmark is available on GitHub, in the imsky/prime-to-js repository.

Results

Graph of compiled JS size vs. runtime

Ruby and Scheme were excluded from the chart.

Language Time (ms) Size (KB)
JavaScript 226 0.2
Clojure 440 92.5
Dart 269 5.7
Go 257 43.2
Haxe 228 0.4
Java 228 0.9
Kotlin 360 1745.8
Nim 367 14.8
Python 467 43.5
Ruby 8771 754.3
Scala 224 80.7
Scheme 5586 78.5

Review

The results of various languages compiling to JavaScript vary significantly. Among other lessons learned, what stood out was:

I was pleasantly surprised by Dart, Haxe, and Scala. Dart produced fast, compact compiled code suitable for client-side and server-side environments. Haxe did the same, with the added bonus of being able to compile the same code to several other languages. Scala output was so well optimized it even outperformed the reference JS implementation on large N, impressive for a third-party JS compiler.

Ruby and Scheme were disappointing. Both produced worse-than-expected performance and enormous output size. As for the rest, Go seems like a good fit for server-side and Kotlin seems best suited to an offline application given its output size. Python, Nim, and Clojure did not produce particularly impressive results.

Conclusion

Going forward, if I had to pick a language to cross-compile to JS, it’d likely be one of Go, Dart, or Haxe - everything else produces either slower or larger outputs and usually has worse ergonomics. As WebAssembly matures, it would be interesting to compare its performance relative to the compilers benchmarked here.

Implementations

JavaScript

This is the reference implementation. It stops once 3333 primes have been found.

function isPrime(n) {
  for (var i = 2; i < n; i++) {
    if (n % i === 0) {
      return false;
    }
  }
  return n > 1;
}

for (var i = 1, found = 0; found < 3333; i++) {
  if (isPrime(i)) {
    found++;
  }
}
$ node -v
v8.9.4
$ wc -c js/prime.js
     213 js/prime.js
$ hyperfine 'node  js/prime.js'
Benchmark #1: node  js/prime.js
  Time (mean ± σ):     226.3 ms ±   2.6 ms    [User: 218.1 ms, System: 7.1 ms]
  Range (min … max):   219.9 ms … 230.0 ms    13 runs

Clojure

Writing Clojure was a fun exercise, though its toolchain could be easier to use. Performance of Clojure-to-JS code is on the poor side and the generated file size is big.

(ns prime.core)

(defn isPrime [n]
  (loop [i 2]
    (if (< i n)
      (if (= 0 (mod n i))
        false
        (recur (inc i)))
      (> n 1))))

(loop [i 1 found 0]
  (if (< found 3333)
    (recur (inc i)
      (if (isPrime i)
        (inc found)
        found))))
$ echo "(println (clojure-version))" | clojure -
1.10.0
$ wc -c clojure/prime.js
   94769 clojure/prime.js
$ hyperfine 'node  clojure/prime.js'
Benchmark #1: node  clojure/prime.js
  Time (mean ± σ):     440.4 ms ±  12.1 ms    [User: 431.0 ms, System: 8.8 ms]
  Range (min … max):   424.9 ms … 465.3 ms    10 runs

Dart

Dart was easy to write and fast to compile. The performance of the generated code was good (same as Dart’s own VM), but the file size could be smaller. I was curious about using Dart’s AOT capabilities to create a native binary for benchmarking, but it appears that’s not yet easily possible in Dart 2.

bool isPrime(int n) {
  for (var i = 2; i < n; i++) {
    if (n % i == 0) {
      return false;
    }
  }
  return n > 1;
}

void main () {
  for (var i = 1, found = 0; found < 3333; i++) {
    if (isPrime(i)) {
      found++;
    }
  }
}
$ dart --version
Dart VM version: 2.2.0 (Tue Feb 26 15:04:32 2019 +0100) on "macos_x64"
$ wc -c dart/prime.js
    5827 dart/prime.js
$ hyperfine 'node  dart/prime.js'
Benchmark #1: node  dart/prime.js
  Time (mean ± σ):     269.1 ms ±   3.1 ms    [User: 261.1 ms, System: 7.3 ms]
  Range (min … max):   265.1 ms … 275.1 ms    11 runs

Go

Writing in Go was a breeze. No semi-colons, no classes, no requirement to use while. The generated file (via GopherJS), however, is enormous, and has tons of unused code. Surprisingly, the compiled Go binary ran 0.7x as fast as the JS.

package main

func isPrime(n int) bool {
	for i := 2; i < n; i++ {
		if n%i == 0 {
			return false
		}
	}
	return n > 1
}

func main() {
	for i, found := 1, 0; found < 3333; i++ {
		if isPrime(i) {
			found++
		}
	}
}
$ go version && gopherjs version
go version go1.11.3 darwin/amd64 GopherJS 1.11-2
$ wc -c go/prime.js
   44193 go/prime.js
$ hyperfine 'node  go/prime.js'
Benchmark #1: node  go/prime.js
  Time (mean ± σ):     257.6 ms ±   4.0 ms    [User: 249.0 ms, System: 7.6 ms]
  Range (min … max):   251.3 ms … 264.6 ms    11 runs

Haxe

I was really impressed with Haxe. Not only can Haxe output JavaScript, it can also output C++, C#, Java, Lua, PHP, and Python. The compiled JS Haxe produced is very clean and runs as fast as the reference implementation. It may be the case that, as more of the Haxe standard library is used, the file size grows and approaches those of the larger languages, but it was still great to see only necessary code in the output.

package;

class Prime {
  static function isPrime(n:Int):Bool {
    var i = 2;
    while (i < n) {
      if (n % i == 0) {
        return false;
      }
      i++;
    }
    return n > 1;
  }

  static function main() {
    var i = 1;
    var found = 0;
    while (found < 3333) {
      if (isPrime(i)) {
        found++;
      }
      i++;
    }
  }
}
$ haxe -version
3.4.7
$ wc -c haxe/prime.js
     361 haxe/prime.js
$ hyperfine 'node  haxe/prime.js'
Benchmark #1: node  haxe/prime.js
  Time (mean ± σ):     228.2 ms ±   3.3 ms    [User: 220.0 ms, System: 7.2 ms]
  Range (min … max):   222.1 ms … 234.6 ms    12 runs

Java

When looking for Java-to-JavaScript compilers, I came across JSweet and TeaVM. TeaVM included a large runtime and generated gibberish output, so I decided to give JSweet a try. JSweet took some effort to get working: it failed on anything but JDK 8 and compiler configuration was undocumented.

JSweet worked well enough, converting Java to TypeScript and TypeScript to JavaScript. The performance and file size of the compiled code were in line with the reference implementation.

package prime;

public class Prime {
  static boolean isPrime(int n) {
    for(int i = 2; i < n; i++) {
      if (n % i == 0) {
        return false;
      }
    }
    return n > 1;
  }

  public static void main(String[] args) {
    for(int i = 1, found = 0; found < 3333; i++) {
      if (isPrime(i)) {
        found++;
      }
    }
  }
}
$ (java -version 2>&1) && (cd java && (mvn -o dependency:list | grep jsweet-core))
java version "1.8.0_201" Java(TM) SE Runtime Environment (build 1.8.0_201-b09) Java HotSpot(TM) 64-Bit Server VM (build 25.201-b09, mixed mode) [INFO]    org.jsweet:jsweet-core:jar:6-SNAPSHOT:compile
$ wc -c java/prime.js
     884 java/prime.js
$ hyperfine 'node  java/prime.js'
Benchmark #1: node  java/prime.js
  Time (mean ± σ):     228.3 ms ±   3.5 ms    [User: 220.1 ms, System: 7.3 ms]
  Range (min … max):   221.4 ms … 232.2 ms    12 runs

Kotlin

Kotlin has been touting its ability to compile to JavaScript for a while, so it seemed like a natural choice for this benchmark. The language is a little odd, but clean. Although the compiled JS Kotlin generated was small, it apparently requires a 1.7 MB runtime to work. Performance was just OK, though the JVM bytecode version of the same code ran as fast as the reference implementation.

fun isPrime(n: Int): Boolean {
  for (i in 2 until n) {
    if (n.rem(i) == 0) {
      return false
    }
  }
  return n > 1
}

fun main() {
  var i = 1
  var found = 0
  while (found < 3333) {
    if (isPrime(i)) {
      found++
    }
    i++
  }
}
$ kotlin -version
Kotlin version 1.3.21-release-158 (JRE 1.8.0_201-b09)
$ wc -c kotlin/prime.js kotlin/node_modules/kotlin/kotlin.js
     500 kotlin/prime.js
 1787225 kotlin/node_modules/kotlin/kotlin.js
 1787725 total
$ hyperfine 'node  kotlin/prime.js'
Benchmark #1: node  kotlin/prime.js
  Time (mean ± σ):     360.6 ms ±   6.5 ms    [User: 341.4 ms, System: 17.5 ms]
  Range (min … max):   348.4 ms … 367.1 ms    10 runs

Nim

While Nim seemed exciting at the start (native compilation, Python-like syntax with types), it turned out to have confusing docs, a cumbersome compilation process, large output size, and mediocre compiled performance. Also, the native binary it produced took twice (!) as long to run as the compiled JS code.

proc isPrime(n: int): bool =
  var i = 2
  while i < n:
    if n mod i == 0:
      return false
    i += 1
  return n > 1

var i = 1
var found = 0

while found < 3333:
  if isPrime(i):
    found += 1
  i += 1
$ nim -v
Nim Compiler Version 0.19.4 [MacOSX: amd64] Compiled at 2019-02-07 Copyright (c) 2006-2018 by Andreas Rumpf  active boot switches: -d:release -d:useLinenoise
$ wc -c nim/prime.js
   15162 nim/prime.js
$ hyperfine 'node  nim/prime.js'
Benchmark #1: node  nim/prime.js
  Time (mean ± σ):     366.8 ms ±   3.6 ms    [User: 359.1 ms, System: 7.5 ms]
  Range (min … max):   362.2 ms … 373.5 ms    10 runs

Python

Compiling Python to JavaScript using Transcrypt was a chore - the generated code uses module imports, which require some workarounds in Node. Generated code performance was poor (though almost 7x faster than the original Python) and the file size was rather large. While Transcrypt has a lot of docs, the toolchain could be improved significantly.

def isPrime(n):
  for i in range(2, n - 1):
    if n % i == 0:
      return False
  return n > 1

i = 1
found = 0

while found < 3333:
  if isPrime(i):
    found += 1
  i += 1
$ (transcrypt | grep Transcrypt) && python3 -V
Transcrypt (TM) Python to JavaScript Small Sane Subset Transpiler Version 3.7.16 Python 3.6.7
$ wc -c python/prime.js python/__target__/org.transcrypt.__runtime__.mjs
    1494 python/prime.js
   43064 python/__target__/org.transcrypt.__runtime__.mjs
   44558 total
$ hyperfine 'node --experimental-modules python/__target__/prime.mjs; echo python/prime.js'
Benchmark #1: node --experimental-modules python/__target__/prime.mjs; echo python/prime.js
  Time (mean ± σ):     466.9 ms ±   8.4 ms    [User: 457.4 ms, System: 8.5 ms]
  Range (min … max):   459.0 ms … 487.3 ms    10 runs

Ruby

Ruby, compiled to JavaScript via Opal, was a big disappointment. The output file was massive and performance was terrible.

def isPrime(n)
  (2...n).each do |i|
    if n % i == 0
      return false
    end
  end
  return n > 1
end

i = 1
found = 0

while found < 3333
  if isPrime(i)
    found += 1
  end
  i += 1
end
$ ruby --version && opal --version
ruby 2.3.7p456 (2018-03-28 revision 63024) [universal.x86_64-darwin17] Opal v0.11.4
$ wc -c ruby/prime.js
  772396 ruby/prime.js
$ hyperfine 'node  ruby/prime.js'
Benchmark #1: node  ruby/prime.js
  Time (mean ± σ):      8.771 s ±  0.171 s    [User: 8.748 s, System: 0.027 s]
  Range (min … max):    8.554 s …  9.142 s    10 runs

Scala

Scala’s JavaScript was produced by Scala.js, which has been around since 2011. I found the language somewhat quirky and the build time slow (slowest or second-slowest among the compared languages), but the generated code performs quite well. The output size is on the large side.

package main

object Prime {
  def isPrime (n:Int) : Boolean = {
    for (i <- 2 until n) {
      if (n % i == 0) {
        return false
      }
    }
    return n > 1
  }

  def main (args: Array[String]): Unit = {
    var i : Int = 1
    var found : Int = 0

    while (found < 3333) {
      if (isPrime(i)) {
        found += 1
      }
      i += 1
    }
  }
}
$ (sbt scalaVersion sbtVersion | tail -n2 | grep -oE [0-9]+.[0-9]+.[0-9]+); (cd scala && sbt libraryDependencies | grep -m1 scalajs | grep -oE [0-9]+.[0-9]+.[0-9]+)
2.12.7 1.2.8 0.6.26
$ wc -c scala/prime.js
   82629 scala/prime.js
$ hyperfine 'node  scala/prime.js'
Benchmark #1: node  scala/prime.js
  Time (mean ± σ):     224.1 ms ±   3.7 ms    [User: 216.0 ms, System: 8.4 ms]
  Range (min … max):   219.0 ms … 233.2 ms    12 runs

Scheme

For Scheme, I expected similar performance to Clojure. Unfortunately the output, generated via Spock from CHICKEN Scheme, turned out to be second-to-last in performance. The generated file size for Scheme was also quite large.

(define (isPrime n)
  (define (*p i)
    (or (>= i n)
      (and (> (modulo n i) 0)
        (*p (+ i 1)))))
  (and (> n 1) (*p 2)))

(let loop ((i 1) (found 0))
  (or (>= found 3333)
    (loop
      (+ i 1)
      (if (isPrime i) (+ 1 found) found))))
$ chicken -version
(c) 2008-2017, The CHICKEN Team (c) 2000-2007, Felix L. Winkelmann Version 4.13.0 (rev 68eeaaef) macosx-unix-clang-x86-64 [ 64bit manyargs dload ptables ] compiled 2017-12-11 on yves.more-magic.net (Linux)
$ wc -c scheme/prime.js
   80375 scheme/prime.js
$ hyperfine 'node  scheme/prime.js'
Benchmark #1: node  scheme/prime.js
  Time (mean ± σ):      5.586 s ±  0.043 s    [User: 5.833 s, System: 0.148 s]
  Range (min … max):    5.522 s …  5.646 s    10 runs