This can’t be the problem with local eval, because we can do that right now with plain-old eval:
julia> function global_mutating(a, b)
eval(:(Base.push!(::Int, x, y) = x * y))
return a + b
end
global_mutating (generic function with 1 method)
julia> global_mutating(1, 2)
3
julia> push!(-1, 3, 5)
15
I still don’t entirely get the world-age mechanism, but I have the impression that it serves to mitigate the impact of this kind of YOLO metaprogramming on already compiled code.
For the record, an answer like “it’s more useful for eval to have global semantics, and no one thinks a separate local_eval with function-local semantics is worth the time and complexity budget to add” is perfectly reasonable in my view.
But if it really stands to deoptimize any function which could ever have it in down stack, anywhere, there’s something useful for me to learn about how Julia’s compiler works in that.
Because yes, localeval might be slow. It might force recompilation of the enclosing function every time it’s called. However, a given feature being inherently somewhat slow isn’t a great argument by itself for not having it.
But I don’t think that’s what you’re saying. If correct semantics for localeval would involve changing the world age for everything in its upstack, thus invalidating all optimizations performed before calling it, that would help me understand the problem here.
I’d still want to know why that’s necessary but this would start to make sense at least.
The reason why invalidating everything upstack is necessary is that in the absence of local eval, the compiler is allowed to run methods before they would be run in an interpreted version of the language and perform optimizations like inlining to find and remove redundant work. When you hit a local eval, every function call that you inlined has to be inspected to see if any of those function calls have a different result, which effectively means that in a language with local eval, the compiler isn’t able to inline anything.
This is the part I’m not getting, clearly. Obvious to me: you can’t inline a function which calls local_eval, ever, nor make any inferences about its return type. Executing that function causes compilation, with all the slowness and allocation that entails.
What I don’t see is how this affects the inlining potential higher in the stack, and since functions have backedges, I definitely don’t see how this could affect anything which doesn’t use one of these uber-slow functions.
I also know that type-instability has a way of percolating up, but that also seems like a normal thing which Julia lets you do, even if it’s a bad idea. It wouldn’t be localeval-specific.
If the answer here is “functions which used a localeval would be bad for performance in a way that’s basically ordinary but means we don’t want to provide this particular footgun”, that’s fine as well.
if you’re willing to recompile the entire world every time a local eval is called, that would technically work, it would just also be incredibly slow (e.g. potentially 100 seconds per local eval)
Ok, but now we’re back to: why the entire world? I still don’t see how this leaks outside of the local function context. Sorry, I’m not trying to be obtuse here, what I’m not seeing is why the compiler can’t look at the localeval call in slow_fn, and tag slow_fn as “never inline this + you can’t infer anything about the result of calling it”. It should be possible to give slow_fn a jump address and an ABI, surely? I don’t see how evaluating a string in a local context could change those.
the problem isn’t evaluating the string. the problem is what it evaluates to. If it evaluates to a new method of an existing function, all uses of that function now need to be recompiled.
The key difference is when the recompilation can happen.with global eval, you are guaranteed that the recompilation doesn’t affect a function you are currently running. With local eval, you need to not only invalidate the functions, but finish the execution of your current function stack with the newly evaluated functions. This is basically impossible because due to optimizations based on the current identities of functions, your code might have already executed code that is now invalid and stored the results of that code in pretty much arbitrary locations. Consider the example
bad() = localeval("Base.(+)(x::Int, y::Int) = 0")
function normalfun()
v = zeros(5)
try
for i in 1:5
i == 4 && bad()
v[i] = i + 1
end
catch
v = [2]
end
return v
end
In the absence of local eval, the compiler is allowed to see that all accesses to v are inbounds and bad doesn’t throw an error, and therefore delete the try/catch.
function normalfun()
bad()
return [2,3,4,5,6]
end
With local eval, however, you get halfway through the loop, and the meaning of + changes, so the definition of iterate changes, and you suddenly try to index the array at index 0, causing a catch that you previously were able to prove could never occur to happen.
This type of thing is impossible to deal with only from back-edges because you now need the ability to invalidate a function that you are currently in, even if that function has modified mutable state that was visible to other people based on assumptions that methods would do what they did when you started executing them.
I think this assertion, not its consequences, is the disconnect here. It’s not apparent to me either why method overwrites affecting the global scope must immediately affect execution instead of waiting until the function stack is done. The localeval example there looks pretty much like @eval examples for world age lags, absent invokelatest. Since the difference is that localeval has access to the caller’s local scope in addition to the global scope, the question is why that makes the usual world age lag for global scope effects impossible.
Thanks, I now understand the problem you’ve been illustrating. I don’t think it is an inevitable consequence of implementing a localeval, however.
Mainly to return or call a function created in local scope. Perhaps on rare occasions to modify variables in local scope as well? At this point I’m leaning toward “it isn’t useful enough to add a second local-specific eval” as the main reason Julia doesn’t have it, since I’m increasingly convinced that the world age mechanism would protect the rest of the code against the sort of deoptimizations you’re concerned about.
That would imply that calling a function returned from localeval would need invokelatest, but that would be fine I think. I can also imagine an implementation which reuses the local-context world age, but only if a function is defined, e.g. any method for an existing function would have to receive a new world age. But that would probably screw a lot of things up.
For what it’s worth, I don’t actually see a lot of point in having a localeval. I’ve written some code which makes an anonymous (gensym) module and evaluates expressions into that module, and when I think about useful things one could actually do with localeval, it appears that baking a module and using that as eval’s global scope is actually a better approach, and of course, it works right now. It doesn’t do “modify the value of local variables”, but that’s a weird thing to want to do with runtime evaluation, and it does a fine job of “make functions without dumping them in the global namespace”.
Coming from Lisp, e.g., Common Lisp and Racket also have eval evaluate in the global scope – called the null lexical environment in their jargon, the reason seems to be related to environment handling. In particular, in compiled code the lexical environment usually has no runtime representation, i.e., the compiler is free to constant fold, stack allocate etc. Now, local eval would require to reify the lexical environment in order to pass it to eval.
Don’t know how contagious this would be, but the lexical environment can certainly be substantial, e.g., considering R’s parent.frame etc which are required to properly chain and construct local environments for eval (R does allow local eval, but does not have a compiler for good reason). Further, a local eval needs to care about which lexical environment to use, i.e., where the code has been constructed or where it’s supposed to be evaluated (see the chapters on metaprogramming of Advanced R for the glorious details). Historically, it seems that compiled languages have moved to use macros instead – which are expanded into the lexical environment by the compiler beforehand – long ago …
Good point, this would de-optimize the entire lexical environment in which a localeval call is defined, it would basically have to be interpreted at runtime. But thanks to lexical scope, the effect wouldn’t propagate to call sites for a localeval-containing function. Common Lisp also has dynamic variables, which might make that sort of thing less guaranteed.
Lua has an interesting solution to this, because environments and upvalues (any local which isn’t directly in scope) are both first class. load from a string isn’t given upvalues, and you could say that means it evaluates in the global scope, but this isn’t entirely right, you can choose the value of _ENV, which corresponds to global scope. Base.eval lets you do this as well, by passing in a module to evaluate the expression. Julia modules are only slightly more cumbersome than Lua environments, and play the same role.
load on a chunk (such as a function serialized with string.dump) can return a function with any number of upvalues, the first of which is always set to _ENV, because in Lua the global scope is always the first upvalue in the linked list, so furthest away from the function. The other upvalues are always nil and can be set to useful values with setupvalue. The returned value of load is always a function, which needs to be called for any effects of evaluation to become part of the program.
So call that another strike against a localeval, here’s another implementation where evaluation can’t affect local scope: one can provide values for upvalues, but there’s no mechanism to force the upvalues to share a reference with the local ones. I’m certain, in fact, that this could be done (carefully!) from the C side of the Lua interface, but that doesn’t really count.
I might suggest that a better answer to “why isn’t eval doing (thing that would imply that evaluation is local)” isn’t just “eval happens in the global scope”, so much as that and “Base.eval can be supplied with a module, which you can create using a gensym with mod = eval(:(module $(gensym()) end))”. I wish actually-local and truly-anonymous modules existed in Julia, but working around that limitation is as easy as the invocation above.