Build. Translate. Understand.

Impureim Sandwichにした曲レコメンデーション

2025-04-29 22:00:25

原文: Song recommendations as an Impureim Sandwich by Mark Seemann

そのデータ、本当にメモリに収まらない?

この記事は関数型プログラミングによる設計の代替アプローチに関する連載記事の一部です。以前の記事では、仕様化テストを使って、Oleksii Holub氏が記事「Pure-Impure Segregation Principle(純粋・不純分離原則)」で紹介した、曲レコメンデーションシステムの意図された挙動を捉える方法を紹介しました。

問題の定義

1つの問題を純粋関数にリファクタリングする方法を示したあと、Oleksii Holub氏は次のように書いています:

「非常に有用ではあるものの、先に紹介した“ロスレス”なリファクタリング手法は、必要なデータが関数の入力パラメータに簡単にカプセル化できる場合にしか使えません。残念ながら、これは常に可能とは限りません。

「関数が外部APIやデータベースから動的にデータを取得しなければならないこともよくありますが、こうした外部リソースを事前に知ることはできません。その結果、純粋な関心事と不純な関心事が混在する実装となり、密結合な構造が生まれがちです。」

その後、記事では曲レコメンデーションの例が紹介されます。これはC#の単一のメソッドで、データストアやサードパーティのサービスを問い合わせて曲を推薦する処理です。おそらく、Spotifyのようなサービスにおける利用履歴データを外部のWebサービス経由で取得するものだと想像されます。

「このアルゴリズムは、ユーザーの最も再生された曲を取得し、同じ曲を聴いたことのある他のユーザーを見つけ、さらに彼らの上位曲を抽出することで動作します。これらの曲を集約してリスト化し、呼び出し元に返します。

「この関数には多くのビジネスロジックが含まれており、純粋関数にすることで大きな恩恵が得られることは明らかです。しかし、以前使った手法では対応できません。

GetRecommendationsAsync(...)を不純な依存関係から完全に分離するには、すべての曲、ユーザー、そのスクロブル(再生履歴)を事前にリストとして渡さなければなりません。何百万人ものユーザーのデータを扱うとなると、それは非現実的、というより不可能でしょう。」

確かに、それは現実的ではなさそうです。

データのサイズ感

とはいえ、直感は本当に信頼できるでしょうか?人間の脳はランダム性や確率の扱いが苦手だとする研究があるように、私はデータサイズの見積もりについても同様の傾向を感じています。

Dr. Evil: One million.

現実世界では、「100万」という数は直感的に理解しにくいほどの大きさです。したがって、私たちの直感が外れても不思議ではありません。100万件のレコードと聞くと非常に多く感じますが、もしそれが単なるいくつかの整数値であれば、本当に問題になるでしょうか?

多くのシステムでは、IDなどに32ビットの整数を使います。すると、100万のIDは3,200万ビット、つまり約4MBとなります。この記事を書いている時点で、Azureの最小インスタンス(Free F1)でさえ1GBのメモリを持っています。OSがメモリの大半を使用するとしても、4MBは微々たるものです。

曲レコメンデーションの問題では、より多くのメモリが必要になりそうです。すべてのマシンで動作するとは限りませんが、本当にメモリに収まらないのか、一度立ち止まって考えてみる価値はあります。

実体験:ストリーミングサービス開発における事例

たまたま私には、ホワイトラベル型の音楽ストリーミングサービス向けにREST APIを開発した実務経験があります。2010年代初頭、その企業のオンライン音楽カタログ、ユーザー管理システム、その他いくつかのサービスの設計・実装を支援しました。この中で、特にカタログ機能が今回の話題と関係があります。というのも、このカタログは毎晩しか更新されず、HTTPによるキャッシュに頼る設計だったからです。

その頃、私たちは新しいカタログサービスを担当するIT運用担当者との打ち合わせで、HTTPのキャッシュタイムアウトを6時間に設定する予定であることを伝え、リバースプロキシを構成してキャッシュ処理をコード側で実装しなくて済むようにできないか相談しました。

彼から「どれくらいのキャッシュ容量が必要か」と尋ねられました。

HTTPレスポンスのサイズやシステム内の曲数・アーティスト数・アルバム数などをもとに、ざっくりと計算して「18GBです」と答えました。

彼はただ肩をすくめて「OK」とだけ言いました。

2012年当時、私は18GBはかなりのデータ量だと感じていました(今でもそう思います)。それでも、運用チームはそれだけの容量を持つサーバーを十分に保有していたのです。

その後もこの企業でいくつかのプロジェクトに携わりましたが、今回の曲レコメンデーションの例と直接関係するものは少ないです。ただ、最初の数日間で学んだある出来事は今回のテーマに関係があります。

私が関わる以前、この企業はレコメンデーションシステムを必要としていましたが、既製品でちょうどよいものが見つかりませんでした。当時はSolrが登場する前、Luceneが存在していた時期です。クライアント企業にどのような要因があったのか正確には分かりませんが、結局彼らは独自の検索・レコメンドエンジンを自作する決断を下しました。

具体的には、夜間に高性能なサーバーがデータベースから関連データをすべて読み込み、処理を行い、必要なデータ構造を生成してメモリ上に保持する、という仕組みでした。前述のリバースプロキシと同様、標準的なピザボックス型1Uサーバーよりも大容量のRAMを持つサーバーが必要でしたが、それでも実現不可能なほどではありませんでした。

コストについて

ハードウェアのコストと開発者の人件費を比較してみてください。専用のサーバーを数台導入することで、数千ドル/ポンド/ユーロの出費になるかもしれません。しかしコードが複雑すぎたり、バグが多かったりすれば、それだけで同等のコストが発生してしまいます。

「すでに開発者がいるなら追加コストはかからない」と反論する方もいるでしょうが、複雑なコードベースはチームの開発効率を下げます。結果的に、誤ったソフトウェア設計によって生じる機会費用は、サーバーコストよりも高くなる可能性があります。

私が関数型プログラミング(FP)に興味を持っている理由のひとつは、コードベースをシンプルにできる可能性があるからです。Impureimサンドイッチは、非常にシンプルで魅力的な設計手法であり、FPの理想を追い求めるためだけでなく、実際にコードの簡素化を実現できる点からも検討する価値があります。

直感的には、曲レコメンデーションのようなシナリオはスケールが大きすぎて、Impureimサンドイッチでは対処できないように思えます。しかしこの連載で検討するように、これは唯一の選択肢ではありません。とはいえ、利点の大きさを考えると、もう一度真剣に検討してみる価値はあります。

分析

例で紹介された GetRecommendationsAsync メソッドは、ネストされたループの中で多数の外部呼び出しを行っています。最初に GetTopScrobblesAsync を呼び出して scrobblesSnapshot という変数を生成し、これには最大100件のオブジェクトが格納されます。このメソッド呼び出しが100件以上のデータを返すと仮定すると、外側の foreach ループでは GetTopListenersAsync を100回呼び出すことになります。

さらに、それぞれのリスナーに対して十分なデータが返ってくると仮定すると、内側の foreach ループでは、それぞれに対して20回 GetTopScrobblesAsync を呼び出すことになります。つまり外側のループの各オブジェクトに対して20回、合計で2,000回の外部呼び出しになります。これに、外側ループの100回、初回の1回を加えると、合計で2,101回の呼び出しです。

この例を最初に見たとき、私はその全体的な文脈をよく知りませんでした。これらの不純な操作がデータベースクエリなのかWebサービスの呼び出しなのかが不明だったため、Oleksii Holub氏に直接尋ねました。

その結果、すべてWebサービスの呼び出しであることがわかりました。そして私の解釈では、GetRecommendationsAsync はバックグラウンドのメンテナンス処理として実行されているとのことです。

「メンテナンス処理中に、全体で約10分かかります」

Tweet, Oleksii Holub, 2022

これは朗報です。Impureimサンドイッチを検討するには、第1段階でギガバイト単位のデータを読み込む必要があります。時間はかかりますが、これがバックグラウンド処理であれば、その時間を確保できるからです。

メモリ使用量の見積もり

曲カタログ全体をメモリにロードするのと、スクロブル(再生履歴)データをロードするのとでは話が違います。曲カタログ全体は2012年時点で18GBを要しました。一方、レコメンデーションを生成するために必要なのはIDだけです。前回の記事で紹介したデータ構造をもう一度見てみましょう:

public sealed record Song(int Id, bool IsVerifiedArtist, byte Rating);

public sealed record Scrobble(Song Song, int ScrobbleCount);

public sealed record User(string UserName, int TotalScrobbleCount);

UserName 以外はすべて intbytebool といった小さく予測可能な値です。string 型は任意の長さになりえますが、前回の記事ではユーザー名は1〜20文字の英数字と仮定しました。

では、曲数はどれくらいでしょうか?Spotifyのようなサービスでは、1億曲程度という数が言われています。1曲あたり、int(4バイト)+ bool(1バイト)+ byte(1バイト)で6バイト程度。オーバーヘッドを加味して8バイトと見積もると、1億曲で800MBです。これは最も小さいクラウドインスタンスでは難しいかもしれませんが、一般的なコンピューターなら簡単に扱えるサイズです。スマートフォンでも対応可能でしょう。

次に、スクロブルについて。私はSpotifyは使っていませんが、Last.fmに再生履歴を共有しています。現時点で約11万4千件のスクロブルがあります。若い頃ほど音楽を聴かなくなったとはいえ、2007年から記録しているため件数は多いです。1人のユーザーが20万件のスクロブルを持つと仮定し、1件あたり8バイトとすると、必要なメモリは約1.6MB。ほとんど無視できるサイズです。

User オブジェクトのサイズはユーザー名の長さに依存しますが、平均で32バイト以下と見積もってよいでしょう。スクロブルのサイズに比べれば影響は小さいです。

問題はユーザー数です。100万人では少なすぎるかもしれませんが、参考値としてそれで計算してみましょう。すると必要なメモリは1.6TBになります。このレベルになると、すべてをRAMに載せるのは現実的ではありません。そうしたメモリを搭載したサーバーは存在しますが、非常に高価であり、先述のコスト論理は適用できなくなります。

とはいえ、ここではいくつか素朴な仮定を置いています。たとえば、各スクロブルを個別の Scrobble オブジェクトとして保存するのではなく、同じ曲を繰り返し再生した場合は ScrobbleCount を使ってまとめることもできます。ある曲を50回聴いたとしても、400バイトではなく8バイトで済むわけです。これは桁違いに効率的です。

結局のところ、概算も重要ですが、実測の方が確実です。プロトタイプを作って、実際のメモリ使用量を測ってみる価値はあるでしょう。

このあと3本の記事で、さまざまな構成における 曲レコメンデーション のImpureimサンドイッチの姿を紹介します:

  • C#で実装する曲レコメンデーションのImpureimサンドイッチ
  • F#で実装する曲レコメンデーションのImpureimサンドイッチ
  • Haskellで実装する曲レコメンデーションのImpureimサンドイッチ

最終的に、このシステムにおいてImpureimサンドイッチが実用に耐えないという結論に至るかもしれません。しかし、この連載の目的はあくまで設計の選択肢を示すことです。曲レコメンデーションはあくまで例であり、あなたの別のシステムにおいて「直感的には無理」と思っていたケースも、数字を当てはめてみると実は実現可能で、しかも望ましい方法かもしれません。

結論

現代のコンピューターは非常に大容量のストレージを備えており、そのため私たちの直感はしばしば当てになりません。何十億件ものデータなどRAMに収まるはずがないと思いがちですが、実際に計算してみると、普通のサーバーで十分に対応できることもあります。

もしそうであれば、Impureimサンドイッチという設計手法も選択肢に入ります。データをメモリにロードし、それを純粋関数に引数として渡し、戻り値を扱う——この流れです。

曲レコメンデーションのケースは、Impureimサンドイッチの限界を試すようなシナリオであり、実際に非現実的かもしれません。それでもプロトタイプを試す価値はあります。一方で、それが非現実的であるなら、代替案の検討も必要です。後続の記事ではそのような選択肢も取り上げますが、まずは、もしご興味があれば、次の記事では3つの言語によるプロトタイプを紹介します。

次回C#版 Impureim サンドイッチにした曲レコメンデーション


インデックスへ戻る