Performance of global variables as default argument values

Is there a performance penalty for using non-const global variables as default argument values in functions? For example, consider the following snippet:

globalval = 10

function addxy(x, y=globalval)
    return x + y


Should globalval be declared const (as per the performance tips) for the compiler to best optimize code? Or are type annotations needed?

I find this case interesting because globalval is technically passed as an argument to the function, but the function behavior and return type could change depending on the type of globalval. I couldn’t tell from the documentation whether this required mitigation to the same level as if globalval was captured by addxy.

(Edit: I am by no means claiming this is a good coding pattern. It would still be good to understand what’s happening though.)

Yes, I think there will be a performance penalty, for exactly the reason you mention: having the default argument value be a global variable makes the 1-argument method of your function type-unstable:

julia> globalval = 10;

julia> addxy(x, y=globalval) = x+y;

julia> methods(addxy)
# 2 methods for generic function "addxy":
[1] addxy(x) in Main at REPL[3]:1    # this method is type-unstable (see below)
[2] addxy(x, y) in Main at REPL[3]:1 # this one is not

julia> @code_warntype addxy(10)
MethodInstance for addxy(::Int64)
  from addxy(x) in Main at REPL[3]:1
1 ─ %1 = (#self#)(x, Main.globalval)::Any
└──      return %1

Yes, that would be a way to avoid the type instability:

julia> const globalval2 = 11;

julia> addxy2(x, y=globalval2) = x+y;

julia> @code_warntype addxy2(10)
MethodInstance for addxy2(::Int64)
  from addxy2(x) in Main at REPL[7]:1
1 ─ %1 = (#self#)(x, Main.globalval2)::Int64
└──      return %1

If the default value can actually change (in type and/or value), another possibility could be to use a small helper function to define the default value:

julia> defaultval() = 12;

julia> addxy3(x, y=defaultval()) = x+y;

julia> @code_warntype addxy3(10)
MethodInstance for addxy3(::Int64)
  from addxy3(x) in Main at REPL[10]:1
1 ─ %1 = Main.defaultval()::Core.Const(12)
│   %2 = (#self#)(x, %1)::Int64
└──      return %2

You could also restrict the type in the function argument:

addxy4(x, y::Int=globalval) = x + y

This is equivalent to

addxy4(x, y::Int) = x + y
addxy4(x) = addxy4(x, globalval)

which will still have the overhead of a dynamic lookup of the type of globalval — it has to decide at runtime whether to call addxy4(x, y::Int) or throw a method error.

So it’s still better to make globalval a const (or a typed global in Julia 1.8).


Yes, thanks for pointing this out.

Thanks for this information!

Just to clarify, is the main issue here type instability? Or is it the dynamic lookup of the type of globalval as @stevengj mentioned?

Ignoring globalval, the function addxy(x,y) seems to be type stable–the output type only depends on the types of x and y, and is independent of the values of x and y. The main issue then seems to be looking up the type of globalval at runtime.

I think we’re actually speaking of the same thing

Let me try and be clearer this time: you’re right that there is no type-stability issue with the 2-argument addxy(x,y) method. However, when you’re defining a 2-argument method with a default value for the second argument, Julia automatically defines for you a 1-argument method that handles cases where the second argument is not provided. That is why we see two methods here:

julia> addxy(x, y=globalval) = x + y
addxy (generic function with 2 methods)

julia> methods(addxy)
# 2 methods for generic function "addxy":
[1] addxy(x) in Main at REPL[1]:1
[2] addxy(x, y) in Main at REPL[1]:1

The 1-argument method (listed first above) has been added for you automatically, with a definition similar to:

addxy(x) = addxy(x, globalval)

This method is not type-stable, in the sense that its return type will not depend only on the type of x (its only argument), but also on the result of a dynamic lookup of the type of globalval. Hence the performance penalty when (and only when) you’re calling your function with one argument, relying on the (global) default value for the second argument.

Does that make more sense?

1 Like

Thanks for this explanation! :grinning: This is helping to clarify things.

Still trying to make sure I have the correct mental model. Totally makes sense that addxy(x) is turned into a call to addxy(x,globalval), and the output type will depend on the type of globalval in addition to x.

Looking at the docs here for function calls, if I call addxy(x,y) with two arguments, the tuple Tuple{typeof(addxy), typeof(x), typeof(y)} is formed. So there’s a lookup of three types: typeof(addxy), typeof(x) and typeof(y). The correct method is then chosen based on these three types.

If I call addxy(x) with only one argument, this function is turned into the call addxy(x,globalval). This also will involve a lookup of three types: typeof(addxy), typeof(x), and typeof(globalval). The correct method will then be chosen based on these three types.

The main difference I see between the two calls addxy(x,y) and addxy(x) then is typeof(y) versus typeof(globalval). Is this the primary source of the performance penalty when globalval is non-const?

AFIU, yes. And I’d say that the implications of having to dynamically dispatch based on typeof(globalval) are twofold:

  • it involves a lookup at runtime, which costs something in itself, and
  • it prevents the compiler from knowing at compile-time what will be the type of the return value, which in turn prevents further optimizations down the road (in the context of the caller)
globalval = 10

function addxy(x, y=globalval)
    return x + y

function foo(x)
   z = addxy(10) # (1) which specialization of addxy(x,y) to choose based on `typeof(globalval)`?
   return z + 1  # (2) which method of + to choose based on `typeof(z)`?