七声ニーナを支えるバックエンド技術

by shintaro-takemura | May 12, 2021
artificial-intelligence | #gcp #serverless #deep learning #voice avatar

データ統括部AI基盤部の竹村(@stakemura)です。本記事では、このたびリリースされた、自分の声をキャラクターの声に変換できるWebサービス VOICE AVATAR 七声ニーナ を支えるバックエンド技術についてお話しします。

本サービスはDelight Boardという部署横断型のプロジェクトにて、1000人を超える社員投票により自分の案がまさかの採択となったことがきっかけとなります。幸運にも、百戦錬磨のプロジェクトメンバーに助けられ今日のリリースを迎えましたが、採択当時は人脈も信用貯金も何もない入社一年目の思いつきにすぎず、言い出しっぺである自分の力不足によりタイトなスケジュールでの開発となってしまいました。本記事では、その限られた開発期間の中で、自分が何を考えて実装したかを中心にお伝えします。

サービングに求められる要件

七声ニーナの音声変換はブラウザから受け取った入力音声に対し、クラウド上でその音声を変換することで実現しています。そしてこの音声変換は、主にPyTorchで学習したモデルによる推論で構成されており、このような機械学習モデルを用いたサービスを外部から使えるようにAPIとして提供することを一般にサービング(Serving)と呼びます。

それではこのサービングは、Webサーバーでよくあるような処理、例えばJSONの入出力やデータベースの操作とは何が違うのでしょうか?まず動画エンコーディングのような計算量の多い処理を想像していただければと思いますが、プロセッサの負荷やメモリ消費量が概して大きいという特徴があります。したがってサービングのリクエストが同時かつ大量に発生したときに、一台のサーバーでこなそうとすると計算リソースがすぐに枯渇してしまいます。もしこれがバッチ処理であれば、メッセッジキューなどを用いてリクエストをキューイングして遅延実行するといった対処が考えられますが、高速なレスポンスが期待されるエンタメ用途のユースケースにはマッチせず、また突発的なリクエスト増に対応できないため、手動でサーバーの数を増減させるのも合理的ではありません。

サーバーレスとコールドスタート

水平方向のスケーラビリティを簡単に実現するソリューションとして、近年重要視されている概念にサーバーレス(Serverless)があります。本題ではないため細かくは述べませんが、サーバレスアーキテクチャを採用することで、リクエスト数に応じたオートスケーリング(Auto scaling)が可能になり、また利用した分だけの課金(Pay-as-you-go)となるため、安価でスケーラブルなサービスを手軽に提供することができます。

ただしサーバレスは決してメリットばかりではなく、特有のペインポイントが複数あります。その中で本サービスに影響があるのは、コールドスタート(Cold start)と呼ばれる実行時の遅延です。ただし一口にコールドスタートといっても、サービスや利用方法によって遅延の長さはかわってきます。例えばサーバレスアーキテクチャを実現するための代表的なサービスの1つにAWS Lambdaがありますが、Cold Starts in AWS Lambda | Mikhail Shilkovに詳しく述べられている通り、プログラムを動かす言語、そしてFaaS(Function as a service)かCaaS(Container as as service)かで傾向が変わります。

前述のMikhail Shilkov氏の記事から、重要なポイントを以下の3点にまとめました。

  • FaaSの遅延は言語により異なり、Goのようにネイティブコードが直接出力できる言語は、JavaのようなJITコンパイラで動く言語より速度面のアドバンテージがある
  • FaaSはCaaSより遅延の最小値が低いという計測結果になったが、パッケージサイズに依存するという欠点がある。記事の例では、1KB→35MBのケースで0.4→4.0秒前後と約10倍増加。
  • 一方CaaSはFaaSと異なり、コンテナサイズが100MB増えようが5GB増えようが、遅延に影響がない

ここで補足しておくと、コンテナサイズがコールドスタートに影響しないという性質は、GCPのCaaSであるCloud Runにおいても同様です。Cloud Run の公式ドキュメントでは、以下の様に説明されています。

Cloud Run では、コンテナ イメージのサイズはコールド スタートやリクエストの処理時間に影響せず、コンテナの使用可能なメモリにはカウントされません。

ディープラーニングにおいて、このCaaSのメリットはとても重要です。なぜならPyTorchTensorflowといった機械学習フレームワークのパッケージサイズは巨大であり、また機械学習モデルも数10MBから数100MBと大きくなることが多いためです。一方、開発言語はどうでしょうか?一般にモデルの実装や学習にはPythonを使いますが、純粋にサービング機能を実装するだけなら他の言語でも可能です。というのも、PyTorchもTensorflowもコアモジュールはC++で実装されてあり、FFI(Foreign function interface)さえ用意できればPython同様に他の言語からも扱うことができます。

理論上、そして著者の経験上、PyTorchで学習したモデルで推論する最速の手段は、コアモジュールであるLibTorchをC++で制御することです。C++で実装されたライブラリは、C++で組み込むのが最もオーバヘッドが小さいのは自明です。実際にGoogleやMicrosoftがサービング用途に提供している、Tensorflow ServingONNX Runtime ServerはC++で実装されており、Pythonを介すよりも高速な推論が可能です。また経験上と申し上げたのは、iOSやAndroid端末のネイティブアプリ上でエッジ(オンデバイス)推論が必要な業務のために、LibTorchを直接扱ってクロスプラットフォームのFFIを実際に開発したことが背景にあります。ここで1つわかりやすい例を挙げると、PyTorch(CPU版を想定)をPCにインストールすると諸々条件にもよりますが、1GB近くのストレージを費してしまいます。また import torch のコストも決して小さくはありません。ところが、LibTorchをデバッグシンボルなどを省いて静的リンクした実行バイナリのサイズは、わずか数10MBに過ぎず、スマホのネイティブアプリに組み込めるほど軽量です。いかにPythonパッケージのオーバーヘッドが大きいかが数字から伝わってきますね。

なおサーバレス特有のペインポイントとして、他にも最長実行時間やGPUが使えないことが挙げられます。が、本サービスは短時間実行が前提かつ、CPUでも十分高速に動くアルゴリズムを採用しており、特に影響がないため割愛したいと思います。

システムアーキテクチャと実装方針

さて、実行性能を追求することが、すべてのケースにおいて必須かというとそうとも限りません。これまでの話の流れに反する形で恐縮なのですが、本サービスのバックエンドはPythonで書かれており、Cloud Run上で動作しています。Pythonを選んだ背景には2つの要因がありました。1つは開発効率です。バックエンドで行う処理は、決してサービングだけではなく、例えばCloud Storageに書き込むといった様々なデータ操作を含みます。そのため、C++よりも生産性が高く、また所属部署内では最も利用者が多いPythonに一元化し、引き継ぎが容易な形で実装するのが合理的でした。「早すぎる最適化は諸悪の根源」という言葉がありますが、最終的にC++が必要とされるケースでも、自分ならまずPythonでプロトタイピングを行い、それからC++に移植するでしょう。

2つ目はAPI設計を工夫することで、体感上の応答待ち時間を削減できたためです。今回、コールドスタートにより遅延が発生し得る操作は、録音完了後とわかっています。一方で本サービスは、ユーザーに音声をブラウザ越しに録音してもらう必要があります。ということは、録音が始まる前にバックエンドAPIを呼び出してしまえばいいわけです。本サービスでは録音時間を最短1秒最長10秒としており、録音秒間の分だけコールドスタートの遅延を相殺することができます。この録音開始直前に呼び出され、録音した音声がアップロードされるまで待機する処理のことを、本稿では「暖機運転」と呼びたいと思います。

録音開始から音声変換完了までの一連のシーケンスをUMLにしたものが以下になります。なお、実際に採用したシステムアーキテクチャを簡略化した上で掲載している旨、ご了承くださいませ。

alt

バックエンドを支えるソフトウェアスタック

以下、本サービスのバックエンドを支えるソフトウェアスタックを紹介していきます。

FastAPI

FastAPIは、その名の通り高速なPython製Webアプリケーションフレームワークとして近年注目されています。所属部署からは、最近amaneさんが技術共有会の資料を公開してくださっていますね。

さよならFlask ようこそFastAPI / goodbye Flask, welcome FastAPI

このように社内外で人気のFastAPIを本サービスでは採用しましたが、その性能をCloud Run上で最大限に生かすためには意識すべきポイントがあります。それは、「できるだけ非同期処理に寄せる」ということです。大前提として、Cloud Runは公式ドキュメントにもある通り、最大250件のリクエストを同時に処理することができます。

デフォルトでは、Cloud Run コンテナ インスタンスは同時に最大 250 件のリクエストを受信できます。

つまりタスクの多重度を上げ、コンテナインスタンスを使い回した方が、コールドスタートを抑えることができるため、レスポンスが高速になります。しかし、タスクの多重度を上げようとしたところで、そもそも多重処理が効率よく回らなければ逆に性能が落ちかねません。PythonはGIL(Global Interpreter Lock)を抱えるためにマルチスレッド処理があまり得意ではないという背景から、FastAPIでは非同期処理による解決を前提としています。このことは、同期的にsleepするtime.sleepと、非同期でsleepするasyncio.sleepを用いて比較実験したQiitaの記事が参考になるでしょう。

FastAPIのバックグラウンド処理の多重度を同期・非同期で比較してみたよ

同記事にあるように、本サービスは多重度を上げて負荷検証することで、ボトルネックを突き止めながら開発を進めました。

google-cloud-firestore

シーケンス図で示されるように、本サービスのバックエンドでは下記3つのAPIを用意しています。

  • [POST] /api/v1/voices
    • 最初に呼ばれることが前提
    • RFC 4122のUUID version 4にもとづく音声IDをJSONで返す
  • [PUT] /api/v1/voices
    • 録音完了後に呼ばれることが前提
    • multipart/form-data形式で音声IDと音声ファイルを送信することで、Firestoreに入力音声をセットする
  • [GET] /api/v1/conversion/{voice_id}
    • 暖機運転のため、録音開始の直前に呼ばれることが前提
    • 音声変換を開始し、変換した音声ファイルをCloud Storageに出力し、そのURLをJSONで返す

API間のデータ受け渡しの要となるのが、Cloud Firestoreです。そして、FirestoreにはネイティブモードとDatastoreモードの二択があるのですが、本サービスでは前者のネイティブモードを以下の2つの理由で採用しています。

1つ目の理由は、Datastoreモードを扱う公式ライブラリであるgoogle-cloud-datastoreは、asyncioに現時点で未対応であり、一方ネイティブモードに対応するgoogle-cloud-firestoreは、asyncioに対応しています。

datastore: async/await support · Issue #2 · googleapis/python-datastore · GitHub

feat: create async interface by rafilong · Pull Request #61 · googleapis/python-firestore · GitHub

前節で述べましたが、Cloud Runのポテンシャルを最大限に引き出すためには「できるだけ非同期処理に寄せる」必要があり、この点でgoogle-cloud-firestoreに軍配が上がりました。

2つ目の理由は、リアルタイムリスナーの存在です。これはRDBでもKVSでも同じことが言えるのですが、API間のデータ受け渡しに安易にPollingを使ってしまうと、アクセスが殺到した時にPolling先がボトルネックになってしまいます。実際に負荷検証を行いましたが、Firestoreもその例外ではありませんでした。

しかし、FirestoreならonSnapshot() メソッドを活用することで、Pollingを使わなくともオブジェクトをリッスンできます。以下、公式ドキュメントからの抜粋です。

onSnapshot() メソッドを使用すると、ドキュメントをリッスンできます。コールバックを使用した最初の呼び出しでは、単一のドキュメントの現在のコンテンツですぐにドキュメント スナップショットが作成されます。次に、コンテンツが変更されるたびに、別の呼び出しによってドキュメント スナップショットが更新されます。

onSnapshot() メソッドはもちろんgoogle-cloud-firestoreでも対応しており、このライブラリのおかげで効率的なデータの受け渡しが可能になりました。

Hypercorn

2021年1月22日に、Cloud RunがWebSocketsやHTTP/2、そしてgRPCに対応したというアナウンスがありました。

Introducing WebSockets, HTTP/2 and gRPC bidirectional streams for Cloud Run

Cloud RunのWebSocket対応は大きなニュースです。WebSocketなら双方向の通信ができるため、3種類ある現状のAPIを一元化できるだけではなく、ストリーミング処理が効率的に扱えるため、レスポンス速度の大幅な高速化が見込めることは明らかでした。一方で、開発初期にはおいて自分がWebフロントエンド実装を兼任しており、WebSocketは経験がないことから、スマホからPCといった様々なデバイス・ブラウザ上で安定動作する確証が得られず、泣く泣く移行は見送りました。

一方でHTTP/2の対応は簡単です。まずデプロイはGoogle Cloud SDKのCLIに以下の様にオプションを追加するだけです。

gcloud beta run deploy http2-test --use-http2 --source=.

また、ASGIサーバーはそれまでUvicornを使っていましたが、Hypercornを移行するだけでHTTP/2対応できました。そして、あらゆる計測条件でHTTP/1より高速であることが確認できたので、本サービスではHTTP/2を採用しています。なお、HypercornのWorkerについては顕著なパフォーマンス差が見られなかったため、デフォルトのasyncioを用いました。

さいごに

以上長々と説明してしまいましたが、本サービスのバックエンドを実装する上での要となるポイントはまだ他にもあります。それらについては運用が安定してから、また何らかの機会でご紹介できればと思います。

そして最後に皆様へお願いです。 VOICE AVATAR 七声ニーナ 、是非体験してくださいね!