Julia 내장하기
Julia 내장하기 (Embedding Julia)
C와 Fortran 코드 호출에서 Julia는 C로 작성된 함수를 호출하는 간단하고 효율적인 방법을 갖고 있다고 봤어요. 그런데 정반대의 상황도 있어요. C 코드에서 Julia 함수를 호출해야 할 때죠. 이 방법을 쓰면 더 큰 C/C++ 프로젝트에 Julia 코드를 통합할 수 있고, 모든 것을 C/C++로 다시 작성할 필요가 없어져요. Julia는 이를 가능하게 하는 C API를 제공합니다. 거의 모든 프로그래밍 언어가 C 함수를 호출하는 어떤 방법을 갖고 있으므로, Julia C API는 더 나아가 다른 언어 브리지(Python, Rust, C#에서 Julia를 호출하는 등)를 만드는 데도 쓸 수 있어요. Rust와 C++도 C 내장 API를 직접 쓸 수 있지만, 둘 다 도와주는 패키지가 있고 C++에는 Jluna가 유용해요.
고수준 내장 (High-Level Embedding)
참고: 이 절은 유닉스 계열 운영체제에서 C에 Julia 코드를 내장하는 방법을 다룹니다. Windows에서 하려면 다음 절인 Visual Studio로 Windows에 고수준 내장을 보세요.
Julia를 초기화하고 일부 Julia 코드를 호출하는 단순한 C 프로그램부터 시작할게요.
#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;
}
이 프로그램을 빌드하려면 Julia 헤더의 경로를 include 경로에 추가하고 libjulia에 링크해야 해요. 예를 들어 Julia가 $JULIA_DIR에 설치되어 있다면, 위 테스트 프로그램 test.c를 gcc로 다음과 같이 컴파일할 수 있습니다.
gcc -o test -fPIC -I$JULIA_DIR/include/julia -L$JULIA_DIR/lib -Wl,-rpath,$JULIA_DIR/lib test.c -ljulia
또는 Julia 소스 트리의 test/embedding/ 폴더에 있는 embedding.c 프로그램을 참고하세요. cli/loader_exe.c 파일은 libjulia에 링크하면서 jl_options 옵션을 설정하는 방법을 보여주는 또 다른 간단한 예시예요.
다른 Julia C 함수를 호출하기 전에 가장 먼저 해야 할 일은 Julia를 초기화하는 겁니다. jl_init를 호출하면 되는데, 이 함수는 Julia의 설치 위치를 자동으로 판별하려고 시도해요. 커스텀 위치를 지정하거나 어떤 시스템 이미지를 로드할지 지정해야 한다면, jl_init_with_image_file이나 jl_init_with_image_handle을 대신 사용하세요.
테스트 프로그램의 두 번째 문장은 jl_eval_string 호출로 Julia 문장 하나를 평가합니다.
프로그램이 종료되기 전에는 jl_atexit_hook을 호출하는 게 강력히 권장돼요. 위 예시 프로그램은 main에서 반환하기 직전에 이 함수를 호출합니다.
현재 libjulia 공유 라이브러리와 동적 링크하려면 RTLD_GLOBAL 옵션을 전달해야 해요. Python에서는 이렇게 보입니다.
>>> julia=CDLL('./libjulia.dylib',RTLD_GLOBAL)
>>> julia.jl_init.argtypes = []
>>> julia.jl_init()
250593296
julia 프로그램이 주 실행 파일의 심볼에 접근해야 한다면, Linux에서는 아래 설명할 julia-config.jl이 생성하는 플래그에 더해 컴파일 시 -Wl,--export-dynamic 링커 플래그를 추가해야 할 수 있어요. 공유 라이브러리를 컴파일할 때는 이게 필요하지 않습니다.
julia-config로 빌드 매개변수 자동 판별하기
julia-config.jl 스크립트는 내장 Julia를 사용하는 프로그램에 필요한 빌드 매개변수를 판별하는 데 도움을 주기 위해 만들어졌어요. 이 스크립트는 호출된 특정 Julia 배포판의 빌드 매개변수와 시스템 구성을 사용해서, 내장 프로그램이 그 배포판과 상호작용하는 데 필요한 컴파일러 플래그를 내보냅니다. 이 스크립트는 Julia 공유 데이터 디렉터리에 있어요.
예시
#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;
}
명령줄에서
이 스크립트를 간단히 쓰는 방법은 명령줄에서 호출하는 거예요. julia-config.jl이 /usr/local/julia/share/julia에 있다고 가정하면, 명령줄에서 직접 호출할 수 있고 세 가지 플래그를 조합해 받습니다.
/usr/local/julia/share/julia/julia-config.jl
Usage: julia-config [--cflags|--ldflags|--ldlibs]
위 예시 소스를 embed_example.c 파일에 저장했다면, 다음 명령이 Linux와 Windows(MSYS2 환경)에서 그것을 실행 가능한 프로그램으로 컴파일합니다. macOS에서는 gcc 대신 clang을 쓰세요.
/usr/local/julia/share/julia/julia-config.jl --cflags --ldflags --ldlibs | xargs gcc embed_example.c
Makefile에서의 사용
일반적으로 내장 프로젝트는 위 예시보다 더 복잡하므로, 다음은 일반적인 makefile 지원도 가능하게 합니다. 셸 매크로 확장을 쓰기 때문에 GNU make를 가정하고요. 게다가 julia-config.jl은 보통 /usr/local 디렉터리에 있지만, 거기 없으면 Julia 자체로 julia-config.jl을 찾을 수 있고 makefile이 이를 활용할 수 있어요. 위 예시를 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
이제 빌드 명령은 그냥 make입니다.
Visual Studio로 Windows에 고수준 내장 (High-Level Embedding on Windows with Visual Studio)
JULIA_DIR 환경 변수가 설정되어 있지 않다면, Visual Studio를 시작하기 전에 시스템 패널에서 추가하세요. JULIA_DIR 아래의 bin 폴더를 시스템 PATH에 넣어야 해요.
Visual Studio를 열고 새 Console Application 프로젝트를 만드는 것부터 시작합니다. 'stdafx.h' 헤더 파일을 열고 끝에 다음 줄을 추가하세요.
#include <julia.h>
그런 다음 프로젝트의 main() 함수를 이 코드로 바꿉니다.
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;
}
다음 단계는 프로젝트가 Julia include 파일과 라이브러리를 찾도록 설정하는 거예요. Julia 설치가 32비트인지 64비트인지 아는 게 중요합니다. 계속하기 전에 Julia 설치와 맞지 않는 플랫폼 구성은 모두 제거하세요.
프로젝트 속성 대화상자를 열고 C/C++ | General로 가서 Additional Include Directories 속성에 $(JULIA_DIR)\include\julia\를 추가하세요. 그다음 Linker | General 섹션으로 가서 Additional Library Directories 속성에 $(JULIA_DIR)\lib를 추가하고, 마지막으로 Linker | Input 아래 라이브러리 목록에 libjulia.dll.a;libopenlibm.dll.a;를 추가합니다.
이 시점에서 프로젝트가 빌드되고 실행되어야 해요.
타입 변환 (Converting Types)
실제 애플리케이션은 표현식을 실행만 하는 게 아니라 그 값을 호스트 프로그램으로 반환해야 해요. jl_eval_string은 힙에 할당된 Julia 객체에 대한 포인터인 jl_value_t*를 반환합니다. Float64 같은 단순 데이터 타입을 이런 방식으로 저장하는 걸 boxing이라 부르고, 저장된 원시 데이터를 꺼내는 걸 unboxing이라고 해요. Julia에서 2의 제곱근을 계산하고 C에서 그 결과를 읽어 오는 개선된 샘플 프로그램의 본문에 이제 이 코드가 들어 있습니다.
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");
}
ret가 특정 Julia 타입인지 확인하려면 jl_isa, jl_typeis, 또는 jl_is_... 함수를 쓸 수 있어요. Julia 셸에 typeof(sqrt(2.0))을 입력하면 반환 타입이 Float64(C에서 double)인 걸 알 수 있어요. 박싱된 Julia 값을 C double로 변환하기 위해 위 코드 조각에서는 jl_unbox_float64 함수를 사용합니다.
반대 방향으로 변환할 때는 그에 대응하는 jl_box_... 함수를 씁니다.
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);
다음에 보겠지만, 특정 인자로 Julia 함수를 호출하려면 boxing이 필요해요.
Julia 함수 호출하기 (Calling Julia Functions)
jl_eval_string은 C가 Julia 표현식의 결과를 얻을 수 있게 하지만, C에서 계산된 인자를 Julia에 넘기지는 못해요. 이럴 때는 jl_call을 써서 Julia 함수를 직접 호출해야 합니다.
jl_value_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);
첫 번째 단계에서 jl_get_function을 호출해 Julia 함수 sqrt에 대한 핸들을 가져옵니다. jl_get_function에 전달하는 첫 번째 인자는 sqrt가 정의된 Base 모듈에 대한 포인터예요. 그다음 double 값을 jl_box_float64로 박싱합니다. 마지막 단계에서 jl_call1로 함수를 호출하죠. 서로 다른 개수의 인자를 편리하게 처리하기 위해 jl_call0, jl_call2, jl_call3 함수도 있습니다. 더 많은 인자를 넘기려면 jl_call을 쓰세요.
jl_value_t *jl_call(jl_value_t *f, jl_value_t **args, int32_t nargs)
두 번째 인자 args는 jl_value_t* 인자들의 배열이고, nargs는 인자의 개수입니다.
아마 더 단순할 수도 있는, Julia 함수를 호출하는 또 다른 방법도 있어요. @cfunction을 통하는 겁니다. @cfunction을 쓰면 타입 변환을 Julia 쪽에서 할 수 있어서, 보통 C 쪽에서 하는 것보다 더 쉬워요. 위 sqrt 예시를 @cfunction으로 쓰면 이렇게 됩니다.
double (*sqrt_jl)(double) = jl_unbox_voidpointer(jl_eval_string("@cfunction(sqrt, Float64, (Float64,))"));
double ret = sqrt_jl(2.0);
여기서는 먼저 Julia에서 C 호출 가능 함수를 정의하고, 그 함수 포인터를 추출한 다음, 마지막으로 호출합니다. 더 고수준 언어에서 타입 변환을 함으로써 변환을 단순화하는 데 더해, @cfunction 포인터로 Julia 함수를 호출하면 jl_call에 필요한 동적 디스패치 오버헤드(모든 인자가 "boxing"되므로)가 사라지고, 네이티브 C 함수 포인터와 동등한 성능을 내야 합니다.
메모리 관리 (Memory Management)
앞서 봤듯이 Julia 객체는 C에서 jl_value_t* 타입의 포인터로 표현됩니다. 그렇다면 이 객체들을 누가 해제하는지가 질문으로 떠오르죠.
일반적으로 Julia 객체는 가비지 컬렉터(GC)가 해제합니다. 그런데 GC는 우리가 C에서 Julia 값을 참조하고 있다는 걸 자동으로 알지 못해요. 즉 GC가 우리 발밑에서 객체를 해제해 버려 포인터를 무효화할 수 있다는 뜻입니다.
GC는 새 Julia 객체가 할당될 때만 실행돼요. jl_box_float64 같은 호출은 할당을 수행하지만, 할당은 실행 중인 Julia 코드의 어느 지점에서든 일어날 수 있습니다.
Julia를 내장하는 코드를 작성할 때, jl_... 호출 사이에서 jl_value_t* 값을 쓰는 건 일반적으로 안전합니다. GC가 그 호출들에 의해서만 촉발되니까요. 하지만 값이 jl_... 호출을 넘어 살아남도록 보장하려면, 우리가 Julia root 값에 대한 참조를 여전히 쥐고 있다고 Julia에 알려야 해요. 이 과정을 "GC rooting"이라고 합니다. 값을 루팅하면 가비지 컬렉터가 실수로 그 값을 사용하지 않는 것으로 판단해 그 값의 기반이 되는 메모리를 해제하는 일을 막아 줍니다. JL_GC_PUSH 매크로로 이 작업을 할 수 있어요.
jl_value_t *ret = jl_eval_string("sqrt(2.0)");
JL_GC_PUSH1(&ret);
// Do something with ret
JL_GC_POP();
JL_GC_POP 호출은 이전 JL_GC_PUSH가 설정한 참조를 해제합니다. JL_GC_PUSH는 참조를 C 스택에 저장하므로, 스코프를 벗어나기 전에, 즉 함수가 반환하거나 제어 흐름이 JL_GC_PUSH가 호출된 블록을 떠나기 전에 반드시 JL_GC_POP과 정확히 짝을 이뤄야 한다는 점을 기억하세요.
JL_GC_PUSH2부터 JL_GC_PUSH6까지의 매크로로 여러 Julia 값을 한 번에 푸시할 수 있습니다.
JL_GC_PUSH2(&ret1, &ret2);
// ...
JL_GC_PUSH6(&ret1, &ret2, &ret3, &ret4, &ret5, &ret6);
Julia 값들의 배열을 푸시하려면 JL_GC_PUSHARGS 매크로를 쓰는데, 이렇게 사용할 수 있어요.
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();
각 스코프는 JL_GC_PUSH* 호출을 단 하나만 가져야 하고, 단일 JL_GC_POP 호출과 짝을 이뤄야 합니다. 루팅하고 싶은 필요한 변수를 모두 한 번의 JL_GC_PUSH* 호출로 푸시할 수 없거나, 푸시할 변수가 6개보다 많아서 인자 배열을 쓰는 게 여의치 않다면, 내부 블록을 쓸 수 있어요.
jl_value_t *ret1 = jl_eval_string("sqrt(2.0)");
JL_GC_PUSH1(&ret1);
jl_value_t *ret2 = 0;
{
jl_value_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.
JL_GC_PUSH*를 호출하기 전에 유효한 jl_value_t* 값을 가질 필요는 없다는 점을 기억하세요. 그중 몇 개를 NULL로 초기화해서 JL_GC_PUSH*에 넘기고, 그다음에 실제 Julia 값을 만드는 것도 괜찮아요. 예를 들면
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();
함수(또는 블록 스코프) 사이에서 변수에 대한 포인터를 쥐고 있어야 한다면 JL_GC_PUSH*를 쓸 수 없어요. 이 경우 Julia 전역 스코프에 그 변수에 대한 참조를 만들고 유지해야 합니다. 이를 달성하는 간단한 방법 하나는 참조를 보관하는 전역 IdDict를 써서 GC가 할당을 해제하지 못하게 하는 거예요. 다만 이 방법은 가변 타입에서만 제대로 동작합니다.
// This functions shall be executed only once, during the initialization.
jl_value_t* refs = jl_eval_string("refs = IdDict()");
jl_value_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);
변수가 불변이라면 IdDict로 푸시하기 전에 동등한 가변 컨테이너, 되도록이면 RefValue{Any}로 감싸야 해요. 이 접근 방식에서는 컨테이너를 C 코드로, 예를 들어 jl_new_struct 함수로 생성하거나 채워야 합니다. 컨테이너가 jl_call*로 생성되었다면, C 코드에 쓸 포인터를 다시 로드해야 해요.
// This functions shall be executed only once, during the initialization.
jl_value_t* refs = jl_eval_string("refs = IdDict()");
jl_value_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);
refs에서 그 변수에 대한 참조를 delete! 함수로 제거하면, 다른 곳에 그 변수에 대한 참조가 없을 때 GC가 변수를 할당 해제하도록 허용할 수 있어요.
jl_value_t* delete = jl_get_function(jl_base_module, "delete!");
jl_call2(delete, refs, rvar);
아주 단순한 경우의 대안으로, Vector{Any} 타입의 전역 컨테이너를 만들고 필요할 때 그 요소를 가져오는 방법이 있어요. 또는 포인터마다 전역 변수 하나를 만드는 방법도 있죠.
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);
GC 관리 객체의 필드 업데이트하기
가비지 컬렉터는 모든 오래된 세대 객체가 더 젊은 세대 객체를 가리키는 것을 인지하고 있다고 가정하며 동작합니다. 포인터가 업데이트될 때마다 그 가정이 깨지므로, jl_gc_wb(write barrier) 함수로 컬렉터에게 반드시 신호를 보내야 해요. 이렇게요.
jl_value_t *parent = some_old_value, *child = some_young_value;
((some_specific_type*)parent)->field = child;
jl_gc_wb(parent, child);
런타임에 어떤 값이 오래될지 예측하는 건 일반적으로 불가능하므로, 모든 명시적 저장 뒤에는 write barrier를 삽입해야 합니다. 한 가지 주목할 예외는 parent 객체가 방금 할당됐고 그 이후로 가비지 컬렉션이 실행되지 않은 경우예요. 대부분의 jl_... 함수가 때때로 가비지 컬렉션을 호출할 수 있다는 점도 기억하세요.
포인터 배열의 데이터를 직접 업데이트할 때도 write barrier가 필요합니다. jl_array_ptr_set를 호출하는 게 보통 훨씬 권장돼요. 하지만 직접 업데이트도 가능합니다. 예를 들면
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);
가비지 컬렉터 제어하기
GC를 제어하는 함수가 몇 가지 있어요. 일반적인 사용 사례에서는 이런 것들이 필요하지 않아야 합니다.
| 함수 | 설명 |
|---|---|
jl_gc_collect(JL_GC_FULL) |
모든 객체에 대한 GC 실행을 강제합니다 |
jl_gc_collect(JL_GC_INCREMENTAL) |
젊은 객체에 대해서만 GC 실행을 강제합니다 |
jl_gc_collect(JL_GC_AUTO) |
전체와 증분 사이를 자동으로 골라 GC 실행을 강제합니다 |
jl_gc_enable(0) |
GC를 비활성화하고 이전 상태를 int로 반환합니다 |
jl_gc_enable(1) |
GC를 활성화하고 이전 상태를 int로 반환합니다 |
jl_gc_is_enabled() |
현재 상태를 int로 반환합니다 |
배열 다루기 (Working with Arrays)
Julia와 C는 데이터를 복사하지 않고 배열을 공유할 수 있어요. 다음 예시가 어떻게 동작하는지 보여줄게요.
Julia 배열은 C에서 jl_array_t* 데이터 타입으로 표현됩니다. 기본적으로 jl_array_t는 다음을 담는 struct예요.
- 데이터 타입에 대한 정보
- 데이터 블록에 대한 포인터
- 배열의 크기에 대한 정보
단순하게 유지하기 위해 1D 배열부터 시작합니다. Float64 요소를 담는 길이 10의 배열을 만드는 건 이렇게 할 수 있어요.
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);
또는 이미 배열을 할당했다면, 그 데이터 주위에 얇은 래퍼를 만들 수 있습니다.
double *existingArray = (double*)malloc(sizeof(double)*10);
jl_array_t *x = jl_ptr_to_array_1d(array_type, existingArray, 10, 0);
마지막 인자는 Julia가 데이터의 소유권을 가져갈지 여부를 나타내는 boolean이에요. 이 인자가 0이 아니면, 배열이 더 이상 참조되지 않을 때 GC가 데이터 포인터에 대해 free를 호출합니다.
x의 데이터에 접근하려면 jl_array_data를 쓸 수 있어요.
double *xData = jl_array_data(x, double);
이제 배열을 채울 수 있습니다.
for (size_t i = 0; i < jl_array_nrows(x); i++)
xData[i] = i;
이제 x에 대해 제자리(in-place) 연산을 수행하는 Julia 함수를 호출해 보겠습니다.
jl_value_t *func = jl_get_function(jl_base_module, "reverse!");
jl_call1(func, (jl_value_t*)x);
배열을 출력해 보면 x의 요소들이 이제 뒤집혔다는 걸 확인할 수 있어요.
반환된 배열에 접근하기
Julia 함수가 배열을 반환하면, jl_eval_string과 jl_call의 반환 값을 jl_array_t*로 캐스팅할 수 있어요.
jl_value_t *func = jl_get_function(jl_base_module, "reverse");
jl_array_t *y = (jl_array_t*)jl_call1(func, (jl_value_t*)x);
이제 y의 내용을 전처럼 jl_array_data로 접근할 수 있습니다. 언제나 그렇듯, 사용하는 동안 배열에 대한 참조를 유지해야 합니다.
다차원 배열
Julia의 다차원 배열은 메모리에 열 우선(column-major) 순서로 저장됩니다. 2D 배열을 만들고 그 속성에 접근하는 코드가 여기 있어요.
// 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;
Julia 배열이 1-기반 인덱싱을 쓰는 반면, C API는 관용적인 C 코드처럼 읽히도록 0-기반 인덱싱을 쓴다는 점(예: jl_array_dim을 호출할 때)을 주목하세요.
예외 (Exceptions)
Julia 코드는 예외를 던질 수 있어요. 예를 들어 다음을 고려해 보죠.
jl_eval_string("this_function_does_not_exist()");
이 호출은 아무것도 하지 않는 것처럼 보일 거예요. 하지만 예외가 던져졌는지 확인하는 게 가능합니다.
if (jl_exception_occurred())
printf("%s \n", jl_typeof_str(jl_exception_occurred()));
예외를 지원하는 언어(Python, C#, C++ 같은)에서 Julia C API를 사용한다면, libjulia에 대한 각 호출을 예외가 던져졌는지 확인하는 함수로 감싼 다음 호스트 언어에서 예외를 다시 던지는 게 합리적이에요.
Julia 예외 던지기
Julia 호출 가능 함수를 작성할 때는 인자를 검증하고 오류를 나타내기 위해 예외를 던져야 할 필요가 있을 수 있어요. 전형적인 타입 검사는 이렇게 생겼습니다.
if (!jl_typeis(val, jl_float64_type)) {
jl_type_error(function_name, (jl_value_t*)jl_float64_type, val);
}
일반 예외는 다음 함수로 발생시킬 수 있어요.
void jl_error(const char *str);
void jl_errorf(const char *fmt, ...);
jl_error는 C 문자열을 받고, jl_errorf는 printf처럼 호출됩니다.
jl_errorf("argument x = %d is too large", x);
이 예시에서 x는 정수라고 가정합니다.
스레드 안전성
일반적으로 Julia C API는 완전히 스레드 안전하지 않아요. 다중 스레드 애플리케이션에 Julia를 내장할 때는 다음 제한을 위반하지 않도록 주의해야 합니다.
jl_init()은 애플리케이션 수명 동안 한 번만 호출될 수 있어요.jl_atexit_hook()에도 마찬가지이며, 이 함수는jl_init()이후에만 호출될 수 있습니다.jl_...()API 함수는jl_init()이 호출된 스레드에서만, 또는 Julia 런타임이 시작한 스레드에서만 호출될 수 있어요. 사용자가 시작한 스레드에서 Julia API 함수를 호출하는 건 지원되지 않으며, 정의되지 않은 동작과 크래시로 이어질 수 있습니다.- 위 두 번째 조건은 Julia가 시작하지 않은 스레드(그 예외는
jl_init()을 호출한 스레드)에서는jl_...()함수를 안전하게 호출할 수 없다는 뜻이에요. 예를 들어 다음은 지원되지 않고 대부분 세그폴트가 날 겁니다.
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);
}
대신, 모든 Julia 호출을 같은 사용자 생성 스레드에서 수행하면 동작합니다.
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);
}
Julia 자신이 시작한 스레드에서 Julia C API를 호출하는 예시를 보겠습니다.
#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_value_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);
}
이 코드를 Julia 스레드 2개로 실행하면 다음 출력을 얻습니다(참고: 출력은 실행과 시스템마다 달라요).
$ 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
보시다시피 Julia 스레드 1은 pthread ID 3bfd9c00에, Julia 스레드 2는 ID 23938640에 대응합니다. 이는 C 수준에서 실제로 여러 스레드가 사용됐고, 그 스레드들에서 Julia C API 루틴을 안전하게 호출할 수 있다는 걸 보여줘요.
더 알아보기 (Learn more)
- Calling C and Fortran Code — C와 Fortran 코드 호출
- jluna — C++에서 Julia C 내장 API를 돕는 패키지
- Julia 공식 문서: Julia 내장하기