Mirrativ tech blog

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

1ms 以下のリアルタイムオブジェクト検出/画像処理を目指して Goの配信サーバサイドで通知ぼかしを実装してみたこと

f:id:octu0:20210908171202p:plain

こんにちは ハタ です。
今回は以前iOSのクライアントサイドで実装していた通知ぼかし機能をサーバサイド(配信サーバ)上に再実装した事を書きたいなと思います

今回はかなり内容を絞りに絞ったのですが、長くなってしまいました、、
目次機能があったのでつけてみました、読み飛ばして読みやすくなった(?)かもしれません

目次

通知ぼかし機能とは

通知ぼかし機能は、ミラティブ上での配信中に写り込んでしまったiOSの通知ダイアログをダイアログの中身を見えないようにぼかし処理をしてあげる機能のことです

f:id:octu0:20210908171236g:plain
よくあるTwitterなどの通知のダイアログ、これを検出してぼかし処理したい

以前はクライアント端末側で実装していた通知ぼかし機能ですが、下記のような問題点がありました

  • 配信中に通知ダイアログの検出およびぼかし処理を行うため端末が熱くなる
  • 配信の異常終了率が上がる

どちらも端末側での処理が大きくなっていることが原因で、既存の配信処理(収録 + エンコーディング + 配信)に加えて通知ぼかし処理を行うのは難しく快適な配信を行えなくなってしまうことから機能そのものがなくなっていました

ですが、TwitterやLINEなどの通知が配信に写り込んでしまうことを防ぐために、今回クライアントサイドではなく、サーバサイドで通知ぼかしを再実装することにしました

サーバサイド通知ぼかし

f:id:octu0:20210908171322p:plain
サーバの構成概要

ミラティブの配信サーバは上記のような構成となっていて、トランスコーダサーバというものが前段に立っていて、映像/音声を受け取るようになっています
今回のサーバサイド通知ぼかしはこのトランスコーダサーバ上に実装しました

以前はiOS上で動作するためにSwiftで実装されていたので、それをGoで再実装ようにしました
基本的な方針は以前のものと同じですがいくつか違いがある状態で実装しています
(以前の実装内容は 過去の記事をご覧ください)

  • 【同じ】教師データなしのアルゴリズムで実装する
  • 【違い】ダークモードにも対応するため輝度変化による検出ロジックを変えている
  • 【違い】端末の情報が取れないため画像回転処理などは別に用意している
  • 【同じ】ミラティブの通知はぼかし処理を行わない(アイコンの検出)

配信サーバ上では多数の配信を受けていることから、かなり処理速度にはシビアに実装する必要がありました
例えば30fpsの配信を受けている場合に、1配信あたり30fps程度のトランスコーディングの速度(約30ms)だとして、複数の配信すべてを30msで処理していてはトランスコーダサーバあたりの収容率が下がってしまい、膨大な数のサーバが必要になってしまいます
その他にも、ミラティブではスケーラビリティの観点からできる限り汎用的なサーバを選択するようにしているため、極力CPU処理だけで処理速度を出せるように注意して実装する必要がありました
そのためクライアントサイドで実装するものよりもう少し工夫したアルゴリズムを組んだりする必要がありました

今回はこうした事情から、ちょっとだけ頑張った最適化処理や利用したライブラリについて書いていきたいなと思います

プロトタイプの実装

既存のSwiftのロジックがあるため、プロトタイプはGoだけを使って実装することにしました
GoのImageパッケージ では画像処理によく利用する YCbCr や画像解析処理で処理しやすい RGBA が利用できるため比較的簡単に実装できそうでした

苦労の始まり その1 画像処理速度

f:id:octu0:20210908171348g:plain
各フレームに数字を振ってみました

これはiOSの通知ダイアログが出てくるアニメーションなのですが、最初の文字が見えるまでの時間を見るとおおよそ3フレーム前後には最初の文字が見えてくることがわかりました

f:id:octu0:20210908171411p:plain
ダイアログが表示されるまでの様子

通知ぼかしでは通知内容を見えないようにしたいため、3フレーム目(おおよそ 100ms以下)で通知ダイアログの判定とぼかし処理を行う必要があります

当初愚直にそのままアルゴリズムのSwiftからGoに移植すればよいのだろうと進めていたのですが
愚直にそのままだとあまりにも実行時間が伸びるものがいくつか出てきました

例えば以下のような YCbCr <-> RGBA に変換する処理です

import (
  "image"
  "image/color"
  "image/draw"
)

func rgbaToYCbCr420(src *image.RGBA) *image.YCbCr {
  r := src.Bounds()
  dst := image.NewYCbCr(r, image.YCbCrSubsampleRatio420)
  for y := 0; y < r.Dy(); y += 1 {
    for x := 0; x < r.Dx(); x += 1 {
      rgba       := src.RGBAAt(x, y)
      yy, uu, vv := color.RGBToYCbCr(rgba.R, rgba.G, rgba.B)

      cy := dst.YOffset(x, y)
      ci := dst.COffset(x, y)
      dst.Y[cy]  = yy
      dst.Cb[ci] = uu
      dst.Cr[ci] = vv
    }
  }
  return dst
}

func ycbcrToRGBADraw(src *image.YCbCr) *image.RGBA {
  r := src.Bounds()
  dst := image.NewRGBA(r)
  draw.Draw(dst, dst.Bounds(), src, image.Pt(0, 0), draw.Src)
  return dst
}

これだけを見ると image/colorimage/draw を使うことで非常にシンプルに変換できるため使いたかったのですが
大量の映像フレームを扱うことになってくるとこのあたりの処理速度が気になってきます
下記は 360p の1フレームあたりの処理時間です、RGBA -> YCbCr に 3.914ms / YCbCr -> RGBA に 0.976ms 合わせて 4.89ms ほどかかりそうです

goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-8569U CPU @ 2.80GHz
BenchmarkRGBAToYCbCr420
BenchmarkRGBAToYCbCr420-8            304           3914520 ns/op
BenchmarkYCbCrToRGBADraw
BenchmarkYCbCrToRGBADraw-8          1214            976054 ns/op
PASS

RGBA -> YCbCr のコード(rgbaToYCbCr420) は X,Y で2重のループになっているのですが、 RGBA を扱う1次元配列の中身は [R, G, B, A, R, G, B, A] のように 4 bound で1つ、また Stride 毎に決められているため
X * Y 回数だけループを回るよりも多少 unroll が行えそうです
下記のように書き直してみました

func rgbaToYCbCr420Unroll(src *image.RGBA) *image.YCbCr {
  b := src.Bounds()
  width, height := b.Dx(), b.Dy()

  stride := 4 // 4 = r + g + b + a
  dst := image.NewYCbCr(b, image.YCbCrSubsampleRatio420)
  for i := 0; i < height * width * stride; i += stride {
    r := src.Pix[i + 0]
    g := src.Pix[i + 1]
    b := src.Pix[i + 2]
    _ = src.Pix[i + 3] // A

    yy, uu, vv := color.RGBToYCbCr(r, g, b)

    is := i / stride
    h := is / width
    w := is % width

    cy := (h * dst.YStride) + w
    ci := ((h / 2) * dst.CStride) + (w / 2)
    dst.Y[cy] = yy
    dst.Cb[ci] = uu
    dst.Cr[ci] = vv
  }
  return dst
}

結果として、多少改善しましたが(3.9ms から 3.4ms)ほんのわずかでした

goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-8569U CPU @ 2.80GHz
BenchmarkRGBAToYCbCr420
BenchmarkRGBAToYCbCr420-8                    300           3928816 ns/op
BenchmarkRGBAToYCbCr420Unroll
BenchmarkRGBAToYCbCr420Unroll-8              349           3409538 ns/op
PASS

RGBA -> YCbCr は仕方がないとして、 YCbCr -> RGBA はどうでしょうか
今回扱っている YCbCr のサブサンプリングは 4:2:0 のため、いわゆる YUV420 なのである程度 stride 毎に unroll 効果が期待できたりするかもしれません

func ycbcrToRGBA(src *image.YCbCr) *image.RGBA {
  b := src.Bounds()
  width, height := b.Dx(), b.Dy()

  rgbPlane := make([]byte, width * height * 4) // 4 = r + g + b + a
  i := 0
  for h := 0; h < height; h += 1 {
    for w := 0; w < width; w += 1 {
      y := src.Y[(h * src.YStride) + w]
      u := src.Cb[((h / 2) * src.CStride) + (w / 2)]
      v := src.Cr[((h / 2) * src.CStride) + (w / 2)]

      r, g, b := rgbBT2020Limited(y, u, v)

      rgbPlane[i + 0] = r
      rgbPlane[i + 1] = g
      rgbPlane[i + 2] = b
      rgbPlane[i + 3] = 0xff // A
      i += 4
    } 
  }   
  return &image.RGBA{Pix: rgbPlane, Stride: 4 * width, Rect: b}          
}

func rgbBT2020Limited(y, u, v byte) (r, g, b byte) {
  yf, uf, vf := float64(y), float64(u), float64(v)

  yy := (yf - 16.0) * 1.164384
  uu := (uf - 128.0)
  vv := (vf - 128.0)

  rr := yy + (1.67867 * vv)
  gg := yy - (0.187326 * uu) - (0.65042 * vv)
  bb := yy + (2.14177 * uu)

  r = byte(clamp(rr, 0.0, 255.0))
  g = byte(clamp(gg, 0.0, 255.0))
  b = byte(clamp(bb, 0.0, 255.0))
  return
}

func clamp(value float64, min, max float64) float64 {
  if value < min {
    return min
  }
  if max < value {
    return max
  }
  return value
}

結果的にこれも早くなったりしませんでした(BenchmarkYCbCrToRGBADrawが元のDrawを使った処理)

goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-8569U CPU @ 2.80GHz
BenchmarkYCbCrToRGBADraw
BenchmarkYCbCrToRGBADraw-8          1258            951475 ns/op
BenchmarkYCbCrToRGBA
BenchmarkYCbCrToRGBA-8               606           1942185 ns/op
PASS

ただ、これらはforループで逐次処理してるためですが、もう少しCPU命令を使えれば最適化できそうです

libyuv はこうした処理に最適化されているためそれぞれ次のように実装しました

import(
  "fmt"
  "image"
)

/*
#cgo CFLAGS: -I${SRCDIR}/include
#cgo LDFLAGS: -L${SRCDIR} -lyuv
#include "libyuv.h"
*/
import "C"

func libyuvRGBAToI420(src *image.RGBA) (*image.YCbCr, error) {
  r := src.Bounds()
  w, h := r.Dx(), r.Dy()

  y := make([]byte, w * h)
  u := make([]byte, ((w + 1) / 2) * ((h + 1) / 2))
  v := make([]byte, ((w + 1) / 2) * ((h + 1) / 2))

  ret := C.ABGRToI420(
    (*C.uchar)(&src.Pix[0]),
    C.int(w * 4),
    (*C.uchar)(&y[0]),
    C.int(w),
    (*C.uchar)(&u[0]),
    C.int(w / 2),
    (*C.uchar)(&v[0]),
    C.int(w / 2),
    C.int(w),
    C.int(h),
  )
  if int(ret) != 0 {
    return nil, fmt.Errorf("error desu i420")
  }
  return &image.YCbCr{
    Y:    y,
    Cb:   u,
    Cr:   v,
    YStride: w,
    CStride: w / 2,
    Rect:    image.Rect(0, 0, w, h),
    SubsampleRatio: image.YCbCrSubsampleRatio420,
  }, nil
}

func libyuvI420ToRGBA(src *image.YCbCr) (*image.RGBA, error) {
  r := src.Bounds()
  w, h := r.Dx(), r.Dy()

  rgba := make([]byte, w * h * 4)
  ret := C.I420ToABGR(
    (*C.uchar)(&src.Y[0]),
    C.int(src.YStride),
    (*C.uchar)(&src.Cb[0]),
    C.int(src.CStride),
    (*C.uchar)(&src.Cr[0]),
    C.int(src.CStride),
    (*C.uchar)(&rgba[0]),
    C.int(w * 4),
    C.int(w),
    C.int(h),
  )
  if int(ret) != 0 {
    return nil, fmt.Errorf("error desu rgba")
  }
  return &image.RGBA{
    Pix:    rgba,
    Stride: w * 4,
    Rect:   image.Rect(0, 0, w, h),
  }, nil
}

CGOの呼び出しの場合で最初にハマるのは、 byte slice を unsignec char* に変換する処理でしょうか、スライスの先頭のアドレスを渡してあげるのですが、久しく C/C++ を触っていなかったので少し戸惑いました
また (*C.uchar)(&slice[0]) よりももう少しちゃんと書くとすると (*C.uchar)(unsafe.Pointer(&slice[0])) 等になるかもしれません、ここではいったん割愛します

上記コードで image.RGBA は [R, G, B, A] のカラーモデルですが、 ABGRToI420I420ToABGR として [A, B, G, R] と逆順のカラーモデルとして呼び出してます
これは FORCC マクロにしたがっているためで LittleEndian の世界から見るとややこしいのですが、いったんこれについても割愛します(今回とは別軸でハマりポイントがあるのでいつか書きたいと思ってます)

さて、 libyuv にしたことにより RGBA -> YCbCr が 0.075ms (Go版は 3.914ms) / YCbCr -> RGBA が 0.125ms (Go版は 0.976ms) となりかなり処理速度が向上しました

goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-8569U CPU @ 2.80GHz
BenchmarkLibyuvRGBAToI420
BenchmarkLibyuvRGBAToI420-8        15346             75770 ns/op
BenchmarkLibyuvI420ToRGBA
BenchmarkLibyuvI420ToRGBA-8         8844            125419 ns/op
PASS

libyuv を使用することにより、画像の回転処理も最適化された呼び出しが行えるので、これを利用するようにしていきました

苦労の始まり その2 データ量

f:id:octu0:20210908171444p:plain

以前の実装では輝度変化に着目して特徴量を計算するようにしていました
これはこれでも良いのですが大量のフレームの走査領域のデータを極力減らしたいと考えました

例えば次のような画像の変化量がある領域とそれ以外の領域に分割してみると骨組みだけ取り出すことができます

f:id:octu0:20210908171500p:plain

ロジックとしては下記のようにしてみました

func maxDeriavVar(img *image.RGBA, x, y int, count int) float64 {
  rSum := float64(0)
  gSum := float64(0)
  bSum := float64(0)

  rgbF := func(c color.RGBA) (float64, float64, float64) {
    return float64(c.R), float64(c.G), float64(c.B)
  }
  ptF := func(x, y int) (float64, float64) {
    return float64(x), float64(y)
  }

  prevR,prevG,prevB := rgbF(img.RGBAAt(x, y))
  prevPtX,prevPtY := ptF(x, y)
  for i := 1; i < count; i += 1 {
    ptX, ptY := ptF(x + i, y + i)
    d := math.Max(ptX - prevPtX, ptY - prevPtY)
    r,g,b := rgbF(img.RGBAAt(x + i, y + i))

    rd := math.Abs(r - prevR) / d
    gd := math.Abs(g - prevG) / d
    bd := math.Abs(b - prevB) / d

    rSum += rd * rd
    gSum += gd * gd
    bSum += bd * bd

    prevR,prevG,prevB = r, g, b
    prevPtX,prevPtY = ptX, ptY
  }
  return math.Max(rSum, math.Max(gSum, bSum))
}

func draw(img *image.RGBA) *image.RGBA {
  c := 8
  b := img.Bounds()
  width, height := b.Dx(), b.Dy()

  mono := image.NewRGBA(b)
  for y := 0; y < height - c; y += 1 {
    for x := 0; x < width - c; x += 1 {
      if 50000 < maxDeriavVar(img, x, y, c) {
        mono.SetRGBA(x, y, color.RGBA{0, 0, 0, 255})
      } else {
        mono.SetRGBA(x, y, color.RGBA{255, 255, 255, 255})
      }
    }
  }
  return mono
}

if 50000 < var としている 50000 はただのしきい値なのであまり意味は無いのですが、次のような画像が出力されるようになりました

f:id:octu0:20210908171525p:plain

ここまで出来れば、 0 と 1 に置き換えることができそうです
uint64 では 8byteで64bit長 のビットを扱うことができるため、2値化の結果を保存するようにします

func imgbits(img *image.RGBA) [][]uint64 {
  c := 8
  b := img.Bounds()
  width, height := b.Dx(), b.Dy()

  val := make([][]uint64, height - c)
  for y := 0; y < height - c; y += 1 {
    wc := (width - c) / 64
    val[y] = make([]uint64, wc)
    for x := 0; x < wc; x += 1 {
      b := uint64(0)
      for i := 0; i < 64; i += 1 {
        if 50000 < maxDeriavVar(img, (x * 64) + i, y, c) {
          b |= 1 << (64 - i)
        }
      }
      val[y][x] = b
    }
  }
  return val
}

この結果を fmt.Printf("%064b", val[y][x]) すると 0 と 1 で構成されたデータを作り出すことができました

f:id:octu0:20210908171550p:plain
コンソールに出ているものをそのままなので見えづらくてスイマセン...

さて、ここで 0 と 1 のデータが作れたことで、 popcount がしやすくなりました
popcount は bit列の中から 1 が付いている個数を個数を取り出す関数です
go では math/bitsにOnesCount64 として実装されています

popcount が利用できることで、ハミング距離の計算も行いやすくなります
例えば何かしらのテンプレートとなる bit列を用意しておき、 XOR で差分を取り差分が小さいものほどテンプレートに一致しているということが取れます
go-popcount では AMD64 系に最適化された popcount を使用でき、また、まとめて uint64 slice の popcount を求めてくれるので、それを利用します

import (
    "fmt"
    "github.com/tmthrgd/go-popcount"
)

func main() {
    tpl := []uint64{
        0b_0000_0000_0000_0000,
        0b_0000_0111_1111_0000,
        0b_0000_0100_0001_0000,
        0b_0000_0100_0001_0000,
        0b_0000_0111_1111_0000,
        0b_0000_0000_0000_0000,
    }
    Foo := []uint64{
        0b_0000_0000_0000_0000,
        0b_0000_0100_0001_0000,
        0b_0000_0100_0001_0000,
        0b_0000_0100_0001_0000,
        0b_0000_0100_0001_0000,
        0b_0000_0000_0000_0000,
    }
    Bar := []uint64{
        0b_0000_0000_0000_0000,
        0b_0000_0111_1111_0000,
        0b_0000_0000_0000_0000,
        0b_0000_0111_1111_0000,
        0b_0000_0111_1111_0000,
        0b_0000_0000_0000_0000,
    }
    hammingDistance := func(a, b []uint64) uint64 {
        d := make([]uint64, cap(a))
        for i := 0; i < cap(a); i += 1 {
            d[i] = a[i] ^ b[i]
        }
        return popcount.CountSlice64(d)
    }
 
    fmt.Println(hammingDistance(tpl, Foo)) // => 10
    fmt.Println(hammingDistance(tpl, Bar)) // => 7
    fmt.Println(hammingDistance(tpl, tpl)) // => 0
}

今回は直接紹介しないのですが bit列を扱えることで ビットシフトを行うことで 画像を必要サイズだけ移動させることができるのも便利な点です

func main() {
    val := []uint64{
        0b_0000_0000_0000_0000,
        0b_0000_0111_1111_0000,
        0b_0000_0100_0001_0000,
        0b_0000_0100_0001_0000,
        0b_0000_0111_1111_0000,
        0b_0000_0000_0000_0000,
    }
    right := func(v []uint64, size int) []uint64 {
        c := make([]uint64, cap(v))
        for i := 0; i < cap(v); i += 1 {
            c[i] = v[i] >> size
        }
        return c
    }
 
    fmt.Println("origin")
    for _, v := range val {
        fmt.Printf("%016b\n", v)
    }
 
    fmt.Println("right 2")
    for _, v := range right(val, 2) {
        fmt.Printf("%016b\n", v)
    }
}
// => origin
// => 0000000000000000
// => 0000011111110000
// => 0000010000010000
// => 0000010000010000
// => 0000011111110000
// => 0000000000000000
// => right 2
// => 0000000000000000
// => 0000000111111100
// => 0000000100000100
// => 0000000100000100
// => 0000000111111100
// => 0000000000000000

さらなる計算量の削減を求めて

2値化とpopcountを使用することで アクティブ探索法も行いやすくなりました
情報量が少ない領域は popcount = 0 とみなすことができるため、情報がある領域だけ計算を行うようにできます

f:id:octu0:20210908171630p:plain
これはよくあるミラティブの通知

例えば上記のような画像では、人間の目にはダイアログの範囲が分かりますが、機械の目で見た時にできるだけそれっぽい領域で計算を行ってもらうように、特徴量が少ない領域を読み飛ばす必要があります

f:id:octu0:20210908171644g:plain

上記の動画は、2値化とpopcountを組み合わせた処理後にプログラムが走査している領域をトレースしたものです
情報がほとんどない領域は読み飛ばしていることがわかると思います
あとはさらに物体を検出できる領域だけに絞り込むことで計算量が減らせます

この処理を組み合わせることでミラティブのアイコンがありそうな位置だけ計算するようにしてみたものがこちらになります

f:id:octu0:20210908171754g:plain

必要の無さそうな範囲は処理されていないことが見えると思います

さらなる最適化へ

ここまで古典的な、教科書的な最適化を行いある程度使用に耐えれる速度になりました

ここでは紹介できていませんでしたが、他にもいくつか手をつけていきました

など libyuv には便利なメソッドがたくさんあるため置き換えを行ったりしてます
Goで必要な画像解析処理を書き、速度が求められる場面では Cgo を経由して libyuv に処理させることである程度処理速度も確保できてきました

f:id:octu0:20210908171843g:plain
左がぼかし処理が行われない画面、右が通知ぼかし処理が行われる画面

上記の動画は一通りの終わった後の通知ぼかしが行われる端末の画面の様子を録画してみたものを並べてみました

ここまでで、 Pure Go だけで実装していた場合だと(正確なログが残っていないのでざっくりになりますが)10-15fps程度(100ms+)だったものが 30fps(30ms以下)程度にはなりました
まだまだ遅いのでさらにチューニングを行っていきます

最初に手掛けたのはSIMDで処理できるところを置き換えていくことでした
例えば []byte 同士の AND 処理は、ほぼそのまま grailbio/base/simd に置き換えれます(AND以外もあります)

func BytesAnd(dst []byte, a, b []byte) {
-  for i := 0; i < cap(a); i += 1 {
-    dst[i] = a[i] & b[i]
-  }
+  simd.And(dst, a, b)
}

これは、 処理内容にもよりますが大量の byte slice を処理する場合に効果があります 下記の /32, /64, /128 は要素数です、数が大きくなればなるほど差がでてきます

goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-8569U CPU @ 2.80GHz
BenchmarkGo
BenchmarkGo/32
BenchmarkGo/32-8        140462671                8.460 ns/op
BenchmarkGo/64
BenchmarkGo/64-8        76551399                16.49 ns/op
BenchmarkGo/128
BenchmarkGo/128-8       16259034                74.08 ns/op
BenchmarkSIMD
BenchmarkSIMD/32
BenchmarkSIMD/32-8              223948428                5.355 ns/op
BenchmarkSIMD/64
BenchmarkSIMD/64-8              170726116                7.042 ns/op
BenchmarkSIMD/128
BenchmarkSIMD/128-8             100000000               11.04 ns/op
PASS

他にも float64 の SUM も stuartcarnie/go-simd で置き換えたりが可能でした (いろんなパッケージ使っちゃってますね..)

さて、ここまで libyuv だったり SIMD 対応などを通して、実ロジック以外 cgo 経由の実行が増えてきました
大量の映像フレームを扱っているため、 cgo 経由のオーバヘッドも気になってきました

例えば、下記のような単純な四則演算の呼び出しですが

/*
int add(int a, int b) {
  return a + b;
}
int sub(int a, int b) {
  return a - b;
}
int mul(int a, int b) {
  return a * b;
}
int foo(int a, int b, int c, int d) {
  int r1 = add(a, b);
  int r2 = sub(r1, c);
  int r3 = mul(r2, d);
  return r3;
}
*/
import "C"

func Add(a, b int) int {
  return int(C.add(C.int(a), C.int(b)))
}

func Sub(a, b int) int {
  return int(C.sub(C.int(a), C.int(b)))
}

func Mul(a, b int) int {
  return int(C.mul(C.int(a), C.int(b)))
}

func Foo(a,b,c,d int) int {
  r1 := Add(a, b)
  r2 := Sub(r1, c)                                                                                                                             
  r3 := Mul(r2, d)
  return r3
}

func CFoo(a,b,c,d int) int {
  return int(C.foo(C.int(a), C.int(b), C.int(c), C.int(d)))
}

これを cgo で呼び出した場合と、Cで直接呼び出した場合の benchmark です

goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-8569U CPU @ 2.80GHz
BenchmarkFoo
BenchmarkFoo-8           7721295               167.2 ns/op
BenchmarkCFoo
BenchmarkCFoo-8         23486498                49.88 ns/op
PASS

この Foo(cgoから四則演算したもの) と CFoo(cgoを1回だけ経由したもの) の差分が、 cgo の FFI によるオーバヘッドだと考えられます
ちょうどいいスライド があったため、解説はこちらをみてもらうとして、やはりここも改善したいなと思うようになりました

(余談ですが、四則演算レベルであれば Go で実装したほうがはるかに早いです)

func goAdd(a, b int) int {
  return a + b
}

func goSub(a, b int) int {
  return a - b
}

func goMul(a, b int) int {
  return a * b
}

func GoFoo(a,b,c,d int) int {
  r1 := goAdd(a, b)
  r2 := goSub(r1, c)
  r3 := goMul(r2, d)
  return r3
}

// goos: darwin
// goarch: amd64
// cpu: Intel(R) Core(TM) i7-8569U CPU @ 2.80GHz
// BenchmarkGoFoo
// BenchmarkGoFoo-8        1000000000               0.2443 ns/op
// PASS

さて、SIMD処理への置き換えやその他ロジックの最適化等を行った結果、 300fps(3ms+) くらいまで下げることができました

cgoのオーバヘッドもそうなのですが、マイクロチューニングを頑張ろうと思えば頑張れるのですが、それよりもコードの見通しの悪さが目立つようになりました
極力 Go で書けるところは Go で書いていたのですが、ロジック(アルゴリズム)の修正とデータの持ち方の修正をどちらも行わなければならなく、このあたりからだんだん辛くなってきました

Halide の世界へ

前置きがかなり長くなってしまいましたが、今回メインで紹介したいのが halide-lang です
halide は C++ の DSL言語として利用できる、プログラミング言語です
大きな特徴はアルゴリズムとスケジューリングの分離が行える言語であることと、CPU/GPUなどに最適化されたコードを出力してくれる点が独特です
言語としてみたときの癖のようなものは強いのですが、それよりもコード全体の見通しの良さや強力な最適化(スケジューリングのチューニングが行いやすい)はメリットとして上回るものがあります

簡単な halide の紹介

halide の紹介は こちらのスライド がとてもまとまっているので、割愛しますが
Goで書いた時の画像処理と halide で書いた画像処理の違いを紹介したいなと思います

例として、グレースケールをGoで書いた時のコードです

const(
  GRAY_R int = 76
  GRAY_G int = 152
  GRAY_B int = 28
)

func Grayscale(src *image.RGBA) *image.RGBA {
  b := src.Bounds()
  w, h := b.Dx(), b.Dy()

  dst := image.NewRGBA(b)
  for y := 0; y < h; y += 1 {
    for x := 0; x < w; x += 1 {
      c := src.RGBAAt(x, y)

      gray := ((GRAY_R * int(c.R)) + (GRAY_G * int(c.G)) + (GRAY_B * int(c.B)))
      grayB := byte(gray)
      dst.SetRGBA(x, y, color.RGBA{
        R: grayB,
        G: grayB,
        B: grayB,
        A: 0xff,
      })
    }
  }
  return dst
}

このときの benchmark 結果です、シンプルな処理なのでさほど気になる処理速度ではありません(0.54ms)

goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-8569U CPU @ 2.80GHz
BenchmarkGrayscale
BenchmarkGrayscale-8        2215            540880 ns/op
PASS

これを halide に置き換えてみます、halideでは For の中身を関数にして記述するイメージで画像処理を書くことができます

const int16_t GRAY_R = 76, GRAY_G = 152, GRAY_B = 28;
Func grayscale(Buffer<uint8_t> in) {
  Var x("x"), y("y"), ch("ch");

  Func f = Func("grayscale");
  Expr r = cast<int16_t>(in(x, y, 0));
  Expr g = cast<int16_t>(in(x, y, 1));
  Expr b = cast<int16_t>(in(x, y, 2));
  Expr gray = ((r * GRAY_R) + (g * GRAY_G) + (b * GRAY_B)) >> 8;

  Expr value = select(
    ch == 0, gray, // R
    ch == 1, gray, // G
    ch == 2, gray, // B
    255 // A
  );

  f(x, y, ch) = cast<uint8_t>(value);
  return f;
}

これの実行ですが、 halide は AOT の他に JIT でも実行できるため、今回は JIT で実行します

int main(int argc, char **argv) {
  Buffer<uint8_t> src = load_and_convert_image(argv[2]);

  Func fn = grayscale(src);
  fn.compile_jit(get_jit_target_from_environment());

  Buffer<uint8_t> out = fn.realize({src.get()->width(), src.get()->height(), 4}); // 4 => r + g + b + a

  save_image(out, argv[3]);
  return 0;
}

また、benchmarkですが、 halide tools の benchmark を使うことで計測できます

int main(int argc, char **argv) {
  Buffer<uint8_t> src = load_and_convert_image(argv[2]);

  Func fn = grayscale(src);
  fn.compile_jit(get_jit_target_from_environment());

  double result = benchmark(1000, 100, [&]() {
    fn.realize({src.get()->width(), src.get()->height(), 4});
  });
  printf("%s: %-3.5fms\n", fn.name().c_str(), result * 1e3);
    
  return 0;
}

さて、この結果から次のように出力され、0.01570ms とまぁまぁそこそこの結果です

grayscale: 0.01570ms

halideの本領を発揮するのはここからです
すでに作成済みの関数に手を加えず次のように schedule を追加します
やっていることは、ch 単位に parallel、 x方向にベクトル処理をするようにしました

Func grayscale(Buffer<uint8_t> in) {
  Var x("x"), y("y"), ch("ch");

  Func f = Func("grayscale");
  ...
  f(x, y, ch) = cast<uint8_t>(value);

+  // schedule
+  f.compute_root()
+    .parallel(ch)
+    .vectorize(x, 16);
  return f;
}

このときの結果は 0.00895ms とほぼコードを書き換えずにチューニングすることができました

grayscale: 0.00895ms

今回はグレースケール処理のため、シンプルなのですが他にもチューニングすることができそうです
例えば unroll 処理を加えてみたり、tile化などもできるかもしれません

// unroll
f.compute_root()
  .unroll(ch, 4)
  .parallel(y)
  .vectorize(x, 16);

// tile
f.compute_root()
  .tile(x, y, xo, yo, xi, yi, 16, 16)
  .fuse(xo, yo, ti)
  .parallel(ch)
  .parallel(ti, 4)
  .vectorize(xi, 16);

より詳細はイメージ画像とともにこちらの動画で紹介されているのでご覧ください

halide によって既存ロジックを置き換えることなく、スケジューリングによってチューニングができるようになったのはとても大きく、また チューニングを加えることがなくてもそこそこの良い速度で処理できるのも良いです

その後、 libyuv や SIMD などの処理をすべて halide に置き換えを行いました
いままでカオスだったロジックがシンプルになり、halide に置き換えができたのは正解だったかもしれません

f:id:octu0:20210908172005g:plain
halideに置き換え後の視聴画面の録画です

上の動画はその当時に撮影したもので、大きく遜色なくダイアログの検出とぼかし処理が行われていると思います
この時点で、0.7ms 程度でfpsにすると1400fpsくらいでしょうか、(もろもろ荒削りですが)検出からぼかし処理までを行えるようになっていました
1ms以下にできたのはとても大きいです

苦労の始まり その3 いざ リリース

いざリリースを行って最初に遭遇したのは SEGV 関連のサーバクラッシュです
Go 単体でのデバッグは比較的容易なのですが、 cgo が絡むととても難易度があがります

クラッシュで一番多かったのは、 malloc などで確保した領域外のアクセスでした
例えば次のようなコードです

/*
#include <stdlib.h>

typedef struct {
  int x;
  int y;
  unsigned char *buf;
} my_result_t;

my_result_t *exec(int x, int y) {
  my_result_t *r = (my_result_t *) malloc(sizeof(my_result_t));
  if(r == NULL) {
    return NULL;
  }
  r->x = x;
  r->y = y;
  return r;
}
*/
import "C"

func Exec(x, y int) *C.my_result_t {
  r := C.exec(
    C.int(x),
    C.int(y),
  )
  return r
}

func foo() {
  r := Exec(12345, 5678)

  println(r.buf) // ここで signal SIGSEGV: segmentation violation ...
}

おそらく上記コードでは落ちることはないので、SEGVになるイメージのコードなのですが、Goに慣れていると ゼロ値で初期化されているつもりでコードを書いている箇所がクラッシュしがちでした
直すとしたらこんな感じでしょうか

my_result_t *exec(int x, int y) {
  my_result_t *r = (my_result_t *) malloc(sizeof(my_result_t));
  if(r == NULL) {
    return NULL;
  }
+ memset(r, 0, sizeof(my_result_t));

  r->x = x;
  r->y = y;
  return r;
}

その他にも Cgo から C の関数を呼び出している場合に、stacktraceが表示されず途方に暮れることがあったのですが、cgosymbolizer は import するだけで利用できるので使うと便利でした

// #include ...
// ....
import "C"
import (
  _ "github.com/benesch/cgosymbolizer"
)

他にも byte slice を cgo に渡す場合、逆に cgo 側で alloc した領域を Go で使う場合も結構こまめにケアが必要でした
いくつかケアするポイントがあった気がするので別途また書ければなと思っています

地道に穴埋めを頑張り、やっと安定するようになりました
即座にクラッシュするものばかりではなかったので、安定するまで本当に長い道のりでした

リリースその後

現在安定版がリリースされ数ヶ月がたちました
一部のiOSユーザさんはこの通知ぼかし機能が自動で適用さているようになっています

最終的にトランスコーダサーバのリポジトリは Go で書いたコードよりも C/C++ のコードのほうが多くなってしまいました

f:id:octu0:20210908181839p:plain
リポジトリ内の言語の割合

今回紹介しきれなかったのですが、画面の回転処理について紹介しきれていません
ミラティブの配信はスマートフォンが多くをしめるのため、ゲーム開始しているときの画面とエモモが表示されているときの画面で、結構頻繁にorientation(landscape/portrait)がちょくちょく変わります

今現在は、画面の3方向(-90/0/90)に対して通知ダイアログの検出を行うようにしているのですが、このあたりの情報はもう少しインタラクティブに端末とサーバで情報の交換(画面の回転情報)をできるようにできればなと思っています(絶賛実装中です)

他にもダークモード/ライトモードで輝度変化の違いがあるため、画像解析まわりで工夫したポイントは紹介しきれていません
機械学習などで精度を高めるためにやっていることとほぼ同じなのですが、なかなか面白かったのでいつか紹介したいなと思っています

今回登場した halide-lang ですが、よく使いそうな処理等とベンチマークを octu0/blurry にてまとめてみたライブラリを作ってみたので、気になる方は見ていただけるといいかもしれません

github.com

We are hiring!

クライアントサイドでできなかった事をサーバサイドで実現するために一緒に開発してくれるエンジニアを募集中です!
もっと最適化の余地がありそうなので我こそはと思った方、応募お待ちしております

www.mirrativ.co.jp

speakerdeck.com