Single-method function as a more re-evaluation-friendly `const`?

So i was thinking about some of the conversations we had at JuliaCon about Julia’s ability to handle redefining const variables, and i was wondering if we should just always prefer to use a single-method function instead of consts, since those play much nicer with Revise and changing them during development?

@oxinabox, @Syx_Pek, @Oscar_Smith, @oschulz and I were talking about julia redefinition and Revise and incrementality, and we reflected on how julia is able to handle redefined methods but not redefined consts. So I’m wondering, then, are there any features that a const variable provides that a global function with only a single zero-arg method wouldn’t? Can you use a function with only a single zero-arg method as a replacement that supports live programming?

I know that if a function has more than one method, it can’t always be devirtualized (e.g. if its calling args are type unstable), but can a zero-argument method always be devirtualized and inlined? If its body is literally just a return statement of a literal, is it always available to the compiler just like a const is? Or do we need something more to get that, like @pure, in which case redefinition / live programming are similarly restricted?

If there are some other requirements to make this work (like the function must have no other methods, and the body must be only a return statement, etc), maybe we could just have a macro that asserts those things?

julia> @constf x() = 2

julia> f(a) = x() + 1

julia> @constf f() = 3
ERROR: Can't declare f() as a constant function; it has other methods.

Or something like that?

6 Likes

This is an interesting idea, and I’ve thought of it before. I don’t have the expertise to answer this, but I’ve just avoided this way simply because I think calling a function incurs more overhead than just defining a const.

But we shall wait for the experts to chime in here.

This is exactly what was used in GitHub - MasonProtter/ToggleableAsserts.jl: Assertions that can be turned on or off with a switch, with no runtime penalty when they're off. so that things would be recompiled if the assertion flag was flipped.

I’m not an expert, but I guess I’d just say it depends on what you mean by “overhead”. If it’s runtime overhead, the answer is no. If it’s compile time overhead, I’m not so sure. Certainly there is extra overhead from causing functions to be recompiled if you change the const, but the alternative is having your functions just not update which doesn’t sound very nice.

7 Likes

Unless you have extremely good reason that the const has to be a constant and you have to mutate it, use a constant ref and mutate that instead. Redefining a function is abusing the system.

4 Likes

const Ref won’t constant fold.
A constant function will

3 Likes

Well, that’s exactly why I said you have to have extremely good reason. Constant folding isn’t a good reason at all. The question is why do you need constant folding.

Ah, @yuyichao, i should have made it more clear: I’m not talking about modifying it during execution, I’m talking about redefining the constant during development. The kind of thing that Julia+Revise.jl supports quite well for redefining functions during development, but of course they don’t support redefining a constant.

I completely agree that redefining a function at runtime is abusing the system, but of course redefining a function during development is expected/encouraged. So I was wondering if we would be losing anything by using a zero-arg method instead of a const variable, so that it would support redefining during development.

4 Likes

For fun I tried to benchmark compilation times.

cvar(i) = Symbol(:const_var_b, i)
cfun(i) = Symbol(:const_fun_b, i)
N = 100

for i in 1:N
@eval const $(cvar(i)) = $i
@eval $(cfun(i))() = $i
end

@eval var_sum() = +($((cvar(i) for i in 1:N)...))
@eval fun_sum() = +($((:($(cfun(i))()) for i in 1:N)...))

julia> @time var_sum()    # this result must have a lot of generic JIT warm-up
  0.008526 seconds (8.52 k allocations: 437.659 KiB)
5050

julia> @time fun_sum()
  0.000012 seconds (2 allocations: 832 bytes)
5050

# ... Redo the whole exercise with the `Symbol(:const_var_b, i)` instead of `:const_var`:

julia> @time var_sum()
  0.000008 seconds (2 allocations: 832 bytes)
5050

julia> @time fun_sum()
  0.000011 seconds (2 allocations: 832 bytes)
5050

# Runtimes are equal, as expected

julia> @btime fun_sum()
@bt  1.462 μs (2 allocations: 832 bytes)
5050

julia> @btime var_sum()
  1.464 μs (2 allocations: 832 bytes)
5050

FWIW

1 Like

I could be wrong, but I don’t think Yuyichao was talking about runtime redefinitions either.

Redefining a function at runtime wouldn’t see your code recompiling until after you hit the global scope anyways. Functions work within a fixed worldage.

2 Likes

:slight_smile: Yeah, but he mentioned using a const Ref as a workaround, which I thought only makes sense in the context of mutating the constant at runtime as part of your program. but maybe I misunderstood!

It doesn’t help with mutating code during development, because e.g. editing the definition of the default value of the Ref would still trigger re-definition. You could I guess update the ref’s value at the REPL (maybe that’s what he meant?) but that’s pretty awkward for the reasons you point out, because you’d certainly not want to use the const Ref in actual production code, so you’d be switching the const to the const Ref at the start of development, and then switching it back.

For what it’s worth, I think arguments like “Redefining a function is abusing the system.” are utterly unconvincing and unlikely to change my behaviour unless @yuyichao can at least tell us why ‘abusing the system’ in this way would lead to bad outcomes.

If the only concern is compile times / invalidations I honestly don’t care if it means I can save development time by restarting julia less, or remove runtime overhead of things like assertions.

15 Likes