Tag Archives: types

Optimize Flight Simulations with Improved Type-Stability

By: Great Lakes Consulting

Re-posted from: https://blog.glcs.io/sim-performance-type-stability

This post was written by Steven Whitaker.

In this Julia for Devs post,we will discuss improving the performanceof simulation code written in Juliaby eliminating sources of type-instabilities.

We wrote another postdetailing what type-stability isand how type-instabilities can degrade performance.We also showed how SnoopCompile.jl and Cthulhu.jlcan be used to pinpoint causes of type-instability.

This post will cover some of the type-instabilitieswe helped one of our clients overcome.

Our client is a technology innovator.They are building a first-of-its-kind logistics systemfocused on autonomous electric deliveryto reduce traffic and air pollution.Their aim is to provideefficient delivery services for life-saving productsto people in both urban and rural areas.Julia is helping them power criticalGuidance, Navigation, and Control (GNC) systems.

With this client, we:

  • Eliminated slowdown-related failureson the most important simulation scenario.
  • Decreased the compilation time of the scenario by 30%.
  • Improved the slowest time stepsfrom 300 ms to 10 ms (30x speedup),enabling 2x real-time performance.

We will not share client-specific code,but will provide similar examplesto illustrate root-cause issuesand suggested resolutions.

Here are the root-cause issues and resolutions we will focus on:

  • Help type-inference by unrolling recursion.
  • Standardize the output of different branches.
  • Avoid loops overTuples.
    • Use SnoopCompile.jl to reveal dynamic dispatches.
    • Investigate functionswith Cthulhu.jl.
  • Avoid dictionaries that map to functions.

Let’s dive in!

Help Type-Inference by Unrolling Recursion

One of the interesting problems we sawwas that there was a part of the client’s codethat SnoopCompile.jl reportedwas resulting in calls to inference,but when we inspected the code with Cthulhu.jlthe code looked perfectly type-stable.

This code consisted of a set of functionsthat recursively called each other,traversing the model treeto grab data from all the submodels.

As it turns out,recursion can pose difficulties for Julia’s type-inference.Basically,if type-inference detects recursionbut cannot prove it terminates(based only on the types of inputs—rememberthat type-inference occurs before runtime),inference gives up,resulting in code that runs like it is type-unstable.(See this Discourse post and comments and links thereinfor more information.)

The solution we implementedwas to use a @generated functionto unroll the recursion at compile time,resulting in a flat implementationthat could be correctly inferred.

Here’s an example that illustratesthe essence of the recursive code:

# Grab all the data in the entire model tree beginning at `model`.get_data(model::NamedTuple) = (; data = model.data, submodels = get_submodel_data(model.submodels))# This generated function is necessary for type-stability.# It calls `get_data` on each of the fields of `submodels`# and returns a `NamedTuple` of the results.# (This is not the generated function implemented in the solution.)@generated function get_submodel_data(submodels::NamedTuple)    assignments = map(fieldnames(submodels)) do field        Expr(:kw, field, :(get_data(submodels.$field)))    end    return Expr(:tuple, Expr(:parameters, assignments...))endget_submodel_data(::@NamedTuple{}) = (;)

Note that in this exampleget_data calls get_submodel_data,which in turn calls get_dataon the submodels.

Here’s the code for unrolling the recursion:

function _gen_get_data(T, path)    subT = _typeof_field(T, :submodels)    subpath = :($path.submodels)    return quote        (;            data = $path.data,            submodels = $(_gen_get_submodel_data(subT, subpath)),        )    endend# This function determines the type of `model.field`# given just the type of `model` (so we can't just call `typeof(model.field)`).# This function is necessary because we need to unroll the recursion# using a generated function, which means we have to work in the type domain# (because the generated function is generated before runtime).function _typeof_field(::Type{NamedTuple{names, T}}, field::Symbol) where {names, T}    i = findfirst(n -> n === field, names)    return T.parameters[i]endfunction _gen_get_submodel_data(::Type{@NamedTuple{}}, subpath)    return quote        (;)    endendfunction _gen_get_submodel_data(subT, subpath)    assignments = map(fieldnames(subT)) do field        T = _typeof_field(subT, field)        path = :($subpath.$field)        Expr(:kw, field, _gen_get_data(T, path))    end    return Expr(:tuple, Expr(:parameters, assignments...))end@generated get_data_generated(model::NamedTuple) = _gen_get_data(model, :model)

Unfortunately,this example doesn’t reproduce the issue our client had,but it does show how to use a @generated functionto unroll recursion.Note that there is still recursion:_gen_get_data and _gen_get_submodel_data call each other.The key, though, is that this recursion happens before inference,which means that when get_data_generated is inferred,the recursion has already taken place,resulting in unrolled codewithout any recursionthat might cause inference issues.

When we implemented this solution for our client,we saw the total memory utilization of their simulationdecrease by ~35%.This was enough to allow them to disable garbage collectionduring the simulation,speeding it upto faster than real-time!And this was the first timethis simulation had run faster than real-time!

Standardize Output of Different Branches

The client had different parts of their modelupdate at different frequencies.As a result,at any particular time steponly a subset of all the submodelsactually needed to update.Here’s an example of what this might look like:

function get_output(model, t)    if should_update(model, t)        out = update_model(model, t)    else        out = get_previous_output(model)    end    return outend

Unfortunately,update_model and get_previous_outputreturned values of different types,resulting in type-instability:the output type of get_outputdepended on the runtime result of should_update.

Furthermore,this function was called at every time pointon every submodel (and every sub-submodel, etc.),so the type-instability in this functionaffected the whole simulation.

The issue was that update_modeltypically returned the minimal subset of informationactually needed for the specific model,whereas get_previous_output was genericand returned a wider set of information.For example,maybe update_model would return a NamedTuplelike (x = [1, 2], xdot = [0, 0]),while get_previous_output would returnsomething like (x = [1, 2], xdot = [0, 0], p = nothing, stop_sim = false).

To fix this issue,rather than manually updating the return valuesof all the methods of update_modelfor all the submodels in the system,we created a function standardize_outputthat took whatever NamedTuple returned by update_modeland added the missing fieldsthat get_previous_output included.Then,the only change needed in get_outputwas to call standardize_output:

out = update_model(model, t) |> standardize_output

The result of making this changewas a 30% decrease in compilation timefor their simulation!

Avoid Loops over Tuples

The client stored submodelsof a parent modelas a Tuple or NamedTuple.This makes sense for type-stabilitybecause each submodel was of a unique type,so storing them in this waypreserved the type informationwhen accessing the submodels.In contrast,storing the submodels as a Vector{Any}would lose the type informationof the submodels.

However,type-stability problems arisewhen looping over Tuplesof different types of objects.The problem is that the compiler needsto compile code for the body of the loop,but the body of the loop needsto be able to handleall types included in the Tuple.As a result,the compiler must resort to dynamic dispatchin the loop body(but see the note on union-splitting further below).

President waiting for receptionist to look up his office

Here’s an example of the issue:

module TupleLoopfunction tupleloop(t::Tuple)    for val in t        do_something(val)    endenddo_something(val::Number) = val + 1do_something(val::String) = val * "!"do_something(val::Vector{T}) where {T} = isempty(val) ? zero(T) : val[1]do_something(val::Dict{String,Int}) = get(val, "hello", 0)end

Using SnoopCompile.jl reveals dynamic dispatches to do_something:

julia> using SnoopCompileCorejulia> tinf = @snoop_inference TupleLoop.tupleloop((1, 2.0, "hi", [10.0], Dict{String,Int}()));julia> using SnoopCompilejulia> tinfInferenceTimingNode: 0.019444/0.020361 on Core.Compiler.Timings.ROOT() with 4 direct childrenjulia> itrigs = inference_triggers(tinf); mtrigs = accumulate_by_source(Method, itrigs)2-element Vector{SnoopCompile.TaggedTriggers{Method}}: eval_user_input(ast, backend::REPL.REPLBackend, mod::Module) @ REPL ~/.julia/juliaup/julia-1.11.5+0.x64.linux.gnu/share/julia/stdlib/v1.11/REPL/src/REPL.jl:247 (1 callees from 1 callers) tupleloop(t::Tuple) @ Main.TupleLoop /path/to/TupleLoop.jl:3 (3 callees from 1 callers)

Looking at tupleloop with Cthulhu.jl:

julia> using Cthulhujulia> ascend(mtrigs[2].itrigs[1])Choose a call for analysis (q to quit):     do_something(::String) >     tupleloop(::Tuple{Int64, Float64, String, Vector{Float64}, Dict{String, Int64}}) at /path/to/TupleLoop.jltupleloop(t::Tuple) @ Main.TupleLoop /path/to/TupleLoop.jl:3 3 function tupleloop(t::Tuple{Int64, Float64, String, Vector{Float64}, Dict{String, Int64}}::Tuple)::Core.Const(nothing) 5 5     for val::Any in t::Tuple{Int64, Float64, String, Vector{Float64}, Dict{String, Int64}} 6         do_something(val::Any) 7     end 9 9 end

And we see the problem!Even though the Tuple is inferred,the loop variable val is inferred as Any,which means that calling do_something(val)must be a dynamic dispatch.

Note that in some casesJulia can perform union-splitting automaticallyto remove the dynamic dispatchcaused by this type-instability.In this example,union-splitting occurs when the Tuplecontains 4 (by default) or fewer unique types.However,it’s not a general solution.

One way to remove the dynamic dispatchwithout relying on union-splittingis to eliminate the loop:

do_something(t[1])do_something(t[2])

But we can quickly seethat writing this codeis not at all generic;we have to hard-codethe number of calls to do_something,which means the code will only workwith Tuples of a particular length.Fortunately,there’s a way around this issue.We can write a @generated functionto have the compiler unroll the loopfor us in a generic way:

@generated function tupleloop_generated(t::Tuple)    body = [:(do_something(t[$i])) for i in fieldnames(t)]    return quote        $(body...)        return nothing    endend

(Note that this code would also workif we specified t::NamedTuplein the method signature.)

Due to the way @generated functions work,SnoopCompile.jl still detects dynamic dispatches,but note that tupleloop_generateddoes not have any dynamic dispatches reported:

julia> using SnoopCompileCorejulia> tinf = @snoop_inference TupleLoop.tupleloop_generated((1, 2.0, "hi", [10.0], Dict{String,Int}()));julia> using SnoopCompilejulia> tinfInferenceTimingNode: 0.022208/0.050369 on Core.Compiler.Timings.ROOT() with 5 direct childrenjulia> itrigs = inference_triggers(tinf); mtrigs = accumulate_by_source(Method, itrigs)3-element Vector{SnoopCompile.TaggedTriggers{Method}}: (g::Core.GeneratedFunctionStub)(world::UInt64, source::LineNumberNode, args...) @ Core boot.jl:705 (1 callees from 1 callers) eval_user_input(ast, backend::REPL.REPLBackend, mod::Module) @ REPL ~/.julia/juliaup/julia-1.11.5+0.x64.linux.gnu/share/julia/stdlib/v1.11/REPL/src/REPL.jl:247 (1 callees from 1 callers) var"#s1#1"(::Any, t) @ Main.TupleLoop none:0 (3 callees from 1 callers)

And we can verify with Cthulhu.jlthat there are no more dynamic dispatches in tupleloop_generated:

julia> using Cthulhujulia> ascend(mtrigs[2].itrigs[1])Choose a call for analysis (q to quit): >   tupleloop_generated(::Tuple{Int64, Float64, String, Vector{Float64}, Dict{String, Int64}})       eval at ./boot.jl:430 => eval_user_input(::Any, ::REPL.REPLBackend, ::Module) at /cache/build/tester-amdci5-12/julialang/julia-release-1-dot-1tupleloop_generated(t::Tuple) @ Main.TupleLoop /path/to/TupleLoop.jl:11Variables  #self#::Core.Const(Main.TupleLoop.tupleloop_generated)  t::Tuple{Int64, Float64, String, Vector{Float64}, Dict{String, Int64}}Body::Core.Const(nothing)    @ /path/to/TupleLoop.jl:11 within `tupleloop_generated`    @ /path/to/TupleLoop.jl:15 within `macro expansion`1  %1  = Main.TupleLoop.do_something::Core.Const(Main.TupleLoop.do_something)   %2  = Base.getindex(t, 1)::Int64         (%1)(%2)   %4  = Main.TupleLoop.do_something::Core.Const(Main.TupleLoop.do_something)   %5  = Base.getindex(t, 2)::Float64         (%4)(%5)   %7  = Main.TupleLoop.do_something::Core.Const(Main.TupleLoop.do_something)   %8  = Base.getindex(t, 3)::String         (%7)(%8)   %10 = Main.TupleLoop.do_something::Core.Const(Main.TupleLoop.do_something)   %11 = Base.getindex(t, 4)::Vector{Float64}         (%10)(%11)   %13 = Main.TupleLoop.do_something::Core.Const(Main.TupleLoop.do_something)   %14 = Base.getindex(t, 5)::Dict{String, Int64}         (%13)(%14)   @ /path/to/TupleLoop.jl:16 within `macro expansion`       return Main.TupleLoop.nothing   

Here we have to examine the so-called “Typed” code(since the source code was generated via metaprogramming),but we see that there is no loop in this code.As a result,each call to do_somethingis a static dispatchwith a concretely inferred input.Hooray!

Avoid Dictionaries that Map to Functions

The client registered functionsfor updating their simulation visualizationvia a dictionary that mapped from a String keyto the appropriate update function.

Sometimes it can be convenientto have a dictionary of functions,for example:

d = Dict{String, Function}(    "sum" => sum,    "norm" => norm,    # etc.)x = [1.0, 2.0, 3.0]d["sum"](x) # Compute the sum of the elements of `x`d["norm"](x) # Compute the norm of `x`

This allows you to write generic codethat can call the appropriate intermediate functionbased on a key supplied by the caller.

You could use multiple dispatch to achieve similar results,but it requires a bit more thoughtto organize the code in such a waythat ensures the caller has access to the types to dispatch on.

As another alternative,you could also have the callerjust pass in the function to call.But again,it takes a bit more effortto organize the codeto make it work.

Unfortunately,using a dictionary in this wayis type-unstable:Julia can’t figure out what functionwill be calleduntil runtime,when the precise dictionary key is known.And since the function is unknown,the type of the result of the function is also unknown.

One partial solutionis to use type annotations:

d[func_key](x)::Float64

Then at least the output of the functioncan be used in a type-stable way.However,this only works if all the functions in the dictionaryreturn values of the same typegiven the same input.

A slightly less stringent alternativeis to explicitly convertthe result to a common type,but this requires conversion to be possible.

Our client updated a dictionaryusing the output of the registered function,so the full solution we implemented for our clientwas to remove the dictionaryand instead have explicit branches in the code.That is,instead of

updates[key] = d[key](updates[key])

we had

if key == "k1"    updates[key] = f1(updates[key]::OUTPUT_TYPE_F1)elseif key == "k2"    updates[key] = f2(updates[key]::OUTPUT_TYPE_F2)# Additional branches as neededend

Note that we needed the type annotationsOUTPUT_TYPE_F1 and OUTPUT_TYPE_F2because updates had an abstractly typed value type.The key that makes this solution workis recognizing that in the first branchupdates[key] is the output of f1from the previous time step in the simulation(and similarly for the other branches).Therefore,in each branch we know what the type of updates[key] is,so we can give the compiler that type information.

Also note that the previously mentioned ideasof using multiple dispatchor just passing in the functions to usedon’t work in this situationwithout removing the updates dictionary(and refactoring the affected code).

Making the above changecompletely removed type-instabilitiesin that part of the client’s code.

Summary

In this post,we explored a few problemsrelated to type-stabilitythat we helped our client resolve.We were able to diagnose issuesusing SnoopCompile.jl and Cthulhu.jland make code improvementsthat enabled our client’smost important simulation scenarioto pass tests for the first time.This was possiblebecause our solutions enabled the scenarioto run faster than real-timeand reduced compilation time by 30%.

Do you have type-instabilities that plague your Julia code?Contact us, and we can help you out!

Additional Links

The cover image backgroundof a person at the start of a racewas found at freepik.com.

The cartoon about looping over Tupleswas generated with AI.

]]>

Type-Stability with SnoopCompile.jl and Cthulhu.jl for High-Performance Julia

By: Great Lakes Consulting

Re-posted from: https://blog.glcs.io/type-stability

This post was written by Steven Whitaker.

The Julia programming languageis a high-level languagethat boasts the ability to achieve C-like speeds.Julia can run fast,despite being a dynamic language,because it is compiledand has smart type-inference.

Type-inference is the processof the Julia compilerreasoning about the types of objects,enabling compilation to create efficient machine codefor the types at hand.

However,Julia code can be written in a waythat prevents type-inference from succeeding—specifically,by writing type-unstable functions.(I’ll explain type-instability later on.)When type-inference fails,Julia has to compile generic machine codethat can handle any type of input,sacrificing the C-like performanceand instead running more like an interpreted languagesuch as Python.

Fortunately,there are tools that Julia developers can useto track down what code causes type-inference to fail.Among the most powerful of these toolsare SnoopCompile.jl and Cthulhu.jl.Using these tools,developers can fix type-inference failuresand restore the C-like performancethey were hoping to achieve.

In this post,we will learn about type-stabilityand how it impacts performance.Then we will see how to useSnoopCompile.jl and Cthulhu.jlto locate and resolve type-instabilities.

Type-Stability

A function is type-stableif the type of the function’s output can be concretely determinedgiven the types of the inputs to the function,without any runtime information.

To illustrate, consider the following function methods:

f(x::Int) = "stable"f(x::Float64) = rand(Bool) ? 1 : 2.0

In this example,if we call f(x) where x is an Int,the compiler can figure outthat the output will be a Stringwithout knowing the value of x,so f(x::Int) is type-stable.In other words,it doesn’t matter whether x is 1, -1, or 176859431;the return value will always be a Stringif x is an Int.

On the other hand,if we call f(x) where x is a Float64,the compiler doesn’t knowwhether the output will be an Int or a Float64because that depends on the result of rand(Bool),which is computed at runtime.Therefore,f(x::Float64) is type-unstable.

Here’s a more subtle example of type-instability:

function g(x)    if x < 0        return 0    else        return x    endend

In this example,g(x) is type-unstablebecause the output will either be an Intor whatever the type of x is,and it all depends on the value of x,which isn’t known at compile time.(Note, however,that g(x) is type-stableif x is an Intbecause then both branches of the if statementreturn the same type of value.)

And sometimes a function that might look type-stablecan be type-unstabledepending on the input.For example:

h(x::Array) = x[1] + 1

In this case,h([1]) is type-stable,but h(Any[1]) is not.Why?Because with h([1]),x is a Vector{Int},so the compiler knowsthat the type of x[1] will be Int.On the other hand,with h(Any[1]),x is a Vector{Any},so the compiler thinksx[1] could be of any type.

To reiterate:a function is type-stableif the compiler can figure outthe concrete type of the return valuegiven only the types of the inputs,without any runtime information.

When Compilation Occurs

Another aspect of type-inferencethat is useful to understandis when compilation (including type-inference) occurs.

In a static language like C,an entire program is compiledbefore any code runs.This is possible because the types of all variablesare known in advance,so machine code specific to those typescan be generated in advance.

In an interpreted language like Python,no code is ever compiledbecause variables are dynamic,meaning their types aren’t really ever knownuntil variables are actually used(i.e., during runtime).

Julia programs can liepretty much anywhere betweenthe extremes of C and Python,and where on that spectrum a program liesdepends on type-stability.

In a just-in-time (JIT) compiled language like Julia,compilation occurs once types are known.

  • If a Julia program is completely type-stable,type-inference can figure out the types of all variablesin the programbefore running any code.As a result,the entire program can be compiledas if it were written in a static language.This is what allows Julia to achieve C-like speeds.
  • If a Julia program is entirely type-unstable,every function has to be compiled individually.In this case,compilation occurs at the momentthe function is calledbecause that’s when the runtime informationof all the input types is finally known.Furthermore,the machine code for a type-unstable functioncannot be efficientbecause it must be able tohandle a wide rangeof potential types.As a result,despite being compiled,the code runs essentially like an interpreted language.

Running a Julia program with type-instabilitiesis like driving down the streetand hitting all the red lights.Julia will compile all the codefor which type-inference succeedsand then start running.But when the program reaches a function callthat could not be inferred,that’s like a car stopping at a red light;Julia stops running the codeto compile the function callnow that it knows the runtime types of the inputs.After the function is compiled,the program can continue execution,like how the car can continue drivingonce the light turns green.

Type-Stability and Performance

As this analogy implies,and as I’ve stated before,type-stability has performance implications.Type-instabilities can cause various performance degradations, including:

  • Dynamic (aka runtime) dispatch.If the compiler knows the input types to a function,the generated machine code can include a callto the specific method determined by those types.But if the compiler doesn’t know those types,the machine code has to include instructionsto perform dynamic dispatch.As a result,rather than jumping directly to the correct method,Julia has to spend runtime CPU cyclesto look up the correct method to call.
  • Increased memory allocations.If the compiler doesn’t know what typea variable will have,it’s impossible to put it in a registeror even allocate stack space for it.As a result,it has to be heap-allocatedand managed by the garbage collector.
  • Suboptimal compiled code.Imagine summing the contents of an array in a loop.If the compiler knows the array contains just Float64s,it can perform optimizations to compute the sumas efficiently as possible,e.g., by using specialized CPU instructions.Such optimizations cannot occurif the compiler doesn’t know what type of datait’s working with.

Here’s an example(inspired by this Stack Overflow answer)that illustrates the impacttype-stability can have on performance:

# Type-unstable because `x` is a non-constant global variable.x = 0f() = [i + x for i = 1:10^6]# Type-stable because `y` is constant and therefore always an `Int`.const y = 0g() = [i + y for i = 1:10^6]using BenchmarkTools@btime f() #  16.868 ms (1998983 allocations: 38.13 MiB)@btime g() # 190.755 s (3 allocations: 7.63 MiB)

Note that the type-unstable versionis two orders of magnitude slower!Also note, however,that this is an extreme examplewhere essentially the entire computationis type-unstable.In practice,some type-instabilities will not impact performance very much.Type-stability mainly matters in “hot loops”,i.e., in parts of the codethat run very frequentlyand contribute to a significant portionof the program’s overall run time.

Detecting Type-Instabilities with SnoopCompile.jl

Now the question is,how do we know if or where our code is type-unstable?One excellent toolfor discovering where type-instabilities occur in codeis SnoopCompile.jl.This package provides functionalityfor reporting how many timesa Julia program needs to stop to compile code.(Remember that a perfectly type-stable programcan compile everything in one go,so every time execution stops for compilationindicates a type-instability was encountered.)

Let’s use an example to illustrate how to use SnoopCompile.jl.First, the code we want to analyze:

module Originalstruct Alg1 endstruct Alg2 endfunction process(alg::String)    if alg == "alg1"        a = Alg1()    elseif alg == "alg2"        a = Alg2()    end    data = get_data(a)    result = _process(a, data)    return resultendget_data(::Alg1) = (1, 1.0, 0x00, 1.0f0, "hi", [0.0], (1, 2.0))function _process(::Alg1, data)    val = data[1]    if val < 0        val = -val    end    result = map(data) do d        process_item(d, val)    end    return resultendprocess_item(d::Int, val) = d + valprocess_item(d::AbstractFloat, val) = d * valprocess_item(d::Unsigned, val) = d - valprocess_item(d::String, val) = d * string(val)process_item(d::Array, val) = d .+ valprocess_item(d::Tuple, val) = d .- valget_data(::Alg2) = rand(5)_process(::Alg2, data) = error("not implemented")end

We’ll use the @snoop_inference macroto analyze this code.Note that this macro should be usedin a fresh Julia session(after loading the code to be analyzed,but before running anything)to get the most accurate analysis results.

julia> using SnoopCompileCorejulia> tinf = @snoop_inference Original.process("alg1");julia> using SnoopCompilejulia> tinfInferenceTimingNode: 0.144601/0.247183 on Core.Compiler.Timings.ROOT() with 8 direct children

You can consult the SnoopCompile.jl docsfor more information about what we just did,but for now,notice that displaying tinf revealed 8 direct children.That means compilation occurred 8 timeswhile running Original.process("alg1").If this function were completely type-stable,@snoop_inference would have reported just 1 direct child,so we know there are type-instabilities somewhere.

Each of the 8 direct childrenis an inference trigger,i.e., calling the specific methodindicated in the inference triggercaused compilation to occur.We can collect the inference triggers:

julia> itrigs = inference_triggers(tinf) Inference triggered to call process(::String) from eval (./boot.jl:430) inlined into REPL.eval_user_input(::Any, ::REPL.REPLBackend, ::Module) (/cache/build/tester-amdci5-12/julialang/julia-release-1-dot-11/usr/share/julia/stdlib/v1.11/REPL/src/REPL.jl:261) Inference triggered to call process_item(::Int64, ::Int64) from #1 (./REPL[1]:30) with specialization (::var"#1#2")(::Int64) Inference triggered to call process_item(::Float64, ::Int64) from #1 (./REPL[1]:30) with specialization (::var"#1#2")(::Float64) Inference triggered to call process_item(::UInt8, ::Int64) from #1 (./REPL[1]:30) with specialization (::var"#1#2")(::UInt8) Inference triggered to call process_item(::Float32, ::Int64) from #1 (./REPL[1]:30) with specialization (::var"#1#2")(::Float32) Inference triggered to call process_item(::String, ::Int64) from #1 (./REPL[1]:30) with specialization (::var"#1#2")(::String) Inference triggered to call process_item(::Vector{Float64}, ::Int64) from #1 (./REPL[1]:30) with specialization (::var"#1#2")(::Vector{Float64}) Inference triggered to call process_item(::Tuple{Int64, Float64}, ::Int64) from #1 (./REPL[1]:30) with specialization (::var"#1#2")(::Tuple{Int64, Float64})

The first inference triggercorresponds to compiling the top-level process functionwe called(this is the inference trigger we always expect to see).But then it looks like Julia had to stop runningto compile several different methods of process_item.

Inference triggers tell us that type-instabilities existedwhen calling the given functions,but what we really want to know iswhere these type-instabilities originated.You’ll note that each displayed inference trigger abovealso indicates the calling functionby specifying from <calling function>.(Note that the from #1 in the above exampleindicates process_item was calledfrom an anonymous function.)

We can use accumulate_by_sourceto get an aggregated viewof what functions made calls via dynamic dispatch:

julia> mtrigs = accumulate_by_source(Method, itrigs)2-element Vector{SnoopCompile.TaggedTriggers{Method}}: eval_user_input(ast, backend::REPL.REPLBackend, mod::Module) @ REPL ~/.julia/juliaup/julia-1.11.5+0.x64.linux.gnu/share/julia/stdlib/v1.11/REPL/src/REPL.jl:247 (1 callees from 1 callers) (::var"#1#2")(d) @ Main REPL[1]:30 (7 callees from 7 callers)

From this,we can see that the example codereally has only one problematic function:the anonymous function var"#1#2".

Diving in with Cthulhu.jl

Now that we have a rough ideaof where the type-instabilities come from,we can drill down into the codeand pinpoint the precise causeswith Cthulhu.jl.We can use the ascend functionon an inference triggerto start investigating:

julia> using Cthulhujulia> ascend(itrigs[2]) # Skip `itrigs[1]` because that's the top-level compilation that should always occur.

ascend provides a menu that shows process_itemand the anonymous function.Select the anonymous function and press Enter.Here’s a screenshot of the Cthulhu output:

Cthulhu output

Reading the output of Cthulhu.jltakes some time to get used to(especially when it can’t display source code,as in this example),but the main thing to rememberis that red is bad.See the Cthulhu.jl README for more information.

In this example,the source of the type-instabilitywas fairly easy to pinpoint.I annotated the screenshotto indicate from where the type-instability arose,which is this Core.Box thing.These are always bad;they are essentially containersthat can hold values of any type,hence the type-instability that ariseswhen accessing the contents.In this particular case,Core.getfield(#self#, :val) indicatesval is a variablethat was captured by the anonymous function.

Once we determine what caused the type-instability,the solution varies on a case-by-case basis.Some potential solutions may include:

  • Ensure different branches of an if statementreturn data of the same type.
  • Add a type annotation to help out inference.For example,
    x = Any[1]y = do_something(x[1]::Int)
  • Make sure a container type has a concrete element type.For example, x = Int[], not x = [].
  • Avoid loops over heterogeneous Tuples.
  • Use let blocks to define closures.(See this section of the Julia manual for more details.)

We’ll use this last solution in our example.The anonymous function in questionis defined by the do block in _process.So, let’s fix the issue of the captured variable val:

module Corrected# All other code is the same as in module `Original`.function _process(::Alg1, data)    val = data[1]    if val < 0        val = -val    end    f = let val = val        d -> process_item(d, val)    end    result = map(f, data)    return resultend# All other code is the same as in module `Original`.end

Now let’s see what @snoop_inference says:

julia> using SnoopCompileCorejulia> tinf = @snoop_inference Corrected.process("alg1");julia> using SnoopCompilejulia> tinfInferenceTimingNode: 0.113669/0.183888 on Core.Compiler.Timings.ROOT() with 1 direct children

There’s just one direct child.Hooray, type-stability!

Let’s see how performance compares:

julia> using BenchmarkToolsjulia> @btime Original.process("alg1");  220.506 ns (16 allocations: 496 bytes)julia> @btime Corrected.process("alg1");  51.104 ns (8 allocations: 288 bytes)

Awesome, the improved code is ~4 times faster!

Summary

In this post,we learned about type-stabilityand how type-instabilities affect compilationand runtime performance.We also walked through an examplethat demonstrated how to use SnoopCompile.jl and Cthulhu.jlto pinpoint the sources of type-instability in a program.Even though the example in this postwas a relatively easy fix,the principles discussed apply to more complicated programs as well.And, of course,check out the documentation for SnoopCompile.jl and Cthulhu.jlfor further examples to bolster your understanding.

Do you have type-instabilities that plague your Julia code?Contact us, and we can help you out!

Additional Links

]]>

Julia ♥ ABM #1: Starting from scratch

By: Frederik Banning

Re-posted from: https://forem.julialang.org/fbanning/issue-1-starting-from-scratch-1ibo

Let’s start from scratch. From this point onward, I’ll assume you’re already familiar with both agent-based modelling as a method[1] as well as basic familiarity with Julia as a programming language, i.e. you have it installed and understand the fundamentals of its syntax[2]. This series on agent-based modelling in Julia will be structured somewhat analogously to the regular modelling process that every agent-based modeller knows in some form or another: We start from the description of a situation, proceed with formulating a research question, and then identify relevant aspects. Afterwards we will formalise them as agent variables and model parameters and then put them into code.

You might wonder why we don’t immediately go to the coding part and just make some exemplary statements about agent behaviour and model evolution. Surely, that would be easier to just explain things, right? While certainly true at its core, I think there are two important points in favour of the approach chosen for this series:

  1. It’s harder to remember facts than it is to remember stories. Providing yet another bunch of blogposts about the technical details of Julia will likely just lead to readers skipping to the parts that they are currently having problems with. It would be somewhat redundant with well-written documentation. Instead, telling a story over the course of this series will hopefully increase the amount of things you can remember in the long run.[3]
  2. Modelling is always a subjective process and depends heavily on the choices made by the modeller. It is only an opinionated representation of the real world and its interdependencies. What I will present to you is just my interpretation of the situation. If you want to do things differently, you’re fully able to do so.

Model background

A day in the life

Our starting point will be a very simple example of two agents with heterogeneous properties that interact with each other on a regular basis. We will extend on this initial description over the course of the series, introducing new aspects, and making assumptions about how the world that they live in works.

Somehow we need to differentiate between our two agents, so we give them names: Sally and Richard. Their names, however, aren’t their only distinctive features. Humans are very diverse, hence we will proceed with a short description of each of them.

Richard is 33 years old and loves his job as a fisherman. Very early in the morning he happily rows out with his boat for a few hours of line fishing. Some days he’s lucky and catches multiple fish per hour (sometimes even up to three) while on other days it seems as if he’s cursed by Glaucus and the fish just won’t bite. In the end this means that Richard’s daily fishing efforts can take anything between one and five hours depending on his luck. After getting back home, he proceeds with different tasks for another one to three hours. Sometimes he takes care of maintenance work on his boat or fishing rod. When he doesn’t need to repair anything, he reads up on newly published reports regarding the overfishing of the seas and which types of fish can still be caught with good conscience. His fishing work is intense but he also has a decent amount of freely assignable time over the day which is less stressful and energy-sapping. In the evening he likes to knit sweaters and listen to records of his favourite Death Metal band.

Sally is 56 years old and loves her job as a baker. However, she has to deal with it all day, every day. It is a bit tiresome to get up early, prepare the dough, heat the oven, and so on but it allows her to produce a steady output of bread every day with the only limiting factor being the size of her oven which can fit up to 20 loaves at once. She has to do these steps, no matter how much bread she wants to produce, thus, her overall workload only changes slightly each day and her working times regularly end up between eight and ten hours, depending linearily on the amount of bread she bakes. When her work day is over, she likes to go trailrunning in the nearby mountains or to just enjoy having a Piña Colada in front of favourite bar (owned by Willy, a retired Barista from New York) near the oceanside.

As time goes by and they go about their work, their stocks in bread or fish increase and also decrease because they need to eat. In the short run, we may want to assume that one can live on bread and fish alone. But to be quite honest, nobody would like to eat only bread or fish every day. So both Sally and Richard have a deep desire to get some of the goods that only the other person produces.

Simply put, they have a regularly occurring need for both bread and fish that has to be satisfied. If this need is neglected over a longer period of time, their work productivity will decline. To avoid this undesirable situation, they take the opportunity to trade with each other every three days whenever they meet at the ocean side for a nightcap. When they do so, Richard trades with Sally for a few loaves of bread and she gets a few fish from Richard in return. As a starting point, let us assume that one fish is worth the same to them as one loaf of bread, i.e. they are willing to trade their goods in a 1:1 ratio, probably just because they know and like each other.

Question? Which question?

Although it is nice to just start coding and see whatever comes out of our endeavours, an agent-based model should aim to answer a specific question. We will now formulate one for the purpose of this exercise:

What is the minimum amount of work that Sally and Richard can do every day while still being able to satisfy their craving for fish and bread through mutual trade?

Given this question, we can now try to identify the aspects from the description above that are relevant to our planned ABM. Maybe first give this a try yourself before reading on. Which pieces of the provided information are interesting for us? How many agents will our model be comprised of? What are important environmental factors that have to be reflected in the model? Which information is incomplete and requires us to make assumptions?

One interpretation of the model’s background described above could look as follows:

  • Our model will contain two agents, Sally and Richard. What they do in their free time is unimportant for our base model for now and only their jobs and their working times are relevant.
  • How many hours they work per day determines the amount of food they need to eat each day.
    • Sally works 8-10 hours each day (medium exertion) and can freely decide to bake 1-20 loaves of bread each day.
    • Richard goes fishing for 1-5 hours (high exertion) and does other work for 1-3 hours (low exertion). During his fishing hours, Richard has an independent chance to catch 0-3 fish each hour.
  • Both agents have variables tracking their stock of fish and bread as well as their hunger for each good.
  • If their aggregate hunger level stays above a certain threshold for a prolonged period of time, agents reduce their work efforts, thus producing less bread and spending less time on fishing.
  • There is an opportunity at the end of every third day to trade their current stocks in fish and bread with each other.
  • Most of what are about processes and do not need to be stored as agent variables. We can therefore identify five distinct agent variables to keep track of: job, pantry_fish, pantry_bread, hunger_fish, hunger_bread.

Wait, wasn’t this supposed to be a Julia tutorial?

Phew. Those were a lot of words about plenty of things and so far not a single thought was wasted on actual code. To avoid losing some precious readers, this is probably as good a time as any to finally start talking about Julia.

A few preparations

Feel free to create a new folder for this series to code along to the examples (call it whatever you want, e.g. “julia-loves-abm”). Open your terminal, navigate to this folder and start Julia from there by running julia. Now execute the following:

julia> using Pkg

julia> Pkg.activate(".")
  Activating new project at `~/Code/julia-loves-abm`

Congratulations, you’ve just mastered the highly sought after skill of creating a fresh Julia project environment with the same name as the folder you’ve started the Julia instance from. You should activate this environment every time you continue working on this project as it will allow Julia to know which packages you have installed and which dependencies or versions should be respected.

As is so often the case in programming, there’s also another way to do the same thing. If you just type ] in the julia> prompt you will enter the built-in pkg> mode:

(@v1.7) pkg> 

This is very easy and approachable as you do not need to run using Pkg before doing this. The pkg> mode is just always available to you. It allows you to quickly use some commands like activate . (to change environment where dot refers to the current working directory) and status (to check installed packages and their versions):

(@v1.7) pkg> activate .
  Activating project at `~/Code/julia-loves-abm`

(julia-loves-abm) pkg> status
      Status `~/Code/julia-loves-abm/Project.toml` (empty project)

As you can see, we’ve switched from the base environment @v1.7 to a newly created one which is automatically assigned the name of our current working directory julia-loves-abm. The status command tells us that the project environment is currently empty, meaning that we haven’t added any extra functionality through Julia packages. For now, you can safely ignore most of the details about environments but just keep in mind that they exist as they will be of great use to us at a later stage.

Creating agents

Let’s remember our story from above. We have two people, so it seems straightforward to initialise two variables called sally and richard that represent them.[4] A little bit earlier we’ve looked at the relevant aspects of their everyday lives, telling us what to formalise as agent variables. There are a few unifying features about them, for example they each have a job which allows them to produce a certain kind of food (fish or bread respectively). Since it’s important for what happens in the model, it needs to be reflected in the code which we could simply do by using a String (a sequence of characters like letters and whitespace) describing their job:

julia> sally = "Baker"
"Baker"

julia> richard = "Fisher"
"Fisher"

Now that’s already an (admittedly pretty crude) representation of what our two agents are. Whenever we call one of the variables, its evaluation will tell us the agent’s job. But we also want to keep track of their stock of food and their hunger levels. This confronts us with a decision about a more appropriate data structure to use for storing all the different kinds of information about Richard and Sally.

Lined up

Maybe the simplest approach would be to use a simple collection like an Array or a Tuple with the values of the agent variables in it. Let’s assume that Richard and Sally are both not hungry in the beginning of our simulation and that they each have a starting stock of 10 fish as well as 10 loaves of bread.

julia> sally = ("Baker", 10, 10, 0, 0)
("Baker", 10, 10, 0, 0)

julia> richard = ("Fisher", 10, 10, 0, 0)
("Fisher", 10, 10, 0, 0)

It becomes immediately obvious that this approach is not very practical. While we can reason about what "Fisher" and "Baker" stands for, it is pretty hard to know what exactly the numbers mean without having the verbal description from above at hand.

Give me names

Indeed, it would be nice if we could label all the entries so that it is clear what they mean. We might want to opt for a NamedTuple instead which allows to provide names to the fields.

julia> sally = (job = "Baker", pantry_fish = 10, pantry_bread = 10, hunger_fish = 0, hunger_bread = 0)
(job = "Baker", pantry_fish = 10, pantry_bread = 10, hunger_fish = 0, hunger_bread = 0)

julia> richard = (job = "Fisher", pantry_fish = 10, pantry_bread = 10, hunger_fish = 0, hunger_bread = 0)
(job = "Fisher", pantry_fish = 10, pantry_bread = 10, hunger_fish = 0, hunger_bread = 0)

Ah, much better. Now we don’t have to remember the order of the data in the Tuple but can easily access the agent data by fieldnames:

julia> sally.job
"Baker"

julia> richard.pantry_bread
10

Again, sally.job is a String like in our initial approach of just assigning a string literal to each agent which described their job. However, richard.pantry_bread is also easily accessible now and shows us that he currently owns 10 loaves of bread.

To see all the fieldnames and the types of their values, we again call the typeof function on one of the agents:

julia> typeof(richard)
NamedTuple{(:job, :pantry_fish, :pantry_bread, :hunger_fish, :hunger_bread), Tuple{String, Int64, Int64, Int64, Int64}}

This tells us that richard is now depicted as a NamedTuple comprised of the fields (:job, :pantry_fish, :pantry_bread, :hunger_fish, :hunger_bread) and their values Tuple{String, Int64, Int64, Int64, Int64}. It is fundamentally the same as in the Tuple case before but enhanced with the information about which value means what.

Change is inevitable

But as we know, data in ABMs regularly change over the course of the simulation. And since a NamedTuple is immutable[5] by nature, this seems to be a really bad choice as a data structure for our agents. If we attempt to change one of the values, we get an error:

julia> sally.pantry_bread = 9
ERROR: setfield!: immutable struct of type NamedTuple cannot be changed
Stacktrace:
 [1] setproperty!(x::NamedTuple{(:job, :pantry_fish, :pantry_bread, :hunger_fish, :hunger_bread), Tuple{String, Int64, Int64, Int64, Int64}}, f::Symbol, v::Int64)
   @ Base ./Base.jl:43
 [2] top-level scope
   @ REPL[37]:1

So to change the value of any of the fields, we would need to reuse some of the old values and insert the new value in the appropriate field.

julia> sally = (sally..., pantry_bread = 9)
(job = "Baker", pantry_fish = 10, pantry_bread = 9, hunger_fish = 0, hunger_bread = 0)

While this approach does have its advantages (e.g. no accidental changes in agent-related data), it can quickly get a bit cumbersome to always overwrite the old variable with a new one. So it’s probably better to completely ditch the idea of using NamedTuples then.

The next best idea that might come to mind would be to use a Dict instead:

julia> sally = Dict(
           :job => "Baker", 
           :pantry_fish => 10, 
           :pantry_bread => 10, 
           :hunger_fish => 0, 
           :hunger_bread => 0
       )
Dict{Symbol, Any} with 5 entries:
  :pantry_fish   => 10
  :hunger_bread => 0
  :job          => "Baker"
  :hunger_fish  => 0
  :pantry_bread  => 10

julia> richard = Dict(
           :job => "Fisher", 
           :pantry_fish => 10, 
           :pantry_bread => 10, 
           :hunger_fish => 0, 
           :hunger_bread => 0
       )
Dict{Symbol, Any} with 5 entries:
  :pantry_fish   => 10
  :hunger_bread => 0
  :job          => "Fisher"
  :hunger_fish  => 0
  :pantry_bread  => 10

The keys and values of dictionaries can be comprised of just about any type that you can think of. Hence you have to take care to use Symbols as keys of the dictionary (or maybe Strings, if you prefer that), because unlike NamedTuples, Dicts don’t just automatically convert the fieldnames into Symbols (which always start with a colon :).

To retrieve data from our agents, we use the regular syntax for dictionaries:

julia> sally[:job]
"Baker"

julia> richard[:pantry_bread]
10

Changing the value of an agent variable is now as easy as writing:

julia> sally[:pantry_bread] -= 1
9

julia> sally
Dict{Symbol, Any} with 5 entries:
  :pantry_fish   => 10
  :hunger_bread => 0
  :job          => "Baker"
  :hunger_fish  => 0
  :pantry_bread  => 9

To see the current keys of a dictionary, you can just start typing the name of the dictionary followed by [: and then press Tab twice[6]:

julia> sally[:
:hunger_bread :hunger_fish   :job           :pantry_bread   :pantry_fish

Again, there are often multiple ways to do the same thing when coding and none of them is more or less correct than the other. Another way to retrieve the current set of keys of a dictionary is to call the keys function on it:

julia> keys(sally)
KeySet for a Dict{Symbol, Any} with 5 entries. Keys:
  :pantry_fish
  :hunger_bread
  :job
  :hunger_fish
  :pantry_bread

While Tab completion is a nice way to interactively explore the current state of your agents, having a KeySet also allows you to go through the agent variables one after another in a programmatic way. Which of these approaches you will choose heavily depends on the current use case you are facing. Generally though, it’s just good to have some options available.

Work smart, not hard

Luckily we currently only have two agents in our model, so it’s not really that problematic to create them one by one. In bigger models, however, we often want to create a relatively high number of agents and that could then quickly get a bit tedious to do one by one. Here’s a general word of advice about coding:

If you have to write something repeatedly, there’s probably a better way to do it. 🙂

Indeed, we can build a custom type for our agents, allowing us to predefine the structure of the data that we want to store. So instead of having to spell out the agent variables each and every time we want to create a new agent, we can just tell Julia to use our custom struct to lay out and label the data that we provide to it. The keyword to create such a data structure (also referred to as a composite type) is struct but we also have to prepend it with mutable to make sure that we are able to change its values (see the problem about immutability described above).

julia> mutable struct Agent
           job::String
           pantry_fish::Int
           pantry_bread::Int
           hunger_fish::Int
           hunger_bread::Int
       end

As you can see, we also had to explicitly define the types for each field of the struct as these are not automatically inferred like when creating a NamedTuple or a Dict. Without going into detail about the variety of types, we just use what we already know. From our previous attempt to create our agents as NamedTuples, we could see that the values we provided to it have been interpreted as String and Int64 types. In the definition of the struct above, we’ve simply used this knowledge and also changed Int64 for the more generalised form Int.[7]

We can now initialise Richard and Sally as two variables of our custom-made and highly specific Agent type:

julia> richard = Agent("Fisher",  10, 10, 0, 0)
Agent("Fisher", 10, 10, 0, 0)

julia> sally   = Agent( "Baker",  10, 10, 0, 0)
Agent("Baker", 10, 10, 0, 0)

This newly created type also allows us to directly access the agent variables. We can do this just like we did it in the case of a NamedTuple:

julia> richard.pantry_fish
10

julia> sally.pantry_bread
10

Changing the values is also possible and just as easy as it was in the case of using a Dict:

julia> richard.pantry_fish -= 1
9

julia> richard
Agent("Fisher", 9, 10, 0, 0)

One of the major downsides of defining our own composite type is that we have to restart our Julia session to change anything about it. Say we would like to add a new field to our agent struct that describes in one convenient number how satisfied they currently are with their life:

julia> mutable struct Agent
           job::String
           pantry_fish::Int
           pantry_bread::Int
           hunger_fish::Int
           hunger_bread::Int
           life_satisfaction::Int
       end
ERROR: invalid redefinition of constant Agent
Stacktrace:
 [1] top-level scope
   @ REPL[2]:1

This might seem inconvenient at first glance but in reality it doesn’t happen all too often. Indeed, we have been smart modellers and took enough time to first deliberate about what we actually want to model and which agent variables are important for answering the underlying question of our model.

Great! Now that we’ve settled on a convenient way how to represent our agents in code, our next step will be dealing with the tasks that our agents do every day and how they affect the filling of their pantries and their hunger levels.

[1]: Explaining agent-based modelling in detail is way out of the scope of this series. If you are unfamiliar with the method but generally interested in learning more about it, you may want to start reading this text book or this introductory article or watch this video series by Complexity Explorer.

[2]: Should you find yourself wondering how to install Julia, it’s really as simple as downloading it from the Julia language website and running the installer. There are also other ways to setup your Julia installation but the aforementioned method works just as fine as anything else. Just to provide an example, my preferred way is Juliaup as a platform-independent tool to manage multiple Julia versions and keep them up to date. Once you’re all set up, you might want to check out the Getting Started section of the official Julia documentation or follow an introductory video course to learn about syntax and basic usage. Done that? Let’s get back to ABM stuff then. 🙂

[3]: I sincerely believe that teaching by telling a simple story is very approachable for most people. Of course there’s room for everything on the world wide web and no approach to learning is inherently better or worse than any other. It very much depends on your preferred style of learning, your previous knowledge, and maybe even your current state of mood. For example, if you already know how to work with Julia and are already very knowledgeable in the arcane arts of agent-based modelling, you might as well just skip this introductory series and go straight to the documentation of Agents.jl and read/work through the well-written tutorial and examples.

[4]: It’s the Julian way to use lower case names with underscores for our variables. This is often referred to as snake_case. There are some more style recommendations that established themselves over time in the Julia community which we will try to adhere to as closely as possible. If you want to read up on the idiomatic Julia coding style, have a look at the official Julia style guide. In the end, however, it doesn’t matter too much as long as you don’t have to share your code with others. In the latter case it is highly recommended to try and stick to a unified coding style as it allows others (not only colleagues but maybe even strangers at some point) to more easily read and understand your code and potentially comment on it, extend it, fix it, et cetera.

[5]: Immutability means that a variable cannot be changed after its creation. This applies to both its value(s) and its composition. Here’s the exemplary case of trying to modify one of the elements in a Tuple and attempting to add a new element to it:

julia> t = (1,2,3)
(1, 2, 3)

julia> t[1] = 1
ERROR: MethodError: no method matching setindex!(::Tuple{Int64, Int64, Int64}, ::Int64, ::Int64)
Stacktrace:
[1] top-level scope
@ REPL[74]:1

julia> t[end+1] = t[end] + 1
ERROR: MethodError: no method matching setindex!(::Tuple{Int64, Int64, Int64}, ::Int64, ::Int64)
Stacktrace:
[1] top-level scope
@ REPL[73]:1

However, this does not mean that we cannot redefine the variable t to refer to something else:

julia> t = 1
1

[6]: This Tab completion also works with a lot of other things, for example NamedTuples. Just write its variable name followed by a single dot:

julia> nt = (a = 1, b = 2)
(a = 1, b = 2)

julia> nt.
a b

[7]: Although definitely not necessary at this stage, you might be interested in what all these types mean. Let’s dive a bit deeper. You might wonder why it is called Int64 and not just Number, Real or Integer. Simply put, every Integer is a Real but not every Real is an Integer. Julia provides us with an easy way to find out about this type hierarchy:

julia> supertypes(Integer)
(Integer, Real, Number, Any)

To go up through the hierarchy, you can read this tuple of types from left to right. If you want to explore it in the opposite direction, there’s also a way to do this:

julia> subtypes(Real)
4-element Vector{Any}:
AbstractFloat
AbstractIrrational
Integer
Rational

As you can see, Integer is necessarily a subtype of Real. We can also programmatically test this with a specific syntax in Julia by writing:

julia> Integer <: Real
true

Now an Int64 is a specific subtype of a signed Integer number with a size of 64 bit.

julia> subtypes(Integer)
3-element Vector{Any}:
Bool
Signed
Unsigned

Being Signed means that the Integer uses a bit of memory to store its mathematical sign. This means the resulting number can take both positive and negative values.

julia> subtypes(Signed)
6-element Vector{Any}:
BigInt
Int128
Int16
Int32
Int64
Int8

There’s no general type called Int in here but only types with predetermined size, e.g. 8 or 64 bit. If we use Int as a type for our Agent struct, Julia asserts that we want the possible size of the Int to be as big as possible. Thus, it is automatically determined by the underlying architecture of our computer (most modern computers are built on 64 bit). When we create an instance of our Agent struct, those fields typed as Int will indeed be of type Int64. This is nice to take into account for the hypothetical case of somebody with a 32 bit computer trying to run our model which is then possible precisely because we didn’t restrict the size of the Int too much (e.g. to always use Int64).

And while all of this is actually very interesting, we luckily don’t have to worry about it in greater detail for now. If you still want to read more about Julia’s type system, have a look at the well-written section of the official docs. Let’s get back to work on our ABM. 🙂