データ集め

データセット構築で最も時間がかかるのがスクレイピングしてデータをダウンロードするところです。サーバー負荷も考えると秒間数十リクエストとかはできません。できればミスをなくして同じデータをもう一度ダウンロードする必要がないようにしたいです。例えば画像をダウンロードするとき、URLのファイル名部分だけを保存したらどうでしょうか?または、そのデータのID部分+拡張子にしたらどうでしょうか? 前者は一意になる保証がなく、後者でもそれが本当に一意であるかどうかわかりません。画像データを取得する上で確実に一意なものはURL全体です。なのでURLをURLエンコードしたものをファイル名とすべきです。パーセント記号が含まれますがそれは見た目の問題で、データの質ほど重要なものはありません。Linuxでは%を含む名前を扱えます。

HTML取得時は画像ファイルと同様にURLをファイル名として保存し、キャッシュすべきです。HTMLを取得、データ整形し、破棄していると、必要なデータを取り切れていなかったときにもう一度ダウンロードしなければならなくなってしまいます。データが入っているHTMLページはたいてい裏でDBが関わっているのでそこまでリクエスト頻度を上げられないことも時間ロスの一因となります。

また、自作のスクレイピングソフトやシェルスクリプトを活用してこの工程を1コマンドで終わらせるようにすることも手間が減るので意外と重要です。

データ転送

24時間可動させるためにデータの収集でVPSを使うことも多いでしょう。VPSからローカルの作業環境へデータを移動するのに最も良い方法は何でしょうか? IOボトルネックを減らすためにtar化してから転送する方法や、rsyncで個々のファイルを転送する手もあります。私は後者が便利だと思っています。rsyncは100万ファイルくらいであれば現実的な時間で増分ファイルリストを転送でき、データを複数回に分けてローカルに転送することができるようになります。tarであれば作業効率から全データ取得後に行うのでしょうが、100GB近くにもなってくると相当高速なネットワーク環境がない限り、途中で中断してしまうことも考えられます。またダウンロードしたファイルが破損してないことを確認するためにmd5チェックサムを計算するのにもかなりの時間がかかってしまいます。

データ整理

上記の方法でデータを取得してもそのままデータセットとして使うことはできません。なぜならこのような小さなファイルではIO性能がボトルネックになってデータの読み込みで最高性能が出せないからです。そこでDBを使うことを考えます。ここでは単純なDBであるSQLiteとNoSQLのMongoDBの双方についてどういう使い方が適しているか見ていきます。

まず、DBを使わなくていいケースがあります。それは画像データの保存先をNVMe M.2 SSDにしている場合です。この場合はDBを使うよりもM.2のほうがパフォーマンスが良いので画像はそのままで、データベースの使用はJSONデータだけにします。しかし、SATA SSDやHDDの場合、ランダムアクセス速度がM.2に劣るので画像にもデータベースを使用することでIOボトルネックを解消することができます。

MongoDBはNoSQLであるため、JSONデータの扱いに長けています。データセットを後から加工するときに、テーブルの定義を変更しなくていいので柔軟性が高いです。Mongoはメモリ上にキャッシュするのでデータを繰り返し読み書きする場合は高速になります。ただ、メモリ上にないデータを読み出そうとするとIOが発生するので、画像のように大きくてメモリに乗り切らないデータを総なめすると性能が落ちます。また、画像データをMongoに入れるとSQLiteと比べて性能が落ちます。これはMongoがSQLiteよりも複雑な機能を持っており、読み出す際にはMongoのデータベースファイルからWiredTigerエンジンによってMongoのメモリへ読み出し、TCP通信によって、それをデータを扱うアプリケーションのメモリへ移動させなければならないからです。計測したところIOは50%ほどしか使われていませんでした。これはIOが性能を出し切れておらず、Mongoの仕組みによってボトルネックが発生してしまったということです。

SQLiteはその単純さ故にデータの読み込みが高速です。これはIO性能を最大限に使えるということです。特にシーケンシャルだと読み込み速度はディスクの最大値を示し、データの読み出しが最高効率となります。Mongoほどメモリを活用しないようですが、ディスクのキャッシュやRAM上のIOキャッシュがあるので繰り返しアクセスした場合も高速です。ただMongoのような柔軟性はないため、画像データを入れるにとどめたほうが楽です。また、JSONデータも入れて、それを更新するとSQLiteのデータベースファイル内のレコードが断片化し、シーケンシャルに読み出しているつもりでも、IO的にはシーケンシャルではなくなってしまいます。その場合VACUUMコマンドでデフラグすることができますが、データベースが大きければ大きいほどこの処理に時間がかかってしまうのでこの使い方はあまり実用的とは言えません。

したがって、画像はSQLiteへ、JSONデータはMongoへ格納するのが最善と言えます。

ベンチマーク

M.2 NVMe SATA SSD HDD
IO性能
Read Files JSON (500B) 17000/s 6500/s 100/s
Read Files Image (150KB) 14000/s 1500/s 130/s
Read Files JSON + Image 8000/s 1000/s 100/s
SQLite (size=1KiB,count=100K,batch=1000)
Seqential Write 78000/s 52000/s 6100/s
Seqential Read 370000/s 140000/s 100000/s
Random Write 52000/s 35000/s 1100/s
Random Read 55000/s 40000/s 3700/s
Mongo (size=1KiB,count=100K,batch=1000)
Seqential Write 110000/s 120000/s 30000/s
Seqential Read 260000/s 180000/s 70000/s
Random Write 25000/s 23000/s 4500/s
Random Read 110000/s 75000/s 22000/s
SQLite (size=100KiB,count=10K,batch=100)
Seqential Write 3400/s 1800/s 320/s
Seqential Read 23000/s 4200/s 1400/s
Random Write 1000/s 1000/s 180/s
Random Read 5100/s 2500/s 220/s
Mongo (size=100KiB,count=10K,batch=100)
Seqential Write 7400/s 4100/s 1200/s
Seqential Read 8800/s 3900/s 1000/s
Random Write 4100/s 1600/s 210/s
Random Read 5000/s 1900/s 240/s

※DB計測では対象ドライブにデータベースファイルを置き、格納するデータは事前にメモリ上にランダムデータを生成。毎回DBを再起動、再接続、RAM上のキャッシュとドライブ上のキャッシュを削除。size,count,batchの条件が変わるたびにデータファイルを削除。3回ずつ計測し平均を取った。ランダムアクセスのためにidにインデックスを作成。Mongo、SQLiteともにジャーナル有効。

  • Mongo(100KiB)がSATAでWriteのが速いのはメモリにキャッシュするからです。SQLiteはあまりメモリにキャッシュせずにIO発行するようです。

速度遷移

1KiB * 100k

小さいレコードだとシーケンシャルでは速度速度ですが、ランダムアクセスでは最初低速で次第に高速になりました。これはデータベースがある程度の数のレコードを同時にメモリに読み込んでいるか、RAM上のIOキャッシュやディスクキャッシュが複数のレコードにまたがる領域をキャッシュしているためと思われます。

Mongoではその傾向が顕著です。MongoのランダムWriteがランダムReadよりも早い段階で速度が上がるのはWiredTigerエンジンが追記型で、既存のデータをあまり読み込む必要がないからだと考えられます。

※ 横軸が経過時間、縦軸が処理したレコード数です。

100KiB * 10k

大きめのレコードではどの条件でも速さが概ね一定です。小さいレコードのようにキャッシュがレコードをまたがることが少なく、キャッシュしていないデータはIOアクセスするためと思われます。