Gunosyデータ分析ブログ

Gunosyで働くデータエンジニアが知見を共有するブログです。

グノシーのパーソナライズアルゴリズムを刷新した話 (アーキテクチャ編)

こんにちは。Gunosy TechLab MediaMLチーム所属の桾澤 (@gumigumi4f) です。

前回の記事に引き続き、グノシーのパーソナライズアルゴリズムを刷新した話について、アーキテクチャの部分を説明したいと思います。

前回の記事から読んでもらえると、パーソナライズの全体像が見えるのでおすすめです。

data.gunosy.io

パーソナライズに求められるレスポンスタイム

前回の記事ではモデルの学習方法やオフラインでの実験などをメインに説明してきましたが、オンラインで実際にA/Bテストするときに考えなければいけないのがレスポンスタイムです。

ユーザーに対して完璧にクリック非クリックが予測できるレコメンドシステムを作成したとしても、レスポンスの生成に1分以上かかるようならリストが表示される前にユーザーが離脱してしまいます。*1 特にニュースアプリであるグノシーの場合、目まぐるしく変化する1万オーダーの候補記事から適切な記事をレコメンドすることを考えると、処理はできるだけ簡潔で軽量なものにしなければなりません。

弊社では某社の 50ms or die という標語*2を元に、レコメンドにかかる時間が50ms以内になるように実装やアルゴリズムに工夫を加えてきました。 実際に2018年にグノシーで実装したシステムは、レスポンスタイムが99 percentileで50ms程度に収まるようになっていました。*3

今回実装した新しいパーソナライズにおいても同様にユーザーへのレスポンスタイムが50ms以内になるようにアーキテクチャを選定、構築しています。

アーキテクチャ

今回構築したシステムは大雑把に以下のようになっています。

f:id:gumigumi4f:20210201175705p:plain

複雑に見えるかもしれませんが、紐解いていくとシンプルな作りになっています。

まず、今回のパーソナライズのシステムは以下の2段階に分けることが可能です。

  1. ユーザーと記事のベクトルに事前に生成し、データストアに保存する処理 (バッチ処理)
  2. ユーザーリクエストに対し、内積演算を行い適切な記事リストを返却する処理

以下でそれぞれを詳しく解説していきます。

ユーザーと記事のベクトル生成とデータストアへの保存

f:id:gumigumi4f:20210202111932p:plain

ユーザーと記事のベクトルを生成するためのAPIであるvectorizer-apiはPythonで記述されており、k8s上のFargateで実行されるようになっています。

なぜFargateなのかですが、これはvectorizer-apiでTensorflowを使ってユーザーと記事のベクトルを生成することに起因しています。 TensorflowのDocker Imageは非常に大きく、また作成されたモデルのロードにも時間がかかってしまうため、Spot Instanceが混ざったノードグループにPodが配置された場合、Terminationに巻き込まれて減ったPodが回復するのに5分以上の時間がかかります。 また、Tensorflowはthreadsafeではないため、複数スレッドでのベクトル生成が難しく、オーバーヘッドが大きいため多くのvCPUがあってもうまく使えない欠点があります。 そのため、新規でノードグループを立ち上げるのではなく、適宜リソースの増減ができ、リソースのムダも少なくなるFargateを選択しました。 欠点としてはFargateはメモリが最大で30GBになってしまうことですが、今回のモデルではメモリは16GB程度で十分であったため、特に問題にはなりませんでした。

ベクトル生成の流れですが、vectorizer-apiはまずS3にあるTensorflowのモデルをロードします。 ロードが完了するとhttpでベクトル生成を待ち受けつつ*4、SQSからjobを取得し始めます。

次に、digdagが定期的にjobをkickし、「入稿された記事」と「記事をクリックしたユーザー」との情報をSQSに投入します。 SQSに投入されたjobはvectorizer-apiで拾われ、記事ベクトルとユーザーベクトルを生成、Aurora MySQLとRedis (Elasticache) に保存します。

Aurora MySQLは主にデータを永続化するために使われ、Elasticache上のRedisに保存されたベクトルが主にリクエストに応じてベクトルを取得する用途に用いられます。

最後にdigdagからrecommendation-apiで使用するキャッシュを生成するためのjobを叩きます。 このキャッシュには候補記事約1万件のベクトルが保存されるようになっており、recommendation-apiではこのベクトルをすべてメモリ上に載せて演算にかかる時間を高速化しています。 候補記事が1万件である場合float32で学習された512次元を保存するのに必要な容量は  10000 \times 4 \times 512 = 20.48 \text{MegaByte} ですので、毎度redisにリクエストを投げるよりかはすべてメモリに載せたほうが効率的です。

ユーザーリクエストに対し適切な記事リストを生成する処理

f:id:gumigumi4f:20210202111952p:plain

ユーザーからのリクエストに対して記事リストを生成するマイクロサービスであるrecommendation-apiはGo言語で記述されています。

recommendation-apiでは先のバッチで作成した候補記事のキャッシュを定期的にS3に取得するようになっています。

リクエストが実際に飛んできた場合、まずユーザーのベクトルをRedis, MySQLの順番に問い合わせ取得します。 そして取得したユーザーベクトルと、メモリ上に乗っている候補記事のベクトルについて内積を取り、スコアを計算します。

内積演算については以前ブログで紹介したSIMDを用いており、Go言語でそのまま記述するよりも2~3倍程度スループットが高まるように工夫をしております。

tech.gunosy.io

最後にスコア上位のリストについてリスト面をキャッシュする用のRedisに書き込み、リクエストを返却しています。

以上のような流れで、なるべく構成がシンプルで、かつリクエストが高速にさばけるような形でシステムを作成しています。

どれくらい高速なのか

あまり具体的な数値を出すのははばかられるのですが、EnvoyのLatencyが大体以下のようになっています。 95 percentileで大体10ms前後 (紫色の線) であることから、かなり高速にリクエストが返却できていることがわかります。

f:id:gumigumi4f:20210202115817p:plain

recommendation-apiはマイクロサービスの一つであり実際には様々なAPIを経由してアプリに返るので、ユーザーへのレスポンスタイムはもう少し長いですが、それを考慮しても十分に高速なレコメンドが行えています。

おわりに

今回の記事では新しいグノシーのパーソナライズの取り組みについてアーキテクチャの部分を解説しました。

前回のモデル編も含めて、グノシーがどのようにパーソナライズに取り組んでいるかについて伝われば幸いです。

*1:バッチで生成するシステムなら行けるかもしれないがコストの問題からあまり現実的ではない

*2:https://tenshoku.mynavi.jp/it-engineer/knowhow/naoya_sushi/05

*3:https://logmi.jp/tech/articles/304737

*4:httpでのベクトル生成は他の用途で使用するために立ち上げています