Writing a static function

Could you please explain whether or not let can be used inside a function? The following error:

iter = 0
function global_fct
    let count1=iter

produces the error:

ERROR: syntax: expected "end" in definition of function "global_fct"
 [1] top-level scope
   @ ~/src/2022/rude/giesekus/MWE_static_function.jl:4

What is happening? Thanks.

You are missing parentheses in the function definition:
function global_fct()

The no-parentheses version can only be written
function foo end
(hence it complaining about the missing end) and is used to define a function but not define any methods. This is common to do for abstract interfaces so that concrete implementations can add methods later.


@stevengj , your solution with let works perfectly. I could not figure out how to use the let within an outer function, so I left it in the global space. Not idea, but I could wrap it with a module if necessary.

The next challenge is to have the ability somehow to reset the static variable (which runs counter the notion of static) without restarting the Julia code. That is why including the let within a function would have been useful. Is it possible to reset the counter? Perhaps using a structure for this purpose might work? Thanks.

You could use a functor, for example: In Julia, how to create a function that saves its own internal state? - #6 by lmiq


This code surprised me :sweat_smile: I never realized the possibility.

It’s interesting to me that count gets boxed and loses type stability like any global, even though tst_static is the only thing that touches it. Adding a type assertion fixes the loss of type stability and improves speed, but it still gets boxed and causes allocations, so is not quite as performant as making a struct/functor or using a Ref.

Code for benchmarking
# code variants
let a=0; global foo() = (a+=1; a) end              # local
let b::Int=0; global bar() = (b += 1; b) end       # local w/ type assertion
c=0; baz() = (global c += 1; c)                    # global
d::Int = 0; qux() = (global d += 1; d)             # global w/ type assertion
let e = Ref(0); global fred() = (e[] += 1; e) end  # local Ref (avoids reassigning identifier)

using BenchmarkTools
@btime foo()  # slow
@btime bar()  # faster
@btime baz()  # slow
@btime qux()  # faster
@btime fred() # fastest (no box, no allocations)

@code_warntype foo()  # type-unstable
@code_warntype bar()  # loses type in box, regains at assertion
@code_warntype baz()  # type-unstable
@code_warntype qux()  # type-stable
@code_warntype fred() # type-stable

@erlebach Perhaps something like this?

let count = Ref(0)
    global function test()
        count[] += 1
        println("count = ", count[])
    global function reset()
        count[] = 0
        println("count reset to ", count[], " successfully!")

For example, a function-call counter:

mutable struct CallCounter{F} <: Function
CallCounter(f::F) where {F} = CallCounter{F}(f, 0)
function (c::CallCounter)(args...; kwargs...)
    c.count += 1
    return c.f(args...; kwargs...)

in which case you can wrap a function in a new CallCounter object any time you want to count the number of times it is invoked:

c = CallCounter(sin)
sum(c, 1:10)
@show c.count

which prints c.count = 10.


You all have certainly given me food for thought. I must now ruminate. Thanks!

Going off stevengj’s example (but with different names), you just need to make another function to interact with the hidden state (code block below).

Worth mentioning that if you’re planning to have many functions interact with the same state, tying a state to any function via a functor would be tricky; if you have a add_count = CountIncrementer(0) instance, add_count() could only increment, so resetting has to look like reset(add_count) or add_count(ResetStyle()). At that point, I’d rather keep the state (reassign a ::Int variable, mutate a Ref{Int}) separate from the functions.

let count=0
    global add_count # formerly tst_static
    global reset_count
    function add_count()
        count += 1
        count # return instead of `@show`
    function reset_count()
        count = 0

But personally I think there are better ways of holding state than closures (a reason in next paragraph). This example was a somewhat unusual pattern to get a globally scoped function to capture a temporary (which can’t be global) variable. But there’s no reason that variable has to be temporary. You could make _count a type-annotated global variable that tst_static increments; I would name it _count so it doesn’t collide with the function Base.count.

The ::Any inference (and the associated allocations) are not because of the global scope, but because a captured variable is reassigned somewhere. The closure and its surrounding scope don’t run at the same time, so their type inference also happen separately. The surrounding scope cannot unilaterally infer anything about variables it shares with the closure, and by the time the closure runs, the variables are stuck as uninferred. The only ways to possibly patch this are type assertions or never reassigning the variable.

This is why I avoided count=0 in favor of count=Ref(0)—so that instead of reassigning the identifier, we merely setindex! a new value into its x field to avoid any performance penalty.

The same applies for globals: working with a constant Ref is faster than reassigning a type-annotated variable. To illustrate (Julia 1.9.0-alpha1):

julia> c1::Int = 0

julia> const c2 = Ref(0)

julia> function f1() global c1 += 1 end
f1 (generic function with 1 method)

julia> function f2() global c2[] += 1 end
f2 (generic function with 1 method)

julia> @btime f1()
  11.300 ns (1 allocation: 16 bytes)

julia> @btime f2()
  4.800 ns (0 allocations: 0 bytes)

What’s a mild surprise for me, is that part of me thinks the compiler should be able to tell through static analysis that the captured value never changes type. Instead, it sees the function make an assignment and it gives up.

The other mild surprise, is that when I think of a capture, I think of a functor whose struct contains its captured values (which can be accessed and manipulated externally, although that’s not official API). For example:

julia> const foo = let i = Ref(0); f() = (i[] += 1; i[]) end
(::var"#f#1"{Base.RefValue{Int64}}) (generic function with 1 method)

julia> ((foo() for _=1:5)...,)
(1, 2, 3, 4, 5)

julia> foo.i[] = 10;

julia> ((foo() for _=1:5)...,)
(11, 12, 13, 14, 15)

By contrast, these global functions are singleton.

julia> let i = Ref(0); global bar() = (i[] += 1; i[]) end
bar (generic function with 1 method)

julia> ((bar() for _=1:5)...,)
(1, 2, 3, 4, 5)

julia> bar.i[] = 10
ERROR: type #bar has no field i

Are these the only state-storing objects in the language whose state is truly private? :thinking:

1 Like

That code example is worth a post by itself. I do agree that there should be enough type information to make f1 as efficient as f2. I wonder if there is some obstacle that I don’t know about or if it’s just that they haven’t implemented typed globals fully yet.

I have actually managed to access and mutate such state before, though it escapes me exactly how. Big part was realizing that it was a method that captures the variables; different methods of a function can be defined whenever and capture different outer variables, so the function’s type cannot specify a fixed number of fields for state. So I somehow accessed a method (hidden internal detail, I’m sure) and found a roots field that was an array holding the state. But it only worked well for state created in a let block, for some reason only symbols for some global variables were stored there.

Capturing variables methodwise seems more flexible, but global functions get to assume they’re a singleton instance, whereas closures are supposed to be many instances holding their own states yet using the same set of methods. Can’t reasonably change the structure of the state in several scattered instances. Closures could be implemented as very similar but separate functions instead, but that’s a lot more to compile.

1 Like

Another instance of https://github.com/JuliaLang/julia/issues/15276?

1 Like

Ha, it’s exactly this scenario.

I don’t see any reason not to parameterize Core.Box so that it behaves like Ref. As it is now, it generally has worse performance than Ref even with type assertions.

Your approach counts how many times the wrapper is invoked. Cool though.

This is intentional. You really don’t want to specialize the compiler on every boxed variable. At the same time, you can’t insert type parameters during lowering because types do not exist yet at that point in the compilation process. You’d more or less end up with Box{Any} (the same happens when you call a function that returns Any, where inference can’t figure it out), leaving you exactly where we are right now.


Makes sense, I think.

I’m swimming outside my depth, but it feels like there ought to be a way: because if I can do it, the compiler—which I trust is far more intelligent than I—ought to be able to :sweat_smile:

I would think that type-annotating a variable that’s going to be boxed, could get syntax-transformed into type-parameterized boxing. We shouldn’t need to know the type; just throw the expression into the curly braces. For example:

let a::MyType = 0
    () -> (a += 1; a)

#= currently transforms into something like this: =#
let a = Core.Box(0)
    () -> (a.contents = a.contents::MyType + 1; a.contents::MyType)

#= maybe better to transform into this: =#
let a = Core.Box{MyType}(0)
    () -> (a.contents = a.contents::MyType + 1; a.contents::MyType)

this is basically what the Ref hack amounts to anyway. The type assertions become redundant, but maybe it’s easiest to leave them in, idk.

The compiler makes the decision to box based on analysis of the syntax, without type information, so it feels like a syntax transform (or something equivalent to one) ought to be the fix?

Note: the least invasive change might be like this:

mutable struct Box{C} contents::C end
Box(contents) = Box{Any}(contents)

then, any code that simply calls Box(x) doesn’t need to change immediately.

I’m not actually sure. I expected at first that reassignment of globals with a fixed type would be implemented without boxing much like mutation of const Refs, which @uniment demonstrates in f2(). But evidently functions do not yet capture the types of typed globals as well as types of const variables, and it does seem a lot like the boxing of a typed local variable described in captured variables section at the end of the Performance Tips.

From what I could gather from the longstanding captured variable issue, it’s more specifically earlier stages in the compilation process making decisions about types before type inference can happen. Someone commented that it would need a redesign of the lowering stage and it would have to happen after rewriting some of the codebase in Julia.

I think that comment was about type inference, since it would need to be known e.g. what type (+)(::Int, ::Int) returns. If the variable’s type has been annotated anyway, then inference shouldn’t be needed I would think.

Interestingly, the fact that a variable is captured causes it to be boxed not only for the function that captures it, but also for the scope where it’s defined. Comparison of a) untyped capture, b) type-annotated capture, and c) typed Ref:

julia> using BenchmarkTools

julia> gena() = let a=0;      for i=1:1000; a+=i   end; ()->a+=1   end
       genb() = let a::Int=0; for i=1:1000; a+=i   end; ()->a+=1   end
       genc() = let a=Ref(0); for i=1:1000; a[]+=i end; ()->a[]+=1 end;

julia> @btime gena(); @btime genb(); @btime genc();
  22.900 μs (1459 allocations: 22.80 KiB)
  5.940 μs (970 allocations: 15.16 KiB)
  6.700 ns (1 allocation: 16 bytes)

julia> a, b, c = gena(), genb(), genc()
       a() == b() == c()

julia> @btime $a(); @btime $b(); @btime $c();
  27.614 ns (1 allocation: 16 bytes)
  11.735 ns (1 allocation: 16 bytes)
  4.805 ns (0 allocations: 0 bytes)

Note ns vs μs timings for the gen functions.

We can confirm our understanding by using Ref{Any} to imitate a Box and inserting type annotations to mimic the behavior of a type-annotated variable. Comparison of d) mimicking untyped capture, e) mimicking type-annotated capture, and f) typed Ref:

julia> gend() = let a=Ref{Any}(0); for i=1:1000; a[]+=i         end; ()->a[]+=1         end
       gene() = let a=Ref{Any}(0); for i=1:1000; a[]=a[]::Int+i end; ()->a[]=a[]::Int+1 end
       genf() = let a=Ref{Int}(0); for i=1:1000; a[]=a[]::Int+i end; ()->a[]=a[]::Int+1 end;

julia> @btime gend(); @btime gene(); @btime genf();
  23.200 μs (1459 allocations: 22.80 KiB)
  6.180 μs (970 allocations: 15.16 KiB)
  6.700 ns (1 allocation: 16 bytes)

julia> d, e, f = gend(), gene(), genf()
       d() == e() == f()

julia> @btime $d(); @btime $e(); @btime $f();
  28.313 ns (1 allocation: 16 bytes)
  11.211 ns (1 allocation: 16 bytes)
  4.600 ns (0 allocations: 0 bytes)

From this demonstration, it seems reasonably likely that making a type-parameterized Core.Box will allow for immediate performance improvements—at least where type annotation is used.

Of course it’d be better for type inference to work in the lowering stage so that we wouldn’t need to make type annotations, but I don’t know what the schedule for that is. Even then, it seems we’d want a type-parameterized Box anyway.

1 Like

I do not understand the dollar sign notation. When I try to execute $a(), I get the error:

ERROR: syntax: "$" expression outside quote around /Users/erlebach/src/2022/rude/giesekus/functor_tst.jl:118
 [1] top-level scope
   @ ~/src/2022/rude/giesekus/functor_tst.jl:118


The dollar signs are part of the usage of the @btime macro, see Manual · BenchmarkTools.jl.

To run the same thing outside of the macro, just remove the dollar signs.

1 Like

Thanks. I remember now. The last time I used this feature was 3 years ago!