Features in Julia
Method tables
Any function in Julia is universal. A universal function is essentially a separate function, which, however, consists of many definitions or methods. The methods of the universal function are stored in the method table. Method tables (type MethodTable') are associated with the names `TypeName'. `TypeName
describes a family of parameterized types. For example, Complex{Float32}
and Complex{Float64}
have a common name object of type `Complex'.
In Julia, all objects can potentially be called, because each object has a type, which, in turn, is associated with TypeName
.
Function calls
When calling f(x, y)
, the following actions are performed: first, the corresponding method table is accessed in the form of typeof(f).name.mt `. Then a tuple of argument types is formed.: `Tuple{typeof(f), typeof(x), typeof(y)}
. Note that the first element is the type of the function itself. The reason is that the type may have parameters, which may require dispatching. The tuple type is searched in the method table.
The dispatching process is performed by the jl_apply_generic
function, which takes two arguments: a pointer to an array of values f
, x
and y
and the number of values (in this case 3).
The system has two types of APIs for working with functions and argument lists: some of them accept functions and arguments separately, while others accept a single argument structure. When using the API of the first type, the part with information about the arguments does not contain information about the function, since it is passed separately. When using the second type of API, the function is the first element of the argument structure.
For example, the following function takes only a pointer to args
to make a call, so the first element of the args array will be the function being called.
jl_value_t *jl_apply(jl_value_t **args, uint32_t nargs)
This entry point with the same purpose accepts the function separately, so the args
array does not contain the function.
jl_value_t *jl_call(jl_function_t *f, jl_value_t **args, int32_t nargs);
Adding methods
In the dispatching process described above, all that is needed to add a new method is (1) the tuple type and (2) the code for the method body. This operation is implemented by the jl_method_def
function. To extract the corresponding method table from the type of the first argument, jl_method_table_for
is called. This is a much more complex process than the corresponding procedure during dispatch, since the type of the tuple of arguments can be abstract. For example, the following definition:
(::Union{Foo{Int},Foo{Int8}})(x) = 0
it will work because all possible methods corresponding to it will be included in the same method table.
Creating universal functions
Since any object can be called, nothing special is required to create universal functions. Therefore, 'jl_new_generic_function` simply creates a single subtype of Function
(zero size) and returns its instance. A function can have a mnemonic display name that is used in debugging data and in outputting information about objects. For example, the name for Base.sin
is sin
. By convention, the name of the type being created matches the name of the function, preceded by the character #
. Therefore, typeof(sin)
returns Base.#sin
.
Built-in functions
The following built-in functions are defined in the Core
module.
<: === _abstracttype _apply_iterate _apply_pure _call_in_world _call_in_world_total _call_latest _compute_sparams _equiv_typedef _expr _primitivetype _setsuper! _structtype _svec_ref _typebody! _typevar applicable apply_type compilerbarrier current_scope donotdelete fieldtype finalizer get_binding_type getfield getglobal ifelse invoke isa isdefined memoryref_isassigned memoryrefget memoryrefmodify! memoryrefnew memoryrefoffset memoryrefreplace! memoryrefset! memoryrefsetonce! memoryrefswap! modifyfield! modifyglobal! nfields replacefield! replaceglobal! set_binding_type! setfield! setfieldonce! setglobal! setglobalonce! sizeof svec swapfield! swapglobal! throw tuple typeassert typeof
All these are single objects, the types of which are subtypes of the type Builtin
, which, in turn, is a subtype of `Function'. Their purpose is to provide entry points during execution that comply with the jlcall call convention.
jl_value_t *(jl_value_t*, jl_value_t**, uint32_t)
The method tables of the built-in functions are empty. Instead, such functions have a single universal entry in the method cache (Tuple{Vararg{Any}}
), whose fptr jlcall pointer points to the corresponding function. It is a kind of crutch, which, however, works quite well.
Named arguments
Named arguments work by adding methods to the kwcall function. This function usually acts as a "named argument sorter" and then calls the internal body of the function (defined anonymously). Each definition in the kwsorter function contains the same arguments as some definition in the regular method table, but the NamedTuple
argument is added at the beginning, which contains the names and values of the passed named arguments. The task of the kwsorter function is to place named arguments in the appropriate positions according to the names, as well as to calculate and substitute all necessary default value expressions. The result is a regular list of positional arguments, which is passed to another function generated by the compiler.
The easiest way to understand this process is to look at how the definition of a method with named arguments is lowered. For the following code:
function circle(center, radius; color = black, fill::Bool = true, options...)
# draw
end
In fact, definitions of _three methods are created. The first one is a function that accepts all arguments (including named ones) both are positional and include the method body code. Her name is generated automatically.:
function #circle#1(color, fill::Bool, options, circle, center, radius)
# draw
end
The second method is the standard definition of the original circle
function for the case when named arguments are not passed.:
function circle(center, radius)
#circle#1(black, true, pairs(NamedTuple()), circle, center, radius)
end
In this case, dispatching is simply performed to the first method with the transmission of default values. The 'pairs` function is applied to a named tuple with the rest of the arguments for key-value iteration. Note that if the method does not accept the other named arguments, this argument is omitted.
Finally, the definition of kwsorter is created:
function (::Core.kwftype(typeof(circle)))(kws, circle, center, radius) if haskey(kws, :color) color = kws.color else color = black end # и т. д. # Остальные именованные аргументы помещаются в `options` options = structdiff(kws, NamedTuple{(:color, :fill)}) # Если метод не принимает остальные именованные аргументы, происходит ошибка # при непустом `options` #circle#1(color, fill, pairs(options), circle, center, radius) end
The Core' function.kwftype(t)
creates the 't.name.mt.kwsorter` field (if it doesn’t exist yet) and returns the function type.
This approach has one feature: if named arguments are not used at the place of the call, no special procedure is required; everything works as if they did not exist in the language at all. However, if named arguments are used at the call location, the function being called is dispatched to the kwsorter sorter. For example, the challenge:
circle((0, 0), 1.0, color = red; other...)
it goes down to the following:
kwcall(merge((color = red,), other), circle, (0, 0), 1.0)
'kwcall' (also in Core
) stands for the signature and dispatching of kwcall. The operation of unpacking named arguments (written as other...
) calls the merge
function of the named tuple. This function further decompresses each element other
, and it is assumed that each of them contains two values (the symbol and the actual value). Naturally, if all the decompressed arguments are named tuples, a more efficient implementation is available. Note that the original circle
function is passed on to handle closures.
Problems related to compilation efficiency
Creating a new type for each function can have serious consequences in terms of compiler resource consumption, combined with Julia’s "default specialization for all arguments" approach. Indeed, the initial implementation of this approach suffered from disadvantages such as a much longer build and testing time, increased memory consumption, and almost twice the size of the system image compared to the base one. With a primitive implementation, the problem is aggravated so much that the system becomes almost impossible to use. To make this approach practical, a number of significant optimizations were required.
The first problem is the excessive specialization of functions for different values of arguments of a functional type. Many functions simply pass the argument somewhere else, for example to another function or to a storage location. Such functions do not need to be specialized for each passed closure. Fortunately, it is easy to recognize such a case: it is enough to check whether the function calls one of its arguments (that is, whether the argument occurs anywhere in the initial position). Higher-order functions that require high performance, such as map
, necessarily call the argument function and therefore specialize as needed. This optimization is implemented by pre-fixing the called arguments during the analyze-variables
pass. When cache_method
detects an argument in the type hierarchy of Function
that is passed to a slot declared as Any
or Function
, the behavior will be the same as with the @nospecialize
annotation. In practice, such a heuristic mechanism proves to be extremely effective.
The next problem is related to the structure of the hash tables in the method cache. Empirical research shows that the vast majority of dynamic dispatch calls use one or two arguments. Moreover, most of these cases can be resolved based only on the first argument. (Let’s get off topic a bit: adherents of single dispatch won’t be surprised at all. However, it actually follows from the above that multiple dispatching is easy to optimize in practice and therefore it should be used rather than making a choice in favor of single dispatching.) Thus, the method cache uses the type of the first argument as the primary key. However, note that for a function call, this corresponds to the second element of the tuple type (the first element is the type of the function itself). As a rule, the types are almost always the same in the initial position. Most functions are single types without parameters. However, in the case of constructors, the situation is different: one method table contains constructors for each type. Therefore, the Type
method table is specialized so that the first element of the tuple type is used instead of the second.
The interface part generates type declarations for all closures. Initially, this was implemented by creating regular type declarations. However, as a result, too many constructors were created, each of which was extremely primitive (all arguments were simply passed to new
). Since the methods are only partially ordered, adding them has an O(n2) algorithmic complexity and, in addition, requires extra resources to store them. Optimization was achieved by creating struct_type
expressions directly (bypassing the creation of default constructors) and directly using new
to create closure instances. Maybe not the most elegant solution, but something had to be done.
The next problem was the macro @test
, which created a closure without arguments for each test case. In fact, there is no need for this, since each test case is simply executed once on the spot. Therefore, the macro @test
was deployed in a try-catch block that captures the test result (true, false, or exception) and calls the test suite handler.