Is immutatble data always slower than mutable state? I was challenged in a discussion over at Reddit about this issue, and I decided to perform a little experiment to back up a rough estimate with some numbers. I was surprised and delighted to see that I could make my case very strong indeed by showing that no, immutable data can be twice as fast as mutable data in a modern functional language.
I concocted a trivial synthetic benchmark using simple records intended to emulate typical business entities:
module P0 = struct type person = { name: string; age: int; balance: float } end module P1 = struct type person = { name: string; mutable age: int; mutable balance: float } end let test0 = let module E = struct open P0 let rec test p iters = if iters == 0 then p.balance else test { p with balance = p.balance +. 1. } (iters - 1) end in E.test { P0.name="John Doe"; P0.age=19; P0.balance=100.} let test1 = let module E = struct open P1 let test p iters = for i = 1 to iters do p.balance <- p.balance +. 1. done end in E.test { P1.name="John Doe"; P1.age=19; P1.balance=100.}
The module P0
encapsulates an immutable business record; test0
is the attendant test that uses functional record updating and recursion in a classicaly pure idiom. The module P1
, on the other hand, with its corresponding test test1
show the typical imperative use of a mutable record. The test and timing harness for both functions is simple enough:
let test times f iters = let best = ref infinity in for i = 1 to times do let t = Sys.time () in let _ = f iters in let t = Sys.time () -. t in if !best > t then best := t done; float iters /. !best let () = let iters = 1_000_000 in Printf.printf "Immutable record: %f updates/s\n" (test 5 test0 iters); Printf.printf "Mutable record: %f updates/s\n" (test 5 test1 iters)
I tested this code with the OCaml bytecode compiler on a 1.6/2.0 GHz Intel Atom computer with Windows XP, and compiled with both the bytecode and native compilers, without optimization, on a 2.2 GHz Core2 Duo MacBook Pro computer with Mac OS X 10.5.7. The results are as follows:
System | Immutable (upd/s) | Mutable (upd/s) | Speedup (Δ%) |
---|---|---|---|
1.6 GHz Atom/Win XP/Bytecode | 3,048,780 | 3,558,719 | 16.73 |
2.0 GHz Atom/Win XP/Bytecode | 4,000,000 | 4,566,210 | 14.16 |
2.2 GHz Core2 Duo/Mac OS/Bytecode | 17,750,324 | 20,418,581 | 15.03 |
2.2 GHz Core2 Duo/Mac OS/Native | 128,882,588 | 58,400,981 | -54.69 |
The speedup afforded by imperative mutation in bytecode is modest, in all cases around 15%. On the other hand, the penalty incurred by mutation under the native compiler is more than double.
Of course, my correspondent was right in that the situation with current object-oriented languages is very different. I translated the benchmark straightforwardly into Java, and the result is what "common sense" would let us expect: Java is heavily oriented to mutation, or rather, it penalizes immutability quite heavily. I tried both client and server VMs, under the Windows environment detailed above:
System | Immutable (upd/s) | Mutable (upd/s) | Speedup (×) |
---|---|---|---|
1.6 GHz Atom/Win XP/Client | 22,831,050 | 107,526,882 | 4.71 |
1.6 GHz Atom/Win XP/Server | 37,735,849 | 322,580,645 | 8.55 |
The difference is dramatic: mutation is between 4.7 and 8.5 times faster than functional update in Java, and even twice as fast than native OCaml code on a faster machine. Moral of the story: test your assumptions with experiments. The benchmark code is:
public final class MutTest { private static final class Person0 { public final String name; public final int age; public final double balance; public Person0(String name, int age, double balance) { this.name = name; this.age = age; this.balance = balance; } public Person0 deposit(double amount) { return new Person0(this.name, this.age, this.balance + amount); } } private static final class Person1 { public String name; public int age; public double balance; public Person1(String name, int age, double balance) { this.name = name; this.age = age; this.balance = balance; } public void deposit(double amount) { this.balance += amount; } } private interface Test { double test(int iters); } private static final Test TEST0 = new Test() { public double test(int iters) { Person0 person = new Person0("John Doe", 19, 100.0); for (int i = 0; i != iters; i++) { person = person.deposit(1.0); } return person.balance; } }; private static final Test TEST1 = new Test() { public double test(int iters) { Person1 person = new Person1("John Doe", 19, 100.0); for (int i = 0; i != iters; i++) { person.deposit(1.0); } return person.balance; } }; private static double test(int times, Test test, int iters) { long best = Long.MAX_VALUE; double balance; for (int i = 0; i != times; i++) { long now = System.currentTimeMillis(); balance = test.test(iters); now = System.currentTimeMillis() - now; if (best > now) best = now; } return (double) iters / ((double) best / 1000.0); } public static void main(String[] args) { final int iters = 10000000; System.out.printf("Immutable record: %f updates/s\n", test(5, TEST0, iters)); System.out.printf("Mutable record: %f updates/s\n", test(5, TEST1, iters)); } }