嵌入 Julia

正如我们在 调用 C 和 Fortran 代码 中看到的, Julia 有着简单高效的方法来调用 C 编写的函数。但有时恰恰相反,我们需要在 C 中调用 Julia 的函数。这可以将 Julia 代码集成到一个更大的 C/C++ 项目而无需在 C/C++ 中重写所有内容。Julia 有一个 C API 来实现这一目标。几乎所有编程语言都能以某种方式来调用 C 语言的函数,因此 Julia 的 C API 也就能够进行更多语言的桥接。(例如在 Python 或是 C# 中调用 Julia ).

高级别嵌入

Note: This section covers embedding Julia code in C on Unix-like operating systems. For doing this on Windows, please see the section following this.

我们从一个简单的 C 程序开始初始化 Julia 并调用一些 Julia 代码:

  1. #include <julia.h>
  2. JULIA_DEFINE_FAST_TLS() // only define this once, in an executable (not in a shared library) if you want fast code.
  3. int main(int argc, char *argv[])
  4. {
  5. /* required: setup the Julia context */
  6. jl_init();
  7. /* run Julia commands */
  8. jl_eval_string("print(sqrt(2.0))");
  9. /* strongly recommended: notify Julia that the
  10. program is about to terminate. this allows
  11. Julia time to cleanup pending write requests
  12. and run all finalizers
  13. */
  14. jl_atexit_hook(0);
  15. return 0;
  16. }

为构建这个程序,你必须将 Julia 头文件的路径放入 include 路径并链接 libjulia 。例如 Julia 被安装到 $JULIA_DIR,则可以用 gcc 来编译上面的测试程序 test.c

  1. gcc -o test -fPIC -I$JULIA_DIR/include/julia -L$JULIA_DIR/lib test.c -ljulia $JULIA_DIR/lib/julia/libstdc++.so.6

然后如果将环境变量 JULIA_BINDIR 设置为 $JULIA_DIR/bin,那么输出的程序test将会被执行。

或者查看 Julia 源代码目录 test/embedding/ 文件夹下的 embedding.c 文件。 文件 ui/repl.c 则是另一个简单示例,用于设置链接 libjuliajl_options 的选项 。

在调用任何其他 Julia C 函数之前第一件必须要做的事是初始化 Julia,通过调用 jl_init 尝试自动确定 Julia 的安装位置来实现。如果需要自定义位置或指定要加载的系统映像,请改用 jl_init_with_image

测试程序中的第二个语句通过调用 jl_eval_string 来执行 Julia 语句。

在程序结束之前,强烈建议调用 jl_atexit_hook。上面的示例程序在 main 返回之前进行了调用。

Note

现在,动态链接 libjulia 的共享库需要传递选项 RTLD_GLOBAL 。比如在 Python 中像这样调用:

  1. >>> julia=CDLL('./libjulia.dylib',RTLD_GLOBAL)
  2. >>> julia.jl_init.argtypes = []
  3. >>> julia.jl_init()
  4. 250593296

Note

如果 Julia 程序需要访问 主可执行文件 中的符号,那么除了下面描述的由 julia-config.jl 生成的标记之外,可能还需要在 Linux 上的编译时添加 -Wl,—export-dynamic 链接器标志。编译共享库时则不必要。

使用 julia-config 自动确定构建参数

julia-config.jl 创建脚本是为了帮助确定使用嵌入的 Julia 程序所需的构建参数。此脚本使用由其调用的特定 Julia 分发的构建参数和系统配置来导出嵌入程序的必要编译器标志以与该分发交互。此脚本位于 Julia 的 share 目录中。

例子

  1. #include <julia.h>
  2. int main(int argc, char *argv[])
  3. {
  4. jl_init();
  5. (void)jl_eval_string("println(sqrt(2.0))");
  6. jl_atexit_hook(0);
  7. return 0;
  8. }

在命令行中

命令行脚本简单用法:假设 julia-config.jl 位于 /usr/local/julia/share/julia,它可以直接在命令行上调用,并采用 3 个标志的任意组合:

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

如果上面的示例源代码保存为文件 embed_example.c,则以下命令将其编译为 Linux 和 Windows 上运行的程序(MSYS2 环境),或者如果在 OS/X 上,则用 clang 替换 gcc。:

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

在 Makefiles 中使用

但通常来说,嵌入的项目会比上面更复杂,因此一般会提供 makefile 支持。由于使用了 shell 宏扩展,我们就假设用 GNU make 。 另外,尽管很多时候 julia-config.jl 会在目录 /usr/local 中出现多次,不过也未必如此,但 Julia 也定位 julia-config.jl,并且可以使用 makefile 来利用它。上面的示例程序使用 Makefile 来扩展。:

  1. JL_SHARE = $(shell julia -e 'print(joinpath(Sys.BINDIR, Base.DATAROOTDIR, "julia"))')
  2. CFLAGS += $(shell $(JL_SHARE)/julia-config.jl --cflags)
  3. CXXFLAGS += $(shell $(JL_SHARE)/julia-config.jl --cflags)
  4. LDFLAGS += $(shell $(JL_SHARE)/julia-config.jl --ldflags)
  5. LDLIBS += $(shell $(JL_SHARE)/julia-config.jl --ldlibs)
  6. all: embed_example

现在构建的命令就只需要简简单单的make了。

High-Level Embedding on Windows with Visual Studio

If the JULIA_DIR environment variable hasn't been setup, add it using the System panel before starting Visual Studio. The bin folder under JULIA_DIR should be on the system PATH.

We start by opening Visual Studio and creating a new Console Application project. To the 'stdafx.h' header file, add the following lines at the end:

  1. #define JULIA_ENABLE_THREADING
  2. #include <julia.h>

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

  1. int main(int argc, char *argv[])
  2. {
  3. /* required: setup the Julia context */
  4. jl_init();
  5. /* run Julia commands */
  6. jl_eval_string("print(sqrt(2.0))");
  7. /* strongly recommended: notify Julia that the
  8. program is about to terminate. this allows
  9. Julia time to cleanup pending write requests
  10. and run all finalizers
  11. */
  12. jl_atexit_hook(0);
  13. return 0;
  14. }

The next step is to set up the project to find the Julia include files and the libraries. It's important to know whether the Julia installation is 32- or 64-bits. Remove any platform configuration that doesn't correspond to the Julia installation before proceeding.

Using the project Properties dialog, go to C/C++ | General and add $(JULIA_DIR)\include\julia\ to the Additional Include Directories property. Then, go to the Linker | General section and add $(JULIA_DIR)\lib to the Additional Library Directories property. Finally, under Linker | Input, add libjulia.dll.a;libopenlibm.dll.a; to the list of libraries.

At this point, the project should build and run.

转换类型

真正的应用程序不仅仅要执行表达式,还要返回表达式的值给宿主程序。jl_eval_string 返回 一个 jl_value_t*,它是指向堆分配的 Julia 对象的指针。存储像 Float64 这些简单数据类型叫做 装箱,然后提取存储的基础类型数据叫 拆箱。我们改进的示例程序在 Julia 中计算 2 的平方根,并在 C 中读取回结果,如下所示:

  1. jl_value_t *ret = jl_eval_string("sqrt(2.0)");
  2. if (jl_typeis(ret, jl_float64_type)) {
  3. double ret_unboxed = jl_unbox_float64(ret);
  4. printf("sqrt(2.0) in C: %e \n", ret_unboxed);
  5. }
  6. else {
  7. printf("ERROR: unexpected return type from sqrt(::Float64)\n");
  8. }

为了检查 ret 是否为特定的 Julia 类型,我们可以使用 jlisajl_typeisjl_is 函数。通过输入 typeof(sqrt(2.0))到 Julia shell,我们可以看到返回类型是 Float64(在C中是 double 类型)。要将装箱的 Julia 值转换为 C 的double,上面的代码片段使用了 jl_unbox_float64函数。

相应的, 用 jlbox 函数是另一种转换的方式。

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

正如我们将在下面看到的那样,装箱需要在调用 Julia 函数时使用特定参数。

调用 Julia 函数

虽然 jl_eval_string 允许 C 获取 Julia 表达式的结果,但它不允许将在 C 中计算的参数传递给 Julia。因此需要使用 jl_call 来直接调用Julia函数:

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

在第一步中,通过调用 jl_get_function 检索出 Julia 函数 sqrt 的句柄(handle)。 传递给 jl_get_function 的第一个参数是 指向 定义sqrt所在的 Base 模块 的指针。 然后,double 值通过 jl_box_float64 被装箱。 最后,使用 jl_call1 调用该函数。也有 jl_call0jl_call2jl_call3 函数,方便地处理不同数量的参数。 要传递更多参数,使用 jl_call

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

它的第二个参数 argsjl_value_t* 类型的数组,nargs 是参数的个数

内存管理

正如我们所见,Julia 对象在 C 中表示为指针。这就出现了 谁来负责释放这些对象的问题。

通常,Julia 对象由垃圾收集器(GC)释放,但 GC 不会自动就懂我们正C中保留对Julia值的引用。这意味着 GC 会在你的掌控之外释放对象,从而使指针无效。

GC 只会在 Julia 对象分配后运行。像 jlbox_float64 这种函数调用会触发内存分配,且有可能会发生在Julia代码运行过程中的任何时期。 但是在两次 jl 调用之间使用指针通常是安全的。但是为了确保值可以在 jl_… 调用中存活,我们必须告诉 Julia 我们保留对 Julia 值的引用。可以使用 JL_GC_PUSH 宏来完成:

  1. jl_value_t *ret = jl_eval_string("sqrt(2.0)");
  2. JL_GC_PUSH1(&ret);
  3. // Do something with ret
  4. JL_GC_POP();

调用 JL_GC_POP 释放先前 JL_GC_PUSH 建立的引用。 请注意 JL_GC_PUSH 正在堆栈上运行,所以在销毁堆栈之前它必须与之前的 JL_GC_POP 完全配对。

使用 JL_GC_PUSH2JL_GC_PUSH3JL_GC_PUSH4 宏可以一次 push 多个Julia 值。 要推送一个 Julia值的数组,可以使用 JL_GC_PUSHARGS 宏,它的用法如下:

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

垃圾收集器也假设在这种情况下工作。即它知道每个老一代对象都指向年轻一代对象。 每次更新指针时,都必须使用 jl_gc_wb (write barrier,写屏障) 函数将指针通知给收集器,如下所示:

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

通常情况下不可能在运行时预测 值是否是旧的,因此 写屏障 必须被插入在所有显式存储之后。一个需要注意的例外是如果 parent 对象刚分配,垃圾收集之后并不执行。请记住大多数 jl_… 函数有时候都会执行垃圾收集。

直接更新数据时,对于指针数组来说 写屏障 也是必需的 例如:

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

控制垃圾收集器

有一些函数能够控制GC。在正常使用情况下这些不是必要的。

函数描述
jl_gc_collect()强制执行 GC
jl_gc_enable(0)禁用 GC, 返回前一个状态作为 int 类型
jl_gc_enable(1)启用 GC, 返回前一个状态作为 int 类型
jl_gc_is_enabled()返回当前状态作为 int 类型

使用数组

Julia 和 C 可以不通过复制而共享数组数据。下面一个例子将展示它是如何工作的。

Julia数组用数据类型 jl_array_t * 表示。基本上,jl_array_t 是一个包含以下内容的结构:

  • 关于数据类型的信息
  • 指向数据块的指针
  • 关于数组长度的信息为了让事情比较简单,我们从一维数组开始,创建一个存有 10 个 FLoat64 类型的数组如下所示:
  1. jl_value_t* array_type = jl_apply_array_type((jl_value_t*)jl_float64_type, 1);
  2. jl_array_t* x = jl_alloc_array_1d(array_type, 10);

或者,如果您已经分配了数组,则可以生成一个简易的包装器来包裹其数据:

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

最后一个参数是一个布尔值,表示 Julia 是否应该获取数据的所有权。 如果这个参数 不为零,当数组不再被引用时,GC 会在数据的指针上调用 free

为了访问 x 的数据,我们可以使用 jl_array_data

  1. double *xData = (double*)jl_array_data(x);

现在我们可以填充这个数组:

  1. for(size_t i=0; i<jl_array_len(x); i++)
  2. xData[i] = i;

现在让我们调用一个对 x 就地操作的 Julia 函数:

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

通过打印数组,可以验证 x 的元素现在是否已被逆置 (reversed)。

获取返回的数组

如果 Julia 函数返回一个数组,jl_eval_stringjl_call 的返回值可以被强制转换为jl_array_t *

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

现在使用 jl_array_data 可以像前面一样访问 y 的内容。一如既往地,一定要在使用数组的时候确保 持有使用数组的引用。

多维数组

Julia的多维数组以 列序优先 存储在内存中。这是一些 创建一个2D数组并访问其属性 的代码:

  1. // Create 2D array of float64 type
  2. jl_value_t *array_type = jl_apply_array_type(jl_float64_type, 2);
  3. jl_array_t *x = jl_alloc_array_2d(array_type, 10, 5);
  4. // Get array pointer
  5. double *p = (double*)jl_array_data(x);
  6. // Get number of dimensions
  7. int ndims = jl_array_ndims(x);
  8. // Get the size of the i-th dim
  9. size_t size0 = jl_array_dim(x,0);
  10. size_t size1 = jl_array_dim(x,1);
  11. // Fill array with data
  12. for(size_t i=0; i<size1; i++)
  13. for(size_t j=0; j<size0; j++)
  14. p[j + size0*i] = i + j;

请注意,虽然 Julia 的数组使用基于 1 的索引,但C API 中使用基于 0 的索引(例如 在调用jl_array_dim)以便用C代码的习惯来阅读。

异常

Julia 代码可以抛出异常。比如:

  1. jl_eval_string("this_function_does_not_exist()");

这个调用似乎什么都没做。但可以检查异常是否抛出:

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

如果您使用 支持异常的语言的 Julia C API(例如Python,C#,C ++),使用 检查是否有异常的函数 将每个调用 包装到 libjulia 中是有意义的,然后异常在宿主语言中重新抛出。

抛出 Julia 异常

在编写 Julia 可调用函数时,可能需要验证参数 并抛出异常表示错误。 典型的类型检查像这样:

  1. if (!jl_typeis(val, jl_float64_type)) {
  2. jl_type_error(function_name, (jl_value_t*)jl_float64_type, val);
  3. }

可以使用以下函数 引发一般异常:

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

jl_error采用 C 字符串,而 jl_errorfprintf 一样调用:

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

在这个例子中假定 x 是一个 int 值。