Type annotating captured variables

Hi all,

In the performance tips section of the officials docs, when it comes to captured variables it suggests that:

… if it is known that a captured variable does not change its type, then this can be declared explicitly with a type annotation.

And they give the following example:

function abmult2(r0::Int)
    r::Int = r0
    if r < 0
        r = -r
    end
    f = x -> x * r
    return f
end

But here the function argument is already type annotated, and according to the docs:

Function arguments themselves act as new variable bindings (new “names” that can refer to values), much like assignments argument_name = argument_value

Which would suggest that the second binding is redundant. Why would the captured variable need to be rebound if it is already type annotated in the outer function definition?

Thanks!

Neither of these type annotations are needed for performance. That section is specifically talking about captured variables in closures.

3 Likes

Apologies if I’ve misunderstood your response, but I’m not sure how to square it with what the docs say:

The type annotation partially recovers lost performance due to capturing because the parser can associate a concrete type to the object in the box.

Do you have more detail? Or are you suggesting the docs are incorrect, or outdated?

Also:

That section is specifically talking about captured variables in closures.

Yes, this was the context of my question.

I think the docs may be slightly confusing here because the outer type annotation is not relevant, which you can check for yourself:

julia> function a(r0::Int)
           r::Int = r0
           if r < 0
               r = -r
           end
           f = x -> x * r
           return f
       end
a (generic function with 1 method)

julia> function b(r0)
           r::Int = r0
           if r < 0
               r = -r
           end
           f = x -> x * r
           return f
       end
b (generic function with 1 method)

julia> function c(r0::Int)
           r = r0
           if r < 0
               r = -r
           end
           f = x -> x * r
           return f
       end
c (generic function with 1 method)

julia> f_a = a(1)
#3 (generic function with 1 method)

julia> f_b = b(1)
#5 (generic function with 1 method)

julia> f_c = c(1)
#7 (generic function with 1 method)

julia> using BenchmarkTools

julia> @btime f_a(1)
  9.009 ns (0 allocations: 0 bytes)
1

julia> @btime f_b(1)
  9.425 ns (0 allocations: 0 bytes)
1

julia> @btime f_c(1)
  18.932 ns (0 allocations: 0 bytes)
1

julia> @btime f_a(1)
  8.967 ns (0 allocations: 0 bytes)
1

julia> @btime f_b(1)
  9.384 ns (0 allocations: 0 bytes)
1

julia> @btime f_c(1)
  18.328 ns (0 allocations: 0 bytes)
1
1 Like

Thanks this was very helpful and cleared up an ambiguity.

Though docs state that “function arguments themselves act as new variable… much like assignments” thinking on it a bit more, clearly the annotations themselves are a bit different. Assignment annotations (on the left) perform conversion, whereas argument annotations do not (presumably for dispatch). Maybe there’s other differences?

Still, I’m a little surprised that the argument annotation is ignored by the compiler and a separate assignment annotation is required. I’m sure there’s probably a reason.

I think you’re over-interpreting the manual a bit.

  1. “Function arguments act as new variables” is just an assertion that a function argument is a binding like any other. That doesn’t per se interact with type information.
  2. You are right that type annotations inside the body of a function behave differently from those on function arguments – dispatch is the biggest difference and others should be implied by it. But this doesn’t contradict (1) unless you interpret (1) as meaning “function arguments are exactly assignment expressions”, which is definitely false.
  3. This example doesn’t provide much useful evidence about what the compiler does with assignment annotations because there’s two different identifiers: r0 and r and the important issues are all about the type information known about r.

Yes, I think I was, your response helped.

The only thing here is that the original function in the docs, which they later modify, did not have two different identifiers. There was only one r with a type annotation that is apparently ignored.

function abmult(r::Int)
    if r < 0
        r = -r
    end
    f = x -> x * r
    return f
end

Any idea why the compiler would ignore that as useful information about the type of r?

It already knows the information without you telling it. Julia specializes functions on the types of inputs, so when you type abmult(4) it compiles a special version for abmult(::Int) whether you tell it the input is an int or not.

1 Like

If it knows the information already then why are the docs telling us to add more annotations:

r::Int = r0

I think this example is misleading you because so many different issues are in play.

  1. r and r0 are two different variables. Properties of one don’t necessarily apply to the other. So there’s no tension between Julia’s behavior being influenced by annotations on r – that is orthogonal to annotations on r0.
  2. Both r and r0 are mutable bindings – in particular, because Julia is a dynamic language, both of them could be rebound to values of a new type. Examples will come below to clarify this.
  3. The actual invariant that Julia’s compiler knows is that when the function body is entered, the type of the value bound to r0 is known exactly. This type is not necessarily invariant during the rest of the body.

Example 1: r0 is rebound during the function body to a new type.

julia> function f1(r0::Int)
           println(typeof(r0))
           r0 = 1.2
           println(typeof(r0))
       end
f1 (generic function with 1 method)

julia> f1(123)
Int64
Float64

Example 2: r is rebound during the function body to a new type.

julia> function f2(r0::Int)
           r = r0
           println(typeof(r))
           r = 1.2
           println(typeof(r))
       end
f2 (generic function with 1 method)

julia> f2(123)
Int64
Float64
1 Like

Thanks @johnmyleswhite for taking the time to explain this in detail. It helped a number of things finally fall into place!

To recap: annotating the function argument does not guarantee type stability of the binding throughout the rest of the body. Whereas the annotation on the assignment, does.

1 Like

And an example of how the assignment annotation enforces stability:

function f(r0::Int64)
    r::Int64 = r0
    r = Int8(r)
    println(typeof(r))
end

julia> f(1)
Int64

I think if you have an assignment with a type assert like r::Int = 1 then Julia simply edits a conversion and type assert into every other assignment to r in the function. Like the next r = something statement will become something like (pseudocode) r = assert(convert(Int, something) isa Int). And those should compile away if the compiler can prove that the new assignments are going to be Int anyway. Maybe one can see this with @code_lowered.