This is not a beginner’s question, it is a question to advanced julia users
whether my concept below is a valid and generalizable approach.
And if the answers turn out to be positive, it might help others to overcome
“world age” errors in their use cases.
I will explain my concept with some demonstration code (I recommend to execute it in REPL).
It is around a function f which is performance critical and is to be optimized.
f0 is the slow first version, suffering from a complex but pure calculation.
Basic optimization idea is to avoid this computation at runtime, doing it
only once at compile time.
Best solution would be to persuade julia compiler to “compile away” the
complex computation, doing constant propagation. In some cases, a reformulation
of the code helps. In very rare case, Base.@pure can enforce it. In my real
application, I tried both, finally without success (interested in details? See
@pure discussion and this issue).
An alternative is metaprogramming, using a macro or, in this case,
a @generated function. It is coded as function f1, but fails due to the
“world age problem”, details follow.
f2, with methods generated by generate, is my proposed solution.
Module M is a tool module, to be used by 3rd party
applications. Module App is such an application.
module M
function bitsizeof(::Type{T}) where T<: Enum
# use "new world" in all function calls depending on T
8*sizeof(Int) - leading_zeros(Int(Base.invokelatest(typemax,T))-Int(Base.invokelatest(typemin,T)))
end
fib(i::UInt) = i<=1 ? 1 : fib(i-1)+fib(i-2) # I know you can optimize it - compiler can't
transform(s) = fib(hash(string(s))%48) # just for demo: a pure but expensive function
function f0(::Type{T},s) where {T<:Enum}
bits = bitsizeof(T)
sTransformed = transform(s) # expensive pure calculation
return (T,bits,sTransformed)
end
@generated function f1(::Type{T},::Val{s}) where {T<:Enum , s}
bits = Int(Base.invokelatest(bitsizeof,T))
sTransformed = transform(s) # expensive pure calculation
return :(($T,$bits,sTransformed))
end
function f2 end
function generate(::Type{T}) where {T<:Enum}
bits = bitsizeof(T)
for s in instances(T) # generate method per enum instance
sTransformed = transform(s) # expensive pure calculation
ex = :(function f2(::Type{T}, ::Val{$s}) where {T <: Enum}
return (T, $bits, $sTransformed)
end)
eval(ex)
end
return nothing
end
export f0,f1,f2,generate
end # module M
module App
using Main.M
@enum MyEnum ::Int8 e1 = -5 e2 = 0 e3=5
export MyEnum, e1,e2,e3
generate(MyEnum)
end # module app
# in application (another module)
using Main.App
using Main.M
f0(App.MyEnum,e2)
f2(App.MyEnum,Val(e2))
@time f0(App.MyEnum,e2)
@time f2(App.MyEnum,Val(e2))
f1(App.MyEnum,Val(e2))
If you execute it, you will see f0 and f2 returning the same result, f1 fails with
ERROR: MethodError: no method matching typemax(::Type{MyEnum})
The applicable method may be too new: running in world age 29649, while current world is 29661.
Closest candidates are:
typemax(::Type{MyEnum}) at Enums.jl:197 (method too new to be called from this world context.)
typemax(::Union{Dates.DateTime, Type{Dates.DateTime}}) at C:\buildbot\worker\package_win64\build\usr\share\julia\stdlib\v1.6\Dates\src\types.jl:426
typemax(::Union{Dates.Date, Type{Dates.Date}}) at C:\buildbot\worker\package_win64\build\usr\share\julia\stdlib\v1.6\Dates\src\types.jl:428
The problem arises from the sequence of function and type compilations:
Functions in module M are compiled before enum MyEnum is created.
The first use of MyEnum by calling f0 or f1 causes multiple dispatch
compilation of a bunch of methods having MyEnum as its parameter.
With normal julia code, it is no problem - all methods are compiled
on the fly when needed. In @generated functions and macros,
this does not happen, that code only looks at the method tables as they
were to its compile time, and does not compile missing methods.
A common recommendation to overcome the “world age problem” is the use of
Base.invokelatest. In the example code, it works for call of bitsizeof
in f1, but that moves the problem only one stage down in the call chain,
“world age problem” occurs again for typemax and typemin. Even wrapping
all calls in bitsizeof by Base.invokelatest does not work
(I have no idea why - can anyone explain?). Even if you manage to
fix that, more world age problems will pop up in transform(s).
Polluting your code with lots of Base.invokelatest calls kills type stability
and is no good idea with respect to performance.
You could try to reorder statements such that MyEnum and all methods used by f1
like typemax are compiled before the first call of f1. It is possible in
the concrete case, but hardly generalizable. And
working around with reordering will conflict with the common practice
to put all using / import statements at the begin of a module.
My solution proposal is: generate code directly with eval in a
generating function. generate looks very similar to f1, it just puts a function
around the computed expression. The critical point: generate must be called after
MyEnum is defined, and before f2 is used with MyEnum as
parameter. This puts some burden on the programmer of module App.
On the other hand, the generating function allows for more control
of code generation. In the sample code, generate produces one method per enum
instance. Fine for the example, impractical if MyEnum happens to have
100.000 instances. generate could get parameterized in a real world scenario
to offer different variants of code generation.
Dear julia experts, what do you think about my approach? Is it reliable?
Or do you see a “back door” for the world age problems to return?
eval has much less restrictions than @generated or macros, but it has.
Will another restriction cause problems with my generated methods in a more
complex setting?