No description has been provided for this image

Programming large-scale parallel systems¶

Julia basics¶

Contents¶

In this notebook, we will cover the basic parts of Julia needed later to learn parallel computing. In particular, we will learn about:

  • Variables
  • Functions
  • Arrays

For a more general introduction to Julia see the nice tutorials made available by JuliaAcademy here or the official Julia educational resources here.

Using Jupyter notebooks in Julia¶

We are going to use Jupyter notebooks in this and other lectures. You probably have worked with notebooks (in Python). If not, here are the basic concepts you need to know to follow the lessons.

Tip: Did you know that Jupyter stands for Julia, Python and R?

How to start a Jupyter notebook in Julia¶

To run a Julia Jupyter notebook, open a Julia REPL and type

julia> ]
pkg> add IJulia
julia> using IJulia
julia> notebook()

A new browser window will open. Navigate to the corresponding notebook and open it.

Warning: Make sure that the notebook is using the same Julia version as the one you used to launch `IJulia`. If it is not the same, go to Kernel > Change Kernel and choose the right version (see figure below).
No description has been provided for this image

Running a cell¶

To run a cell, click on a cell and press Shift + Enter. You can also use the "Run" button in the toolbar above.

In [ ]:
1+3
4*5

As you can see from the output of previous cell, the value of the last line is displayed. We can suppress the output with a semicolon. Try it. Execute next cell.

In [ ]:
1+3
4*5;

Cell order is important¶

Running the two cells below in reverse order won't work (try it).

In [ ]:
foo() = "Well done!"
In [ ]:
foo()

A very easy first exercise¶

Run the following cell. It contains definitions used later in the notebook.

In [ ]:
function why_q1()
    msg = """
    In the first line, we assign a variable to a value. In the second line, we assign another variable to the same value. Thus, we have 2 variables associated with the same value. In line 3, we associate y to a new value (re-assignment). Thus, we have 2 variables associated with 2 different values. Variable x is still associated with its original value. Thus, the value at the final line is x=1.
    """
    println(msg)
end
function why_q2()
    msg = """
    It will be 1 for very similar reasons as in the previous questions: we are reassigning a local variable, not the global variable defined outside the function.
    """
    println(msg)
end
function why_q3()
    msg = """
    It will be 6. In the returned function f2, x is equal to 2. Thus, when calling f2(3) we compute 2*3.
    """
    println(msg)
end
println("🥳 Well done! ")

REPL modes¶

This is particular to Julia notebooks. You can use package, help, and shell mode just like in the Julia REPL.

In [ ]:
] add MPI
In [ ]:
? print
In [ ]:
; ls

Variables¶

The usage of variables in Julia is pretty similar to Python and quite different from C/C++ and Fortran. However, there are also some differences with Python.

Creating a variable¶

A variable is a name associated (bound) to a value. We associate variables with values with = as usual.

In [ ]:
a = 1

When assigning a variable, the value on the right hand side is not copied into the variable. It is just an association of a name with a value (much like in Python).

Re-assign a variable¶

We can re-assign a variable, even with a value of another type. However, avoid changing the variable type for performance reasons.

In [ ]:
a = 2
In [ ]:
a = 1.0
In [ ]:
a = "Hi!"

Unreachable objects¶

When an object is not associated with a variable any more, it cannot be reached by the user. This can happen, e.g., when we re-assign a variable. Another case is when local variables, e.g in a function, go out of scope. The following line allocates a large array and assigns it to variable a

In [ ]:
a = zeros(300000000);

If we re-assign the variable to another value, the large array will be inaccessible.

In [ ]:
a = nothing

Garbage collector¶

Luckily, Julia has a garbage collector that deallocates unreachable objects. You don't need to bother about manual deallocation! Julia is not constantly looking for unreachable objects. Thus, garbage collection does not happen instantaneously, but it will happen at some point. You can also explicitly call the garbage collector, but it is almost never done in practice.

In [ ]:
GC.gc()

Type declarations are optional¶

Julia knows the type of the object associated with a variable.

In [ ]:
a = 1
typeof(a)

We can annotate types if we want, but this will not improve performance (except in very special situations). Thus, annotating types is not done in practice.

In [ ]:
c::Int = 1
typeof(c)

If you annotate a variable with a type, then it cannot refer to objects of other types.

In [ ]:
c = "I am a string"

Variable names¶

There is a great flexibility to choose variable names in Julia. See all the rules in the manual if you are interested.

We can use Unicode (UTF-8 encoding) characters in variables and function names.

In [ ]:
🐱 = "I am a cat"
🐶 = "I am a dog"
🐱 == 🐶

We can also use Greek letters and other mathematical symbols. Just write the corresponding LaTeX command and press Tab. For example: ω is written \omega + Tab

In [ ]:
ω = 1.234
sin(ω)

In fact, some useful mathematical constants are predefined in Julia with math Greek letters.

In [ ]:
sin(π/2)
Question (NB1-Q1): What will be the value of `x` in the last line ? (Think your answer before executing next cell to find out the result)
In [ ]:
x = 1
y = x
y = 2
x

Run next cell to get an explanation of this question.

In [ ]:
why_q1()

Functions¶

Julia is very much a functional programming language. In consequence, Julia is more centered on functions than on types. This is in contrast to object-oriented languages, which are more centered on types (classes). For instance, you don't need to know the details of the Julia type system to learn parallel programming in Julia, but you need to have a quite advanced knowledge of how Julia functions work.

Defining functions¶

Functions are defined as shown in next cell. The closing end is necessary. Do not forget it! However, the return is optional. The value of last line is returned by default. Indentation is recommended, but it is also optional. That's why the closing end is needed.

In [ ]:
function add(a,b)
    return a + b  
end

Once defined, a function can be called using bracket notation as you would expect.

In [ ]:
add(1,3)

Broadcast syntax¶

We can apply functions to arrays element by element using broadcast (dot) syntax.

In [ ]:
a = [1,2,3]
b = [4,5,6]
add.(a,b)

Mathematical operators can also be broadcasted (like in Matlab). Multiplying the vectors a * b directly won't work. If we want to multiply element by element, we can use the broadcasted version below.

In [ ]:
a .* b
Question (NB1-Q2): What will be the value of `x` in the last line ?
In [ ]:
function q(x)
    x = 2
    x
end
In [ ]:
x = 1
y = q(x)
x

Run next cell to get an explanation of this question.

In [ ]:
why_q2()

References¶

As you can see variables are passed "by value". Passing variables "by reference" is done using a reference.

In [ ]:
function q!(x)
    x[] = 2
    x
end
In [ ]:
x = Ref(1)
q!(x)
x[]

Defining functions (shorter way)¶

For short functions, we can skip the function and end keywords as follows.

In [ ]:
add_short(a,b) = a+b
In [ ]:
add_short(1,3)

Anonymous (lambda) functions¶

Since we can assign function to variables, it is not needed for a function to have a function name in many cases. We can simply create an anonymous function (i.e., a function without name) and assign it to a variable.

In [ ]:
add_anonymous = (a,b) -> a+b

We can call the function by using the variable name.

In [ ]:
add_anonymous(2.0,3.5)

Note that add_anonymous is not a function name. It is just a variable associated with a function with no function name (well, it has a name technically, but with an arbitrary value).

In [ ]:
nameof(add_anonymous)
In [ ]:
nameof(add)

Functions are first-class objects¶

We can work with Julia functions like with any other type of object. For instance, we can assign functions to variables.

In [ ]:
a = add

Now, we can call the function using the variable name.

In [ ]:
a(4,5)

We can also create an array of functions (this will not work in Python).

In [ ]:
funs = [+,-,*]

To call a specific function in the array, we index the array and then call the returned function

In [ ]:
funs[2](2,3)

Higher-order functions¶

Higher order functions are functions that take and/or return other functions. And example is the count function in Julia.

For instance, we can pass a user-defined function to count the number of even elements in an array.

In [ ]:
func = i->i%2==0
a = [1,2,3,5,32,2,4]
count(func,a)

Do-blocks¶

There is yet another way to define anonymous functions. If a function takes a function in its first argument (like count) we can skip the first argument, when calling the function, and define the function we want to pass in a do-block. This is useful, e.g., if we want to define a multi-line anonymous function. The two next cells are equivalent.

In [ ]:
function f(i)
    m = i%2
    m != 0
end
count(f,[1,2,3,5,32,2,4])
In [ ]:
count([1,2,3,5,32,2,4]) do i
    m = i%2
    m != 0
end

Returning multiple values¶

Julia functions always return a single variable. To return multiple values, we can wrap them in a tuple.

In [ ]:
function divrem(a,b)
    α = div(a,b)
    β = rem(a,b)
    (α,β)
end

The output is a tuple as expected, but we can recover the individual values by unpacking the tuple.

In [ ]:
d,r = divrem(10,3)
In [ ]:
d
In [ ]:
r

Variable number of input arguments¶

Functions with multiple arguments are also supported. The following example iterates over the given arguments and prints them. args is just a tuple with all arguments.

In [ ]:
function showargs(args...)
    for (i,arg) in enumerate(args)
        println("args[$i] = $arg")
    end
end
In [ ]:
showargs(1,"Hi!",π)
In [ ]:
showargs(6)

Positional and keyword arguments¶

Functions can combine positional and keyword arguments much like in Python, but keyword arguments start with semicolon ; in Julia.

In [ ]:
function foo(a,b;c,d)
    println("Positional: a=$a, b=$b. Keyword: c=$c, d=$d")
end
In [ ]:
foo(3,4,d=2,c=1)

Optional arguments¶

We can provide default values to arguments to make them optional.

In [ ]:
function bar(a,b=0;c,d=1)
    println("Positional: a=$a, b=$b. Keyword: c=$c, d=$d")
end
In [ ]:
bar(1,c=2)
Question (NB1-Q3): Which will be the value of `x` below?
In [ ]:
function hofun(x)
    y -> x*y
end
In [ ]:
f2 = hofun(2)
In [ ]:
x = f2(3)
x

Run next cell to get an explanation of this question.

In [ ]:
why_q3()

Arrays¶

Julia supports multi-dimensional arrays. They are very similar to Numpy arrays in Python. Let's learn the basics of Julia arrays.

Array literals¶

We can create (small) arrays from the given values using array literals.

Next cell creates a vector with 3 integers. Note for Python users: there is no difference between vectors and lists in Julia.

In [ ]:
vec = [1,2,3]

We can create a matrix as follows.

In [ ]:
mat = [1 2 3 4
       5 6 7 8
       9 10 11 12]

Array initialization¶

We can create arrays with all the entries equal to zero, to one, or to a specific given value. The value can be any Julia object, even a function!

In [ ]:
zeros(4)
In [ ]:
zeros(Int,4)
In [ ]:
ones(2,3)
In [ ]:
fill(5.0,3,4)
In [ ]:
fill(add,3,4)

Array comprehensions¶

We can also create the items in the array using a loop within an array comprehension.

In [ ]:
squares = [ i^2 for i in 1:8 ]

Indexing¶

We can get and set the items of an array by indexing the array.

In [ ]:
squares[3]
In [ ]:
squares[end]
In [ ]:
squares[2:4]
In [ ]:
squares[4] = 16
In [ ]:
squares[2:3] = [4,9]

Immutable element type¶

Note that once set, the type of the elements in the array cannot be changed. If we try to set an item with an object of a different type, Julia will try to do a conversion, which can fail depending on the passed value.

In [ ]:
a = [10,11,12,13]
In [ ]:
a[2] = "Hi!"

Arrays of any element type¶

Arrays of fixed element type seem to be very rigid, right? Python list do not this limitation. However, we can use arrays of the Any type, which are as flexible as Python lists, or even more since they can also contain functions.

In [ ]:
a = Any[10,11,12,13]
In [ ]:
a[3] = "HI!"
In [ ]:
a

Loops¶

The loop in next cell visits the elements in a one after the other.

In [ ]:
a = [10,20,30,40]
for ai in a
    @show ai
end

This loop visits the integers from 1 to the length of the array and indexes the array at each of these integers.

In [ ]:
for i in 1:length(a)
    ai = a[i]
    @show (i,ai)
end

This loop "enumerates" the items in the array.

In [ ]:
for (i,ai) in enumerate(a)
    @show (i,ai)
end

Arrays indices are 1-based by default¶

Be aware of this if you are a C or Python user.

In [ ]:
a = [10,20,30,40]
a[0]

Slicing allocates a new array¶

This is also different from Numpy in Python.

In [ ]:
a = [1 2 3
     4 5 6]
In [ ]:
s = a[:,2]
In [ ]:
s[2] = 0
In [ ]:
a

Array views¶

If you want to modify the original array, use view instead.

In [ ]:
v = view(a,:,2)
In [ ]:
v[1] = 0
In [ ]:
a

Exercises¶

Exercise 1¶

Implement a function ex1(a) that finds the largest item in the array a. It should return the largest item and its corresponding position in the array. If there are multiple maximal elements, then the first one will be returned. Assume that the array is not empty. Implement the function in the next cell. Test your implementation with the other one.

In [ ]:
# Implement here
In [ ]:
using Test
arr = [3,4,7,3,1,7,2]
@test ex1(arr) == (7,3)

Exercise 2¶

Implement a function ex2(f,g) that takes two functions f(x) and g(x) and returns a new function h(x) representing the sum of f and g, i.e., h(x)=f(x)+g(x).

In [ ]:
# Implement here
In [ ]:
h = ex2(sin,cos)
xs = LinRange(0,2π,100)
@test all(x-> h(x) == sin(x)+cos(x), xs)

Exercise 3 (hard)¶

Function mandel estimates if a given point (x,y) in the complex plane belongs to the Mandelbrot set.

In [ ]:
function mandel(x,y,max_iters)
    z = Complex(x,y)
    c = z
    threshold=2
    for n in 1:max_iters
        if abs(z)>threshold
            return n-1
        end
        z = z^2 +c
    end
    max_iters
end

If the value of mandel is less than max_iters, the point is provably outside the Mandelbrot set. If mandel is equal to max_iters, then the point is provably inside the set. The larger max_iters, the better the quality of the estimate (the nicer will be your plot).

Plot the value of function mandel for each pixel in a 2D grid of the box.

$$(-1.7,0.7)\times(-1.2,1.2).$$

Use a grid resolution of at least 1000 points in each direction and max_iters at least 10. You can increase these values to get nicer plots. To plot the values use function heatmap from the Julia package GLMakie. Use LinRange to divide the horizontal and vertical axes into pixels. See the documentation of these functions for help. GLMakie is a GPU-accelerated plotting back-end for Julia. It is a large package and it can take some time to install and to generate the first plot. Be patient.

In [ ]:
# Implement here

License¶

This notebook is part of the course Programming Large Scale Parallel Systems at Vrije Universiteit Amsterdam and may be used under a CC BY 4.0 license.