while(1) work();
반응형

환경

Java : 1.8 (회사 환경에 맞추다보니..)

Go : 1.21.5

gcc : 10.2.1

OS : debian bullseye

 

본문

Golang 함수 작성하기

mkdir golang
cd golang
go mod init main

 

slice의 합을 구하여 반환하는 간단한 함수를 만들었다. (main.go)

package main

func main() {
	// do nothing..
}

func sum(data []float64) float64 {
	var result float64 = 0

	for _, v := range data {
		result += v
	}

	return result
}

Java 코드 작성하기

public class Main {
    public static native double golangSum(double[] args);

    public static void main(String[] args) {
        double[] data = new double[]{10.5, -2.5, 0, 2.0, 3.14159265};
        double result = golangSum(data); // 13.14159265

        System.out.println(result);
    }
}

 

Java에서는 golang(혹은 타 언어)로 작성하고자 하는 메서드를 native로 선언한다.

그게 Java 코드의 전부이다. (거짓말이다. 사실 shared library를 불러오는 코드를 추가로 작성해야한다. 이 부분은 후술한다.)

 

저 상태로 javac (java compiler)를 사용하여 컴파일 할 수 있다.

 

참고로 javac 옵션으로 -h {헤더파일이 담길 디렉토리}를 주면

native method에 대한 C 헤더파일을 생성해준다.

 

이 헤더파일에 작성되어있는 함수 시그니처에 맞추어서 golang 코드를 작성하면 된다.

(우리는 golang으로 작성할 것이기 때문에 헤더파일은 필요 없다. 함수 시그니처를 보기 위해서 생성한 뒤 지워도 된다.)

javac -h . java/Main.java

 

 

JNI library 빌드하기

cd golang
touch type_wrapper.h
touch type_wrapper.c # c파일은 go파일과 같은 위치이어야 함
// type_wrapper.h
#include "jni.h"

// const char* type_wrapper_jstring_to_UTFChars(JNIEnv *, jstring, jboolean *);
// void type_wrapper_release_UTFChars(JNIEnv *, jstring, const char *);

const unsigned long type_wrapper_getArraySize(JNIEnv *, jarray);

const double* type_wrapper_getDoubleArray(JNIEnv *, jdoubleArray);
void type_wrapper_releaseDoubleArray(JNIEnv *, jdoubleArray, jdouble *);
#include "type_wrapper.h"
// const char* type_wrapper_jstring_to_UTFChars(JNIEnv * env, jstring str, jboolean * isCopy) {
//     return (*env)->GetStringUTFChars(env, str, isCopy);
// }
// void type_wrapper_release_UTFChars(JNIEnv * env, jstring str, const char *utf) {
//     (*env)->ReleaseStringUTFChars(env, str, utf);
// }

const unsigned long type_wrapper_getArraySize(JNIEnv * env, jarray data) {
    return (*env)->GetArrayLength(env, data);
}

const double* type_wrapper_getDoubleArray(JNIEnv * env, jdoubleArray data) {
    return (*env)->GetDoubleArrayElements(env, data, NULL);
}

void type_wrapper_releaseDoubleArray(JNIEnv * env, jdoubleArray data, jdouble* cArray) {
    (*env)->ReleaseDoubleArrayElements(env, data, cArray, 0);
}

 

Go에서 직접적으로 java type의 데이터를 사용하기는 어렵다. (primitive 는 쉽다.)

따라서, JNI API를 이용해 java data를 C 데이터로 변환하는 과정이 필요하다.

Go에서는 JNI API를 호출할 수 없기 때문에 C 코드로 작성해야 한다.

 

jdoubleArray (java의 double[] 타입)를 double* 로 변환하는 코드는 위와 같다.

GC가 자동으로 실행되지 않기 때문에 golang단에서 getDoubleArray를 호출했다면, 반드시 수동으로 release해주어야 한다.

 

필요한 사람들을 위하여... jstring -> char* 변환 함수도 (주석으로) 추가하였다.

 

 

 

Golang 중간다리 함수 작성하기

이제 JNI의 명세에 맞추어, golang에서 피 호출될 함수를 작성해주어야 한다.
함수의 시그니쳐(이름, 파라미터, 리턴)는 JNI 명세에서 정한대로 작성해주어야 한다. (그래야 JVM이 어떤 함수를 호출할 지 알 수 있다.)

중간다리 함수라고 칭한 이유는, 함수의 호출 스택이
Java main -> Java golangSum -> Golang 지금작성하려는중간다리함수 -> Golang sum
꼴이 될 것이기 때문이다.

javac를 이용하여 만든 헤더파일을 열어보면 함수 시그니쳐를 어떻게 작성해야하는지 알 수 있다.

JNIEXPORT jdouble JNICALL Java_Main_golangSum
  (JNIEnv *, jclass, jdoubleArray);


물론 우리는 golang을 사용할 것이기 때문에.. 문법은 조금 달라질 수 있다. 시그니쳐 참고용도로만 사용하자.

수정된 golang 코드는 다음과 같다.

package main

/*
#cgo CFLAGS: -I/usr/local/java/jdk1.8.0_202/include/
#cgo linux CFLAGS: -I/usr/local/java/jdk1.8.0_202/include/linux
#cgo windows CFLAGS: -I/usr/local/java/jdk1.8.0_202/include/win32

#include "./type_wrapper.h"
*/
import "C"
import (
	"unsafe"
)

func main() {
	// do nothing..
}

func sum(data []float64) float64 {
	var result float64 = 0

	for _, v := range data {
		result += v
	}

	return result
}

//export Java_Main_golangSum
func Java_Main_golangSum(env *C.JNIEnv, cls C.jclass, data C.jdoubleArray) C.jdouble {
	size := C.type_wrapper_getArraySize(env, (C.jarray)(data))
	arr := C.type_wrapper_getDoubleArray(env, data)

	defer C.type_wrapper_releaseDoubleArray(env, data, arr)

	// array := *(*[5]float64)(unsafe.Pointer(arr))
	slice := unsafe.Slice((*float64)(arr), size)
	result := sum(slice)

	return (C.jdouble)(result)
}

 

 

golang에서는 빌드를 한 번 해주어야, C 패키지 하위에 jclass, jdoubleArray 등이 잘 인식된다.

( IDE의 빨간 경고가 사라진다.)

 

C 패키지는, import "C" 상단의 주석내용을 참조하므로 주석 내용을  잘 작성해야한다.

 

더불어, Java_Main_golangSum 함수 위에 작성된 export Java_Main_golangSum 주석도 반드시 필요하다.

 

go build

go build -buildmode=c-shared -o ./lib/libgojava.so  .

 

go 코드를 빌드해서 shared object를 만들자.

 

 

Java build

Java 코드에 라이브러리를 동적으로 불러오는 코드를 추가해야 한다.

 

(참고 : gojava 라이브러리가 경로에 없더라도 컴파일은 된다. (당연함))

 

public class Main {
    static {
        System.loadLibrary("gojava");
    }

    public static native double golangSum(double[] args);

    public static void main(String[] args) {
        double[] data = new double[]{10.5, -2.5, 0, 2.0, 3.14159265};
        double result = golangSum(data); // 13.14159265

        System.out.println(result);
    }
}

 

javac Main.java

 

실행

java -Djava.library.path=/me/blog/lib Main

 

실행 시 java.library.path를 잘 설정하여(libgojava.so 파일이 있는 곳으로) 실행하면

123.456이라는 결과가 출력되는 것을 확인할 수 있다.

 

 

결과 및 속도측정

public class Main {
    static {
        System.loadLibrary("gojava");
    }

    public static native double golangSum(double[] args);

    public static void main(String[] args) {
        final int SIZE = 128000000; // 1GB

        double[] data = new double[SIZE]; 

        for (int i = 0; i < SIZE; i++) {
            data[i] = Math.random();
        }

        fnGo(data);
        fnJava(data);
    }

    public static void fnGo(double[] data) {
        long a = System.currentTimeMillis();
        double result = golangSum(data);
        long b = System.currentTimeMillis();

        System.out.println("calling golang fn " + (b - a) + " ms");
        System.out.println(result);
    }

    public static void fnJava(double[] data) {
        long a = System.currentTimeMillis();

        double result = 0;
        for (int i = 0; i < data.length; i++) {
            result += data[i];
        }

        long b = System.currentTimeMillis();

        System.out.println("calling java fn " + (b - a) + " ms");
        System.out.println(result);
    }
}
package main

/*
#cgo CFLAGS: -I/usr/local/java/jdk1.8.0_202/include/
#cgo CFLAGS: -I/usr/local/java/jdk1.8.0_202/include/linux
#cgo LDFLAGS: -L${SRCDIR}/../lib -ltypewrapper

#include "../c/type_wrapper.h"
*/
import "C"
import (
	"fmt"
	"time"
	"unsafe"
)

func main() {
	// do nothing..
}

func sum(data []float64) float64 {
	var result float64 = 0

	for _, v := range data {
		result += v
	}

	return result
}

//export Java_Main_golangSum
func Java_Main_golangSum(env *C.JNIEnv, cls C.jclass, data C.jdoubleArray) C.jdouble {
	start := time.Now()
	size := C.type_wrapper_getArraySize(env, (C.jarray)(data))
	end := time.Since(start)
	fmt.Println("type_wrapper_getArraySize", end)

	start = time.Now()
	arr := C.type_wrapper_getDoubleArray(env, data)
	end = time.Since(start)
	fmt.Println("type_wrapper_getDoubleArray", end)

	defer C.type_wrapper_releaseDoubleArray(env, data, arr)

	start = time.Now()
	// array := *(*[5]float64)(unsafe.Pointer(arr))
	slice := unsafe.Slice((*float64)(arr), size)
	end = time.Since(start)
	fmt.Println("make slice", end)

	start = time.Now()
	result := sum(slice)
	end = time.Since(start)
	fmt.Println("sum", end)

	start = time.Now()
	return_data := (C.jdouble)(result)
	end = time.Since(start)
	fmt.Println("make C.jdouble", end)

	return return_data
}

 

Java와 Go 코드에 속도 측정을 위한 코드를 덕지덕지 붙여놓았다.

메서드를 여러 번 호출해서 측정하는 것이 정확하겠으나, 편의상(?) 한 번 만 돌렸다.

 

type_wrapper_getArraySize 8.4µs
type_wrapper_getDoubleArray 432.34601ms
make slice 100ns
sum 139.434096ms
make C.jdouble 100ns
calling golang fn 665 ms
6.249829239307785E7
calling java fn 121 ms
6.249829239307785E7

 

 

JNI를 통해 golang 함수를 호출하는것은... 결코 빠르지 않다.

타입을 변환하는 과정에서 오랜 시간이 걸리는 것 으로 추정된다.

 

그런데... golang에서 의 sum이 139.4ms가 소요되었는데, Java8에서 121ms 소요된 것은 조금 이상하다..

 

(참고) vscode에서 디버깅을 위한 launch.json

{
    "version": "0.2.0",
    "configurations": [
        {
            "name": "Go Launch file",
            "type": "go",
            "request": "launch",
            "mode": "debug",
            "program": "${file}",
            "buildFlags": ["--ldflags -r=${workspaceFolder}/lib"]
        },
        {
            "type": "java",
            "name": "Java Run Main",
            "request": "launch",
            "mainClass": "Main",
            "vmArgs": ["-Djava.library.path=${workspaceFolder}/lib"]
        }
    ]
}

(참고) 여러가지 속도 측정

 

10KB짜리 데이터(길이가 1280인 double[])를 보내고 값을 받는 것을 10000번 반복한 뒤

소요시간을 평균내어 속도를 비교하였다.

 

JAVA : 63us
Go : 92us (Golang 내부의 sum함수만 측정하면 1.3µs. 나머지는 JNI overhead)
Go HTTP : 1162us (Golang으로 http서버를 만든 뒤 double[]을 JSON으로 변환하고 HTTP요청 보냄. golang server에서는 json을 파싱한 뒤 sum 계산. JSON 파싱 언파싱 시간 포함.)

 

 

반응형
profile

while(1) work();

@유호건

❤️댓글은 언제나 힘이 됩니다❤️ 궁금한 점이나 잘못된 내용이 있다면 댓글로 남겨주세요.

검색 태그