How can I avoid using global variables that will vary their values?

I am using Julia to solve a quantum system with a self-consistence iteration algorithm. In my current code, many global variables and arrays are used which, from many documents, are harmful to the performance. However, I don’t know how to avoid this. I post the structure of my code here and wonder how to improve it.

Part 1:
I will define some parameters that describe the system and calculate something from them. These things are global. They will be useful and stay unchanged in Part 2 but will be changed in Part 3.

# define some parameters that will be constant
const m = 1.1 # electron mass
const ManyOtherParameters = ...

# define some parameters that will be changed
B = 2.2 # magnetic field
d = 3.3 # some meaningful distance
ManyOtherThings = ...

# calculate some useful things from magnetic field and other things
# This step consumes a lot of time
function CalculateArray(Mag = B, Dis = d)
    ...
    # calculation
    ...

    return Array
end
A = CalculateArray(B, d)

# an interface to change magnetic field and other parameters that will be useful in Part 3
function ChangeParameters(; Mag = B, Dis = d, OtherThings...)
    global B = Mag
    global d = Dis
    ...
    global A = CalculateArray(B, d)
end

Part 2:
I will solve the system using the global variables in Part 1.

# one step of iteration
function Solve_OneStep(initialSolution = 0.0)
    ...
    # use B, d, A and initialSolution to find out renewedSolution
    ...
    return renewedSolution
end

# Solve the system
function Solve(initialSolution = 0.0; ControlParameters...)
    solution = initialSolution 
    for i in 1:IterationTimes # for example, 1000
        ...
        # iteration
        ...
    end
    return solution
end

Part 3:
I will vary the system parameters and see how the result changes. For example, I will change the value of these global variables, solve it, and record the solution.

using CairoMakie

# set the parameters first
ChangeParameters(Mag = 0.5, Dis = 2.0, OtherThings...)

# the varying range for magnetic field
B_range = 0. : 0.1 : 20.
results = similar(B_range, Float64)

# solve the system for each magnetic fields
for i in eachindex(B_range)
    ChangeParameters(Mag = B_range[i])
    results[i] = Solve()
end

# plot the results
lines(B_range, results)

Try this. Pack all your global variables into an instance parameters of a mutable struct MyParameters.

mutable struct MyParameters
    b::Float64
    d::Float64
end

const parameters = MyParameters(2.2, 3.3) # the memory address is constant but the values of `b` and `d` can be changed.

Any function that uses these parameters needs to take the above struct as an additional argument. For example, instead of
function solve_one_step(initial_solution = 0.0)
define it as
function solve_one_step(parameters::MyParameters, initial_solution = 0.0)
so that the function can access, for example, parameters.b.

Then you can change parameters by mutating the struct,

function change_parameters!(parameters::MyParameters, mag, dis) # mutating functions end with `!` by convention
    parameters.b = mag
    parameters.d = dis
end

Now always pass parameters as an argument when you call these functions.

P.S. Alternatively, pack your parameters into a normal (non-mutable) struct MyParameters. First compute with parameters1::MyParameters, and when you need to compute with a different set of parameters, compute again with parameters2::MyParameters, i.e. instead of changing the values in parameters1.

Thanks for your suggestion. But should I also include my global array A in this struct?

Don’t modify A in your function. Change it in the calling code.

And functions that read A should accept it in the function parameters.

Also consider using @kwdef - this significantly will ease default initialization.

Julia v.1.8 introduced non-constant typed globals
(Julia 1.8 Highlights). If you declare the types of your non-constant globals there’s a good chance that they won’t give give you a performance hit.

That’s cool. Thanks.

That does help.

I do not really understand. The array is still global in your code. Do you mean changing a global array globally is better than changing it in a function? Also, I see other people saying that passing a global array to functions does not help much in performance because Julia passes arrays by referring. Is that true?

Thanks for all advice. But I am still confused about the global array A.

Actually, the value of elements of it is used more frequently in the following solving functions so it is more important. And because calculating it costs time, I choose to store it in a global array instead of calculating it in the functions that need it.

The element type and number of dimensions of A are fixed, but the sizes of each dimension may vary with different magnetic fields.

Can I put it in the struct that stores all the parameters? Can I simply claim it with the keyword const? Just leaving it global and passing it as an argument to the functions that use it?

Yes, you can put the array in a struct. My suggestion was packing all global variables (arrays or not) into a struct.

Summarizing what people said: You can do something like this:

# system definition with default values
julia> @kwdef struct System{T}
           m::T = 1.1 # electron mass
           B::T = 2.2 # magnetic field
           d::T = 3.3 # some meaningful distance
           # ... other properties
       end

# Function that calculates something
julia> function calculate_something(system::System)
           # unpack (for convevienence, otherwise use system.m
           (; m, B, d) = system
           return m * B * d
       end
calculate_someting (generic function with 1 method)

# Initialize the system with the properties, potentially changing something
julia> system = System(d = 5.3)
System{Float64}(1.1, 2.2, 5.3)

julia> calculate_something(system)
12.826000000000002

Now for a different set of properties you initialize a new system. This is the most idiomatic way, probably. And you don’t use global variables at all inside your functions. Everything will easier to understand and debug.

Your System struct can contain output variables as well. If they are arrays you can just add them there, because arrays are mutable. But one generally separates the properties of the input from the properties of the output. I would keep System as the input of the calculation, and keep it immutable.

ps: If you really need your system to be mutable, that is, you want to be able to redefine one of a few variables from a system that is very different from the default, make it a mutable struct. Or, alternatively, you can use Accessors and create a new system by copying the system you have, changing something specific:

julia> using Accessors

julia> system2 = @set system.d = 1.0
System{Float64}(1.1, 2.2, 1.0)
3 Likes

The important part is whether the variable is global, not the value that the variable is bound to. The compiler can’t generate fast code for non-constant global variables. If code in a function operates on a global variable then the function can’t be fast. When you pass the array to the function the array is bound to a local variable in the function and the compiler can usually generate fast code. (You still must avoid type instability in the function)

A good advice (not only in Julia) is: try to avoid referencing global variables always. There are not many cases where you really need to use them (really). Make all function self-contained, meaning, variables inside that function are either received as parameters (packed into structs, tuples, or not), or defined inside the function.

That will avoid these performance traps and, importantly, make your code much easier to debug, optimize, etc, because you will be able to test and develop each function independently from the rest of the code, just providing a sensible input data to it.

3 Likes

Emphasizing @lmiq’s advice: https://dl.acm.org/doi/10.1145/953353.953355 https://www.baeldung.com/cs/global-variables

2 Likes