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.
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 methodConsider
Currently we lower this to:
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:
conceptually, users are allowed to create new kinds of
GlobalRef
like things with customgetbinding
behavior (such as TLS).Binding-GlobalRef unification
Currently
Module
contains a global table of non-first-classjl_binding_t
objects:The proposal here is to make bindings first class and extensible. The rough object hierarchy may look something like:
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 ofgetbinding
? 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: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 bareSymbol
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
GlobalRef
s 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 theGlobalRef
object associated to a particularjl_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:
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.