Chapter 7: Writing good Julia functions

Writing good functions is not an art - it's something you can learn with reasonably little practice. A good function, by our definition, is one that is

  • performant: it consumes as few resources as needed,
  • type-stable: it always returns the same type of object, and
  • legible: Julia is a fairly easy to read language, and so should your code be.

These desiderata aren't always compatible with each other, and it is your job as a programmer to figure out how to balance them, with their application in mind. The following therefore are not strict requirements, they are ways to accomplish each of the individual objectives.

Performant code

Generally, the more complex an operation, the more the impact of performance optimisations is. The O ('big O') notation expresses this aptly. Consider two functions, one running at linear time $O(n)$ and one running at exponential time $O(2^n)$. For a sufficiently small number of elements, the difference might not be visible. However, as the number of elements grows, the performance gaps are going to be huge, and well-written functions are the difference between a calculation taking minutes versus hours or even days.

Define arguments' types, whenever you can and however precisely you can

When it comes to arguments, have a good think about what your function is supposed to do and what types it can, and what types it cannot, ingest. Planning first (or, according to some, even documenting first) is a good idea - it will help you have an understanding of the needs of your code.

A functional programming language with multiple dispatch challenges you to think in methods, not functions. Create small, narrow methods that do something for particular and narrowly defined types, rather than broad functions. Especially to programmers coming from OOP thinking, the idea that functions are not atomic and actually break down into methods is difficult to digest. However, it's worth the try - functions that Julia doesn't have to 'guess' about evaluate much faster. Defining types precisely is also a sieve for incorrect input types. Programmers from other paradigms might be used to having to test whether inputs are the right format – in Julia, this is a 'built-in' feature: as long as you define your code precisely, it will accept only the right kind of data and raise an error for any other call. I call this a win-win!

    function add_integers_badly(x, y)
        x = int(x)
        y = int(y)
        x + y
    end

The function above is problematic because it says one thing and does another. On its face, it accepts all kinds of values. In reality, however, not only is the function meant to only work with numeric types (since x + y has a very specific meaning for other types), it is in fact meant to enforce a particular type (Int types) by using a type conversion command (int()). If so, it would be easier to simply limit the function to accepting the right kind of input type. Not only would this prevent the overhead penalty of converting data types, it would provide for type-stable and performant code that tells readers (and automated documentation generators) what the function takes in.

Time your functions, time your changes

Julia includes a very convenient macro, @time, that helps you keep track of the time and memory allocation of your functions. It pays to check the execution time of your functions, in particular when you have made changes that you think will affect performance. It's easiest to use the REPL or IJulia to time functions. Define your function first, then invoke it following the macro @time. In the following, we will define a Fibonacci function fib(n) = n < 2 ? n : fib(n - 1) + fib(n - 2), and look at its speed in detecting the 32nd Fibonacci number:

    julia> @time fib(32)
    elapsed time: 0.028219738 seconds (33112 bytes allocated)
    2178309

You can also use @elapsed, @time's younger sibling, which returns only the elapsed seconds:

    julia> @elapsed fib(32)
    0.028076393

However, @time is vastly superior. Runaway memory is the first sign that something is not going well with your application.

Write short, concise functions

The core ideology, and key success factor, of *NIX systems was to conceive of a complex system as a sum of small applications that did one thing, and did it well. Many, such as grep, have been in use for decades with much success. The same idea applies for Julia. Not only will it make your code better (it's easier to forget something in the middle of a big, complicated function), it will also make your code faster. This is because Julia's compiler can benefit from type-specialising code at function boundaries. Julia's own documentation has a great documentation of this feature:

    function strange_twos(n)
        a = Array(randbool() ? Int64 : Float64, n)
        for i = 1:n
            a[i] = 2
        end
        return a
    end

When Julia's compiler is handed this code, it does not know at the time of executing the loop what the value of a is - only that it can be one of Array{Int64} or Array{Float64}. As such, it will have to provide for both outcomes. A better pair of functions would separate the inner and outer loops:

    function fill_twos!(a)
        for i=1:length(a)
            a[i] = 2
        end
    end

    function fast_strange_twos(n)
        a = Array(randbool() ? Int64 : Float64, n)
        fill_twos!(a)
        return a
    end

The result is that when calling fast_strange_twos(n), Julia's interpreter will know whether to compile fill_twos() for Int64 or Float64. This will yield a performance benefit:

    julia> @time strange_twos(16)
    elapsed time: 0.000767759 seconds (7120 bytes allocated)

    julia> @time fast_strange_twos(16)
    elapsed time: 0.009221679 seconds (179592 bytes allocated)

Wait a second! That's actually 12 times slower! What happened? Let's see if it's just an accident.

    julia> @time fast_strange_twos(16)
    elapsed time: 9.286e-6 seconds (256 bytes allocated)

That looks much closer to what we expected. The reason is that when we called fast_strange_twos() the first time, the time included the JIT compiler's compilation time. The lesson is to run every function once before testing the time it takes, and where multiple functions can result in multiple types, we might need to understand that unless the JIT compiler has encountered each type, there might be anomalous results. At second execution, we were lucky - the random number generator had the program retrieve the same type of number to fill the array. Then we ran it again:

    julia> @time fast_strange_twos(16)
    elapsed time: 0.002645026 seconds (6600 bytes allocated)

This time, the numbers to fill the array were different. As a result, it took a little longer. However, from this point on, until I close down the REPL, the fast_strange_twos() function will evaluate at the same time and at the same memory cost.

Danger zone

Julia allows you something called performance annotations, one of which is @inbounds. This speeds up array processing by circumventing bounds checking. Thus, for instance, a function to calculate the dot product of two arrays, might make use of this function:

    function dotproduct(x::Array, y::Array)
        result = zero(eltype(x))
        for i = 1:length(x)
            @inbounds result += x[i] * y[i]
        end
        return result
    end

In this case, @inbounds is safe because we know that due to the way i is defined (1:length(x)), there will never be an i that exceeds the length of x that would result in an out-of-bounds subscript. As such, bounds checking is more or less superfluous. To see the effect this has on execution time, let's compare it with the same function without the bounds checking disabled, called slow_dotproduct:

    julia> @time(dotproduct([1234, 5747, 2243243, 535345, 76345, 2346], [23468, 4563, 2457, 124556, 27563, 63456]))
    elapsed time: 1.4494e-5 seconds (288 bytes allocated)
    74500427955

    julia> @time(slow_dotproduct([1234, 5747, 2243243, 535345, 76345, 2346], [23468, 4563, 2457, 124556, 27563, 63456]))
    elapsed time: 0.004523183 seconds (105816 bytes allocated)
    74500427955

Not only did the bounds-checking dot product require over 350 times the allocated memory of our non-bounds-checking product, but it also took over 300 times the execution time of the faster process. This might be inperceptible, since even the slower function took only 4.5ms to execute, but at a scale, these differences extrapolate to massive performance gains through good but circumspect programming. Just be sure you eliminate bounds checking only where you have a good reason to assume you will not need it because your own script provides for it.

Type-stable code

Type-stable is functional programmer speak for code in which variables don't change their type. The reason why this is important is that type conversion represents an overhead that, at scale, slows down the process dramatically. Consider the following example:

    function badly_written(n::Integer)
        result = 0
        for i in 1:n
            result += cos(rand())
        end
        return result
    end

This function adds the cosine of a pseudorandom number (rand() generates a pseudorandom number using Julia's Mersenne twister implementation) to a result variable that is initialised as zero, which will be interpreted by Julia as an Int64. What we do know about cosines, however, is that they generally do not tend to yield integers, and indeed what we do know about Julia is that the cosine function will yield even results expressible as integers as floating-point results (try cos(2π)). Therefore, we know that before it can do anything, Julia will need to convert result, an Int64, to a Float type it can add another Float to. This, effectively, is wasted time.

A better function, thus, would be

    function well_written(n::Integer)
        result = 0.0
        for i in 1:n
            result += cos(rand())
        end
        return result
    end

Just how significant is the performance difference? After doing a dry run of running each function once, which allows the JIT compiler to compile the function so that we avoid the issues we discussed above, we get the following:

    julia> @time badly_written(100000)
    elapsed time: 0.006668156 seconds (3200048 bytes allocated)

    julia> @time well_written(100000)
    elapsed time: 0.001911581 seconds (64 bytes allocated)

Not only did type conversion (just once!) make our code about 3.5 times slower, it also made it consume 50,000 (!) times the memory. Type stability makes for faster and more performant code.

Legible code

Julia is a high-level language, meaning that it is closer to natural languages than low-level languages are. In fact, it could, like Python, be described as 'executable pseudocode', with a minimum of syntax, eschewing unnecessary braces and parentheses and instead using a clear, legible indented structure. However, it's not difficult to write illegible code in Julia – indeed, the Stack Exchange Code Golf board, where users specialise in cramming their solution into the shortest possible sequence, has quite a lot of it. Unless you're code golfing, your code should be beautiful and legible.

Unfortunately, legible means different things to different people, and that's how style guides came to be. Julia, being a young language, does not have as many and as thoroughly debated style guides as, say, Python's PEP8 or various Javascript style guides. The following seeks to point out a few of the most salient points of writing idiomatic Julia, as observed from core packages and prominent Julia packages, as well as the official Style Guide and John Myles White's Style.jl. Both are definitely worth reading - the former is more a high-level overview while the latter is very specific. However, coding is a matter of judgment and as a programmer, one of the things you are paid for is your sense of judgment, both in resolving the occasional inconsistencies within and among style guides by balancing countervailing objectives and in deciding whether to follow particular rules. Sure, the style guide says to stick to 80 characters a line, but should I break an 81 character line? The conventions suggest to eschew lowercase_separated_names and CamelCase, but what if the name is too long and cannot be sensibly abbreviated (a problem people encounter in the educational context quite often, as the author did while writing this book and trying to balance adherence to writing more understandable code)?

results matching ""

    No results matching ""