Engee documentation

Julia Code Implementation

As already mentioned in the chapter Calling C and Fortran code, in Julia there is a simple and efficient way to call functions written in C. But there are situations where the opposite is required: to call Julia functions from the C code. This feature is used to integrate Julia code into a larger C or C++ project.++ without having to rewrite everything in C or C++. To implement it, Julia has an API for C. Since almost all programming languages can call functions in C, the Julia API for C can also be used to form interactions with other languages (for example, calling Julia from Python, Rust, or C#). Although in Rust and C++ You can use the C code injection API directly, and there are packages for both languages that simplify this task. For C++ The package will be useful. https://github.com/Clemapfel/jluna [Jluna].

High-level implementation

Note. This section provides information about the implementation of Julia code in C on operating systems like Unix. For information about performing this task on Windows, see High-level implementation in Windows using Visual Studio.

Let’s start with a simple C program that initializes Julia and calls some Julia code.:

#include <julia.h>
JULIA_DEFINE_FAST_TLS // only define this once, in an executable (not in a shared library) if you want fast code.

int main(int argc, char *argv[])
{
    /* required: setup the Julia context */
    jl_init();

    /* run Julia commands */
    jl_eval_string("print(sqrt(2.0))");

    /* strongly recommended: notify Julia that the
         program is about to terminate. this allows
         Julia time to cleanup pending write requests
         and run all finalizers
    */
    jl_atexit_hook(0);
    return 0;
}

To create this program, you need to add the Julia header path to the path of the included files and link with libjulia. For example, if the Julia code is set to $JULIA_DIR, you can compile the above test program test.c with gcc using the following line:

gcc -o test -fPIC -I$JULIA_DIR/include/julia -L$JULIA_DIR/lib -Wl,-rpath,$JULIA_DIR/lib test.c -ljulia

Alternatively, take a look at the embedding.c program in the Julia source code tree in the test/embed/ folder. The program file cli/loader_exe.c is another simple example of setting the parameters jl_options when linking with `libjulia'.

The first thing to do before calling any other C function in Julia is to initialize Julia. To do this, call the jl_init method, which tries to automatically determine the location of the Julia installation. If you need to specify a custom location or specify which system image to download, use `jl_init_with_image'.

The second operator in the test program computes the Julia operator by calling `jl_eval_string'.

It is strongly recommended to call jl_atexit_hook before terminating the program. In the above command example, the call is executed immediately before returning from `main'.

Currently, dynamic linking with the shared library libjulia requires passing the parameter `RTLD_GLOBAL'. In Python, it looks like this.

```
>>> julia=CDLL('./libjulia.dylib',RTLD_GLOBAL)
>>> julia.jl_init.argtypes = []
>>> julia.jl_init()
250593296
```

If the Julia program needs access to symbols from the main executable file, it may be necessary to add the linker flag -Wl,--export-dynamic during compilation on Linux in addition to those created using the script julia-config.jl described below. This is an optional action when compiling a shared library.

Using julia-config to automatically detect build parameters

The script julia-config.jl allows you to determine which build parameters are required by a program using embedded Julia code. It applies the build parameters and system configuration of the specific Julia distribution that calls it to export the necessary compiler flags for the embedded program in order to interact with that distribution. The script is located in the Julia shared data directory.

Example

#include <julia.h>

int main(int argc, char *argv[])
{
    jl_init();
    (void)jl_eval_string("println(sqrt(2.0))");
    jl_atexit_hook(0);
    return 0;
}

Using it from the command line

The easiest way is to use this script from the command line. If we assume that the script julia-config.jl is located in the folder /usr/local/julia/share/julia, it can be called directly from the command line, and it accepts any combination of three flags:

/usr/local/julia/share/julia/julia-config.jl
Usage: julia-config [--cflags|--ldflags|--ldlibs]

If the source code from the example above is saved in the file embed_example.c, the following command will compile it into an executable program on Linux and Windows (MSYS2 environment). In macOS, replace `clang' with `gcc'.

/usr/local/julia/share/julia/julia-config.jl --cflags --ldflags --ldlibs | xargs gcc embed_example.c

Usage in makefiles

In general, project implementation will be a more complex process compared to the example above, so a common makefile is also supported when using the GNU make utility in connection with the use of macro shell extensions. In addition, although the julia-config.jl file is usually located in the /usr/local directory, if it is not there, you can use the Julia environment itself to search for julia-config.jl, which can be used in the makefile. The above example is expanded to use a makefile.:

JL_SHARE = $(shell julia -e 'print(joinpath(Sys.BINDIR, Base.DATAROOTDIR, "julia"))')
CFLAGS   += $(shell $(JL_SHARE)/julia-config.jl --cflags)
CXXFLAGS += $(shell $(JL_SHARE)/julia-config.jl --cflags)
LDFLAGS  += $(shell $(JL_SHARE)/julia-config.jl --ldflags)
LDLIBS   += $(shell $(JL_SHARE)/julia-config.jl --ldlibs)

all: embed_example

Now the build command is just `make'.

High-level implementation in Windows using Visual Studio

If the environment variable JULIA_DIR is missing, add it using the System panel before launching Visual Studio. The bin folder in JULIA_DIR should be located in the PATH system variable.

Let’s start by opening Visual Studio and creating a console application project. Open the stdafx header file.h and add the following lines to the end of it:

#include <julia.h>

Then, in the project, replace the main() function with the following code:

int main(int argc, char *argv[])
{
    /* required: setup the Julia context */
    jl_init();

    /* run Julia commands */
    jl_eval_string("print(sqrt(2.0))");

    /* strongly recommended: notify Julia that the
         program is about to terminate. this allows
         Julia time to cleanup pending write requests
         and run all finalizers
    */
    jl_atexit_hook(0);
    return 0;
}

The next step is to set up a project to search for included Julia files and libraries. It is important to know the Julia installation version: 32-bit or 64-bit. Before proceeding, delete all platform configurations that do not match the Julia installation.

Using the Project Properties dialog box, go to the C/C++ section

General and add $(JULIA_DIR)\include\julia\ in the "Additional directories of included files" property. Then go to the Linker section

General and add $(JULIA_DIR)\lib to the "Additional directories of included files" property. Finally, in the Linker section

Input add libjulia.dll.a;libopenlibm.dll.a; to the list of libraries.

At this stage, the project must be assembled and launched.

Type Conversion

Real applications will not only have to execute expressions, but also return their values to the main program. 'jl_eval_string` returns the value jl_value_t*, which is a pointer to the Julia object allocated on the heap. Storing simple data types like Float64 is called `boxing', and extracting stored primitive data is called `unboxing'. An improved example of a program that calculates the square root of two in Julia and returns the result in C contains the following code in its body:

jl_value_t *ret = jl_eval_string("sqrt(2.0)");

if (jl_typeis(ret, jl_float64_type)) {
    double ret_unboxed = jl_unbox_float64(ret);
    printf("sqrt(2.0) in C: %e \n", ret_unboxed);
}
else {
    printf("ERROR: unexpected return type from sqrt(::Float64)\n");
}

To check whether ret belongs to a specific Julia type, you can use the functions jl_isa, jl_typeis or jl_is_.... When entering typeof(sqrt(2.0)) in the Julia shell, you can see that the return type is Float64 (double in C). The above code snippet uses the function jl_unbox_float64 to convert the packed Julia value to the C double type.

The corresponding functions jl_box_... are used for the reverse conversion.:

jl_value_t *a = jl_box_float64(3.0);
jl_value_t *b = jl_box_float32(3.0f);
jl_value_t *c = jl_box_int32(3);

As we will see later, to call Julia functions with certain arguments, a packaging transformation is required.

Calling Julia functions

Although the 'jl_eval_string` script allows you to get the result of the Julia expression in C, it prohibits passing arguments calculated in C to Julia. To do this, you will need to call Julia functions directly using jl_call:

jl_function_t *func = jl_get_function(jl_base_module, "sqrt");
jl_value_t *argument = jl_box_float64(2.0);
jl_value_t *ret = jl_call1(func, argument);

In the first step, the descriptor for the Julia function sqrt is extracted by calling jl_get_function'. The first argument passed to the `jl_get_function function is a pointer to the Base module in which the sqrt function is defined. The double-precision decimal value is then packed using jl_box_float64'. In the last step, the function is called using `jl_call1'. There are also functions `jl_call0, jl_call2 and jl_call3' that allow efficient processing of any number of arguments. To pass a large number of arguments, use the `jl_call function.:

jl_value_t *jl_call(jl_function_t *f, jl_value_t **args, int32_t nargs)

Its second argument, args', is an array of `jl_value_t* arguments, and nargs represents the number of arguments.

There is also an alternative and possibly simpler way to call Julia functions — by @cfunction. Using @cfunction allows type conversion on the Julia side, which is usually easier than on the C side. The above example with the sqrt function can be rewritten using @cfunction like this:

double (*sqrt_jl)(double) = jl_unbox_voidpointer(jl_eval_string("@cfunction(sqrt, Float64, (Float64,))"));
double ret = sqrt_jl(2.0);

Here, we first define the called C function in Julia, then extract the function pointer from it, and finally call it. In addition to simplifying type conversion by executing in a higher-level language, calling Julia functions via @cfunction pointers eliminates the cost of dynamic dispatch required by jl_call pointers (for which all arguments are "packed"), and should have performance equivalent to native C function pointers.

Memory management

As we have already seen, Julia objects are represented in C as pointers of the type jl_value_t*. This raises the question of who is responsible for releasing these objects.

Julia objects are usually freed by the garbage collector, but it is initially unaware of the stored reference to the Julia value from C. This means that the garbage collector can free objects, invalidating pointers.

The garbage collector will only work when new Julia objects are placed in memory. Allocation is performed using calls like jl_box_float64, but it can also occur at any time during the execution of Julia code.

When writing code with embedded Julia functions, it is usually safe to use the values of jl_value_t* between calls to jl_... (since the garbage collector is activated only by these calls). But so that the values can be guaranteed to persist after calls to jl_..., it is necessary to inform Julia that there is a reference to Julia values that require https://www.cs.purdue.edu/homes/hosking/690M/p611-fenichel.pdf [superuser rights]. This process is called granting superuser rights to the garbage collector. Obtaining superuser rights for a value ensures that the garbage collector does not accidentally identify it as unused and free up the memory in which it is stored. This can be done using the macro JL_GC_PUSH:

jl_value_t *ret = jl_eval_string("sqrt(2.0)");
JL_GC_PUSH1(&ret;);
// Do something with ret
JL_GC_POP();

Calling 'JL_GC_POP` releases the links set by the previous macro JL_GC_PUSH'. Note that the macro `JL_GC_PUSH stores references in the C stack, so it must be precisely paired with the macro JL_GC_POP before exiting the scope. That is, before the function returns a value or the control flow leaves the block in which the macro JL_GC_PUSH was called.

Using macros JL_GC_PUSH2--JL_GC_PUSH6, you can send multiple Julia values simultaneously:

JL_GC_PUSH2(&ret1;, &ret2;);
// ...
JL_GC_PUSH6(&ret1;, &ret2;, &ret3;, &ret4;, &ret5;, &ret6;);

To send an array of Julia values, you can use the macro JL_GC_PUSHARGS as follows.

jl_value_t **args;
JL_GC_PUSHARGS(args, 2); // args can now hold 2 `jl_value_t*` objects
args[0] = some_value;
args[1] = some_other_value;
// Do something with args (e.g. call jl_... functions)
JL_GC_POP();

There should be only one call to JL_GC_PUSH* in each area, which should be matched with only one call to JL_GC_POP. If all the necessary variables cannot be sent simultaneously using a single macro call JL_GC_PUSH* or more than six variables need to be sent, and using an array of arguments is not an appropriate solution, internal blocks can be used.:

jl_value_t *ret1 = jl_eval_string("sqrt(2.0)");
JL_GC_PUSH1(&ret1;);
jl_value_t *ret2 = 0;
{
    jl_function_t *func = jl_get_function(jl_base_module, "exp");
    ret2 = jl_call1(func, ret1);
    JL_GC_PUSH1(&ret2;);
    // Do something with ret2.
    JL_GC_POP();    // This pops ret2.
}
JL_GC_POP();    // This pops ret1.

Note that it is optional to have valid values of jl_value_t* before calling JL_GC_PUSH*'. It is acceptable to initialize some variables with the value `NULL, pass them to JL_GC_PUSH*, and then create valid Julia values. For example:

jl_value_t *ret1 = NULL, *ret2 = NULL;
JL_GC_PUSH2(&ret1;, &ret2;);
ret1 = jl_eval_string("sqrt(2.0)");
ret2 = jl_eval_string("sqrt(3.0)");
// Use ret1 and ret2
JL_GC_POP();

If you need to save a pointer to a variable between functions (or block scopes), you cannot use the macro JL_GC_PUSH*. In this case, you need to create and save a reference to the variable in the Julia global scope. One of the easiest ways to accomplish this task is to use the global type IdDict, which will store references, prohibiting the garbage collector from releasing them. However, this method will only work correctly with mutable data.

// This functions shall be executed only once, during the initialization.
jl_value_t* refs = jl_eval_string("refs = IdDict()");
jl_function_t* setindex = jl_get_function(jl_base_module, "setindex!");

...

// `var` is the variable we want to protect between function calls.
jl_value_t* var = 0;

...

// `var` is a `Vector{Float64}`, which is mutable.
var = jl_eval_string("[sqrt(2.0); sqrt(4.0); sqrt(6.0)]");

// To protect `var`, add its reference to `refs`.
jl_call3(setindex, refs, var, var);

If the variable is immutable, it must be enclosed in an equivalent mutable container or, preferably, in a RefValue{Any} before sending it to the 'IdDict'. With this approach, the container must be created or filled using C code using, for example, the 'jl_new_struct` function. If the container is created using jl_call*, the pointer that will be used in the C code must be reloaded.

// This functions shall be executed only once, during the initialization.
jl_value_t* refs = jl_eval_string("refs = IdDict()");
jl_function_t* setindex = jl_get_function(jl_base_module, "setindex!");
jl_datatype_t* reft = (jl_datatype_t*)jl_eval_string("Base.RefValue{Any}");

...

// `var` is the variable we want to protect between function calls.
jl_value_t* var = 0;

...

// `var` is a `Float64`, which is immutable.
var = jl_eval_string("sqrt(2.0)");

// Protect `var` until we add its reference to `refs`.
JL_GC_PUSH1(&var;);

// Wrap `var` in `RefValue{Any}` and push to `refs` to protect it.
jl_value_t* rvar = jl_new_struct(reft, var);
JL_GC_POP();

jl_call3(setindex, refs, rvar, rvar);

To allow the garbage collector to free a variable, remove the reference to it from refs using the delete!' function. provided that no other reference to the variable is stored in any other location.:

jl_function_t* delete = jl_get_function(jl_base_module, "delete!");
jl_call2(delete, refs, rvar);

Alternatively, for very simple cases, you can simply create a global container of type Vector'.{Any} and extract elements from it when necessary. Or you can even create one global variable for each pointer using the example below.

jl_module_t *mod = jl_main_module;
jl_sym_t *var = jl_symbol("var");
jl_binding_t *bp = jl_get_binding_wr(mod, var, 1);
jl_checked_assignment(bp, mod, var, val);

Updating fields of objects managed by the garbage collector

When the garbage collector is running, it is also assumed that it knows about every object of the old generation that points to an object of a newer generation. Each time the pointer is updated, the assumption is eliminated, which should be indicated to the collector using the jl_gc_wb (write barrier) function as follows.

jl_value_t *parent = some_old_value, *child = some_young_value;
((some_specific_type*)parent)->field = child;
jl_gc_wb(parent, child);

In general, it is impossible to predict which values will be considered old at runtime, so the write barrier must be inserted after all explicitly specified stores. A significant exception is when the parent object has just been allocated and garbage collection has not been performed since. Keep in mind that most of the functions are jl_... the garbage collection process can sometimes be triggered.

A write barrier is also necessary for arrays of pointers when updating their data directly. Calling jl_array_ptr_set is usually much preferable. But you can also perform a direct update. For example:

jl_array_t *some_array = ...; // e.g. a Vector{Any}
void **data = jl_array_data(some_array, void*);
jl_value_t *some_value = ...;
data[0] = some_value;
jl_gc_wb(jl_array_owner(some_array), some_value);

Garbage Collector Management

There are several functions for managing the garbage collector. They are not needed in normal use.

Function Description

jl_gc_collect()

Forcibly starts the garbage collector

jl_gc_enable(0)

Disables the garbage collector, returns the previous state as initial

jl_gc_enable(1)

Enables the garbage collector, returns the previous state as initial

jl_gc_is_enabled()

Returns the current state as initial

Working with arrays

Julia and C can share array data without copying. The following example will show how it works.

Julia arrays are represented in C with the data type 'jl_array_t*. In fact, `jl_array_t is a structure that contains:

  • information about the data type;

  • pointer to a block of data;

  • information about the size of the array.

For simplicity, let’s start with a one-dimensional array. You can create an array containing Float64 elements with a length of 10 as follows.

jl_value_t* array_type = jl_apply_array_type((jl_value_t*)jl_float64_type, 1);
jl_array_t* x          = jl_alloc_array_1d(array_type, 10);

Or if you have already allocated an array, you can create a thin shell around its data.:

double *existingArray = (double*)malloc(sizeof(double)*10);
jl_array_t *x = jl_ptr_to_array_1d(array_type, existingArray, 10, 0);

The last argument is logical and indicates whether the data should be taken over by Julia. If this argument is non-zero, the garbage collector will call free for the data pointer when the array is no longer referenced.

To access the x data, you can use jl_array_data:

double *xData = jl_array_data(x, double);

Now you can fill in the array:

for (size_t i = 0; i < jl_array_nrows(x); i++)
    xData[i] = i;

Let’s call the Julia function, which performs an operation with x in place.:

jl_function_t *func = jl_get_function(jl_base_module, "reverse!");
jl_call1(func, (jl_value_t*)x);

When outputting the array, you can make sure that the elements of x are now arranged in reverse order.

Access to returned arrays

If the Julia function returns an array, the return value of jl_eval_string and jl_call can be converted to jl_array_t*:

jl_function_t *func  = jl_get_function(jl_base_module, "reverse");
jl_array_t *y = (jl_array_t*)jl_call1(func, (jl_value_t*)x);

The contents of y can now be accessed, as before, using `jl_array_data'. Don’t forget to keep a reference to the array while using it.

Multidimensional arrays

Julia multidimensional arrays are stored in memory by columns. Below is the code that creates a two-dimensional array and accesses its properties.

// Create 2D array of float64 type
jl_value_t *array_type = jl_apply_array_type((jl_value_t*)jl_float64_type, 2);
int dims[] = {10,5};
jl_array_t *x  = jl_alloc_array_nd(array_type, dims, 2);

// Get array pointer
double *p = jl_array_data(x, double);
// Get number of dimensions
int ndims = jl_array_ndims(x);
// Get the size of the i-th dim
size_t size0 = jl_array_dim(x,0);
size_t size1 = jl_array_dim(x,1);

// Fill array with data
for(size_t i=0; i<size1; i++)
    for(size_t j=0; j<size0; j++)
        p[j + size0*i] = i + j;

Note that while Julia arrays use indexing counting from 1, the API for C uses indexing counting from 0 (for example, when calling jl_array_dim) to read as idiomatic C code.

Exceptions

Julia’s code can cause exceptions. For example, consider the following.

jl_eval_string("this_function_does_not_exist()");

This call doesn’t seem to do anything. However, you can check if an exception has occurred.:

if (jl_exception_occurred())
    printf("%s \n", jl_typeof_str(jl_exception_occurred()));

If you use the Julia API for C from a language that supports exceptions (for example, Python, C#, C++), it makes sense to enclose each call to libjulia in a function that checks if an exception has been raised, and then re-creates the exception in the main language.

Invoking exceptions in Julia

When writing called Julia functions, you may need to check the arguments and raise exceptions to indicate errors. The standard type check is as follows.

if (!jl_typeis(val, jl_float64_type)) {
    jl_type_error(function_name, (jl_value_t*)jl_float64_type, val);
}

Common exceptions can be caused by using functions:

void jl_error(const char *str);
void jl_errorf(const char *fmt, ...);

'jl_error` accepts the string C, and jl_error is called as printf:

jl_errorf("argument x = %d is too large", x);

where x is considered an integer in this example.

Thread safety

In general, the Julia C API is not completely thread-safe. When embedding Julia code in a multithreaded application, be careful not to violate the following restrictions.

  • 'jl_init()` can be called only once during the lifetime of the application. The same applies to the function jl_atexit_hook(), which can only be called after `jl_init()'.

  • API functions jl_...() can only be called from the thread in which the jl_init() function was called, _ or from threads started by the Julia_ runtime environment. Calling Julia API functions from user-launched threads is not supported and may lead to undefined behavior and crashes.

It follows from this second condition that it is impossible to safely call the jl_ functions...() from threads that are not running in the Julia environment (the exception is the thread that calls jl_init()). For example, the following call option is not supported and is likely to crash:

void *func(void*)
{
    // Wrong, jl_eval_string() called from thread that was not started by Julia
    jl_eval_string("println(Threads.threadid())");
    return NULL;
}

int main()
{
    pthread_t t;

    jl_init();

    // Start a new thread
    pthread_create(&t;, NULL, func, NULL);
    pthread_join(t, NULL);

    jl_atexit_hook(0);
}

In turn, executing all Julia calls from the same user-created thread will be successful.:

void *func(void*)
{
    // Okay, all jl_...() calls from the same thread,
    // even though it is not the main application thread
    jl_init();
    jl_eval_string("println(Threads.threadid())");
    jl_atexit_hook(0);
    return NULL;
}

int main()
{
    pthread_t t;
    // Create a new thread, which runs func()
    pthread_create(&t;, NULL, func, NULL);
    pthread_join(t, NULL);
}

Example of a Julia API call from a thread launched by the Julia environment itself:

#include <julia/julia.h>
JULIA_DEFINE_FAST_TLS

double c_func(int i)
{
    printf("[C %08x] i = %d\n", pthread_self(), i);

    // Call the Julia sqrt() function to compute the square root of i, and return it
    jl_function_t *sqrt = jl_get_function(jl_base_module, "sqrt");
    jl_value_t* arg = jl_box_int32(i);
    double ret = jl_unbox_float64(jl_call1(sqrt, arg));

    return ret;
}

int main()
{
    jl_init();

    // Define a Julia function func() that calls our c_func() defined in C above
    jl_eval_string("func(i) = ccall(:c_func, Float64, (Int32,), i)");

    // Call func() multiple times, using multiple threads to do so
    jl_eval_string("println(Threads.threadpoolsize())");
    jl_eval_string("use(i) = println(\"[J $(Threads.threadid())] i = $(i) -> $(func(i))\")");
    jl_eval_string("Threads.@threads for i in 1:5 use(i) end");

    jl_atexit_hook(0);
}

If you run this code with two Julia threads, you will get the following result (please note: it may vary depending on the system and with each execution):

$ JULIA_NUM_THREADS=2 ./thread_example
2
[C 3bfd9c00] i = 1
[C 23938640] i = 4
[J 1] i = 1 -> 1.0
[C 3bfd9c00] i = 2
[J 1] i = 2 -> 1.4142135623730951
[C 3bfd9c00] i = 3
[J 2] i = 4 -> 2.0
[C 23938640] i = 5
[J 1] i = 3 -> 1.7320508075688772
[J 2] i = 5 -> 2.23606797749979

As you can see, stream Julia 1 corresponds to the identifier pthread 3bfd9c00, and stream Julia 2 corresponds to the identifier 23938640. This indicates that several threads are actually used at the C level and that Julia API routines can be safely called from them.