In one of my first attempts to create functional code, I ran into a performance issue.
I started with a common task – multiply the elements of two arrays and sum up the results:
var first:Array[Float] ...
var second:Array[Float] ...
var sum=0f;
for (ix<-0 until first.length)
sum += first(ix) * second(ix);
Here is how I reformed the work:
sum = first.zip(second).map{ case (a,b) => a*b }.reduceLeft(_+_)
When I benchmarked the two approaches, the second method takes 40 times as long to complete!
Why does the second method take so much longer? How can I reform the work to be both speed efficient and use functional programming style?
The main reasons why these two examples are so different in speed are:
Let’s consider the slower one by parts. First:
That creates a new array, an array of
Tuple2. It will copy all elements from both arrays intoTuple2objects, and then copy a reference to each of these objects into a third array. Now, notice thatTuple2is parameterized, so it can’t storeFloatdirectly. Instead, new instances ofjava.lang.Floatare created for each number, the numbers are stored in them, and then a reference for each of them is stored into theTuple2.Now a fourth array is created. To compute the values of these elements, it needs to read the reference to the tuple from the third array, read the reference to the
java.lang.Floatstored in them, read the numbers, multiply, create a newjava.lang.Floatto store the result, and then pass this reference back, which will be de-referenced again to be stored in the array (arrays are not type-erased).We are not finished, though. Here’s the next part:
That one is relatively harmless, except that it still do boxing/unboxing and
java.lang.Floatcreation at iteration, sincereduceLeftreceives aFunction2, which is parameterized.Scala 2.8 introduces a feature called specialization which will get rid of a lot of these boxing/unboxing. But let’s consider alternative faster versions. We could, for instance, do
mapandreduceLeftin a single step:We could use
view(Scala 2.8) orprojection(Scala 2.7) to avoid creating intermediary collections altogether:This last one doesn’t save much, actually, so I think the non-strictness if being “lost” pretty fast (ie, one of these methods is strict even in a view). There’s also an alternative way of zipping that is non-strict (ie, avoids some intermediary results) by default:
This gives much better result that the former. Better than the
foldLeftone, though not by much. Unfortunately, we can’t combinedzippedwithfoldLeftbecause the former doesn’t support the latter.The last one is the fastest I could get. Faster than that, only with specialization. Now,
Function2happens to be specialized, but forInt,LongandDouble. The other primitives were left out, as specialization increases code size rather dramatically for each primitive. On my tests, thoughDoubleis actually taking longer. That might be a result of it being twice the size, or it might be something I’m doing wrong.So, in the end, the problem is a combination of factors, including producing intermediary copies of elements, and the way Java (JVM) handles primitives and generics. A similar code in Haskell using supercompilation would be equal to anything short of assembler. On the JVM, you have to be aware of the trade-offs and be prepared to optimize critical code.