One key feature for doing math in Julia are the broadcast
and map
operations. The map
operation is like an R or MATLAB apply
which applies a function to each element of an array. For example, we can apply the anonymous function (x)->x^2
to each element via:
In [1]:
map((x)->x^2,1:5)
Out[1]:
The broadcast
operation is similar except it is for "elements which have a shape" and it will "broadcast the shaped objects to a common size by expanding singleton dimensions". For example, the following broadcast's +
onto A
and B
:
In [2]:
A = 1:5 # Acts like a column vector, Julia is "column-major" so columns come first
B = [1 2
3 4
5 6
7 8
9 10]
broadcast(+,A,B)
Out[2]:
If A
and B
are the same size, then broadcasting is the same as mapping.
One major area (which is under a lot of active development) is the specialized broadcasting syntax. The short summary is, putting a .
with a function or operator causes it to broadcast. For example, we can broadcast any function with the syntax f.(x)
, and broadcast operators by .+
and related. For example:
In [4]:
A = 1:5
B = [2;3;4;5;6]
A.*B
Out[4]:
People coming from MATLAB might recognize this as "element-wise multiplication". If this was a basic introduction to Julia, I'd say this was element-wise multiplication and be done with it. However, this is the non-trivial introduction. [Note: Some of this is not true right now (v0.5) but is becoming true...].
While it looks the same to the user, the implementation is very different In MATLAB and earlier versions of Julia, .*
was an operator. In Julia's more refined world, we can explain this as .*{T<:Number,N}(x::Array{T,N},y::Array{T,N})
being a function, and A.*B
calling this function. However, if .*
is just a function, then
In [5]:
C = [3;4;5;2;1]
A.*B.*C
Out[5]:
In [10]:
function broadcast_mult(x,y)
output = similar(x) # Makes an array of similar size and shape as x
for i in eachindex(x) # Let the iterator choose the fast linear indexing for x
output[i] = x[i]*y[i]
end
output
end
Out[10]:
Notice that broadcast_mult
creates an array every time it is called. Therefore a naive approach where .*
is a function creates two arrays in the call A.*B.*C
. We saw earlier that reducing memory allocations leads to vastly improved performance, so a better implementation would be to do this all together as one loop:
In [11]:
function broadcast_mult(x,y,z)
output = similar(x) # Makes an array of similar size and shape as x
for i in eachindex(x) # Let the iterator choose the fast linear indexing for x
output[i] = x[i]*y[i]*z[i]
end
output
end
Out[11]:
(but notice this doesn't really work because now .*
isn't a binary operator and therefore the inline syntax won't work). This optimization is known as "loop fusing". Julia does this by searching for all of the broadcasts in a line and putting them together into one broadcast statement during parsing time. Therefore, in Julia A.*B.*C
creates an anonymous function and broadcasts on it, like
In [12]:
broadcast((x,y,z)->x*y*z,A,B,C)
Out[12]:
Notice that this is equivalent to our 1-loop solution. However, because all array-based math uses this broadcasting syntax with a .
, Julia can fuse the broadcasts on all sorts of mathematical expressions on arrays:
In [13]:
A.*B.*sin.(C)
Out[13]:
One last thing to note is that we can also broadcast =
. This would be the same thing is as the loop A[i] = ...
and thus requires the array A
to already be define. Thus for example, if we let
In [14]:
D = similar(C)
Out[14]:
then the operation (Using @btime
in BenchmarkTools.jl to get an accurate measurement)
In [15]:
using BenchmarkTools
@btime D.=A.*B.*C
Out[15]:
does not allocate any arrays. Reducing temporary array allocations is one way Julia outperforms other scientific computing languages.
Summary: .
makes operations element-wise, but in a very smart way.
In [16]:
A = rand(4,4) # Generate a 4x4 random matrix
A[1:3,1:3] # Take the top left 3-3 matrix
Out[16]:
Note that Julia is column-major, meaning that columns come first in both indexing order and in the computer's internal representation.
In [17]:
@time A[1:3,1:3]
Out[17]:
Do you always have to allocate memory when making new arrays? We saw before this wasn't the case when dealing with references. Recall the example where modifying one array modified another:
In [18]:
a = [1;3;5]
@time b = a
a[2] = 10
a
@time c = copy(a)
Out[18]:
Notice that in the first case making b
didn't allocate an array: it just made an object with a pointer (an Int64), and had that pointer point to the same array as a
. To better understand this behavior and exploit to for major performance gains, we need to make a distinction. The array itself is the memory layout. For Julia arrays, this is actually a C-pointer to a contiguous 1-dimensional slot of memory. The Array
type in Julia (and thus Vector
and Matrix
which are type-alises for Array{T,1}
and Array{T,2}
respectively) is a "view" to to that actual array. A view is a type which points to an array, and has a compatibility layer that changes how things like the indexing works. For example: if we define the matrix
In [19]:
A = rand(4,4)
Out[19]:
then the array that we made is actually a 16-number long sequence (of 64-bit Floats) in memory, and A
is a view to that array which makes it index "like" it was 2-dimensional (reading down the columns). This tells us one thing already: looping through the columns is faster than looping through the rows. Indeed we can easily test this:
In [20]:
function testloops()
b = rand(1000,1000)
c = 0 # Need this so that way the compiler doesn't optimize away the loop!
@time for i in 1:1000, j in 1:1000
c+=b[i,j]
end
@time for j in 1:1000, i in 1:1000
c+=b[i,j]
end
bidx = eachindex(b)
@time for i in bidx
c+=b[i]
end
end
testloops()
One should normally use the eachindex
function since this will return the indices in the "fast" order for general iterator types.
In this terminology A[1:3,1:3]
isn't a view to the same memory. We can check this by noticing that it doesn't mutate the original array:
In [21]:
println(A)
B = A[1:3,1:3]
B[1,1]=100
println(A)
If we instead want a view, then we can use the view
function:
In [22]:
B = view(A,1:3,1:3) # No copy involved
B[1,1] = 100 # Will mutate A
println(A)
There are many cases where you might want to use a view. For example, if a function needs the i
th column, you may naively think of doing f(A[i,:])
. But, if A
won't be changed in the loop, we can avoid the memory allocation (and thus make things faster) by sending a view to the original array which is simply the column: f(view(A,i,:))
. Two functions can be used to give common views. vec
gives a view of the array as a Vector and reshape
builds a view in a different shape. For example:
In [23]:
C = vec(A)
println(C)
C = reshape(A,8,2) # C is an 8x2 matrix
C
Out[23]:
Since these operations do not copy the array, they are very cheap and can be used without worrying about performance issues.
In [24]:
A = rand(4,4); B = rand(4,4)
C = A*B # Matrix Multiplication
D = A.*B # Element-wise Multiplication
C-D # Not zero
Out[24]:
A common operation is to solve the linear system Ax=b
. In Julia this is done by A\b
:
In [25]:
b = 1:4
A\b
Out[25]:
Note that this uses a direct solver. Iterative solvers for linear equations can be found in IterativeSolvers.jl hosted by the JuliaMath organization.
In MATLAB/Python/R you're told to "vectorize" your options, i.e. use apply
or these .*
operations, in order to speed up computations. This is because these operations call C programs which will be faster than any interpreted MATLAB/Python/R loop. In Julia, that's not the case: as long as your functions are type-stable, you will get maximal performance. Thus vectorization does not improve performance.
In fact, vectorization can reduce performance by creating "temporary arrays". Those are the intermediate array allocations that come up with doing operations like C[i,:] = A[i,:] .* B[i,:]
. In general, for the best performance one should avoid vectorized calls or be careful to use the broadcast/view syntax to define a statement without temporaries:
In [27]:
i = 1
C[i,:] .= view(A,i,:) .* view(B,i,:)
Out[27]:
In [29]:
using SparseArrays
A = sparse([1;2;3],[2;2;1],[3;4;5])
Out[29]:
They can be converted into a dense matrix with the Array
command
In [31]:
Array(A)
Out[31]:
The documentation shows a lot more that you can do.
Like the rest of Julia, types and multiple dispatch is used to "secretly enhance performance". There are many matrix types, so I will just show a few and leave the rest to the documentation.
Many matrices follow specific forms: diagonal, tridiagonal, etc. Julia has special types for these common matrix forms. For example, we can define a Tridiagonal
by giving it three vectors:
In [33]:
using LinearAlgebra
A = Tridiagonal(2:5,1:5,1:4)
Out[33]:
We can inspect it to see its internal form:
In [36]:
fieldnames(typeof(A))
Out[36]:
In [37]:
A.d
Out[37]:
Notice that what the array stores is the vectors for the diagonals themselves. It's clear to see that this gives a memory enhancement over a dense matrix, and it gives a performance advantage because a dense matrix would have an extra operation for each 0
. However, it's also faster than a sprase matrix since a sparse matrix is stored as a table (i,j,value)
and retriving from the table has a bit of overhead, while this is stored as 3 (actually 4...) contiguous arrays. Therefore you get a performance boost by using a special matrix form like Tridiagonal
whenever one is available. Note that these special matrix forms are outfitted with dispatches so that operations on them work seamlessly like with normal matrices. For example, we can multiply a Tridiagonal by a dense matrix:
In [38]:
A*rand(5,5)
Out[38]:
One interesting type is the UniformScaling
operator I
. Essentially, I uses dispatches to cleverly act like the identity matrix without ever forming a matrix. For example, to mathematically subtract a scalar λ
from a matrix A
we use the notation $$ A - \lambda I$$
We can do this naturally with the I
operator:
In [43]:
A = rand(5,5)
λ = 2
A - λ*I
Out[43]:
The MATLAB or NumPy way would be to create the identity matrix via a command like eye(5)
, but notice this prevents the allocation of a 5x5 array. For large matrices, this operation is huge and thus this could lead to some good performance improvements.
One last type of interest are the factorization forms. In many cases, you factorize a matrix using some factorization command in order to speed up subsequence A\b
calls. Normally you have to remember how this is done. For example, we can use a QR factorization to solve a linear system like:
In [44]:
b = 1:5
A = rand(5,5)
Q,R = qr(A)
println(A\b)
println(inv(R)*Q'*b)
Thus we can save the variables Q
and R
and use inv(R)*Q'*b
instead of A\b
and get better performance. This is the NumPy/MATLAB way. However, that requires remembering the details of the factorization. Instead, we can have Julia return a factorization type:
In [46]:
q = qr(A)
Out[46]:
What this does is it internally stores Qt
(Qt = Q'
) and Rinv
(Rinv = inv(R)
). There is a dispatch defined for this type on \ which makes the QRCompactWY
type perform the fast algorithm Rinv*Qt*b
, giving you the performance without having to remember anything:
In [47]:
q\b
Out[47]:
The result is fast algorithms with clean code.
In [48]:
rand(5)
Out[48]:
In [49]:
randn(5,5)
Out[49]:
The argument is the size of the array. You can make random numbers which match another array with the size
function:
In [50]:
a = [1 2
3 4]
randn(size(a))
Out[50]: