Category Archives: Julia

How to (Almost) Never Lose A Game

By: Alec Loudenback

Re-posted from: https://alecloudenback.com/posts/counting-chickens/index.html

Count Your Chickens is a cooperative game for children. I have very much enjoyed playing it with my daughter but an odd pattern appeared across many attempts: we never lost.

The game is entirely luck-based and is fairly straightforward. There are a bunch of chicks out of the chicken coop, and as you move from one space to another, you collect and return the chicks to the coop based on how many spaces you moved. You simply spin a spinner and move to the next icon that matches what you spun. There are some bonus spaces (in blue) where you get to collect an extra chick and you can also spin a fox which removes a chick from the coop.

I had a suspicion that if you were missing chicks from the game, that the game would quickly become much easier to “win” by getting all of the chicks back into the coop. Simultaneously, I had learned about SumTypes.jl and wanted to try it out. So could we simulate the game by using enumerated types? Yes, and here’s how it worked:

Setup

We’ll use four packages:

1using SumTypes
2using CairoMakie
3using ColorSchemes
4using DataFramesMeta
1
Used to model the different types of squares.
2
We’ll use this to plot outcomes of games.
3
To show the distribution of outcomes, we’ll use a custom color set for the plot.
4
Dataframe manipulation will help transform our simulated results for plotting.

Sum Types

What they are is nicely summarized as:

Sum types, sometimes called ‘tagged unions’ are the type system equivalent of the disjoint union operation (which is not a union in the traditional sense). In the Rust programming language, these are called “Enums”, and they’re more general than what Julia calls an enum.

At the end of the day, a sum type is really just a fancy word for a container that can store data of a few different, pre-declared types and is labeled by how it was instantiated.

Users of statically typed programming languages often prefer Sum types to unions because it makes type checking easier. In a dynamic language like Julia, the benefit of these objects is less obvious, but there are cases where they’re helpful, like performance sensitive branching on heterogeneous types, and enforcing the handling of cases.

We have two sets of things in this game which are similar and candidates for SumTypes:

  1. The animals on the spinner, and
  2. The different types of squares on the board.

It’s fairly simple for Animal, but Square needs a little explanation:

"Animal resprents the type of creature on the spinner and board."
@sum_type Animal begin
    Cow
    Tractor
    Sheep
    Dog
    Pig
    Fox
end

"""
Square represents the three different kinds of squares, including regular and bonus squares that contain data indicating the `Animal` in the square.
"""
@sum_type Square begin
    Empty
1    Regular(::Animal)
    Bonus(::Animal)
end
1
@sum_type will create variants that are all of the same type (Square in this case). The syntax Regular(::Animal) indicates that when, e.g, we create a Regular(Dog) we will get a Square that encloses data indicating it’s both a Regular variant of a Square in addition to holding the Dog instance of an Animal. That is, Regular(Dog) is an instance of Square type and does not create a distinct subtype of Square.

A couple of examples to show how this works:

typeof(Pig), Pig isa Animal
(Animal, true)
typeof(Bonus(Dog)), Bonus(Dog) isa Square
(Square, true)

Game Logic

I’ll first define a function that outlines how the game works and allow the number of chicks in play to vary since that’s the thesis for why it might be easier to win with missing pieces. Then I define two helper functions which give us the right behavior depending on the result of the spinner and the current state of the board. They are described in the docstrings.

"""
    playgame(board,total_chicks=40)

Simulate a game of Count Your Chickens and return how many chicks are outside of the coop at the end. The players win if there are no chicks outside of the coop. 
"""
function playgame(board, total_chicks=40)
    position = 0
    chicks_in_coop = 0
    while position < length(board)
        spin = rand((Cow, Tractor, Sheep, Dog, Pig, Fox))
        if spin == Fox
            if chicks_in_coop > 1
                chicks_in_coop -= 1
            end
        else
            result = move(board, position, spin)
            # limit the chicks in coop to available chicks remaining
            moved_chicks = min(total_chicks - chicks_in_coop, result.chicks)
            chicks_in_coop += moved_chicks
            position += result.spaces
        end
    end
    return total_chicks - chicks_in_coop

end
"""
    move(board,cur_position,spin)

Represents the result of a single turn of the game. 
Returns a named pair (tuple) of the number of spaces moved and chicks collected for that turn. 
"""
function move(board, cur_position, spin)
    next_square = findnext(space -> ismatch(space, spin), board, max(cur_position, 1))

    if isnothing(next_square)
        # nothing found that matches, so we must be at the end of the board
        l = length(board) - cur_position + 1
        (spaces=l, chicks=l)
    else
        n_spaces = next_square - cur_position
1        @cases board[next_square] begin
            Empty => (spaces=n_spaces, chicks=n_spaces)
            Bonus => (spaces=n_spaces, chicks=n_spaces + 1)
            Regular => (spaces=n_spaces, chicks=n_spaces)
        end
    end
end
1
SumTypes.jl provides a way to match the value of the board at the next square to Empty (which shouldn’t actually happen), Bonus, or Regular and the result depends on which kind of board we landed on.
"""
    ismatch(space,spin)

True or false depending on if the `spin` (an `Anmial`) matches the data within the `square` (`Animal` if not an `Empty` `Square`). 
"""
function ismatch(square, spin)
    @cases square begin
        Empty => false
1        [Regular, Bonus](a) => spin == a
    end
end
1
The [...] lets us simplify repeated cases while the (a) syntax allows us to reference the encapsulated data within the Square SumType.

Last part of the setup is declaring what the board looks like (unhide if you want to see – it’s just a long array representing each square on the board):

Code
board = [
    Empty,
    Regular(Sheep),
    Regular(Pig),
    Bonus(Tractor),
    Regular(Cow),
    Regular(Dog),
    Regular(Pig),
    Bonus(Cow),
    Regular(Dog),
    Regular(Sheep),
    Regular(Tractor),
    Empty,
    Regular(Cow),
    Regular(Pig),
    Empty,
    Empty,
    Empty,
    Regular(Tractor),
    Empty,
    Regular(Tractor),
    Regular(Dog),
    Bonus(Sheep),
    Regular(Cow),
    Regular(Dog),
    Regular(Pig),
    Regular(Tractor),
    Empty,
    Regular(Sheep),
    Regular(Cow),
    Empty,
    Empty,
    Regular(Tractor),
    Regular(Pig),
    Regular(Sheep),
    Bonus(Dog),
    Empty,
    Regular(Sheep),
    Regular(Cow),
    Bonus(Pig),]

Examples

Here are a couple examples of how the above works. First, here’s an example where we check if our spin (a Pig matches a candidate square Bonus(Pig)):

ismatch(Bonus(Pig), Pig)
true

If our first spin was a Pig, then we would move 3 spaces and collect 3 chicks:

move(board, 0, Pig)
(spaces = 3, chicks = 3)

And a simulation of a game:

playgame(board, 40)
0

Game Dynmaics

To understand the dynamics, we will simulate 1000 games for each variation of chicks from 35 (less than should come with the game) to 42 (more than should come with the game).

chick_range = 35:42
n = 1000
n_chicks = repeat(chick_range, n)
outcomes = playgame.(Ref(board), n_chicks)

df = DataFrame(; n_chicks, outcomes)


df = @chain df begin
    # create a wide table with the first column being the 
    # number of remaining chicks while the others 
    # total chicks
    unstack(:outcomes, :n_chicks, :outcomes, combine=length)
    # turn the missing values into 0 times this combination occurred
    coalesce.(_, 0)
    # # calculate proportion of outcomes within each column
    transform(Not(:outcomes) .=> x -> x / sum(x), renamecols=false)
    # restack back into a long table
    stack(Not(:outcomes))
end
# parse the column names which became strings when unstacked to column name
df.n_chicks = parse.(Int, df.variable)
df
104×4 DataFrame
79 rows omitted
Row outcomes variable value n_chicks
Int64 String Float64 Int64
1 0 35 0.986 35
2 2 35 0.004 35
3 1 35 0.01 35
4 5 35 0.0 35
5 3 35 0.0 35
6 6 35 0.0 35
7 4 35 0.0 35
8 7 35 0.0 35
9 8 35 0.0 35
10 14 35 0.0 35
11 11 35 0.0 35
12 9 35 0.0 35
13 10 35 0.0 35
93 2 42 0.22 42
94 1 42 0.186 42
95 5 42 0.07 42
96 3 42 0.188 42
97 6 42 0.028 42
98 4 42 0.113 42
99 7 42 0.011 42
100 8 42 0.01 42
101 14 42 0.001 42
102 11 42 0.001 42
103 9 42 0.002 42
104 10 42 0.0 42

Now to visualize the results, we want to create a custom color scheme where the color is green if we “win” and an increasingly intense red color the further we were from winning the game (not all chicks made it back to the coop).

colors = vcat(get(ColorSchemes.rainbow, 0.5), get.(Ref(ColorSchemes.Reds_9), 0.6:0.025:1.0))

let
    f = Figure()
    ax = Axis(f[1, 1],
        title="Count Your Chickens Win Rate",
        xticks=chick_range,
        xlabel="Number of chicks",
        ylabel="Proportion of games",
    )
    bp = barplot!(df.n_chicks, df.value,
        stack=df.outcomes,
        color=colors[df.outcomes.+1],
        label=df.outcomes,
    )

    f
end
┌ Warning: Found `resolution` in the theme when creating a `Scene`. The `resolution` keyword for `Scene`s and `Figure`s has been deprecated. Use `Figure(; size = ...` or `Scene(; size = ...)` instead, which better reflects that this is a unitless size and not a pixel resolution. The key could also come from `set_theme!` calls or related theming functions.
└ @ Makie ~/.julia/packages/Makie/fyNiH/src/scenes.jl:220

We can see that if we have 40 chicks (probably by design) we’d expect to win just over 50% of the time (which is less often that I would have guessed for the game’s family friendly approach).

However, if you were missing a few pieces like we were, your probability of winning dramatically increases and explains our family’s winning streak.

Endnotes

Environment

Julia Packages:

using Pkg;
Pkg.status();
Status `~/prog/alecloudenback.com/posts/counting-chickens/Project.toml`
  [13f3f980] CairoMakie v0.11.5
  [35d6a980] ColorSchemes v3.24.0
  [1313f7d8] DataFramesMeta v0.14.1
  [8e1ec7a9] SumTypes v0.5.5

Acknowledgements

Thanks to Mason Protter who provided some clarifications on the workings of SumTypes.jl.

A little exercise in CSV.jl and DataFrames.jl

By: Blog by Bogumił Kamiński

Re-posted from: https://bkamins.github.io/julialang/2024/01/19/puzzles.html

Introduction

This week I have discussed with my colleague the Lichess puzzle dataset
that I use in my Julia for Data Analysis book.

The dataset contains a list of puzzles along with information about them,
such as puzzle difficulty, puzzle solution, and tags describing puzzle type.

We were discussing if tags assigned to puzzles in this dataset are accurate.
In this post I give you an example how one can check it
(and practice a bit CSV.jl and DataFrames.jl).

The post was written under Julia 1.10.0, CSV.jl 0.10.12, and DataFrames.jl 1.6.1.

Getting the data

In this post I show you a relatively brief code. Therefore I assume that first
you download the file with the puzzle dataset and unpack it manually.
(In the book I show how to do it using Julia. You can find the source code on
GitHub repository of the book.)

Assuming you downloaded and unpacked the dataset into the puzzles.csv file
we read it in. We are interested only in columns 3 and 8 of this file,
so I use the following commands:

julia> using CSV

julia> using DataFrames

julia> df = CSV.read("puzzles.csv", DataFrame; select=[3, 8], header=false)
2132989×2 DataFrame
     Row │ Column3                            Column8
         │ String                             String
─────────┼──────────────────────────────────────────────────────────────────────
       1 │ f2g3 e6e7 b2b1 b3c1 b1c1 h6c1      crushing hangingPiece long middl…
       2 │ d3d6 f8d8 d6d8 f6d8                advantage endgame short
       3 │ b6c5 e2g4 h3g4 d1g4                advantage middlegame short
       4 │ g5e7 a5c3 b2c3 c6e7                advantage master middlegame short
       5 │ e8f7 e2e6 f7f8 e6f7                mate mateIn2 middlegame short
       6 │ a6a5 e5c7 a5b4 c7d8                crushing endgame fork short
       7 │ d4b6 f6e4 h1g1 e4f2                crushing endgame short trappedPi…
       8 │ d8f6 d1h5 h7h6 h5c5                advantage middlegame short
    ⋮    │                 ⋮                                  ⋮
 2132982 │ d2c2 c5d3 c2d3 c4d3                crushing fork middlegame short
 2132983 │ b8d7 c3b5 d6b8 a1c1 e8g8 b5c7      crushing long middlegame quietMo…
 2132984 │ g7g6 d5c6 c5c4 b3c4 b4c4 c6d6      crushing defensiveMove endgame l…
 2132985 │ g1h1 e3e1 f7f1 e1f1                endgame mate mateIn2 short
 2132986 │ g5c1 d5d6 d7f6 h7h8                advantage middlegame short
 2132987 │ d2f3 d8a5 c1d2 a5b5                advantage fork opening short
 2132988 │ f7f2 b2c2 c1b1 e2d1                endgame mate mateIn2 queensideAt…
 2132989 │ c6d4 f1e1 e8d8 b1c3 d4f3 g2f3      advantage long opening
                                                            2132973 rows omitted

julia> rename!(df, ["moves", "tags"])
2132989×2 DataFrame
     Row │ moves                              tags
         │ String                             String
─────────┼──────────────────────────────────────────────────────────────────────
       1 │ f2g3 e6e7 b2b1 b3c1 b1c1 h6c1      crushing hangingPiece long middl…
       2 │ d3d6 f8d8 d6d8 f6d8                advantage endgame short
       3 │ b6c5 e2g4 h3g4 d1g4                advantage middlegame short
       4 │ g5e7 a5c3 b2c3 c6e7                advantage master middlegame short
       5 │ e8f7 e2e6 f7f8 e6f7                mate mateIn2 middlegame short
       6 │ a6a5 e5c7 a5b4 c7d8                crushing endgame fork short
       7 │ d4b6 f6e4 h1g1 e4f2                crushing endgame short trappedPi…
       8 │ d8f6 d1h5 h7h6 h5c5                advantage middlegame short
    ⋮    │                 ⋮                                  ⋮
 2132982 │ d2c2 c5d3 c2d3 c4d3                crushing fork middlegame short
 2132983 │ b8d7 c3b5 d6b8 a1c1 e8g8 b5c7      crushing long middlegame quietMo…
 2132984 │ g7g6 d5c6 c5c4 b3c4 b4c4 c6d6      crushing defensiveMove endgame l…
 2132985 │ g1h1 e3e1 f7f1 e1f1                endgame mate mateIn2 short
 2132986 │ g5c1 d5d6 d7f6 h7h8                advantage middlegame short
 2132987 │ d2f3 d8a5 c1d2 a5b5                advantage fork opening short
 2132988 │ f7f2 b2c2 c1b1 e2d1                endgame mate mateIn2 queensideAt…
 2132989 │ c6d4 f1e1 e8d8 b1c3 d4f3 g2f3      advantage long opening
                                                            2132973 rows omitted

Note that the file does not have a header so when reading it we passed header=false
and then manually named the columns using rename!.

The task

I wanted only these two columns since today I want to check if the tags related
to mating are accurate. You can notice in the above printout that in the "tags"
column we have a tag "mateIn2". It indicates that the puzzle is mate in two moves.
This is the case for example for rows 5, 2132985, and 2132988.
In the matching "moves" column we see that we have 4 corresponding moves.
The reason is that we have two players making the move (and 2 + 2 = 4).

What we want to check if these "mateInX" tags are correct. I will check the
values of X from 1 to 5 (as only these five options are present in tags,
I leave it to you as an exercise to verify).

When should we call the tags correct. There are two conditions:

  • there is no duplicate tagging (e.g. a puzzle cannot be "mateIn1" and "mateIn2" at the same time);
  • the number of moves in a puzzle matches the tag.

Let us check it.

The solution

As a first step we (in place, i.e. modifying our df data frame) transform the original columns
into more convenient form. Instead of the raw "moves" I want the "nmoves" column that gives me
a number of moves in the puzzle. Similarly instead of "tags" I want indicator columns "mateInX"
for X ranging from 1 to 5 showing me the puzzle type. Here is how you can achieve this:

julia> select!(df,
               "moves" => ByRow(length∘split) => "nmoves",
               ["tags" => ByRow(contains("mateIn$i")) => "mateIn$i" for i in 1:5])
2132989×6 DataFrame
     Row │ nmoves  mateIn1  mateIn2  mateIn3  mateIn4  mateIn5
         │ Int64   Bool     Bool     Bool     Bool     Bool
─────────┼─────────────────────────────────────────────────────
       1 │      6    false    false    false    false    false
       2 │      4    false    false    false    false    false
       3 │      4    false    false    false    false    false
       4 │      4    false    false    false    false    false
       5 │      4    false     true    false    false    false
       6 │      4    false    false    false    false    false
       7 │      4    false    false    false    false    false
       8 │      4    false    false    false    false    false
    ⋮    │   ⋮        ⋮        ⋮        ⋮        ⋮        ⋮
 2132982 │      4    false    false    false    false    false
 2132983 │      6    false    false    false    false    false
 2132984 │      6    false    false    false    false    false
 2132985 │      4    false     true    false    false    false
 2132986 │      4    false    false    false    false    false
 2132987 │      4    false    false    false    false    false
 2132988 │      4    false     true    false    false    false
 2132989 │      6    false    false    false    false    false
                                           2132973 rows omitted

Now we see that some of the rows are not tagged as "mateInX". Let us filter them out,
to have only tagged rows left (again, we do the operation in-place):

julia> filter!(row -> any(row[Not("nmoves")]), df)
491743×6 DataFrame
    Row │ nmoves  mateIn1  mateIn2  mateIn3  mateIn4  mateIn5
        │ Int64   Bool     Bool     Bool     Bool     Bool
────────┼─────────────────────────────────────────────────────
      1 │      4    false     true    false    false    false
      2 │      4    false     true    false    false    false
      3 │      2     true    false    false    false    false
      4 │      4    false     true    false    false    false
      5 │      2     true    false    false    false    false
      6 │      4    false     true    false    false    false
      7 │      4    false     true    false    false    false
      8 │      2     true    false    false    false    false
   ⋮    │   ⋮        ⋮        ⋮        ⋮        ⋮        ⋮
 491736 │      6    false    false     true    false    false
 491737 │      4    false     true    false    false    false
 491738 │      2     true    false    false    false    false
 491739 │      4    false     true    false    false    false
 491740 │      2     true    false    false    false    false
 491741 │      2     true    false    false    false    false
 491742 │      4    false     true    false    false    false
 491743 │      4    false     true    false    false    false
                                           491727 rows omitted

Note that in the condition I used the row[Not("nmoves")] selector, as I wanted to check all columns except the "nmoves".

Now we are ready to check the correctness of tags:

julia> combine(groupby(df, "nmoves"), Not("nmoves") .=> sum)
10×6 DataFrame
 Row │ nmoves  mateIn1_sum  mateIn2_sum  mateIn3_sum  mateIn4_sum  mateIn5_sum
     │ Int64   Int64        Int64        Int64        Int64        Int64
─────┼─────────────────────────────────────────────────────────────────────────
   1 │      2       136843            0            0            0            0
   2 │      4            0       274135            0            0            0
   3 │      6            0            0        68623            0            0
   4 │      8            0            0            0         9924            0
   5 │     10            0            0            0            0         1691
   6 │     12            0            0            0            0          367
   7 │     14            0            0            0            0          127
   8 │     16            0            0            0            0           25
   9 │     18            0            0            0            0            7
  10 │     20            0            0            0            0            1

The table reads as follows:

  • There are no duplicates in tags.
  • Tags "mateInX" for X in 1 to 4 range are correct.
    The "mateIn5" tag actually means a situation where there are five or more moves.

So the verdict is that tagging is correct, but we need to know the interpretation of
"mateIn5" column as it is actually five or more moves. We could rename the column to
e.g. "mateIn5+" to reflect that or add a metadata to our df table where we would store
this information (I leave this to you as an exercise).

Conclusions

I hope that CSV.jl and DataFrames.jl users found the examples that I gave today useful and interesting. Enjoy!

Julia’s Parallel Processing

By: Great Lakes Consulting

Re-posted from: https://blog.glcs.io/parallel-processing

Julia is a relatively new,free, and open-source programming language.It has a syntaxsimilar to that of other popular programming languagessuch as MATLAB and Python,but it boasts being able to achieve C-like speeds.

While serial Julia code can be fast,sometimes even more speed is desired.In many cases,writing parallel codecan further reduce run time.Parallel code takes advantageof the multiple CPU coresincluded in modern computers,allowing multiple computationsto run at the same time,or in parallel.

Julia provides two methodsfor writing parallel CPU code:multi-threading and distributed computing.This post will coverthe basics ofhow to use these two methodsof parallel processing.

This post assumes you already have Julia installed.If you haven’t yet,check out our earlierpost on how to install Julia.

Multi-Threading

First, let’s learn about multi-threading.

To enable multi-threading,you must start Julia in one of two ways:

  1. Set the environment variable JULIA_NUM_THREADSto the number of threads Julia should use,and then start Julia.For example, JULIA_NUM_THREADS=4.
  2. Run Julia with the --threads (or -t) command line argument.For example, julia --threads 4 or julia -t 4.

After starting Julia(either with or without specifying the number of threads),the Threads module will be loaded.We can check the number of threads Julia has available:

julia> Threads.nthreads()4

The simplest wayto start writing parallel codeis just to use the Threads.@threads macro.Inserting this macro before a for loopwill cause the iterations of the loopto be split across the available threads,which will then operate in parallel.For example:

Threads.@threads for i = 1:10    func(i)end

Without Threads.@threads,first func(1) will run,then func(2), and so on.With the macro,and assuming we started Julia with four threads,first func(1), func(4), func(7), and func(9)will run in parallel.Then,when a thread’s iteration finishes,it will start another iteration(assuming the loop is not done yet),regardless of whether the other threadshave finished their iterations yet.Therefore,this loop will theoretically finish 10 iterationsin the time it takes a single thread to do 3.

Note that Threads.@threads is blocking,meaning code after the threaded for loopwill not run until the loop has finished.

Image of threaded for loop

Julia also provides another macro for multi-threading:Threads.@spawn.This macro is more flexible than Threads.@threadsbecause it can be used to run any codeon a thread,not just for loops.But let’s illustrate how to use Threads.@spawnby implementing the behavior of Threads.@threads:

# Function for splitting up `x` as evenly as possible# across `np` partitions.function partition(x, np)    (len, rem) = divrem(length(x), np)    Base.Generator(1:np) do p        i1 = firstindex(x) + (p - 1) * len        i2 = i1 + len - 1        if p <= rem            i1 += p - 1            i2 += p        else            i1 += rem            i2 += rem        end        chunk = x[i1:i2]    endendN = 10chunks = partition(1:10, Threads.nthreads())tasks = map(chunks) do chunk    Threads.@spawn for i in chunk        func(i)    endendwait.(tasks)

Let’s walk through this code,assuming Threads.nthreads() == 4:

  • First, we split the 10 iterationsevenly across the 4 threadsusing partition.So, chunks ends up being[1:3, 4:6, 7:8, 9:10].(We could have hard-coded the partitioning,but now you have a nice partition functionthat can work with more complicated partitionings!)
  • Then, for each chunk,we create a Task via Threads.@spawnthat will call funcon each element of the chunk.This Task will be scheduledto run on an available thread.tasks contains a referenceto each of these spawned Tasks.
  • Finally, we wait for the Tasks to finishwith the wait function.

To reemphasize, note that Threads.@spawn creates a Task;it does not wait for the task to run.As such, it is non-blocking,and program execution continuesas soon as the Task is returned.The code wrapped in the taskwill also run, but in parallel, on a separate thread.This behavior is illustrated below:

julia> Threads.@spawn (sleep(2); println("Spawned task finished"))Task (runnable) @0x00007fdd4b10dc30julia> 1 + 1 # This code executes without waiting for the above task to finish2julia> Spawned task finished # Prints 2 seconds after spawning the above taskjulia>

Spawned tasks can also return data.While wait just waits for a task to finish,fetch waits for a taskand then obtains the result:

julia> task = Threads.@spawn (sleep(2); 1 + 1)Task (runnable) @0x00007fdd4a5e28b0julia> fetch(task)2

Thread Safety

When using multi-threading,memory is shared across threads.If a thread writes to a memory locationthat is written to or read from another thread,that will lead to a race conditionwith unpredictable results.To illustrate:

julia> s = 0;julia> Threads.@threads for i = 1:1000000           global s += i       endjulia> s19566554653 # Should be 500000500000

Race condition

There are two methods we can useto avoid the race condition.The first involves using a lock:

julia> s = 0; l = ReentrantLock();julia> Threads.@threads for i = 1:1000000           lock(l) do               global s += i           end       endjulia> s500000500000

In this case,the addition can only occuron a given threadonce that thread holds the lock.If a thread does not hold the lock,it must wait for whatever thread controls itto release the lockbefore it can run the codewithin the lock block.

Using a lock in this exampleis suboptimal, however,as it eliminates all parallelismbecause only one thread can hold the lockat any given moment.(In other examples, however,using a lock works great,particularly when only a small portionof the code depends on the lock.)

The other way to eliminate the race conditionis to use task-local buffers:

julia> s = 0; chunks = partition(1:1000000, Threads.nthreads());julia> tasks = map(chunks) do chunk           Threads.@spawn begin               x = 0               for i in chunk                   x += i               end               x           end       end;julia> thread_sums = fetch.(tasks);julia> for i in thread_sums           s += i       endjulia> s500000500000

In this example,each spawned task has its own xthat stores the sumof the values just in the task’s chunk of data.In particular,none of the tasks modify s.Then, once each task has computed its sum,the intermediate values are summedand stored in sin a single-threaded manner.

Using task-local buffersworks better for this examplethan using a lockbecause most of the parallelism is preserved.

(Note that it used to be advisedto manage task-local buffersusing the threadid function.However, doing so does not guaranteeeach task uses its own buffer.Therefore, the method demonstrated in the above exampleis now advised.)

Packages for Quickly Utilizing Multi-Threading

In addition to writing your own multi-threaded code,there exist packages that utilize multi-threading.Two such examples are ThreadsX.jl and ThreadTools.jl.

ThreadsX.jl provides multi-threaded implementationsof several common functionssuch as sum and sort,while ThreadTools.jl provides tmap,a multi-threaded version of map.

These packages can be greatfor quickly boosting performancewithout having to figure out multi-threadingon your own.

Distributed Computing

Besides multi-threading,Julia also provides for distributed computing,or splitting work across multiple Julia processes.

There are two ways to start multiple Julia processes:

  1. Load the Distributed standard library packagewith using Distributedand then use addprocs.For example, addprocs(2)to add two additional Julia processes(for a total of three).
  2. Run Julia with the -p command line argument.For example, julia -p 2to start Julia with three total Julia processes.(Note that running Julia with -pwill implicitly load Distributed.)

Added processes are known as worker processes,while the original process is the main process.Each process has an id:the main process has id 1,and worker processes have id 2, 3, etc.

By default,code runs on the main process.To run code on a worker,we need to explicitly give code to that worker.We can do so with remotecall_fetch,which takes as inputsa function to run,the process id to run the function on,and the input arguments and keyword argumentsthe function needs.Here are some examples:

# Create a zero-argument anonymous function to run on worker 2.julia> remotecall_fetch(2) do           println("Done")       end      From worker 2:    Done# Create a two-argument anonymous function to run on worker 2.julia> remotecall_fetch((a, b) -> a + b, 2, 1, 2)3# Run `sum([1 3; 2 4]; dims = 1)` on worker 3.julia> remotecall_fetch(sum, 3, [1 3; 2 4]; dims = 1)1x2 Matrix{Int64}: 3  7

If you don’t need to wait for the result immediately,use remotecall instead of remotecall_fetch.This will create a Futurethat you can later wait on or fetch(similarly to a Task spawned with Threads.@spawn).

Super computer

Separate Memory Spaces

One significant differencebetween multi-threading and distributed processingis that memory is shared in multi-threading,while each distributed processhas its own separate memory space.This has several important implications:

  • To use a package on a given worker,it must be loaded on that worker,not just on the main process.To illustrate:

    julia> using LinearAlgebrajulia> IUniformScaling{Bool}true*Ijulia> remotecall_fetch(() -> I, 2)ERROR: On worker 2:UndefVarError: `I` not defined

    To avoid the error,we could use @everywhere using LinearAlgebrato load LinearAlgebra on all processes.

  • Similarly to the previous point,functions defined on one processare not available on other processes.Prepend a function definition with @everywhereto allow using the function on all processes:

    julia> @everywhere function myadd(a, b)           a + b       end;julia> myadd(1, 2)3# This would error without `@everywhere` above.julia> remotecall_fetch(myadd, 2, 3, 4)7
  • Global variables are not shared,even if defined everywhere with @everywhere:

    julia> @everywhere x = [0];julia> remotecall_fetch(2) do           x[1] = 2       end;# `x` was modified on worker 2.julia> remotecall_fetch(() -> x, 2)1-element Vector{Int64}: 2# `x` was not modified on worker 3.julia> remotecall_fetch(() -> x, 3)1-element Vector{Int64}: 0

    If needed,an array of data can be sharedacross processesby using a SharedArray,provided by the SharedArrays standard library package:

    julia> @everywhere using SharedArrays# We don't need `@everywhere` when defining a `SharedArray`.julia> x = SharedArray{Int,1}(1)1-element SharedVector{Int64}: 0julia> remotecall_fetch(2) do           x[1] = 2       end;julia> remotecall_fetch(() -> x, 2)1-element SharedVector{Int64}: 2julia> remotecall_fetch(() -> x, 3)1-element SharedVector{Int64}: 2

Now, a note about command line arguments.When adding worker processes with -p,those processes are spawnedwith the same command line argumentsas the main Julia process.With addprocs, however,each of those added processesare started with no command line arguments.Below is an example of where this behaviormight cause some confusion:

$ JULIA_NUM_THREADS=4 julia --banner=no -t 1julia> Threads.nthreads()1julia> using Distributedjulia> addprocs(1);julia> remotecall_fetch(Threads.nthreads, 2)4

In this situation, we have the environment variable JULIA_NUM_THREADS(for example, because normally we run Julia with four threads).But in this particular casewe want to run Julia with just one thread,so we set -t 1.Then we add a process,but it turns out that processhas four threads, not one!This is because the environment variable was set,but no command line arguments were givento the added process.To use just one threadfor the added process,we would need to use the exeflags keyword argumentto addprocs:

addprocs(1; exeflags = ["-t 1"])

As a final note, if needed,processes can be removedwith rmprocs,which removes the processesassociated with the provided worker ids.

Summary

In this post,we have provided an introductionto parallel processing in Julia.We discussed the basicsof both multi-threading and distributed computing,how to use them in Julia,and some things to watch out for.

As a parting piece of advice,when choosing whether to use multi-threading or distributed processing,choose multi-threadingunless you have a specific needfor multiple processes with distinct memory spaces.Multi-threading has lower overheadand generally is easier to use.

How do you use parallel processing in your code?Let us know in the comments below!

Additional Links