JuliaLang / julia

The Julia Programming Language
https://julialang.org/
MIT License
45.64k stars 5.48k forks source link

RFC: First class bindings, GlobalRef unification, TLS improvements #47569

Closed Keno closed 1 year ago

Keno commented 1 year ago

This is a design overview that combines a few threads that have been discussed recently, including the GlobalRef-Binding unification (briefly touched upon in #46729 and probably something we want to do in #40399) and potential improvements to TLS variables (e.g. mentioned in https://github.com/JuliaLang/julia/pull/46259#issuecomment-1234715338).

Proposal

Summary

Currently, in julia, there are essentially two kinds of variables that julia knows about: local variables and global variables [1]. We also have support for task-local variables, which have a different mechanism and go through a dictionary (so need special syntax, and don't have type inference support etc.). The gist of the current proposal is to extend syntax support for non-local variables to bindings with storage semantics other than all-thread, global accesses. The running example here is of course task local storage, but other storage classes are imaginable (OS-specific thread local storage, storage shared among some particular task group, etc.). The basic idea is to retain the two basic kinds of variables for lowering purposes, but add an extra layer of indirection for global variables that allows user-extensible specification of the storage location.

Details

The getbinding generic method

Consider

module M
f() = foo
end

Currently we lower this to:

@eval f() = $(GlobalRef(M, :foo #= Implicit, non-first-class pointer to the Binding object for M.foo =#))

The easiest way to thing of this proposal (though there's some special implementation concerns discussed below) is that we introduce an extra generic function in this lowering:

@eval f() = getbinding(GlobalRef(M, :foo))

conceptually, users are allowed to create new kinds of GlobalRef like things with custom getbinding behavior (such as TLS).

Binding-GlobalRef unification

Currently Module contains a global table of non-first-class jl_binding_t objects:

typedef struct {
    // not first-class
    jl_sym_t *name;
    _Atomic(jl_value_t*) value;
    _Atomic(jl_value_t*) globalref;  // cached GlobalRef for this binding
    struct _jl_module_t* owner;  // for individual imported bindings -- TODO: make _Atomic
    _Atomic(jl_value_t*) ty;  // binding type
    uint8_t constp:1;
    uint8_t exportp:1;
    uint8_t imported:1;
    uint8_t deprecated:2; // 0=not deprecated, 1=renamed, 2=moved to another package
} jl_binding_t;

The proposal here is to make bindings first class and extensible. The rough object hierarchy may look something like:

abstract type Binding end

mutable struct GlobalBinding <: Binding
    # These fields are common to all bindings, and the layout is enforced
    # at construction time.
    owner::Module
    name::Symbol
    ty::Type
    flags::UInt8

    # This is private to each binding, the runtime doesn't look at it
    val::Any
end

mutable struct TaskLocalBinding <: Binding
    owner::Module
    name::Symbol
    ty::Type
    flags::UInt8

    val::Vector{Any}     # For illustration purposes only, the actual implementation would of course need more logic
end

mutable struct GlobalRef <: Binding # aka UnresolvedBinding
    owner::Module
    name::Symbol
    # Maybe, or hardcoded to `Any`, `UInt8(0)`
    ty::Type
    flags::UInt8
end

In this system, modules would keep a first class IdDict{Symbol, Binding} that is used for symbol lookup, but the actual storage location for globals would be up to the user implementation.

IR space and bootstrap considerations

There's two problems with the getbinding method as proposed above. First, what would be the scope resolution of getbinding? It can't be a global since it's part of the implementation. We could of course hack around that, but I think a more elegant solution would be to fold its functionality into functionality of (::Binding)(...). In particular the zero argument method (::Binding)() would read the binding (i.e. be the equivalent of getbinding above) and the 1-argument method (::Binding)(@nospecialize(val)) would write the binding. Sample implementations of the above may look like:

(g::GlobalBinding)() = @atomic g.val
(g::GlobalBinding)(@nospecialize(val)) = @atomic g.val = val

# Similar for TaskLocalBinding

(g::GlobalRef)() = Core.resolve_binding(g.M, g.name)()  # aka Core.getglobal(g.M, g.name) 
(g::GlobalRef)(@nospecialize(val)) = Core.resolve_binding(g.M, g.name)(val)

For IR size, I think, we probably want at least the 0-arg version to be implicit, i.e. a bare object that is a subtype of Binding in the IR would implicitly be called (might seem a bit weird, but it's not unusual, e.g. think of the effects of a bare Symbol in IR). This would slightly pessimize the lowering of a global assignment (requiring an extra quote node), but I think that's ok. Global assignments are much rarer than global reads.

Special considerations for lowering

Lowering (as well as inference where appropriate) would be allowed to resolve GlobalRefs to the appropriate binding kind. This can be seen as a sort of special-case super early inlining/constant folding. We already do something similar now when we finding the GlobalRef object associated to a particular jl_binding_t. For performance, we may want similar optimizations in inference, but I think that should be just fine. We already do a few of these heuristically required special cases.

Types for TLS

One of the particular advantages worth pointing out here is that this would give us properly typed and namespaced TLS. Currently, our TLS implementation is neither. Different packages using the same TLS names will step on each other, and inference has no capability to infer types for TLS. I do also want to teaches the optimizer to optimize TLS more, though that is somewhat orthogonal to this proposal (though I think it gives better justification for it, since right now TLS just happens to be "some IdDict that Task carries around"). It also clarifies the exact semantics for TLS. They're essentially the same as for globals, except with a different storage location.

Implication for uniqueness of names

One of the constraints of this design worth pointing out is that globals and TLS variables would share the same name space. I think this is fine and maybe even desirable, but it's worth pointing out specifically. Another implication is that it's not possible to know whether a particular access is task local or not. Foo.bar could be an implicitly task-local. Of course the same is true in many other languages also, but our user base has gotten used to the globalness of these accesses, so this might be surprising behavior.

End user interface

This proposal does not prescribe a particular end-user interface. The new functionality would be accessed using macros, along the lines of:

macro task_local(sym)
    :(global $(TaskLocalBinding(@__module__, sym)))
end

Naturally, the end-user interface for current globals is unchanged (though the exact way the builtins work might be re-arranged).

[1] Well, and type parameters, but they mostly behave like local variables and should probably more so - I thought we had an issue for this somewhere, but I can't find it.

StefanKarpinski commented 1 year ago

Clever design!