Tag Archives: Julia

Slowly moving to the Julia language from R

Julia is a new computing language that’s gotten a lot of attention lately (e.g., this Wired piece) and that I’ve ignored until recently. But I checked it out a few days ago and, holy crap, it’s a nice language. I’m rewriting the code in my book to use Julia instead of R and I’m almost certainly going to use it instead of R in my PhD class next fall.

So, why Julia and why not R? (And, I suppose why not Python/Matlab/other languages?)

  • Multiple dispatch. So you can define a function
    function TrickyAlgorithm(aThing, bThing)

    differently to depend on whether aThing is a matrix of real numbers and bThing is is a vector, or aThing is a vector and bThing is a matrix, or any other combinations of data types. And you can do this without lots of tedious, potentially slow, and confusing (to people reading and maintaining the code) argument checks and conversion within the function.

    Note that this is kind of similar to Object Oriented Programming, but in OOP TrickyAlgorithm would need to be a method of aThing or bThing. Also note that this is present in R as well.

  • Homoiconicity — the code can be operated on by other parts of the code. Again, R kind of has this too! Kind of, because I’m unaware of a good explanation for how to use it productively, and R’s syntax and scoping rules make it tricky to pull off. But I’m still excited to see it in Julia, because I’ve heard good things about macros and I’d like to appreciate them (I’ve started playing around with Clojure and like it a lot too…). And because stuff like this is amazing:
    @devec r = exp(-abs(x-y))

    which devectorizes x and y (both vectors) and evaluates as

    for i = 1:length(x)
        r[i] = exp(-abs(x[i]-y[i]))
    end
    

    (this example and code is from Dahua Lin’s blog post, Fast Numeric Computation in Julia). Note that “evaluates as” does not mean “gives the same answer as,” it means that the code r = exp(-abs(x-y)) is replaced with the loop by @devec and then the loop is what’s run.

  • Decent speed. Definitely faster than well written R; I don’t have a great feel for how well it compares to highly optimized R (using inline C++, for example), but I write one-off simulation programs and don’t write highly optimized R.And the language encourages loops, which is a relief. R discourages loops and encourages “vectorized” operations that operate on entire objects at once (which are then converted to fast loops in C…). But I use loops all the time anyway, because avoiding loops in time series applications is impossible. R’s poor support for recursion doesn’t help either.And, more to the point, I teach econometrics to graduate students. Many of them haven’t programmed before. Most of them are not going to write parts of their analysis in C++.
  • The syntax is fine and unthreatening, which will help for teaching. It basically looks like Matlab done right. Matlab’s not a bad language because its programs look like they’re built out of Legos, it’s a bad language because of its horrendous implementation of functions, anonymous functions, objects, etc. Compared to R, Matlab and Julia look downright friendly. Compared to Clojure… I can’t even imagine asking first year PhD students (some with no programming experience at all) to work with a Lisp.
  • The last point that’s always mentioned in these language comparisons. What about all of the R packages? There are thousands and thousands of statistical packages coded up for R, and you’re giving that up by moving to a different language.This is apparently a big concern for a lot of people, but… have you looked at the source code for these packages? Most of them are terrible! But some are good, and it might take some time to port them to Julia. Not that much time, I think, because most high-performance popular R packages are a thin layer of interoperability over a fast implementation in C or C++, so the port is just a matter of wrapping it up for Julia. And most of the well designed packages are tools for other package developers.That’s not quite true of R’s statistical graphics, though. They’re really great and could be hard to port. And that’s more or less the only thing that I’m sure that I’ll miss in Julia. (But hopefully not for too long.)
  • Lastly, and this is important: the same massive quantity of packages for R is a big constraint on its future development. Breaking backwards compatibility is a big deal but avoiding it too much imposes costs.

Anyway, since I converted some R code to Julia I thought it would be fun to compare speeds. The first example is used to show the sampling distribution of an average of uniform(0,1) random variables. In R, we have

rstats <- function(rerror, nobs, nsims = 500) {
  replicate(nsims, mean(rerror(nobs)))}

which is (I think) pretty idiomatic R (and is vectorized, so it’s supposed to be fast). Calling it gives

R> system.time(rstats(runif, 500))

[out]:  user  system elapsed 
       0.341   0.002   0.377

For comparison to the Julia results, we’re going to care about the “elapsed” result of 0.377 seconds; the “system” column isn’t relevant here.  Calling it for more observations and more simulations (50,000 of each) gives

R> system.time(rstats(runif, 50000, 50000))

[out]:   user  system elapsed 
      204.184   0.217 215.526

so 216 seconds overall. And, just to preempt criticism, I ran these simulations a few times each and these results are representative; and I ran a byte-compiled version that got (unexpectedly) slightly worse performance.

Equivalent Julia code is

function rmeans(dist, nobs; nsims = 500)
    means = Array(Float64,nsims)
    for i in 1:nsims
        means[i] = mean(rand(dist, nobs))
    end
    return means
end

which is pretty easy to read, but I have no idea if it’s idiomatic. This is my first code in Julia. If you like to minimize lines of code and preallocation of arrays, Julia has list comprehensions and you can write the stylish one line definition (that gave similar times)

rmeans_pretty(dist, nobs; nsims = 500) =
    [ mean(rand(dist, nobs)) for i = 1:nsims ]

We can time  (after loading the Distributions packages):

julia> @elapsed rmeans(Uniform(), 500)

[out]: 0.093662961

so 0.09 seconds, or about a quarter the time as R. But (I forgot to mention earlier), Julia uses a Just In Time compiler, so the 0.09 seconds includes compilation and execution. Running it a second time gives

julia>  @elapsed rmeans(Uniform(), 500)

[out]: 0.004334132

which is half the time again. (Update on 3/17: as Jules pointed out in the comments, 0.004 is 1/20th of 0.09, so this is substantially faster than I’d initially thought. So we are getting into the ~100 times faster range. That’s actually a pretty exciting speed increase, but I’ll need to look into it some more. Well, that was embarrassing.)

Running the larger simulation, we have

julia> @elapsed rmeans_loop(Uniform(), 50000, nsims = 50000)

[out]: 77.318591953

so the R code is a little less than three times slower here. (The compilation step doesn’t make a meaningful difference.) So, Julia isn’t hundreds of times faster, but it is noticeably faster than R, which is nice.

But speed in this sort of test isn’t the main factor. I’m really excited about multiple dispatch — it’s one of the few things in R that I really, really liked from a language standpoint. I really like what I’ve read about Julia’s support for parallelism (but need to learn more). And I like metaprogramming, even if I can’t do it myself yet. So Julia’s trying to be a fast, easy to learn, and elegantly designed language. That’s awesome. I want it to work.

ps: and it’s open source! Can’t forget that.

Filed under: Blog Tagged: julia, programming, R

Inheriting type behavior in Julia

By: Christian Groll

Re-posted from: http://grollchristian.wordpress.com/2014/01/22/julia-inheriting-behavior/

In object oriented programming languages, classes can inherit from classes of objects on a higher level in the class hierarchy. This way, methods of the superclass will apply to the subclass as well, given that they are not explicitly re-defined for the subclass. In many regards, super- and subclasses hence behave similarly, allowing the same methods to be applied and similar access behavior. Joined together, they hence build a coherent user interface.

In Julia, such a coherent interface for multiple types requires a little bit of extra work, since Julia does not allow subtyping for composite types. Nevertheless, Julia’s flexibility generally allows composite types to be constructed such that they emulate the behavior of some already existing type. This only requires a little bit of extra coding, but can be implemented efficiently through metaprogramming.

1 Justification of inheritance

Such an emulation or inheritance of behavior is desired in basically two situations.

1.1 Implementing constrained subsets

The first situation where behavior inheritance could be useful is when interest lies only in a subset of all possible instances of an already existent type. For example, let’s assume that we want to create a new composite type Simplex, which shall contain all points on the n-dimensional simplex \Delta^{n}. Thereby, the simplex is defined as the set of n-dimensional points with individual entries being positive and summing up to one:

\displaystyle \Delta^{n}=\{x\in \mathbb{R}^{n}| \sum_{i=1}^{n}x_{i}=1, x_{i}>0\}

Clearly, \Delta^{n} is a subset of \mathbb{R}^{n}. And, for points of \mathbb{R}^{n} we already have a quite simple and suitable type in Julia: Array{Float64, 1}. However, representing the simplex in Julia, we would like to have a type that corresponds to only a subset of all instances of Array{Float64, 1}. This way, we can achieve more robust coding, since users will not be able to manually break the summation constraint \sum_{i=1}^{n}x_{i}=1 too easily. Also, the creation of a new composite type allows overloading function definitions. We hence could create a method plot(x::Simplex) which suits the specific characteristics of the subset of \mathbb{R}^{n}. On the other hand, however, any instance of Simplex still represents an element of \mathbb{R}^{n}, and hence ideally should behave as such in Julia as well. Any methods generally working on Array{Float64, 1} should also be working for instances of Simplex. In contrast to some object oriented languages, Julia does allow subtypes only for abstract types, and not for composite types. Hence, even though any mathematical operation on arbitrary points in \mathbb{R}^{n} also holds simultaneously for points of \Delta^{n} as well, methods of Array{Float64, 1} will not automatically apply for instances of type Simplex.

A naive way to fix this would be through definition of an appropriate conversion method, allowing to re-interpret instances of Simplex as instances of Array{Float64, 1}. This way, an already existing method of Array{Float64, 1} could be generally expressed for type Simplex:

function f(x::Simplex)
    xAsArray = convert(Array{Float64, 1}, x)
    return f(xAsArray)
end

Even more efficiently, one could incorporate a tight relation to Array{Float64, 1} into type Simplex from scratch up, defining Simplex such that it contains only one field, which is of type Array{Float64, 1}.

type Simplex
    points::Array{Float64, n}
end

This way, the conversion step becomes unnecessary, as methods only need to be delegated to the respective field of Simplex:

f(x::Simplex) = f(x.points)

Hence, we now already have a quite good starting point for inheritance of behavior. Instances of type Simplex, when plugged into the function, return the same value as if the n-dimensional simplex point was simply represented as Array{Float64, 1}. Nevertheless, as we will see further on, a lot of intricacies are yet to come. But, before we get there, we first take a look at a second situation where inheritance of behavior might be desired.

1.2 Extending existing types

In the first case we where implementing a true subset relation, with \Delta^{n}\subset \mathbb{R}^{n}. Here, we will take a given type and try to extend it with some new attribute. For example, let’s assume that we had to deal with numeric time series data that does not have any missing values. Of course, the data could be suitably stored and processed as instance of the very general type DataFrame. Time information would simply be stored in the first column, with values of the observations in the subsequent columns. However, data might be better stored as instances of a type that is more tailored to the specific characteristics of the data. For example, as the data values itself are numeric (except for the time column), they permit statistical operations like, for example, deriving mean, minimum and maximum values. Separating numeric observations from dates, code becomes more robust, as we are able to better distinguish between functions applying to the numerical part of the data and functions applying to the dates information.

For illustration, let’s look at two distinct ways of translating time series data into Julia data types. In the first case, we simply treat the data as true subset of type DataFrame, although in a newly created type TimeSeriesDf. This way, we still can overload functions and create methods that take into account the specific characteristics of the data. For example, a method plot(ts::TimeSeriesDf) would show date labels on the x-axis. Hence, we define:

type TimeSeriesDf
    vals::DataFrame
end

The problem is, that it becomes more tedious to separate dates and numeric data. For example, a method mean(ts::TimeSeriesDf) now might be implemented as

mean(ts::TimeSeriesDf) = mean(ts.vals[:, 2:end])

In contrast, we could also define a type TimeSeries that has two fields, where the first field deals with dates, and the second field contains numeric observations only.

type TimeSeries
    dates::Array{Date{ISOCalendar}, 1}
    vals::DataFrame
end

The distinction between dates and numeric data hence becomes more hard-coded into the data type. Here, a mean method would require no additional indexing:

mean(ts::TimeSeries) = mean(ts.vals)

In a way, instances of type TimeSeries are no true subtypes of type DataFrame anymore, as they are characterized through an additional dates field. It extends a simple DataFrame with an additional array of dates. Ideally, such a type should inherit behavior of both individual field types, making its application a naturally extension to already existing types. For example, adding a scalar value should take into account field vals only:

+(ts::TimeSeries, x::Numeric) = +(ts.vals, x)

This way, one can achieve a very nice and intuitive way of handling time series data. For more information, simply take a look at package TimeData, where you can see the benefits of type inheritance and type extension in a more elaborate use case.

In my opinion, these are the two most common cases where one would like to have a new composite type behaving like some already existent type. In both cases, the key lies in the delegation of functions to certain fields of the composite type.

2 Intricacies

Let’s now take a more detailed look at the intricacies that we have to expect in the course of implementation. Thereby, we want to look at the most general case, where we want to simultaneously create multiple new types. Each type shall borrow from the same existent type, and implement multiple functions with probably multiple methods per function. To avoid theorizing only, we want to look at a concrete example, where we will create types that inherit from DataFrame. The first type is called NumDf, and it represents DataFrames that consist of either numeric values or NAs only. The second type is called ArrayDf, and it even excludes NAs, too. Hence, for ArrayDf, the values itself could be stored as Array{Float64, 2}. However, relating it to DataFrames still allows to make use of column names. Furthermore, for reasons that will become clear later, we also render both types subtypes of an abstract type AbstractDf. For both types, the inner constructor will first need to check whether the constraints on the data are fulfilled.

abstract AbstractDf

type NumDf <: AbstractDf
    vals::DataFrame

    function NumDf(df::DataFrame)
        chkForNumericValues(df)
        return new(df)
    end
end

type ArrayDf <: AbstractDf
    vals::DataFrame

    function ArrayDf(df::DataFrame)
        chkForNumericValues(df)        
        chkNoNAs(df)
        return new(df)
    end
end

In the example, the newly created types consist of only one field which already is the same type as the type that should be emulated. However, this restriction is only for reasons of simplicity, and all subsequent propositions could easily be transferred to more complex structures, too. Again, if you are interested in this, simply take a look at package TimeData. It implements very similar types that also borrow functionality from DataFrames, but with an additional field for dates:

type Timenum <: AbstractTimenum
    vals::DataFrame
    dates::DataArray

    function Timenum(vals::DataFrame, dates::DataArray)
        chkDates(dates)
        chkNumDf(vals)
        if(size(vals, 1) != length(dates))
            if (length(dates) == 0) | (size(vals, 1) == 0)
                return new(DataFrame([]), DataArray([]))
            end
            error("number of dates must equal number of columns of data")
        end
        return new(vals, dates)
    end
end

2.1 Type preservation

We already have seen examples of functions that are easily delegated to the respective field of the new type. For functions like size() this makes perfect sense, and the method definition becomes:

size(nd::NumDf) = size(nd.vals)

However, not all the time this kind of perfect delegation is how we want methods to behave. Simply delegating all methods this way, we would end up permanently escaping our own data type. For example, simply delegating method exp(nd::NumDf) would indeed evaluate the exponential function on each individual entry. However, it would return these values as an instance of type DataFrame, as does method exp(df::DataFrame):

exp(nd::NumDf) = exp(nd.vals)
nd = NumDf(DataFrame(rand(3, 2)));
nd2 = exp(nd);
typeof(nd)
typeof(nd2)
NumDf (constructor with 1 method)
DataFrame (constructor with 22 methods)

Given that the returned values still meet the required constraints, we would like the method to return the resulting values as type NumDf again. Otherwise there would be no persistence in our data representation, as we would end up with type DataFrame sooner or later anyways.

Hence, we need to write some methods such that the original type of our data will remain unaffected. Methods get delegated to methods of the respective emulated type, but the returned value needs to be transferred back to the inheriting type. Therefore, we simply need to hand over the result to the constructor at the end:

function exp(nd::NumDf)
    valsDf = exp(nd.vals)
    return NumDf(valsDf)
end

We hence need to state more precisely what “inheriting behavior” should mean for any given method: should it return exactly the same output as the emulated type, or should it wrap up the resulting values at the end, in order to return an instance of the same type as the caller type? Both alternatives require slightly different code. Also, this distinction will become relevant in the course of meta-programming for more general cases comprising multiple functions and multiple new types.

2.2 Multiple method signatures

The next complication arises for functions with multiple method signatures. Due to multiple dispatch, functions may well have multiple methods defined that need to be delegated. For example, you can examine the size of DataFrames with two different methods size():

size(df::AbstractDataFrame)
size(df::AbstractDataFrame, i::Integer)

In principle, each of both methods could just be delegated for itself.

size(tn::NumDf) = size(tn.vals)
size(tn::NumDf, i::Integer) = size(tn.vals, i)

This, of course, seems to be redundant, as it is quite cumbersome to delegate each method individually. Hence, one might be tempted to tackle both methods in one assignment, making use of variable function arguments:

size(tn::NumDf, args...) = size(tn.vals, args...)

Although this seems quite reasonable, one still needs to be very careful with this approach, since it could easily lead to method ambiguities. For example, let’s take a look at function .== for DataFrames. Amongst others, there exist the following methods:

.==(a::DataFrame,b::NAtype)
.==(a::DataFrame,b::DataFrame)
.==(a::DataFrame,b::Union(Number,String))

Using variable function arguments, the delegation would be implemented as:

.==(x::NumDf, args...) = .==(x.vals, args...)

This, however, will lead to the following warning:

Warning: New definition 
    .==(NumDf,Any) at none:1
is ambiguous with: 
    .==(Any,AbstractArray{T,N}) at bitarray.jl:1450.
To fix, define 
    .==(NumDf,AbstractArray{T,N})
before the new definition.

Hence, there are two methods that possibly could be called for (NumDf,AbstractArray{T,N}), and Julia will kind of randomly pick one of them.

Although in this case both methods .== would most likely lead to the same result when called with .==(NumDf,AbstractArray{T,N}) anyways, it is considered poor style to simply ignore these warnings.

Furthermore, the ambiguity is not a side effect of the variable function argument only, but it also appears with the following method definition:

.==(x::NumDf, y::Any) = .==(x.vals, y)

In my opinion, it is better to refrain from extensive method delegations with args... or Any whenever possible. Even if your own code worked, excessive use of such extensive delegations could still impose problems for other people that want to build on your code.

Nevertheless, this does not mean that you have to type any individual method definition by itself! For example, a really large part of methods of DataFrames comes with the exact same method signatures:

f(b::NAtype,a::DataFrame)
f(a::DataFrame,b::NAtype)
f(a::DataFrame,b::DataFrame)
f(a::DataFrame,b::Union(String,Number))
f(b::Union(String,Number),a::DataFrame)

Using macros and metaprogramming, one can easily define all functions with equal method signatures simultaneously in one rush.

3 Implementation

Given these intricacies, let’s look at the actual implementation of inheritance for the most general case of multiple inheriting types, multiple functions and possibly multiple methods per function.

In a first step, all functions need to be classified with respect to two criteria:

  • the method signatures of the function
  • whether the function is type preserving or not

For example, for the case of type NumDf, the following table classifies some exemplary functions with respect to three different method signatures.

method signatures type preserving non-preserving
f(nd::NumDf) :abs, :exp, :log :length, :isempty
f(nd::NumDf) :round, :floor :size
f(nd::NumDf, x::Int)
f(b::NAtype,a::NumDf) :+, :-, :*, :.^ :.<, :.>, :.==
f(a::NumDf,b::NAtype)
f(a::NumDf,b::NumDf)
f(a::NumDf,b::Union(String,Number))
f(b::Union(String,Number),a::NumDf)

Once that all desired functions are classified, functions within the same combination of method signatures and preservation kind can be simultaneously defined in a loop. One simply needs to iterate over all functions within a block, and interpolate the function names into an adequate macro.

Additionally, when multiple new types need to be defined simultaneously, one could also iterate over all types. However, for non-preserving functions, methods equivalently could be expressed with reference to an abstract supertype, so that the additional loop over all new types becomes unnecessary.

Let’s just make this more clear through an example implementation for the case of method signatures given by

f(nd::NumDf)
f(nd::NumDf, x::Int)

First, let’s look at the implementation for non-preserving functions. (In this case, the only function within this block is size. Nevertheless, we implement it as loop over all functions, since this provides more flexibility for later extensions.)

single_or_two_args_non_preserving = [:size]

for f in single_or_two_args_non_preserving
    eval(quote

        function $(f)(nd::AbstractDf)
            return $(f)(nd.vals)
        end

        function $(f)(nd::AbstractDf, i::Integer)
            return $(f)(nd.vals, i)
        end        

    end)
end

For the case of type preservation, one additionally needs to iterate over all new types.

single_or_two_args_type_preserving = [:round, :floor]

for t in (:NumDf, :ArrayDf)
    for f in single_or_two_args_type_preserving
        eval(quote

            function $(f)(nd::$(t))
                valuesDf = $(f)(nd.vals)
                return $(t)(valuesDf)
            end

            function $(f)(nd::$(t), i::Integer)
                valuesDf = $(f)(nd.vals, i)
                return $(t)(valuesDf)
            end

        end)
    end
end

3.1 Special cases

Using the instructions above, you’re now able to emulate behavior of a different type in almost all cases. Still, however, there are some special cases where you will most likely need to find your own solution.

3.1.1 Outer constructors

Outer constructors are different from general functions in that their name will naturally differ for each individual type. Hence, the function name (which is the name of the constructor) needs to be interpolated for each type as well.

for t in (:NumDf, :ArrayDf)
    eval(quote
        function $(t)(vals::Array{Float64, 2})
            $(t)(DataFrame(vals))
        end
    end)
end

3.1.2 Partially type preserving functions

In some cases, you either might want to have partially type preserving functions, or inherit from such functions. For example, take a look at getindex for DataFrames, which returns different types depending on the input:

using DataFrames
df = DataFrame(rand(4, 3));
typeof(df[2:4, 1:2])
typeof(df[:, 1])
typeof(df[1, 1])
DataFrame (constructor with 22 methods)
DataArray{Float64,1} (constructor with 1 method)
Float64

If you want to build on such a function, and you do not want to simply give back the same variation of types, you most likely need to manually adapt every single method. Sadly, these functions can be quite cumbersome to implement.

4 Conclusions

Although Julia does not come with a straightforward way to inherit behavior out of the box, its extensive meta-programming capabilities still provide the flexibility to achieve this with a minimal amount of effort. Still, however, meta-programming might represent quite a hurdle to overcome for beginners, and without it inheritance is rather cumbersome to implement.

Filed under: Julia Tagged: inheritance, types

The Relationship between Vectorized and Devectorized Code

By: John Myles White

Re-posted from: http://www.johnmyleswhite.com/notebook/2013/12/22/the-relationship-between-vectorized-and-devectorized-code/

Introduction

Some people have come to believe that Julia’s vectorized code is unusably slow. To correct this misconception, I outline a naive benchmark below that suggests that Julia’s vectorized code is, in fact, noticeably faster than R’s vectorized code. When experienced Julia programmers suggest that newcomers should consider devectorizing code, we’re not trying to beat R’s speed — our vectorized code does that already. Instead, we’re trying to match C’s speed.

As the examples below indicate, a little bit of devectorization goes a long way towards this loftier goal. In the specific examples I show, I find that:

  • Julia’s vectorized code is 2x faster than R’s vectorized code
  • Julia’s devectorized code is 140x faster than R’s vectorized code
  • Julia’s devectorized code is 1350x faster than R’s devectorized code

Examples of Vectorized and Devectorized Code in R

Let’s start by contrasting two pieces of R code: a vectorized and a devectorized implementation of a trivial snippet of code that does repeated vector addition.

First, we consider an example of idiomatic, vectorized R code:

vectorized <- function()
{
    a <- c(1, 1)
    b <- c(2, 2)
    x <- c(NaN, NaN)

    for (i in 1:1000000)
    {
        x <- a + b
    }

    return()
}

time <- function (N)
{
    timings <- rep(NA, N)

    for (itr in 1:N)
    {
        start <- Sys.time()
        vectorized()
        end <- Sys.time()
        timings[itr] <- end - start
    }

    return(timings)
}

mean(time(10))

This code takes, on average, 0.49 seconds per iteration to compute 1,000,000 vector additions.

Having considered the vectorized implementation, we can then consider an unidiomatic devectorized implementation of the same operation in R:

devectorized <- function()
{
    a <- c(1, 1)
    b <- c(2, 2)
    x <- c(NaN, NaN)

    for (i in 1:1000000)
    {
        for (index in 1:2)
        {
            x[index] <- a[index] + b[index]
        }
    }

    return()
}

time <- function (N)
{
    timings <- rep(NA, N)

    for (itr in 1:N)
    {
        start <- Sys.time()
        devectorized()
        end <- Sys.time()
        timings[itr] <- end - start
    }

    return(timings)
}

mean(time(10))

This takes, on average, 4.72 seconds per iteration to compute 1,000,000 vector additions.

Examples of Vectorized and Devectorized Code in Julia

Let’s now consider two Julia implementations of this same snippet of code. We’ll start with a vectorized implementation:

function vectorized()
    a = [1.0, 1.0]
    b = [2.0, 2.0]
    x = [NaN, NaN]

    for i in 1:1000000
        x = a + b
    end

    return
end

function time(N)
    timings = Array(Float64, N)

    # Force compilation
    vectorized()

    for itr in 1:N
        timings[itr] = @elapsed vectorized()
    end

    return timings
end

mean(time(10))

This takes, on average, 0.236 seconds per iteration to compute 1,000,000 vector additions.

Next, let’s consider a devectorized implementation of this same snippet:

function devectorized()
    a = [1.0, 1.0]
    b = [2.0, 2.0]
    x = [NaN, NaN]

    for i in 1:1000000
        for index in 1:2
            x[index] = a[index] + b[index]
        end
    end

    return
end

function time(N)
    timings = Array(Float64, N)

    # Force compilation
    devectorized()

    for itr in 1:N
        timings[itr] = @elapsed devectorized()
    end

    return timings
end

mean(time(10))

This takes, on average, 0.0035 seconds per iteration to compute 1,000,000 vector additions.

Comparing Performance in R and Julia

We can summarize the results of the four examples above in a single table:

Approach Language Average Time
Vectorized R 0.49
Devectorized R 4.72
Vectorized Julia 0.24
Devectorized Julia 0.0035

All of these examples were timed on my 2.9 GHz Intel Core i7 MacBook Pro. The results are quite striking: Julia is uniformly faster than R. And a very small bit of devectorization produces huge performance improvements. Of course, it would be nice if Julia’s compiler could optimize vectorized code as well as it optimizes devectorized code. But doing so requires a substantial amount of work.

Why is Optimizing Vectorized Code Hard?

What makes automatic devectorization tricky to get right is that even minor variants of the snippet shown above have profoundly different optimization strategies. Consider, for example, the following two snippets of code:

function vectorized2()
    a = [1.0, 1.0]
    b = [2.0, 2.0]

    res = {}

    for i in 1:1000000
        x = [rand(), rand()]
        x += a + b
        push!(res, x)
    end

    return res
end

function time(N)
    timings = Array(Float64, N)

    # Force compilation
    vectorized2()

    for itr in 1:N
        timings[itr] = @elapsed vectorized2()
    end

    return timings
end

mean(time(10))

This first snippet takes 1.29 seconds on average.

function devectorized2()
    a = [1.0, 1.0]
    b = [2.0, 2.0]

    res = {}

    for i in 1:1000000
        x = [rand(), rand()]
        for dim in 1:2
            x[dim] += a[dim] + b[dim]
        end
        push!(res, x)
    end

    return res
end

function time(N)
    timings = Array(Float64, N)

    # Force compilation
    devectorized2()

    for itr in 1:N
        timings[itr] = @elapsed devectorized2()
    end

    return timings
end

mean(time(10))

This second snippet takes, on average, 0.27 seconds.

The gap between vectorized and devectorized code is much smaller here because this second set of code snippets uses memory in a very different way than our original snippets did. In the first set of snippets, it was possible to entirely avoid allocating any memory for storing changes to x. The devectorized code for the first set of snippets explicitly made clear to the compiler that no memory needed to be allocated. The vectorized code did not make this clear. Making it clear that no memory needed to be allocated led to a 75x speedup. Explicitly telling the compiler what it can avoid spending time on goes a long way.

In contrast, in the second set of snippets, a new chunk of memory has to be allocated for every x vector that gets created. And the result is that even the devectorized variant of our second snippet cannot offer much of a performance boost over its vectorized analogue. The devectorized variant is slightly faster because it avoids allocating any memory during the steps in which x has a and b added to it, but this makes less of a difference when there is still a lot of other work being done that cannot be avoided by devectorizing operations.

This reflects a more general statement: the vectorization/devectorization contrast is only correlated, not causally related, with the actual performance characteristics of code. What matters for computations that take place on modern computers is the efficient utilization of processor cycles and memory. In many real examples of vectorized code, it is memory management, rather than vectorization per se, that is the core causal factor responsible for performance.

The Reversed Role of Vectorization in R and Julia

Part of what makes it difficult to have a straightforward discussion about vectorization is that vectorization in R conflates issues that are logically unrelated. In R, vectorization is often done for both (a) readability and (b) performance. In Julia, vectorization is only used for readability; it is devectorization that offers superior performance.

This confuses some people who are not familiar with the internals of R. It is therefore worth noting how one improves the speed of R code. The process of performance improvement is quite simple: one starts with devectorized R code, then replaces it with vectorized R code and then finally implements this vectorized R code in devectorized C code. This last step is unfortunately invisible to many R users, who therefore think of vectorization per se as a mechanism for increasing performance. Vectorization per se does not help make code faster. What makes vectorization in R effective is that it provides a mechanism for moving computations into C, where a hidden layer of devectorization can do its mgic.

In other words, R is doing exactly what Julia is doing to get better performance. R’s vectorized code is simply a thin wrapper around completely devectorized C code. If you don’t believe me, go read the C code for something like R’s distance function, which involves calls to functions like the following:

static double R_euclidean(double *x, int nr, int nc, int i1, int i2)
{
    double dev, dist;
    int count, j;

    count= 0;
    dist = 0;
    for(j = 0 ; j < nc ; j++) {
    if(both_non_NA(x[i1], x[i2])) {
        dev = (x[i1] - x[i2]);
        if(!ISNAN(dev)) {
        dist += dev * dev;
        count++;
        }
    }
    i1 += nr;
    i2 += nr;
    }
    if(count == 0) return NA_REAL;
    if(count != nc) dist /= ((double)count/nc);
    return sqrt(dist);
}

It is important to keep this sort of thing in mind: the term vectorization in R actually refers to a step in which you write devectorized code in C. Vectorization, per se, is a red herring when reasoning about performance.

To finish this last point, let’s summarize the performance hierarchy for R and Julia code in a simple table:

Worst Case Typical Case Best Case
Julia Vectorized Code Julia Devectorized Code
R Devectorized Code R Vectorized Code C Devectorized Code

It is the complete absence of one column for Julia that makes it difficult to compare vectorization across the two languages. Nothing in Julia is as bad as R’s devectorized code. On the other end of the spectrum, the performance of Julia’s devectorized code simply has no point of comparison in pure R: it is more similar to the C code used to power R behind the scenes.

Conclusion

Julia aims to (and typically does) provide vectorized code that is efficient as the vectorized code available in other high-level languages. What sets Julia apart is the possibility of writing, in pure Julia, high performance code that uses CPU and memory resources as effectively as can be done in C.

In particular, vectorization and devectorization stand in the opposite relationship to one another in Julia as they do in R. In R, devectorization makes code unusably slow: R code must be vectorized to perform at an acceptable level. In contrast, Julia programmers view vectorized code as a convenient prototype that can be modified with some clever devectorization to produce production-performance code. Of course, we would like prototype code to perform better. But no popular language offers that kind of functionality. What Julia offers isn’t the requirement for devectorization, but the possibility of doing it in Julia itself, rather than in C.