Mirrativ tech blog

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

iOSDC Japan 2021 にエンジニア2名が登壇します!!

こんにちは、クライアントエンジニアの竹澤(@to4iki)です。
先日のApple Eventを見てポートがUSB Type-Cとなった新しい iPad mini が欲しくなりました。

さて、今年も毎年恒例のiOSDC Japanの開催時期となりましたね!
昨年と同様にオンラインで開催され、事前収録によるレギュラートークのストリーミング配信、ライブ配信のLT、スピーカーと直接話すことができるAsk the Speakerなどが予定されています。 iosdc.jp

そして今年は、ミラティブからは2名のエンジニアのトークが採択され、レギュラートークで登壇します。
2名とも9/19(日) Day2 の登壇となりますので、 コメントと合わせて簡単に紹介します。ぜひご覧ください!

登壇情報

■ ケースに応じたUICollectionViewのレイアウト実装パターン

日時: 2021/09/19 13:30〜 Track A(20分)
登壇者: to4iki / @to4iki
fortee.jp

iOS13以降SwiftUIに注目が集まる中、UICollectionView関連のAPIも大きな進化を遂げています。
新旧のUICollectionViewLayoutのサブクラスの特徴と利用ケースを説明しながら、実装パターンを紹介します。

■ App Extension のスタックトレース情報からクラッシュを解析/集計する

日時: 2021/09/19 14:10〜 Track D(20分)
登壇者: 千吉良 成紀 / @_naru_jpn
fortee.jp

サービスを開発する中でクラッシュを調査していて、いくつか分かったことやできるようになったことがありました。
スタックトレースの内容を理解するために必要な情報はなにか、なにをすればいいのか、手順を追って説明し、クラッシュの改善結果なども併せてご紹介します。

We are hiring!

ミラティブでは一緒にアプリを作ってくれる iOS エンジニアを募集しています!
トークを見て少しでも興味を持っていただいた方は気軽にご連絡ください。 www.mirrativ.co.jp

speakerdeck.com

最近 Meety の特集ページもできたので覗いてみてください。 meety.net

Mirrativのバックエンド開発におけるMySQLとの向き合い方

こんにちは、バックエンドエンジニアの牧野です。先日、LINE LIVEさんとの共催イベントにて「Mirrativを支えるバックエンド開発 ~MySQLとの向き合い方~」というテーマでLTをしました。

connpass.com

speakerdeck.com

今回はLTの内容から一部抜粋して、Mirrativのバックエンド開発において遭遇したMySQLに関する問題と、その対策について紹介します。

問題 その1

データ量/QPSの増加に伴って、非効率なクエリが顕在化した

サービス初期の段階ではデータ量が少なかったり、ユーザーのアクティビティが少ないために問題がなかったクエリも、サービスの成長に伴ってデータ量・QPSが増加したことによって、MySQLに負荷をかけてしまうことがありました。

具体例を以下にいくつか示します。

  • 数千件レコードのfilesort
  • limit offsetを利用したページングを実装し、offsetが大きい値になったときに劣化する
    • limit 10 offset 10000のようなクエリは10,000件のレコードをスキャンした後に10件のレコードを返すので、offsetに大きい値が指定されると非効率なクエリになってしまいます。Mirrativの開発では、以下の記事に紹介されているようなカーソルベースのページングを実装することを推奨しています。
    • Evolving API Pagination at Slack - Slack Engineering
  • パーティションプルーニングが効かないクエリを発行してしまう
  • ユニークなキーを指定せずに排他ロックをかけてしまい、広範囲にロックをかけてしまう

こういったクエリが混入してしまわないように、非効率なクエリを機械的に検知できるような対策を導入しました。

対策

①クエリ発行時にEXPLAINを実行し、非効率なクエリになっていないかチェックする

素朴なアプローチです。CIでEXPLAINを実行し、EXPLAIN結果にfilesortといった文言が含まれていないかチェックしています。
ただし、CI環境では本番環境と同等のデータ量を用意することができていないのもあり、本番環境とは実行計画が異なる可能性があります。
そのため、本番環境でもEXPLAINの実行を行っています。また、EXPLAINの実行によるオーバーヘッドがあるので、ユーザー影響のない頻度に絞って行うようにしています。

②クエリを宣言し、lintによってチェックする

MirrativではYAMLファイルにテーブル定義と利用するクエリを書き、DDLやクエリを発行するコードの生成を行っています。YAMLファイルのフォーマットは以下のようなイメージです。

user:
  columns:
    - name: user_id
      type: uint64
    - name: name
      type: string
    - name: locale
      type: string
  sql:
    - select * from user where user_id = :user_id

このYAMLに書かれたクエリに対してlintをかけて、ルールを守ったクエリになっているかチェックを行っています。

問題 その2

クエリがどこから発行されているのか分からない

スロークエリログなどから問題となるクエリが判明したとしても、そのクエリがアプリケーションのどこから発行されているのか調査に時間がかかるという問題がありました。

MirrativのバックエンドシステムはMVCのアーキテクチャを採用していたのですが、あらゆるレイヤーからSQLが投げられていました。また、ORMによってコード上で柔軟にSQLを組み立てることができていたのも、発見までに時間がかかる要因となっていました。

対策

①Clean Architectureへの移行

Clean Architectureを参考にシステムのアーキテクチャを再設計し、クエリを発行する責務を持ったレイヤーを明確に分離することで、見通しを良くしました。 詳細については以下の記事をご覧ください。 tech.mirrativ.stream

②クエリからコード生成を行う

前述したようにアプリケーションで利用するクエリをYAMLに書いておき、そこからコード生成を行っています。(https://github.com/kyleconroy/sqlcを参考に実装しています)
クエリとコードが1対1になり、問題となっているコードを特定することが容易になりました。

③クエリにコメントを仕込む

これもよくある素朴なアプローチですが、以下のようなコメントをクエリに仕込むことで、問題となるログを発見した際にどのようなコンテキストで発行されているかが分かるようになりました。

/* [endpoint]([trace_id]) */ select * from user where user_id = 1;

Mirrativでは、エンドポイント名やリクエストを識別するIDなどをコメントに仕込んでいます。

番外編

先日のイベントでは時間の都合上発表できなかったtipsを紹介します。

開発環境での強制的なレプリ遅延

MySQLにおいてレプリケーションを利用すると、数秒程度のレプリケーション遅延(スレーブへの反映の遅れ)が発生することがあるので、アプリケーション側ではレプリケーション遅延を考慮した実装を行う必要があります。 ですが、レプリケーション遅延はスレーブに負荷がかかったときに発生しやすく、開発環境では問題が発生せずに本番環境で初めて問題が発生するといったことが起こりがちです。

Mirrativでは遅延レプリケーション(MASTER_DELAYオプション)の設定を有効にすることで、開発環境では意図的に遅延を発生させて、レプリケーション遅延による問題を早期発見できるようにしています。 dev.mysql.com

まとめ

Mirrativのバックエンド開発において遭遇したMySQLに関する問題と、その対策を紹介しました。
今後の展望としては、本番同等のデータ量がある検証環境でCIを行ったり、人力によるレビューに頼ってlint化できていないルールがあるので、このあたりの改善を行うことでより堅牢なシステムを作っていければと思います。

また、今回はバックエンドチームの取り組みについて紹介しましたが、インフラチームによるMySQL運用ノウハウに関する記事もぜひご覧ください。 tech.mirrativ.stream

We are hiring!

高トラフィック環境で発生する問題に愚直に向き合うことが好きなバックエンドエンジニアを募集中です!

www.mirrativ.co.jp

speakerdeck.com

Meetyもぜひ! meety.net

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

ISUCON11予選に参加して予選敗退しました

こんにちわ、エンジニアのタテノです。

8/21 に ISUCON11予選 が開催され、ミラティブから私とかずたかさん、stakmeさんの3人でチームミラティブとして参加しました。

isucon.net

3人ともISUCON参加ははじめてで、各々事前準備しつつ、当日、一時30位くらいまでスコアが伸びる場面もあるなど健闘しましたが、残念ながら予選敗退しました。 ミラティブからは、牧野さんが別チームで参加しており、見事10位で予選通過しました!おめでとうございます!

tech.mirrativ.stream

本記事では、チームミラティブでのISUCON予選を振り返っていきます。

参加表明

5/28 にISUCON11 の予選受付日程がアナウンスされ、牧野さんからエンジニアチームに対して、ミラティブチームでも出ませんかとお声がけがあります。

f:id:hirota982:20210826112649p:plain

私は、これまでちょくちょくISUCONの話を聞いていたものの参加したことはなく気になっていました。 牧野さんは本戦出場経験もあり、後にコンテスト中にどんなことをやるといいのかをシェアしてくれるのですが、やはり経験者が近くにいて話が聞けて心強かったですし、いい機会だなと思えたので参加することにしました。

ISUCONはメンバー3人までのチーム制です。かずたかさんは、もともとISUCONに参加してみたいと思っていたとのことで、お誘いしました。 stakmeさんは、普段一緒に業務をすることも多いのですが、陽気で冗談が好きな人で、きっと一緒に楽しくがんばってくれそうだと思って、お誘いしたところ、快く引き受けてくれました。

f:id:hirota982:20210826113430p:plain
参加表明から横手さんのお仕事&社内ISUCON部爆誕までの流れ

f:id:hirota982:20210826100237p:plain
高速でOKをくれるstakmeさん

予選受付

6/28 予選受付。昨年は当日に参加枠が埋まったとのことでみんなで予約開始を待機。当日16:00開始に変更になりつつ、無事登録done。開始2時間で600チームの枠が埋まります。来年もきっとすぐ埋まりそうなので、参加する場合はすぐ登録したほうがよさそうです。

予選まで

各自ISUCON対策をやります。カッコの名前は記載者。

  • 入門書や過去のISUCONに関する投稿を見て、ざっくりの感触を掴む(タテノ)
  • 過去問をやる。ISUCON10 のログ出力とサマリ出力、見方を確認。負荷分布を見て、mysql, go を修正してみる、あたりまでやってみた。正味 10時間くらい。(タテノ)
  • AWS予選環境作成など(タテノ)
  • とりあえず当日にisuconできる状態まで持っていくことを念頭に準備(かずたか)
  • AWSを使って過去問の環境構築(かずたか)
  • alp, pt-query-digestのインストール手順・ログ周りをDocsにまとめる(かずたか)
  • deployスクリプトの準備・過去環境で調整(かずたか)
  • 2週間くらい前に去年の過去問を見る(stakme)
  • あんまり深く考えず寝る(stakme)

日々の業務の結果として成績が上がる面はあるものの、コンテスト特有の要素もあるわけで、そのための準備に十分な時間を充てるのはなかなか難しかったですね。

当日

9:30 くらいに集合し、みんなでオンライン予選ライブ中継をみながら軽く流れを確認するなどウォームアップします。

10:00 予選開始。最初のbenchmark のスコアが3000点くらい。もろもろ準備するなか、早いチームはどんどんスコアを伸ばしていきます。 私達のチームも12:30くらいにクエリ改善したあたりで2万点台のスコアがでて、順位も30位くらいになります。このときはみんなとてもテンションがあがりました。楽しい!

ただ、その後はうまくスコアを伸ばすことができず、最終的にスコア35578 で順位でいうと100位くらい。また動作確認のためにAWSのセキュリティポリシーを変更していたのですが、それがレギュレーション違反。いろいろ振り返ることの多い予選参加となりました。

isucon.net

以下、振り返りです。カッコの名前は記載者。

開始時

  • マニュアル&レギュレーション内容をみんなで読み込み(タテノ)
  • とりあえず何も考えずにbenchmark実行し、初期状態確認(タテノ)
  • Deployスクリプトを事前に準備していた。それを使えるようにした(かずたか)
  • alp, pt-query-digestのインストール等の手順を事前に準備していた。それに沿って各種ツールの導入をスムーズにできた(かずたか)
  • 安心して作業・更新できる基盤を欠いているので、その整備をやった(stakme)
  • 主要なファイルをrsyncで引っこ抜き、手元でgitにぶちこみ、作業後GitHub経由で送りつけてsshでスクリプト叩くだけ。そこは変に凝らずに整頓できた(stakme)
  • alp を使って、呼び出されているエンドポイントの分布を確認、負荷が大きいもののあたりを付ける(タテノ)
  • slow-log, pt-query-digest を使って、クエリの負荷状況を確認(タテノ)

スピードアップポイント所感

  • isu テーブルと isu_conditionテーブル周りで負荷大きいところを潰していく(かずたか)
  • isu.imageをなるべく見ない (アスタリスクでSELECTしない)(かずたか)
  • n+1 insertやめる(かずたか)
  • 謎のトランザクションをやめ、Begin/Commit消せるだけ消す(かずたか)
  • isu.imageをDBではなくWebサーバーに置きたかったが、そう思ったときには時間的に手が出せなかった(早くやればよかった) (かずたか)
  • 不要なレコードをSELECTしている処理あるな〜と思ったが、そう思ったときには… (かずたか)
  • slow query見るだけでシュッと直せそうなクエリは1本しかなさそう(その後すぐindex貼って計測した)(stakme)
  • 妙にCommit多くね?不要なトランザクションありそう(stakme)
  • ログ吐く処理でファイルIO出て重いかもしれない、それは最後に撤去すればよさそう(stakme)
  • isu.image でかい、DBから不用意に取り出すとネットワーク食い潰しそう(stakme)
  • forのなかでクエリを打つn+1結構あるけど、パッと見であきらかに直せるのはbulk insertくらいしかなさそう(stakme)
  • システム(あるいはベンチマーク)の要件が妙に細かい(stakme)
  • 「最新のisu_condition」を取る処理がリファクタできると改善できるところが多い(stakme)
  • WebサーバとDBサーバの振り分け。とりあえずWebサーバとDBサーバは分けた。最終的にinsert量が大きいtableをDBサーバx2で処理するようにした。(タテノ)

困ったこと、改善点

  • alp, pt-query-digestを導入したがほとんどみなかった(かずたか)
  • どういうサービスかも見ず、コードだけでボトルネックを探そうとした(かずたか)
  • チームでの予習ができなかった。当日にgithubにどのファイルを保存するか等の話になってたりしたのは勿体なかった(かずたか)
  • クエリについてはslow queryログで計測できるし、CPU/メモリの使用状況も継続的に報告をもらったが、goの具体的なネックを探すツールを準備できなかった(stakme)
  • 勘でボトルネックを探していくことになり大変だった(stakme)
  • ISUCONDITIONの説明書をじっくり読み込む係がいないため、サービスの解像度が低いままだった(stakme)
  • リファクタしたところがバグってしまい、結局入れられなかった(stakme)
  • (なぜか自分の手元だけsshセッションが不安定だったり、ログがtailできなかったりして普通に困った)(stakme)
  • 誰かの修正を反映している間は環境が専有される。サーバ3台をすべてサービスに使わず1台はトライアンドエラーできる環境として残しておいてもよかった(タテノ)

あれやっとけばよかった的な、トライ

  • Go言語のプロファイラとわかり合っておけばよかった(stakme)
  • 競技のドキュメントをじっくり読み込む係が必要だ!と叫べばよかった (stakme)
  • もろもろのセットアップが完了して最初の修正をいれてbenchmarkを実行できるようになるのに2時間くらいかかったが、それはもっと短くできそう(タテノ)
  • ログ集計結果を自動でslackにポストするツールまで準備できていれば、分析結果のシェアがよりスムーズにできた(タテノ)

総括

チームミラティブとしてISUCON11予選に挑み、予選敗退となり残念でしたが、予選当日はみんなであれこれ言いながらプログラムやサーバをいじりたおし、スコアを伸ばしていくのはとても楽しい経験でした。予選を経験したことで、確実にISUCON慣れできましたし、そこで得られたことは日々の業務でも活かせることもたくさんあると感じました。

来年、またリベンジし、本選に出場したいと思います💪

最後に

ミラティブは今年のISUCON11に協賛しており、社内にもISUCON部ができました。
エンジニアを積極採用中なので、ISUCONが好きな方の応募をお待ちしております!

www.mirrativ.co.jp

speakerdeck.com

ISUCON11予選に参加して10位で予選突破しました

こんにちは、バックエンドエンジニアの牧野です。先日行われたISUCON11予選に参加し、10位で予選突破することができました。

isucon.net

私が所属している「カレーおじさん」チームは、前職の同僚の @sugaret, @lazydg と組んでいるチームで、ISUCON参加は3回目、予選突破は去年に続いて2回目になります。

それでは、さっそくISUCON11予選を振り返っていきたいと思います。

使用したツール

alpはaccess log解析、pt-query-digestはslow query解析で毎度使っているものです。

今年はCloud Profilerを新たに使ってみることにしました。いつもはベンチマーク実行時にpprofを手で叩いており面倒だったのですが、Cloud Profilerは継続的にプロファイリングを実行し収集してくれ、GCPコンソール上でさくっとプロファイル結果を見ることができるので便利でした。

f:id:tatsumack:20210823153221p:plain
初回ベンチマーク実行時の様子

予選の問題

椅子(ISU)からコンディションが送られてきて、そのコンディションをグラフ等で可視化するIoTサービスでした。

ISUCONDITION アプリケーションマニュアル · GitHub

あなたの大事なパートナーである ISU が教えてくれるコンディションから、そのメッセージやコンディションレベルを知ることで、大事な ISU と長く付き合っていくためのサービスです。

YouTube配信での事前説明では、世界観が独特でどんなサービスなのかイメージしにくかったのですが、マニュアルが丁寧に書かれていたので競技が始まればスッと理解することができました。 また、今回はスコア計算のロジックが複雑で、読み解くのに時間がかかりました。

当日やったことの振り返り

レポジトリはこちらです。使用言語はGoです。 github.com

indexの追加

計測ツールを仕込んでベンチマークを実行すると、DBの負荷が高かったので、まずはクエリのチューニングをすることにしました。

1番時間がかかっていたクエリがこちらです。

SELECT * FROM `isu_condition` WHERE `jia_isu_uuid` = 'e1512b1d-07ba-4dad-8104-22f236521f82' ORDER BY `timestamp` DESC LIMIT 1\G

適切なindexがなくfilesortになっていたので、jia_isu_uuid, timestampにindexを追加しました。

isucondition add index · tatsumack/isu11q@a48f4a8 · GitHub

クライアントサイドPrepareを有効にする

slow queryを見るとPrepareが大量に発行されていました。sql.Open()に渡すdsnにinterpolateParams=trueを追加することで、クライアントサイドでPrepareを完結させることができ、ラウンドトリップの削減とDBの負荷を下げることができます。*1

dsn := fmt.Sprintf("%v:%v@tcp(%v:%v)/%v?parseTime=true&loc=Asia%%2FTokyo&interpolateParams=true", mc.User, mc.Password, mc.Host, mc.Port, mc.DBName)

client prepare · tatsumack/isu11q@61292fa · GitHub

echoのdebugモードを無効にし、ログを切る

Cloud Profilerを見たところ、ログの書き込みに時間がかかっていました。今回、Goではechoというフレームワークが使われていたのですが、debugモードが有効になっていると整形されたjsonをログ出力し高負荷になってしまうというのを前回のisuconで学んでいたので、debugモードを無効にしログも切ってしまいました。

echo debug log off · tatsumack/isu11q@d1ad819 · GitHub

icon画像をDBから剥がす

icon画像のbinaryがDBに格納されていたので、ファイルに書き出すようにしました。

change icon db to file · tatsumack/isu11q@ae2da92 · GitHub

また、ベンチマーカーがConditional Getをサポートしているとのことだったので、レスポンスにCache-Controlヘッダを設定するようにしました。

c.Response().Header().Set("Cache-Control", "max-age=3600")

cache control · tatsumack/isu11q@4460858 · GitHub

isu_conditionをbulk insertする

isu_conditionテーブルへのinsert回数が多く、実装を見たところ1件ずつinsertするような処理になっていたので、bulk insertするようにしました。

condition bulk insert · tatsumack/isu11q@db31058 · GitHub

アプリとDBを別のサーバーに分ける

複数台構成の最終形として、アプリとDBは別のサーバーに分けることは確実だろうということで、アプリとDBを別のサーバーに分ける対応を行いました。

getIsuConditionsFromDBにLIMITの絞り込みを入れる

中盤になるとgetIsuConditionsFromDB の処理が支配的になってきました。実装を見ると、指定されたjia_isu_uuidisu_conditionを全件取得するようになっており、LIMITで絞り込むようにしました。が、実はこのときの対応が不十分だったことに後ほど気付きます。

add limit · tatsumack/isu11q@6c5adeb · GitHub

POST /api/condition/ drop割合の調整

アプリとDBの負荷が下がってきたので、 POST /api/condition/:jia_isu_uuidで一定確率でリクエストを落とす処理の調整を行いました。 最終的にはdrop割合を0にしています。

GET /api/isu/:jia_isu_uuid/graph timestampで絞り込み

グラフの表示の際にも、指定されたjia_isu_uuidisu_conditionを全件取得するような実装になっていました。指定された日のデータだけで十分だったので、timestampで範囲を絞り込むようにしました。

fix graph date · tatsumack/isu11q@21c582e · GitHub

1時間あたりのpost数を制限

短時間に大量のデータを送ってくるクライアントがあり、マニュアルを読むと1時間あたり20件のデータがあれば十分だということが分かったので、20件を超えるデータに関しては無視する処理を入れました。

drop per hour · tatsumack/isu11q@e80a112 · GitHub

getTrend の絞り込み

終盤は GET /api/trendが支配的になってきていました。 実装を見ると、こちらも指定されたjia_isu_uuidに対して全件取得するような処理になっており、最新のisu_conditionを取得すればいいだけなのでLIMIT 1を追加しました。

trend limit 1 · tatsumack/isu11q@1ddfb74 · GitHub

condition levelの正規化

上記のGET /api/trendの改修を入れたことによって、GET /api/condition/:jia_isu_uuid で不整合が発生しFAILするようになってしまいました。 getIsuConditionsFromDBLIMITの絞り込みを入れた際にcondition levelの考慮が抜け漏れており、condition levelで絞り込む際に正しくデータを取得できない状況になっていたのが原因でした。GET /api/trendの改修によってユーザーが増え、さらにisu_conditionテーブルのレコードが増えたことによってこの問題が顕在化したのだと思われます。

isu_conditionテーブルに level カラムを追加し、condition levelの絞り込みが正しく動くように改修をしました。

level · tatsumack/isu11q@45a149d · GitHub

上記の修正を入れた時点で、スコアは28万点になりました。 まだ2台しか使っていない状態でしたが、残り時間が20分しかないのと、このスコアであれば予選突破ラインは超えているだろうと判断し、手を止めることにしました。

感想

今年も中盤くらいから予選突破のボーダーラインをウロウロしていて、良い緊張感をもって競技を楽しむことができました。

今回の問題はあからさまにダメな処理が各所に仕込まれており、目についたところから手を付けているとあっという間に時間がなくなっていたと思います。 「推測するな計測せよ」の格言通り、計測によってボトルネックを特定し、改修の優先度を見極めながら取り組むことが大事だということを再認識させられる良い問題でした。

また、ベンチマーカーも終盤のアクシデント以外は安定しており不可解なエラーなどもなく、とても快適に競技に取り組むことができました。 運営のみなさま、ありがとうございました。

去年の本戦ではFailしてしまったので、今年は正のスコアを取れるように頑張ります。

最後に

ミラティブは今年のISUCON11に協賛しており、社内にもISUCON部ができました。
エンジニアを積極採用中なので、ISUCONが好きな方の応募をお待ちしております!

www.mirrativ.co.jp

speakerdeck.com

Mirrativ×Unity as a Library 活用事例と開発テクニック

こんにちは、Unityエンジニアの菅谷です。今回はUnity as a Libraryの活用事例と開発テクニックとして、以前のLTで紹介した内容をまとめました。Mirrativでの活用事例を通してUnity as a Libraryの強みや課題を共有し、他のプロダクトでの導入の参考になれば幸いです。2021/6〜2021/7にMirrativ×Unity as a Libraryのテーマで行った3本のLTをまとめた内容となっています。

mirrativ.connpass.com

meetup.unity3d.jp

mirrativ.connpass.com

MirrativにおけるUnity

Mirrativにはエモモという配信をサポートする3Dアバターが存在します。ユーザーは衣装やアクセサリーなどのアイテムを組み合わせてエモモを着飾ることができます。MirrativはiOSはSwift、AndroidはKotlinのネイティブアプリとして開発していますが、エモモはUnityで開発しています。つまりMirrativはネイティブとUnityが融合したアプリとなっています。iOS/AndroidのネイテイブアプリでありながらUnityの機能も使えるため、Unityが得意とする3Dモデルの扱いやリッチなグラフィックが実現できます。この技術のバックグラウンドとしてはUnity as a Libraryを活用しています。

Unity as a Library(UaaL)とは

Unity as a Library(UaaL)はUnityをネイティブアプリのライブラリとして利用する技術で、アプリで作ったViewとUnityで作ったViewを共存させられます。Unity2019.3で正式サポートとなりましたが、MirrativではUnity2018.2から独自で使用していました。Unity2018.2からUnity2019.3へのアップデートの話は過去の記事にて紹介しています。

tech.mirrativ.stream

UaaLで作るメリット

UaaLで作ることでネイティブ、Unityそれぞれの得意領域を1つのアプリ内に共存させられます。ネイティブの強みは配信のための安定したライブラリが使えます。Unityの強みは3Dを簡単に使える仕組みが整っていることや、Unityエディタでのデザイナー向けの開発機能が充実していることがあげられます。

UaaLにおいてUnityとネイティブの役割分担には一長一短ありますが、Mirrativではエモモなど3Dの描画部分とミニゲームの機能のみにUnityを利用しています。サーバーとの通信やミニゲーム以外でのUIの表示などネイティブで実装できるものはできるだけネイティブで行うようにしています。Unityの実装部分に機能をもたせすぎるとネイティブとUnityとで複雑な連携が必要になったり、内部で持つ衣装アイテムなどのパラメータが重複し管理しにくくなったりするため、できるだけUnityはViewの役割に制限することでネイティブからコントールしやすくしています。

Mirrativは歴史的経緯もあり、iOS/Androidでのネイティブアプリの上にあとからUnityで実装したエモモの機能を乗せました。UaaLでは既存のネイティブアプリを作り直さずにUnityの機能を追加できるのも特徴の一つです。3Dを使ったエモモのカスタマイズや豪華なギフトの演出はUnityなしでは実現できませんでした。また、Unityを用いることでアプリのビルドなしにデザイナーのみで演出を作ることができ、更にはUnityのCinemachineやTimelineなど演出に特化した機能を使った開発もできます。豪華なギフト演出については別の記事にて解説しています。

tech.mirrativ.stream

MirrativにおけるUaaLの例とViewの構成

MirrativでのUaaL利用の例を紹介します。 配信画面では、Unityでレンダリングされたエモモの表示は画面全体を覆う1つのViewになっており、その上にネイティブで実装している配信のコメントやUIが存在しています。

配信画面では画面全体にエモモを写すUnityViewがあり、それ以外のコメントやUI(赤枠)がUnityViewの上に乗っています。

エモモの着せ替え画面では、エモモの表示は画面上部のみとなおっており、画面下部の着せ替えUIはネイティブで表示しています。着せ替え画面のようにUnityのViewとネイティブのViewを独立して設置することもできます。

着せ替え画面では画面上部のUnityView(黄枠)と画面下部の着せ替えUI(赤枠)がそれぞれ独立しています。

また、ユーザーのタッチやスワイプなどの入力はUnity側でも検知することができます。ただし、配信画面などUnityのViewの上にネイティブでUIを表示する場合は、干渉を避けるためにネイティブ側で入力を検知しUnityにそのイベントを送っています。また、Mirrativ内のミニゲームなどネイティブのUIがUnityのView上に乗っておらず、Unityの入力の仕組みを活用したほうがよい場合はUnity側で直接入力を検知するようにしています。

Mirrativ内でのオリジナルミニゲームの一つです。ミニゲームはエモモの着せ替え画面と同様にUnityのView(黄枠)とコメント(赤枠)が独立しているため、ミニゲーム内のUIはUnityで作成しタップなどの入力検知もUnity側で行っています。実際にはユーザーの入力ログはネイティブでも使用しているため、ネイティブ側にもイベントとして入力内容を送っています。

MirrativでのUaaLの処理の流れ

エモモを表示するまでのフローを例としてUaaLの動きを紹介します。

  1. ネイティブでの実装部分がエモモの衣装リストをサーバーから取得します。
  2. ネイティブ部分が取得した衣装リストを元にUnityでの実装部分にパラメータとして渡します。
  3. Unity側が受け取ったパラメータを元にアセットバンドルをロードし、3Dモデルの衣装データの生成を行います。
  4. Unity側がネイティブプラグインを通してネイティブ側に衣装ロード完了の通知を送り、衣装ロードの処理を完了させます。

f:id:t_sugaya:20210824174708p:plain

このようにMirrativのUaaLはネイティブを仲介して動く仕組みになっています。

ネイティブからUnityへのメッセージング

ネイティブからUnityへのパラメータを渡す処理はSendMessageを用いて行われます。

  1. ネイティブから呼び出したいUnity(C#)側のメソッドを作成し公開する
  2. シーン上のオブジェクトにスクリプトをアタッチする
  3. ネイティブではsendMessageToGO(Swift)またはUnityPlayer.UnitySendMessage(Kotlin)によりUnityへメッセージングを行う

Unity側の実装はUnityでSendMessageを利用する方法と同じで、Mirrativでは専用のSendMessageアトリビュートを作成して外部から呼ばれるメソッドであることを明示しています。

以下は実際のエモモのロード処理です。Swift側でApiHandlerというUnityのオブジェクトを指定し、LoadAvatarModelメソッドを呼び出しています。その際に衣装リストなどをパラメータとして付与しています。

public class ApiHandler : MonoBehaviour {
    [SendMessage]
    public void LoadAvatarModel(string command) {
        実際のロード処理
    }

Unity(C#)側のコードです。上記のスクリプトはシーン上のApiHanderというGameObjectにアタッチしておきます。

UnityFramework.getInstance()?.sendMessageToGO(
    withName:       “ApiHandler”,
    functionName:   “LoadAvatarModel”,
    message:        “パラメータ”)

iOS(swift)側のコードです。シーン上のオブジェクト名、Unity側のメソッド名、付与するパラメータを指定します。

MirrativではUnityで機能を作るたびにメソッドを作成しネイティブからそれぞれ呼び出していましたが、パラメータの形式が統一されておらず混乱の元になっていました。最近はパラメータのフォーマット統一化を進めており、その中でネイティブからは1つのメソッドのみを呼び出すようにし、パラメータ内で指定したメソッド名を元にそれぞれの機能を実行するよう仕組みを整えています。ネイティブから呼ばれる入り口を1つに絞ることでエラー処理やログ出力など、共通の処理が入れやすくなりました。これらの設計については一長一短があるため日々議論と改善を行っています。

Unityからネイティブへのメッセージング

Unityからネイティブへのメッセージングはネイティブプラグイン経由で行われます。Mirrativでは衣装ロード完了のコールバックなどUnity側で処理が完了した場合や、ミニゲーム中のUnity側のボタン入力イベントなどのイベント発行のタイミングで必要に応じてネイティブ側にメッセージングを行っています。

[DllImport("__Internal")]
static extern void sendMessage(string msg);

public void SendMessage(string msg)
{
    sendMessage(msg);
}

Unity(C#)側のコードです。ネイティブプラグインでsendMessage関数を用意しておきます。また、実際のネイティブプラグイン内ではsendMessageがreceiveMessageというメソッドを呼び出します。

extension Unity: UnityCallback {
    public func receiveMessage(message: String!) {
       messageの中身に応じて処理を行う 
    }

iOS(Swift)側のコードです。Unityから送られたパラメータをmessageとして受け取り、その内容によって処理を分岐させるようにしています。

Unityとネイティブとのメッセージングに関して、より詳しい内容は以前の記事で解説しています。

tech.mirrativ.stream

UaaLでの開発の難しさとミラティブでの取り組み

UaaLはネイティブとUnity両方のいいとこ取りができますが、特有の開発の難しさも多数存在します。Mirrativの開発で感じた難しさとその解決に向けた取り組みについて紹介します。

UaaL特有の課題や問題

UaaLを利用した際の特有の開発の難しさや複雑さがあります。

UaaLではUnityのライブラリとしてのビルドとネイティブアプリのビルドの両方のビルドが必要になります。更にはUnityのライブラリをネイティブアプリが取り込むため、アプリとして動かすためのフローが多くなります。対策としてミラティブではUnityとネイティブアプリそれぞれのCI/CDを整えてビルドを簡単に行えるようにしています。

また、Unityとネイティブアプリの連携部分の開発も必要になります。2つが独立したシステムになってしまうためUnity側に問題があるのか、ネイティブアプリ側に問題があるのかの調査が難しくなります。ミラティブではネイティブエンジニアとUnityエンジニアはそれぞれ別メンバーであるため、お互いのコミュニケーションも重要です。安定した開発の仕組みづくりのために以下のような対策をしています。

  • Unity上でのシミュレーションを充実させてネイティブアプリを使用しなくても動作が確認できるチェッカーの作成(後述)
  • プロトタイプの作成時などはUnity単体でアプリとしてビルドし実機で動かす
  • FirebaseやUnity Cloud Diagnosticsなどのパフォーマンス計測ツールの活用
  • Flipperなどのデバッグ用ツールの導入
  • 日々の定例やSlack・口頭でのコミュニケーションを密に行い連携を図る

Flipperの導入は以前の記事にて紹介しています。

【Android】デバッグツールを変更して開発体験を向上する - Mirrativ tech blog

UaaLを使用した開発での具体的な課題

UaaLでの開発において遭遇した具体的な課題について紹介します。

Unityはネイティブアプリ内で起動するとUnityのViewが写っていなくてもUnityの処理が行われてしまい、無駄にアプリの負荷が高くなります。MirrativではUnityのViewが非表示の画面ではUnityを一時停止させ、UnityのViewを表示する際にUnityを再開させることでアプリの負荷を軽減させています。

また、1つのアプリの中でネイティブアプリとUnityの両方が動くため簡単に負荷が高くなってしまうという課題もあります。Unityは起動するだけで数十MBのメモリを使用してしまいますが、その上エモモはハイクオリティな3Dモデルを用いているためGPU/CPU/メモリどれも負荷が高くなります。特に豪華なギフト演出では様々な3Dモデルや表現方法を用いるため負荷には十分に気をつける必要があります。Unity単体では問題なくともネイティブと組み合わせることで負荷がかなり高くなってしまうこともあり気づきにくい点となっています。デザイナーだけで演出を作れるようにしていますが、最終的にはエンジニアもプロファイラーを活用して負荷に問題がないかを確認しています。ギフトの演出は豪華さと負荷のバランスが大事なので、許容ラインの判定や負荷の軽減にはエンジニアの知識や経験が問われます。

ギフトの演出は登場するオブジェクトやカメラ、エフェクトなどをTimelineで制御することで自由度高く作れるようにしています。この自由度の高さのため負荷も高くなりやすく注意が必要です。演出作成時はできるだけデザイナーが見せたいデザイン要望を叶えられるよう日々課題に向き合っています。

他にも、Unityの動作状況によらずアプリ側では別の処理が行えてしまうため、Unityが止まっているままメッセージングが行われ期待した処理にならないことがあったり、ネイティブアプリがマルチスレッドを利用している場合、Unityをメインスレッド以外で動かそうとしてUnity側がクラッシュしてしまったりとUaaL特有の難しさがありました。

エモモの動作確認ツール(チェッカー)の紹介

UaaLを利用したアプリはネイティブと連動することが前提となるため、Unity単体での動作確認が難しくなりやすいです。そのためチェッカーでのデバッグ機能やアセットのテストを充実させることで開発効率を向上させています。

ミラティブでは新しい機能を作成する際にUnity上でのチェック用にシミュレーション機能も合わせて作成しています。着せ替えやカメラの動き、ギフト演出などのエモモのデザインや機能を確認したり、ミニゲームの開発に利用しています。チェッカーではUnityとアプリとが連携する機能をUnity上だけで完結できるようにしています。

チェッカーの画面構成です。エモモのデザインを確認したりギフトをシミュレーションしたりといった機能が入っています。

チェッカーの構成

MirrativアプリのUnityの機能は1つのシーン(Mainシーン)で作られています。機能開発用のチェッカーでは専用のチェッカーシーン(AvatarCheckerシーン)がMainシーンを動かすことでシミュレーションを実現しています。AvatarCheckerシーンがSendMessageを利用してMainシーンを動かしているため、アプリと同じ仕組みでシミュレーションさせることができます。そのためMainシーンには開発専用の機能は入っていません。

ビルドにはMainシーンのみが含まれ、AvatarCheckerシーンは含まれません。チェッカーの動作中のみAvatarCheckerシーンが使われます。

f:id:t_sugaya:20210824174954p:plain

チェッカーは主にAvatarCheckerシーン内に作成したチェッカーUIから動かします。チェッカーがネイティブ実装の代わりとなることでMainシーン以降はアプリと同じ動作になります。また、アセットバンドルのロードでは実際のアセットバンドルを使わず、Unityエディタ内にあるアセットを直接ロードできるようにしています。これはUnityのAddressableにおけるFastモードに相当しています。

チェッカーの機能

チェッカーではエモモの機能確認の他にデザイナー向けの開発ツールやテスト機能も入れています。主に機能の以下があります。

  • アセットバンドルの依存性チェック
  • 3Dモデルのボーン名と構造のチェック
  • ファイルサイズや圧縮設定のチェック
  • アセットバンドルをビルドせずにUnity上でロードする機能

チェッカーによりUnity上でのシミュレーションやテストができるようになっても実機での検証は必要です。例えばアセットバンドルでの不具合やシェーダーのOS間差分など、実機でのみ発生する不具合や、Unityの一時停止/再開により正常な処理が行われない不具合が発生します。実機でのみ発生する不具合は原因がUnity本体にあることも多く、調査や対処が難しくなりがちです。チェッカーの機能を充実させることは実装におけるミスを減らすことができるため、実機での不具合への向き合いやすさに繋がります。エモモの機能開発と並行してチェッカーやツールも開発する必要があり、チェッカー自体にもまだまだ改善点は多く残っています。全体の開発効率に直結するため向き合い続けている課題の一つとなっています。

We are hiring!

Unity as a Libraryはアプリ開発における一つの可能性であり、まだまだ発展していける技術です。Unityとネイティブの両方の機能を使ってみたい方、新しい技術の先端を追いかけたい方はミラティブがオススメです。ミラティブではUnityエンジニア、ネイティブアプリエンジニアともに積極的に採用しています。是非分かり合っていきたいですね。

www.mirrativ.co.jp

最近はエンジニア向けの資料も公開しました!

speakerdeck.com

Mirrativ のアバター活用事例を紹介します!

こんにちは、バックエンドエンジニアの平松です。今回は Mirrativ でのアバター(エモモ)を活用した機能開発事例を紹介します。

Mirrativ のエモモ

Mirrativ にはエモモと呼ばれるUnityで動く独自の3Dアバター機能が存在します。 エモモはユーザが自身の好みに合わせて自由にカスタマイズ可能なアバターであり、 Mirrativ では定期的に開催しているイベントに合わせて様々なパーツや衣装などのカスタマイズ用のアイテムを多数リリースしています。

カスタマイズ用のアイテムの追加のみではなく、エモモを活用した様々な機能の開発・リリースも行っています。 本記事ではこれまで開発してきた機能について紹介していきたいと思います。

なお、エモモがどのようにして動いているのかの詳細については今回は割愛しますので、興味のある方はこちらの記事をご覧ください。

tech.mirrativ.stream tech.mirrativ.stream

続きを読む

【iOS】ゲームアプリの音声設計とミラティブの配信について

 こんにちは。エンジニアのshogo4405です。ゲーム開発会社様より、iOSで画面収録またはミラティブで配信をすると、SEは鳴るが、BGMが消える場合がある。技術的な仕様について教えてくださいと連絡をいただく場合があります。

 本エントリーでは、ミラティブの配信中にゲームのBGMが鳴らない現象について、ゲームアプリ側での回避方法の例をご紹介したいと思います。

続きを読む

【iOS】Unity Framework とクラッシュ解析の取り組み

こんにちは、Mirrativ iOS エンジニアのちぎらです。クラッシュが発生して、その原因が分からないととてもかなしい気持ちになります。このブログでも以前から触れているように、Mirrativ のクライアントアプリではエモモなどの表示に Unity を使用しています。今回は、Unity の Framework とその内部で発生したクラッシュ解析の取り組みについて紹介をしたいと思います。

続きを読む

Androidアプリをリアーキテクチャした話

Mirrativ Androidエンジニアのmorizoooです。今回はAndroidアプリをFluxにリアーキテクチャした話をします。

背景

Mirrativは2015年春頃に開発が始まり、もうすぐリリースから6周年を迎えようとしています。以前はアーキテクチャについてのルールが決まっておらず、個々人が思うがままコードを書いており、開発しているメンバーでさえ処理の流れが追えなくなっているような状況でした。そこで、まずは既存のコードの改善を行いました。詳しくは以下の記事をご覧ください。 tech.mirrativ.stream

既存コードを改善した後に、開発の指針としてFluxアーキテクチャを選定しました。

Fluxを選定した理由

  • 状態がどこで更新されているのか分からなくなるという一番の課題が、Flux の導入によって解消できそうだった
  • iOSはFluxで実装していくという話が出ており知見をシェアできそうだった

Fluxとは

Facebookが提唱しているデータを単一方向で取り扱うアーキテクチャです。 詳細については以下をご覧ください。 facebook.github.io

Mirrativ内の実装

Androidにおける実装として、以下の資料を参考に実装を行いました。
speakerdeck.com

Mirrativ内でのFluxのフローとしては下記の図の流れで行っています。

f:id:morizo999:20210531003404p:plain
flow

具体例としてLive情報をAPIから取得して表示を行うサンプルを紹介します

続きを読む