Efficiently passing a set of constants to a function

I am using physical constants in a function in a package. The access to these constants should be fast, best the compiler could replace the values in the lower-level-code.

I can actually achieve this by using global constants in a module. However, I want to allow the user to experiment with those “constants” when called in a setting that I did not envison yet. E.g using a modified gravitational constant when applying functions on the moon instead of the earth.

I came up with a solution using Modules, calleble Singletons and Value Types so that by passing a different singleton, the constant can be drawn from a different module:

module mymod
    const g = 9.81
end

module mymod2
    const g = 9.81/6
end


struct Constants1 end
(c::Constants1)(::Val{s}) where s = getproperty(mymod, s)
cst1 = Constants1()
f1(::Val{cst}) where cst = 2*cst(Val(:g))
f1(Val(cst1))
@code_llvm f1(Val(cst1))

struct Constants2 end
# this callable queries a different module
(c::Constants2)(::Val{s}) where s = getproperty(mymod2, s)
cst3 = Constants2()
f1(Val(cst3))
@code_llvm f1(Val(cst3))
define double @julia_f1_2475() #0 {
top:
  ret double 3.270000e+00
}

I think value types are ok here, because there will be only very few different configurations of constants that need to be passed around, and I want the compiler to use a different thing, i.e. replacement, for different constants.

Passing around module names directly did not work, because I cannot combine them with Value Types.

However, with the solution above, when I put the definitions of the constants into a submodule BigleafConstantsDef of my package module (package named Bigleaf.jl) then the generated llvm code is not any more compact:

struct ConstantsB end
(c::ConstantsB)(::Val{s}) where s = getproperty(Bigleaf.BigleafConstantsDef, s)
cstB = ConstantsB()
cstB(Val(:g))
f1(Val(cstB))
@code_llvm f1(Val(cstB))
;  @ /User/homes/twutz/julia/dev/Bigleaf/inst/learn_constants.jl:16 within `f1`
define nonnull {}* @julia_f1_3340() #0 {
top:
  %0 = alloca [2 x {}*], align 8
  %gcframe2 = alloca [3 x {}*], align 16
  %gcframe2.sub = getelementptr inbounds [3 x {}*], [3 x {}*]* %gcframe2, i64 0, i64 0
  %.sub = getelementptr inbounds [2 x {}*], [2 x {}*]* %0, i64 0, i64 0
  %1 = bitcast [3 x {}*]* %gcframe2 to i8*
  call void @llvm.memset.p0i8.i32(i8* nonnull align 16 dereferenceable(24) %1, i8 0, i32 24, i1 false)
  %thread_ptr = call i8* asm "movq %fs:0, $0", "=r"() #3
  %ppgcstack_i8 = getelementptr i8, i8* %thread_ptr, i64 -8
  %ppgcstack = bitcast i8* %ppgcstack_i8 to {}****
  %pgcstack = load {}***, {}**** %ppgcstack, align 8
; ┌ @ /User/homes/twutz/julia/dev/Bigleaf/inst/learn_constants.jl:27 within `ConstantsB`
; │┌ @ Base.jl:35 within `getproperty`
    %2 = bitcast [3 x {}*]* %gcframe2 to i64*
    store i64 4, i64* %2, align 16
    %3 = getelementptr inbounds [3 x {}*], [3 x {}*]* %gcframe2, i64 0, i64 1
    %4 = bitcast {}** %3 to {}***
    %5 = load {}**, {}*** %pgcstack, align 8
    store {}** %5, {}*** %4, align 8
    %6 = bitcast {}*** %pgcstack to {}***
    store {}** %gcframe2.sub, {}*** %6, align 8
    %7 = load atomic {}*, {}** inttoptr (i64 140076403027704 to {}**) unordered, align 8
    %8 = getelementptr inbounds [3 x {}*], [3 x {}*]* %gcframe2, i64 0, i64 2
    store {}* %7, {}** %8, align 16
; └└
  store {}* inttoptr (i64 140078443561120 to {}*), {}** %.sub, align 8
  %9 = getelementptr inbounds [2 x {}*], [2 x {}*]* %0, i64 0, i64 1
  store {}* %7, {}** %9, align 8
  %10 = call nonnull {}* @jl_apply_generic({}* inttoptr (i64 140078264376576 to {}*), {}** nonnull %.sub, i32 2)
  %11 = load {}*, {}** %3, align 8
  %12 = bitcast {}*** %pgcstack to {}**
  store {}* %11, {}** %12, align 8
  ret {}* %10
}

Why does this work with modules defined in the same script, but not with modules defined in the package?

Are there better ways of approaching the “configurable” constants problem?

I think the most basic solution would be:

struct MyConstants
  g::Float64
end

f1(params::MyConstants) = 2 * params.g

const earth = MyConstants(9.81)
const moon = MyConstants(1.625)

This has many advantages, first and foremost that it’s completely obvious what’s going on.

It’s true that there might be some opportunities to move more of that data into compile time, but I would be reluctant to do that until I had actual benchmarks to prove it was necessary and helpful. Have you tried a simple solution like this, and do you have benchmark data to show that it’s insufficient?

8 Likes

Thanks @rdeits for reminding me on first locating bottlenecks before optimizing. I have not yet done benchmarks. The current implementation uses a function returning a Dictionary which currently does not impart performance issues.

The proposed solution goes along the lines of Parameters.jl, which in addition provides a keyword based constructor and default values. However, I thought there could be a more effective way in Julia for providing constants.

The overhead maybe small, but potentially occurs very often. Many functions are intented to have default keyword argument constants = MyConstants() so that the typical use-case does not need to care for these constants. If the struct has fields, i.e. is no singleton type, then on every usual function call (without explicitly overriding the default), an object is created.

The solution inspired by your answer is using constants = earth as the default and provide a single module-global instance earth.

Typically one would use a structure which contains the constants, such as:

struct PlanetarySystem{V}
   coordinates::V
   g::Float64
   etc...
end

and that would be object being passed around, to the functions like

compute_forces(s::PlanetarySystem) = ...

such that the constants are defined only once. That would give the user full flexibility in defining the constants. (ps: if you are inspecting llvm code you are probably a much better programmer than me, but maybe something in this notebook serves as inspiration: Particle simulations with Julia).

1 Like

Juli (v"1.7.0-rc3") awaits again a postivie surpise to me:
In the package:

@with_kw struct BigleafConstants 
  g          = 9.81            # gravitational acceleration (m s-2)
end
const bigleaf_constants = BigleafConstants()

Which is optimized best:

f2(;cst=bigleaf_constants) = 2 * cst.g
f2()
@code_llvm f2()
define double @julia_f2_1236() #0 {
top:
  ret double 1.962000e+01
}

I checked this before but forgot the const before bigleaf_constants - This made the difference.

1 Like

I cannot really read llvm code, but I recognize when the compiler succeeded in replacing the constant by a value.

1 Like

I’m not sure I understand exactly what you mean, but I doubt that any object needs to be created in most cases. The compiler would just replace the object with its contained value.

You can avoid this with a (const) global variable for your defaults:

julia> const earth = MyConstants(9.81)

julia> weight(mass, params=earth) = mass * params.g
weight (generic function with 2 methods)

julia> weight(2.0)
19.62

Note that the compiler is smart enough to stick the literal 9.81 for Earth’s gravity into the generated code:

julia> @code_llvm weight(2.0)
...
   %1 = fmul double %0, 9.810000e+00
...
}
1 Like

Does that mean that the compiler specialized to the value of the argument? If the same function is called with a different argument a new method will be compiled?

Edit: ah, no, with a single parameter the function will use one method. But that won’t behave like that if the second parameter is given, am I wrong?

Yeah, I think that’s exactly right.

1 Like

Thanks @DNF: I was wrong again. Julia is smart enough to not create an object here, but use the value directly:

f3(;cst=BigleafConstants()) = 2 * cst.g  # note constructor call
@code_llvm f3()   # return statement of a single value

where BigleafConstants struct is defined in the package, as in my example above.

However, this is currently not optimized when the default argument is overridden:

f3(;cst=BigleafConstants()) = 2 * cst.g   # same as above
@code_llvm f3(cst=BigleafConstants())  # explicit computation of multiplication

Still amazing, how the Julia compiler optimizes the default case.

Not sure if it makes a difference, but can you try this with positional instead of keyword arguments? Kwargs are quite different from ‘regular’ arguments, and there are some pitfalls.

3 Likes

Another solution that I think hasn’t yet been suggested here would be to use small functions/methods that return the constants instead of (possibly const) variables that hold them.

Something along these lines:

julia> struct Earth end

julia> g(::Earth) = 9.81
g (generic function with 1 method)

julia> weight(mass ; context=Earth()) = mass*g(context)
weight (generic function with 1 method)

The default value of g is correctly inlined:

julia> @code_llvm weight(2)
define double @julia_weight_159(i64 signext %0) {
top:
  %1 = sitofp i64 %0 to double
  %2 = fmul double %1, 9.810000e+00
  ret double %2
}

Extending the system to override the default values is a simple matter of defining new methods:

julia> struct Moon end

julia> g(::Moon) = 1.625
g (generic function with 2 methods)

And again the value is correctly inlined:

julia> @code_llvm weight(2, context=Moon())
define double @"julia_weight##kw_189"(i64 signext %0) {
top:
  %1 = sitofp i64 %0 to double
  %2 = fmul double %1, 1.625000e+00
  ret double %2
}
3 Likes

Thanks @ffevotte.
Having a function for each constant is not very attractive. My real use case
involves dozens of constants.
However, your solution inspired another solution that works compiler-efficient also for keyword arguments with Julia 1.7.1.

I pass the singleton context and then get a specific class holding all the constants for that context. This solution is a bit more verbose because it requires the call inside each function body cst = bigleaf_constants(context).

using Parameters

struct DefaultBigleafContext end
@with_kw struct BigleafConstants{FT} 
    g::FT          = 9.81            # gravitational acceleration (m s-2)
end
bigleaf_constants() = BigleafConstants()
bigleaf_constants(::DefaultBigleafContext) = BigleafConstants()


struct MoonContext end
@with_kw struct BigleafConstantsMoon{FT} 
    g::FT          = 9.81/6            # gravitational acceleration (m s-2)
end
bigleaf_constants(::MoonContext) = BigleafConstantsMoon()
  

function f2(; context = DefaultBigleafContext()) 
    cst = bigleaf_constants(context)
    2 * cst.g
end
f2()
@code_llvm f2()

f2(;context=MoonContext())
@code_llvm f2(;context=MoonContext())  # now just returns a constant

Moving cst from keyword argument to the function body, and passing a Singleton to
the keyword argument makes a difference to the compiler.

Because of specifying constants directly instead of a context I will stick to solution of post 11 despite being not as compiler efficient with the non-standard case.

This proposal of gathering the constants into a struct would be acceptable and memory efficient if only a small set of constants is needed throughout a module?

Yes.

1 Like

I was trying to do something like what @bgctw asked but for fewer constants. I know that I can use Parameters.jl but I was wondering how I could write a simple solution with the limited knowledge I have using Julia. I got here:

mutable struct Constants
    a :: Float64
    b :: Float64
    c :: Float64
    d :: Float64
    Constants() = (K = new(); 
        K.a = 1.0; 
        K.b = 1.0; 
        K.c = sqrt(a * b) / 2; 
        K.d = 2 * c;
        return K)
end

This works, when I call the struct I can get its inner “constants”. I wanted to make things a little bit more tidy so I tried to specify all the types as mentioned in the docs under the subsection for parametric constructors:

mutable struct Constants{T<:Float64}
    b :: T
    a :: T
    c :: T
    d :: T
    Constants() = (K = new(); 
        K.a = 1.0; 
        K.b = 1.0; 
        K.c = sqrt(a * b) / 2; 
        K.d = 2 * c;
        return K)
end

But I got an error:

ERROR: syntax: too few type parameters specified in "new{...}" around REPL[6]:1

I am not trying to reinvent the wheel, I am just playing trying to understand how things work. I know this is not the main question but I think this could help someone else coming along the way.

So, how could this error be fixed if all the inner constants are of the same type without specifying the same type individually?

You need to make sure the compiler knows the type, one way to do that if you want the caller to supply the type could be

julia> mutable struct Constants{T<:Float64}
           b :: T
           a :: T
           c :: T
           d :: T
           function Constants{T}() where T
               K = new{T}()
               K.a = 1.0
               K.b = 1.0
               K.c = sqrt(K.a * K.b) / 2
               K.d = 2 * K.c
               return K
           end
       end

julia> Constants{Float64}()
Constants{Float64}(1.0, 1.0, 0.5, 1.0)

Though this will still only work for Float64 since you have {T<:Float64} in the struct definition, so to allow for other number types for example you could do {T<:Number}.

And if you want it to always be Float64 you could just do K=new{Float64}() in your original code.

1 Like

The size of the struct for which it makes sense to store the constants and pass it around is probably similar to the recommended size of StaticArrays, which is roughly 100 floats.

Greater than that it is probably better to store them in a mutable (heap allocated) object. Yet, internally, it would be bad to depend on the access of the heap every time a constant is required. In this case, probably it is better to put function barriers, and pass subsets of the constraints as scalars (or contained in smaller non-mutable structures, or tuples).

Something like:

julia> const Consts = Dict(:c => 1.0, :g => 9.8)
Dict{Symbol, Real} with 2 entries:
  :c => 1
  :g => 9.8

julia> function compute()
           c = Consts[:c]
           hot_part_of_the_code(c)
       end
compute (generic function with 1 method)

1 Like