Mirrativ Tech Blog

株式会社ミラティブの開発者(バックエンド,iOS,Android,Unity,機械学習,インフラ, etc.)によるブログです

Go Conference 2023 にて Go/Cgoで映像・音声のリアルタイム処理をやるまでの道のり というタイトルでお話してきました

こんにちは ハタ です。
このたび Go Conference 2023 Online にて Go/Cgoで映像・音声のリアルタイム処理をやるまでの道のり というタイトルで発表しました
久しぶりの大きなカンファレンスでの登壇だったのでとても緊張しましたが、10周年を迎えた機会に登壇できて光栄です

gocon.jp

今回発表した資料はこちらになります、当日のアーカイブは近々公開されると思いますのでお待ち下さい

speakerdeck.com

(2023/06/28追記)
アーカイブ公開されたようです!
もし当日ご覧になれなかった方はこちらからご視聴ください!

zenn.dev

さて、今回登壇するにあたり資料を用意していたのですが、発表時間の関係で削除してしまった内容がいくつかあるため、ここで紹介させてください

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_assemblyFunc#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をやるそうですので、ご興味のある方はぜひ参加ください!

mirrativ.connpass.com

We are hiring!

配信基盤チームでは cgo も活用してより高速に安定した配信サーバの開発を行っています!
われこそはという方お待ちしております!

www.mirrativ.co.jp