Language restriction to solve 15276?

e2 isn’t a problem because it’s a local variable in the loop, right? Whether k is a problem depends on how we implement things; I was only suggesting making variables created through explicit assignment type constant. I’m not saying it wouldn’t be a breaking change. There are definitely other issues to consider, though, like union types…

1 Like

Default: Nothing, get slow, just as today. If run with command-line option julia -warn: Emit a warning. Modulo bikeshedding whether the option is command-line, envronment variable, can be set in the startup-file, and how it is called.

I would definitely prefer a more chatty compiler. Most people wouldn’t, which is also fine, and is a sensible default.

1 Like

Although in a way it nicely parallels the inmutable structs by default, the proposal of type-constant variables by default is a huge departure of the current Julia model. I would really like to know the opinion of some core devs on this. I suspect this would be a mistake. A more chatty compiler, probably yes. Less Python-like flexibility, no please! There must be a better way forward with #15276

3 Likes

Does this discussion indicate that 15276 is unsolvable with current design by principle?

I would say that there are two different interpretations of “solving” 15276:

(a) All variables captured in a closure that have inferrable types in the outer scope can also be inferred in the closure.

(b) If the programmer follows certain documented rules when writing closures, then all variables captured in a closure that have inferrable types in the outer scope can also be inferred in the closure.

Obviously, attaining (a) is a bigger demand on the core devs than attaining (b). I personally would be fine with (b). Could someone with more knowledge than me answer about whether either (a) or (b) is attainable in the current language?

Unfortunately inference can’t help us much here. The boxing is occurring at the syntax level. This is before Julia has access to inference. It’s before compilation. That’s what makes it so tricky. We’re in #development — let’s make it more #development-y. Here’s one of the simpler examples from the big #15276 thread:

julia> Meta.@lower function f()
           if true
           end
           r = 1
           cb = ()->r
           cb()
       end

(output)

There’s a lot going on there, and that’s pretty hard to read for folks not well versed in Julia’s intermediate representation, but it’s approximately:

function f end
struct __9_10 <: Core.Function
    r::Core.Box
end
(self::__9_10)() = isdefined(self.r, :contents) ? self.r.contents : error()
function f
    r = Core.Box()
    if true
    end
    r.contents = 1
    cb = __9_10(r)
    return cb()
end

That syntax isn’t just lowering to the method you wrote — it’s also defining a callable struct that represents the anonymous function, and more importantly it’s transforming r into something that has behavior. I highly recommend folks who are interested to play around with this basic framework and see how the Meta.@lower output changes as you add an ::Int annotation or remove the branch or add another assignment to r in between constructing and calling cb. You can see how this being a syntax-level transform is important: assignments to r need to mutate something that has identity and behavior in order to also affect the inner function. It can no longer just be a machine integer.

If the parser can prove that r is constant-valued over its entire lifespan, the above becomes approximately:

struct __9_10{R} <: Core.Function
    r::R
end
(self::__9_10)() = self.r
function f
    if true
    end
    r = 1
    cb = __9_10(r)
    return cb()
end

Now that’s the kind of thing inference/inlining/codegen is gonna love. The hard part is proving it.


I hope that helps de-mystify the boxes and anonymous functions. I know just writing this up helped me get a better handle on what’s happening here.

Alright, so, what can we do about this? Well, since it’s at the syntax-level, that means that linting it can be really easy. Just check Meta.@lower — no runtime values or types needed. At the same time, since it’s syntax-level, expanding and improving this analysis requires working in julia-syntax.scm. That’s also why the let x=x trick works: it simplifies the scope of the captured variable to a super-trivial block that’s easy on the parser.

I’d love it if we could get the parser to the point where a rule like “only one assignment and no uses before definition” would be sufficient to get the optimized behavior. That’d be my ideal documented rule. I have absolutely no idea how hard that is; I fear the “before definition” part makes it tantamount to the halting problem. I don’t know what the rule is now, but there is one and it’s somewhere between “no other statements or control flow blocks beyond the variable and its closure with a well-defined lifespan” (the let trick) and my ideal.

In the meantime, we have let workarounds. Perhaps this would be a good impetus for getting local consts working — they’ll only become available after 0.7 but maybe that could be an easier task? It’d be a slightly less-annoying workaround, and we already have the syntax for it.

13 Likes

Ok, I think I get the problem now, and it is even worse: We currently miscompile closures in order to avoid the performance slowdown. Or is the following behavior correct?

@noinline cb2(x) = (x.r.contents = "foo";);

function f1(cb2)
    if true end;     
    r=1;       
    cb = ()->r;           
    cb2(cb);           
    r
end
@show f1(cb2);
#f1(cb2) = "foo"


function f2(cb2) 
    r=1;       
    cb = ()->r;           
    cb2(cb);           
    r
end

@show f2(cb2);
#ERROR: type Int64 has no field contents

Edit: The behavior on f1 is correct and on f2 is wrong, imo. Except if we changed the language semantics.

No this is not wrong. Just because it happens at lowering doesn’t mean it’s the semantics. It also doesn’t mean inference can’t do anything about it but it is much harder.

So, do you think that either f1 or f2 is misbehaving, or rather that this is proper as-intended UB?

This is UB.

1 Like

“Soft” UB, as in f1(cb2) should do something sensible or throw? Or “hard UB”, as in f1(cb2) would be allowed to spawn bats out of your ears (corrupt memory)?

Neither.

It’s an implementation detail so you just can’t assume it’ll do anything you want. It doesn’t really make sense to be more specific about it since you should never do it other than for debugging, in which case you are always exploring compiler implementations anyway.

It won’t be something sensible or throw. It’s allowed to not do what you want without throwing.

It is a valid julia syntax so it also shouldn’t corrupt memory on it’s own. (Of course, if the user code is relying on some invariance in the closure captured vars that the assignment breaks, anything can happen due to the user code.)

Thank you for taking the time to write this really clear explanation of the issue. This is very helpful. I didn’t realize that the problem arises before type inferencing.

If I understand your write-up correctly, it seems that a user could ensure that 15276 won’t arise if there were a means to declare the type of the captured variable inside the closure rather than in the outer scope. In other words, if your original program had the statement

                cb = () -> (r::Int)

rather than cb = () -> r then the parser could create a callable struct in which r has a fixed type. Is this correct? I just tried this out in an 0.7 pre-release, and it didn’t seem to eliminate the occurrence of 15276, so perhaps I’m misunderstanding.

No, the parser has no idea that Int is a constant type.

I see your point about Int. It seems that there is no syntax in the current language for a closure to indicate to the parser that a captured variable has a definite type, so the parser is stuck with a bunch of heuristics. If there were such a syntax, would that solve 15276 (if a code uses it)? Would the core developers have any interest in introducing such a syntax?

Type declaration.

See

local x::Int 
x::Int = 1

https://docs.julialang.org/en/stable/manual/types/#Type-Declarations-1

Last time I checked that did help with the 15276 cases, but if it doesn’t with any of them then still that should be the appropriate syntax since it already exists in the language for declaring a constant type in a local scope.

Edit: I think there may be some confusion in that comment because of how it was written? @Stephen_Vavasis wrote a type assertion instead of a declaration.

1 Like

typeconstant x = 1 would be a bit easier cause you don’t have to keep track of what type x is, tho?

Edit:

Maybe a @typeconstant macro would do which is sugar for

x = 1 => local x::typeof(1) = 1?

using MacroTools: postwalk, @capture

macro typeconstant(e)
    esc(postwalk(e) do e
        if @capture e a_ = b_
            :(local $a::$typeof($b) = $b)
        else
            e
        end
    end)
end

Just to be clear: I’m suggesting that the declaration that a captured variable has a definite type should be inside the closure rather than in the enclosing scope. If I understand Matt Bauman’s description of how closures are parsed, it seems that the parser needs to make a decision about captured variables with only a limited understanding of the outer scope context.