Fastcall
Benchmark Result
goos: darwin
goarch: amd64
pkg: github.com/barmco/fastcall/test
cpu: Intel(R) Xeon(R) CPU E5-1620 v2 @ 3.70GHz
BenchmarkFastNoop-8 220709697 5.409 ns/op 0 B/op 0 allocs/op
BenchmarkFastCallbackGo-8 129235213 9.115 ns/op 0 B/op 0 allocs/op
BenchmarkFastRoundtrip-8 81416146 14.79 ns/op 0 B/op 0 allocs/op
BenchmarkCGoNoop-8 6661453 176.1 ns/op 0 B/op 0 allocs/op
BenchmarkCGoCallback-8 6579610 183.3 ns/op 0 B/op 0 allocs/op
BenchmarkCGoRoundtrip-8 2789162 403.9 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/barmco/fastcall/test 9.402s
--
#GOMAXPROCS=1
goos: darwin
goarch: amd64
pkg: github.com/barmco/fastcall/test
cpu: Intel(R) Xeon(R) CPU E5-1620 v2 @ 3.70GHz
BenchmarkFastNoop 193424708 5.415 ns/op 0 B/op 0 allocs/op
BenchmarkFastCallbackGo 134540083 8.904 ns/op 0 B/op 0 allocs/op
BenchmarkFastRoundtrip 83491647 18.21 ns/op 0 B/op 0 allocs/op
BenchmarkCGoNoop 6786681 179.7 ns/op 0 B/op 0 allocs/op
BenchmarkCGoCallback 6437816 182.4 ns/op 0 B/op 0 allocs/op
BenchmarkCGoRoundtrip 2998263 423.0 ns/op 0 B/op 0 allocs/op
PASS
ok github.com/barmco/fastcall/test 9.755s
설탕, 양념 그리고 흑마법 같은 것들을 섞어 만든 CGo 를 위한 도핑제의 효과 테스트냥.
그리고 효과는 좋았다냥.
벤치마크 참고냥.
이건 어셈블리 레벨에서 구현된 트램폴린 함수를 타고서 CGo 의 빡빡하고 느려터진 실행 과정을 전부 무시한 채로 노출된 C 함수를 호출하는 흑마법이다냥.
경고냥: 속도만 보고 혹해서 이걸 이용하다가는 불의의 사태가 일어날 수 있으니깐 조심해서 사용하는게 권장된다냥.
아래 레퍼런스들에 기재된 어셈블리 트램폴린 테크닉과 실제 구현들을 참고했다냥.
다만, 이 리포지토리의 구현은 C 함수에서 다시 Go 함수를 호출 (콜백) 할 때, 정상적인 스택 포인터로 회귀하지 못했던 문제점을 올바르게 고쳤다냥.
C 함수쪽에서 올바르게 Go 함수를 호출하기 위해 필요한 fast_crosscall2
함수는 정적 라이브러리로 빌드된 공유 변수에 런타임에 대입된다냥.
cgo 패키지를 통해서 fastcall.Crosscall2
를 내보내면 cgo 가 래핑해버리기 때문에 어쩔 수 없이 해키한 방법을 사용했다냥. 이건 go1.10 이상부터 go:cgo_export_static
디렉티브를 cgo 나 runtime 패키지에서만 사용가능하도록 Golang 팀이 제약을 걸어놓은 것에 대한 영향이다냥.
정적 라이브러리 빌드를 위해서는 CMake
가 필요하다냥.
Vscode 편집기에서 빠른 컴파일을 위해서 도움이 되는 스크립트도 내장했다냥.
스크립트는 전부 bin
폴더에 있으니깐 관심 있으면 열어서 보면 된다냥.
본론으로 돌아와서 빌드를 하려면 터미널을 키고 아래의 명령을 프로젝트 폴더 안에 있는 상태에서 입력하면 된다냥.
> ./bin/build.sh
fastcall 을 어떻게 사용할 수 있는지 궁금하다면 test
폴더를 참고하면 된다냥.
기본적인 활용 예제는 만들어뒀다냥.
'^'b
주의: fast_crosscall2 함수를 호출할 때 주의할 점
fastcall_amd64.s
파일을 보면 알겠지만냥,
CallXX 로 시작하는 모든 어셈블리 트램폴린은 기본적으로 스택 사이즈가 2056
이다냥. 여기서 8바이트는 이전 BP 를 저장하기 위해서 사용하기 때문에, 실제로 할당되고 사용 가능한 공간은 2048
이다냥.
여기서 주의할 점은 fast_crosscall2 을 경유해서 다시 Go 함수를 실행시켰을 때 그때까지 늘어난 스택 메모리가 2048
바이트를 절대 넘어서서는 안된다라는거다냥.
모든 Go 함수에는 특별한 지시어로 처리가 되어있지 않는 이상 함수의 시작 부분(프롤로그) 와 끝 부분(에필로그) 부분에 스택이 부족할 때를 대비해서 시작 부분에서 실행하려는 함수의 필요 스택 바이트 수와 지금까지 늘어났던 스택의 차를 계산해서 더 큰 스택 메모리로 지금까지의 모든 함수 프레임을 이전 시켜야하는지 판단한다냥.
Go 의 스택이 어떻게 생겼는지 아마 아는 사람도 있을거다냥. Go 의 스택은 필요에 따라서 런타임에 늘어나는 구조인데냥, 이 매커니즘이 리포지토리에서 구현한 빠른 C 함수 호출에 대해서 조건적으로 복병이 되는 경우가 있다냥.
통상의 Go 함수라면 에필로그쪽으로 가서 runtime.morestack 이라는 어셈블리 함수를 호출하고 용량이 더 늘어난 스택 메모리로 데이터를 이전하고 스택 포인터의 위치를 새로운 스택 메모리의 주소로 재정렬
한다냥.
문제가 되는 부분은 이 재정렬의 부분인데냥, 스택 프레임 사이사이에 Go 함수 프레임하고 C 함수 프레임이 섞여있으면 재정렬하는 도중에 오류가 발생한다라는거다냥.
runtime.morestack 은 오로지 Go 함수나 Cgo 로 알려진 정석적인 C 호출 릴레이 패키지를 통해서만 스택이 관리될 것을 가정하고 있다냥.
아래는 이것이 문제될 수 있는 Use case 다냥. 참고해라냥.
- Go 함수에서 C 함수 호출 (fastcall.CallN 을 통해서 호출)
- 1의 과정으로 2048 바이트 만큼의 사용 가능한 스택 메모리가 있다는 것이 Go 의 런타임에 알려짐
- 1에서 Callee 였던 C 함수가 다시 fast_crosscall2 함수를 이용해서 Go 함수를 호출
- 2의 과정으로 알려진 남은 공간과 지금 실행하려는 Go 함수의 필요 스택 공간을 계산
- 만약 필요 스택 공간이 2의 잉여 공간을 넘어서지 않는 경우에는, 그대로 함수 호출 속행 (정상적)
- 만약 필요 스택 공간이 2의 잉여 공간을 넘어선다면 runtime.morestack 을 호출하게 되고
runtime.gentrackback 에서 스택 포인터의 위치를 새로운 스택 메모리 공간으로 재정렬 하던 도중 끼어있는 C 함수 프레임의 정보를 찾지 못해 fatal 상태로 들어감.
결론부터만 말하자면 fast_crosscall2 를 통한 Go 함수의 재호출 (어떤 함수던 간에) 을 하기 위해서는 직접 그 함수를 기점으로 (이후 연쇄적인 모든 Go 함수 호출) 할당될 것으로 여겨지는 스택 메모리 공간의 총합을 계산한 다음에 그 총합이 2048 바이트를 넘어서지 않을거라는 확인 이후에 사용하는 것이 안전하다냥.
C 함수로부터의 Round-trip 을 하고 싶다면 꼭 명심해야할 부분이다냥.
참고냥.
Reference
[1]: https://blog.filippo.io/rustgo
[2]: https://github.com/petermattis/fastcgo
[3]: https://github.com/choleraehyq/fastercgo