こんにちは ハタ です。
このたび Go Conference 2023 Online にて Go/Cgoで映像・音声のリアルタイム処理をやるまでの道のり というタイトルで発表しました
久しぶりの大きなカンファレンスでの登壇だったのでとても緊張しましたが、10周年を迎えた機会に登壇できて光栄です
今回発表した資料はこちらになります、当日のアーカイブは近々公開されると思いますのでお待ち下さい
(2023/06/28追記)
アーカイブ公開されたようです!
もし当日ご覧になれなかった方はこちらからご視聴ください!
さて、今回登壇するにあたり資料を用意していたのですが、発表時間の関係で削除してしまった内容がいくつかあるため、ここで紹介させてください
go <-> cgo のオーバーヘッドを何とかしたい
やはり cgo を使っていて一番気になるのが go から cgo を呼び出している時のオーバーヘッドだと思います
例えば次のようなコード
/* int add(int a, int b) { return a + b; } */ import "C" func Cadd(a, b int) int { return int(C.add(C.int(a), C.int(b))) } func Goadd(a, b int) int { return a + b }
cgo と go の呼び出しですが、これくらいの差があります
BenchmarkAdd BenchmarkAdd/cgo BenchmarkAdd/cgo-8 22191978 49.25 ns/op BenchmarkAdd/go BenchmarkAdd/go-8 1000000000 0.2318 ns/op
cgo の呼び出しでは 約50ns/op
に対して、go で直接実装されているものは 約0.2ns/op
です
50ns 程度のオーバーヘッドであれば気にすることもないと思いますが、
このオーバーヘッドも減らせると ロマンがある と思うため挑戦してみました
トランポリンコードの世界
結論から言うと 完全には 出来ていません が、トランポリンコードを用いて go から直接 cgo の関数を呼び出してみました
まずは、次のような C.foo
という関数を go からどのようにして呼び出しているのかを紐解きます
go からの呼び出しは go tool cgo xxxx.go
を使うことで中間生成されるコードを確認することができます
生成されたコードをみると、次のようにして実行していることがわかります
... //go:linkname _cgo_runtime_cgocall runtime.cgocall func _cgo_runtime_cgocall(unsafe.Pointer, uintptr) int32 ... //go:cgo_import_static _cgo_5eb283ffa28f_Cfunc_foo //go:linkname __cgofn__cgo_5eb283ffa28f_Cfunc_foo _cgo_5eb283ffa28f_Cfunc_foo var __cgofn__cgo_5eb283ffa28f_Cfunc_foo byte var _cgo_5eb283ffa28f_Cfunc_foo = unsafe.Pointer(&__cgofn__cgo_5eb283ffa28f_Cfunc_foo) //go:cgo_unsafe_args func _Cfunc_foo() (r1 _Ctype_int) { _cgo_runtime_cgocall(_cgo_5eb283ffa28f_Cfunc_foo, uintptr(unsafe.Pointer(&r1))) if _Cgo_always_false { } return }
C.foo
の呼び出しは __cgofn_cgo_xxxxx_Cfunc_foo
を経由して //go:linkname runtime.cgocall
から runtime.cgocall
に渡されていることがわかります
runtime.cgocall の実装は見ていただくとして、最終的に runtime.asmcgocall
を呼び出しています
トランポリンコードではこれらを迂回し、直接関数にジャンプします
まずはジャンプするための定義 trampoline.go
package trampoline import "unsafe" //go:noescape func Call(unsafe.Pointer, int64, int64) int64
ジャンプするコードは Go のアセンブラ でこのようにしました
#include "textflag.h" // func Call(unsafe.Pointer, int64, int64) int64 TEXT ·Call(SB), 0, $0-32 MOVQ cfunc+0(FP), AX MOVQ arg0+8(FP), DI MOVQ arg1+16(FP), SI MOVQ SP, BX ADDQ $2048, SP ANDQ $~15, SP CALL AX MOVQ BX, SP MOVQ AX, ret+24(FP) RET
さて、これで 2つの int64 引数を持ち、 int64 を返却するようなコードにジャンプできます
これを利用するには、 cgo の関数を unsafe.Pointer
として渡す必要があります
これは reflect
を使いながら取り出せます
次のような C.mul
であれば
/* long long mul(long long a, long long b) { return a * b; } */ import "C" var ( CmulFunc = unsafe.Pointer(reflect.ValueOf(C.mul).Pointer()) ) func Trampoline(a, b int64) int64 { return trampoline.Call(CmulFunc, int64(a), int64(b)) } func Cmul(a, b int64) int64 { return int64(C.mul(C.longlong(a), C.longlong(b))) } func Gomul(a, b int64) int64 { return a * b }
Trampoline
が トランポリン呼び出しです
Cmul は cgo のもの、 Gomul が Go で実装したものになるので、ベンチマークを取ってみます
BenchmarkMul BenchmarkMul/cgo BenchmarkMul/cgo-8 22838055 49.73 ns/op BenchmarkMul/go BenchmarkMul/go-8 1000000000 0.2326 ns/op BenchmarkMul/trampoline BenchmarkMul/trampoline-8 472055712 2.548 ns/op
2.5ns/op
まで減らすことができました。元が 50ns/op
程度あるので 25倍高速です
ただ、このコードには問題があります
Goのランタイムがやっていることを実施できていません、例えばスタックの拡張が行えていないため
ユーザスタックの 2048B 以上を利用する場合に fatal: morestack on g0
となり runtime.morestack
を呼ばなければいけません
そのためオーバーヘッドにあたる 50ns
をどう見るかなのですが
Go が安全に実行できるようにしてくれていることなので 、 cgo では 50nsの下駄を履いている と思うようにして、実際にトランポリンコードは使わないようにしています
今後非常に小さなオーバーヘッドも気になる場合は使う機会があるかもしれません
実はこのアセンブリの呼び出しなのですが、 Halide では Func#compile_to_assembly や Func#compile_to_llvm_assembly でアセンブリコードを出力する仕組みがあります
このアセンブリをplan9s形式に変換してから呼び出せば良いはずなのですが、変換が思った以上に面倒だったので今回のトランポリンコードを作成する形になりました
cgo で malloc したものを使いたい
cgo を利用する上で悩ましいのはメモリ空間だと思います
特に Go で初期化した cgo のアドレスを持ち込むことができないため、C側でwrapperを用意することがあることでしょうか
例えば次のようなコードは Go で初期化した値を C に渡そうとしている例です
package main /* typedef struct my_type_t { unsigned char *data; } my_type_t; int sum(my_type_t *src, int size) { int sum = 0; for(int i = 0; i < size; i += 1) { sum += (int)src->data[i]; } return sum; } */ import "C" import ( "unsafe" ) func CreateMyType(data []byte) *C.my_type_t { return &C.my_type_t{ (*C.uchar)(unsafe.Pointer(&data[0])), } } func SumMyType(a *C.my_type_t, size int) int { ret := C.sum(a, C.int(size)) // => panic: runtime error: cgo argument has Go pointer to Go pointer return int(ret) } func main() { data := make([]byte, 10) data[0] = 100 data[1] = 200 a := CreateMyType(data) println(SumMyType(a, 10)) }
このコードは runtime error: cgo argument has Go pointer to Go pointer
と言われ panic します
これを回避するには、 GODEBUG=cgocheck=0
を用いるか、 C で初期化してから Go で使うかになります
今回は後者を利用します
package main /* #include <stdlib.h> #include <string.h> typedef struct my_type_t { unsigned char *data; } my_type_t; int sum(my_type_t *src, int size) { int sum = 0; for(int i = 0; i < size; i += 1) { sum += (int)src->data[i]; } return sum; } my_type_t *create_my_type(){ my_type_t *t = (my_type_t *) malloc(sizeof(my_type_t)); memset(t, 0, sizeof(my_type_t)); return t; } void free_my_type(my_type_t *t) { free(t); } */ import "C" import ( "unsafe" ) func CreateMyType(data []byte) *C.my_type_t { t := C.create_my_type() t.data = (*C.uchar)(unsafe.Pointer(&data[0])) return t } func SumMyType(a *C.my_type_t, size int) int { ret := C.sum(a, C.int(size)) return int(ret) } func main() { data := make([]byte, 10) data[0] = 100 data[1] = 200 a := CreateMyType(data) defer C.free_my_type(a) // free() を忘れずに! println(SumMyType(a, 10)) // => 300 }
このコードでは C 側で malloc して my_type_t*
を作成し、Goではそのポインタを渡して使うようにしています
また my_type_t*
の メモリ空間は C 側になるため、 defer を使って C.free_my_type()
を呼び出し内部で free
を呼び出して開放しています
ポインタどのように管理されているかの詳細は cmd/cgo#Passing pointers あたりを確認ください
cgo.Handle への応用
go1.17 から runtime/cgo.Handle が導入され、cgo側から Go の呼び出しが可能になりました
例えば先ほどのコードであれば Go で作ったものは Go で処理すれば良いため、最初のコードを cgo.Handle
を使って次のように変えれます(Go側では go_sum
を exportしてます)
package main /* typedef struct my_type_t { unsigned char *data; int size; } my_type_t; extern int go_sum(void *ctx); static int sum(void *ctx) { return go_sum(ctx); } */ import "C" import ( "runtime/cgo" "unsafe" ) //export go_sum func go_sum(ctx unsafe.Pointer) C.int { h := *(*cgo.Handle)(ctx) defer h.Delete() t := h.Value().(*C.my_type_t) data := unsafe.Slice((*byte)(t.data), int(t.size)) sum := 0 for _, d := range data { sum += int(d) } return C.int(sum) } func CreateMyType(data []byte) *C.my_type_t { t := &C.my_type_t{} t.data = (*C.uchar)(unsafe.Pointer(&data[0])) t.size = C.int(len(data)) return t } func main() { data := make([]byte, 10) data[0] = 100 data[1] = 200 a := CreateMyType(data) h := cgo.NewHandle(a) sum := C.sum(unsafe.Pointer(&h)) println(sum) // => 300 }
これで、C側でmallocする必要はなくなり、Goで確保したメモリ空間だけで利用できます(この例だと何も嬉しさはありませんが...)
さて、 cgo.Handle
を使うことで C から Go の呼び出しが行えるようになりました
これは今まで C のメモリ空間とGoのメモリ空間は別々に管理しなければいけなかったものが、統合しやすくなりました
例として次のようなメモリアロケータ風のものを実装してみます
package main /* #include <stdlib.h> */ import "C" import ( "unsafe" ) type Allocator struct { pool chan unsafe.Pointer } func (a *Allocator) Get(size uint) unsafe.Pointer { select { case buf := <-a.pool: return buf default: buf := C.malloc(C.size_t(size)) return unsafe.Pointer(buf) } } func (a *Allocator) Put(p unsafe.Pointer) { select { case a.pool <- p: // ok default: C.free(p) } } func NewAllocator(size int) *Allocator { return &Allocator{ make(chan unsafe.Pointer, size), } }
これを cgo.Handle
を経由させることで、 CとGoどちらも同じソースから確保出来るようになります
package main /* extern void *pool_get(void *ctx, unsigned int size); extern void pool_put(void *ctx, void *data); static void example(void *ctx) { unsigned char *data = (unsigned char *) pool_get(ctx, 1024); // do something for(int i = 0; i < 1024; i += 1) { data[i] = 255; } pool_put(ctx, data); } */ import "C" import ( "runtime/cgo" "unsafe" ) //export pool_get func pool_get(ctx unsafe.Pointer, size C.uint) unsafe.Pointer { handle := *(*cgo.Handle)(ctx) alloc := handle.Value().(*Allocator) return alloc.Get(uint(size)) } //export pool_put func pool_put(ctx unsafe.Pointer, data unsafe.Pointer) { handle := *(*cgo.Handle)(ctx) alloc := handle.Value().(*Allocator) alloc.Put(data) } func ExampleCgo(alloc *Allocator) { handle := cgo.NewHandle(alloc) defer handle.Delete() C.example(unsafe.Pointer(&handle)) } func ExampleGo(alloc *Allocator) { p := alloc.Get(1024) defer alloc.Put(p) data := unsafe.Slice((*byte)(p), 1024) // do something for i := 0; i < 1024; i += 1 { data[i] = 127 } } func main() { alloc := NewAllocator(1000) // use C ExampleCgo(alloc) // use Go ExampleGo(alloc) }
これの副作用としては GC の無いC側でメモリが確保されるため、メモリリークに気をつけなくてはいけない
一方で、GCによるメモリ解放の細かな影響は受けにくくなります
まとめ
cgo はさまざまな資産と連携できるとても良い機能です、GoからCを参照する場合でも安全になる工夫が沢山用意されているのでうまく活用していきたいです
ここに書いたもの以外にも、アウトラインを書いている段階ではもう少し細かなネタがあったのですが、発表で話したかったの内容から発散しそうでしたのでカットしています
僕の slack チャンネルには記録が残っているのでいつかどこかのタイミングで書き起こせたらなと思っています
Go Conference 2023 ではとても良い機会を与えていただけました
今後もコミュニティに還元できそうなものがあれば積極的にやっていきたいなと思っています!
宣伝
来る 6/15 には 【Gophers Talk】スポンサー4社による合同LT & カンファレンス感想戦 と題して
スポンサー4社による非公式After Partyをやるそうですので、ご興味のある方はぜひ参加ください!
We are hiring!
配信基盤チームでは cgo も活用してより高速に安定した配信サーバの開発を行っています!
われこそはという方お待ちしております!