300MBで5000万行を集計|DuckDBの外部集約の仕組み

大きなCSVを読み込んで集計しようとしたら、途中で処理が落ちてしまった。そんな経験はありませんか。メモリが足りないことは分かっても、なぜ足りないのか、どうすれば動くのかまでは分かりにくいものです。この記事では、5,000万行のGROUP BYを300MBという厳しい上限で動かした検証を題材に、データベースが裏でやっている工夫を追いかけます。結論を先に言うと、メモリに載らないデータの集計はいったんディスクへ逃がして小分けにすることで成立しています。

仕組みの説明はDuckDB公式の解説と公式ドキュメントで裏を取りました(参考リンク1〜3)。実行時間やメモリ量の数値は、あとで条件を添えて示すとおり、第三者が公開した検証記事の実測値です(参考リンク4)。

目次

GROUP BYがメモリを食うのはなぜか

GROUP BYが重いのは、計算量が多いからではなく、最後まで答えが確定しないからです。

たとえば「部署ごとの売上合計」を出すとします。1行目を読んだ時点では、営業部の合計はまだ途中経過にすぎません。最終行に営業部の売上がもう1件あるかもしれないからです。つまりデータを最後まで読み切るまで、すべてのグループの途中経過をどこかに持っておく必要があります。

この途中経過をしまう場所が、ハッシュテーブルと呼ばれる表です。グループの種類が1万件なら1万行分、1億件なら1億行分の置き場が必要になります。行数そのものより、グループの種類数がメモリを決めるわけです。ここが、単純な合計や件数カウントとの大きな違いになります。

要するに、GROUP BYで落ちるときは「データが大きすぎた」のではなく「持ち越すべき途中経過が多すぎた」と読み替えると、対処の方向が見えてきます。

メモリに載らないGROUP BYは、区画に分けて逃がし、あとから区画ごとに仕上げることで成立する
図: メモリに載らないGROUP BYは、区画に分けて逃がし、あとから区画ごとに仕上げることで成立する(筆者作成)

DuckDBの外部集約は二段階で動く

分析用の軽量データベースであるDuckDBは、手元のPCで大きなデータを扱う用途で人気が高まっています。そのDuckDBは、ハッシュテーブルがメモリに収まらないとき、処理を二段階に分けて切り抜けます。公式ドキュメントは、メモリより大きい処理を一時ディレクトリへの書き出しで支えていると説明しています(参考リンク2)。

第一段階は、スレッドごとの集計です。読み込んだ行は10万行前後のかたまりに区切られてスレッドへ配られ、各スレッドが自分のハッシュテーブルへ入れていきます。このとき表の中身はキーのハッシュ値であらかじめ区画に分けられており、あふれた区画のページから順にディスクへ書き出されます(参考リンク1)。集計をやめてただ書き出すのではなく、集計しながら、置ききれない分だけを逃がしているという点が肝です。

第二段階は、区画ごとの仕上げです。各スレッドが区画を一つ受け持ち、その区画のデータだけをメモリに読み戻して集計を完成させます。公式の解説では、区画をスレッド数より多めに作っておくことで、一度に扱う量を小さく保てると説明されています。

この方法が成立する理由は、同じキーが必ず同じ区画に落ちるからです。営業部の行がAとBに分かれてしまうと合計が壊れますが、ハッシュ値で振り分ければ営業部はどれか一つに集まります。だから、区画ごとの結果をあとから並べるだけで正しい答えになります。

身近な例でいうと、名刺の山を一人で数えるときの動きに近いはずです。机が小さくて全部は広げられないので、まず会社名の頭文字ごとに封筒へ仕分けます。そのあと封筒を一つずつ開けて数えれば、机の広さは足りたままで最後まで数え切れます。

公式ドキュメントは、この区画分けのおかげで性能の落ち方がなだらかになり、急に遅くなる段差を避けられるとも書いています。ディスクへ逃がすぶん遅くはなりますが、動くか落ちるかの二択にはならない、ということです。

DuckDBの外部集約にも限界がある

ディスクへ逃がせるとはいえ、上限をいくらでも下げられるわけではありません。ここが今回の検証でいちばん面白いところです。

先に条件を書いておきます。以下の数値は、1.7GBのParquetに入った5,000万行を4スレッドで集計した第三者の検証記事の実測値です(参考リンク4)。キーが連番なので全行がそれぞれ別のグループになる、ハッシュテーブルにとって最も厳しい形です。上限を指定しない既定の状態(38.3GiB)では、ハッシュテーブルが1,960MiBまで育ち、一時ファイルへの退避はゼロ、実行時間は3.0秒でした。

同じ集計をmemory_limit=300MBで走らせると、メモリ上のハッシュテーブルは200MiB前後で頭打ちになり、1,770MiBぶんが一時ファイルへ退避されました。指定した上限は、きちんと守られていたことになります。時間は第一段階が約1.5秒、第二段階が約7秒で、合計すると既定の3.0秒に対して約2.8倍です。落ちる代わりに遅くなるという交換が起きている、と読めます。

ところが同じクエリで上限だけを下げると、様子が変わります。

  • 300MB: 4回とも最後まで完走した
  • 250MB: 4回のうち完走は2回だけ。残る2回はメモリ不足で停止した
  • 200MB以下: メモリ不足のエラーで停止した

失敗したときのエラーは「failed to allocate data of size 8.0 MiB」、つまり8.0MiBの確保に失敗したという内容でした。これは、ディスクへ逃がせない種類の領域が上限に達したことを示しています。DuckDB公式のトラブルシューティングにも、一部の操作はバッファマネージャを通らないため、memory_limitで許した以上のメモリを確保することがあると明記されています(参考リンク3)。データそのものは退避できても、処理の作業場として押さえておく領域までは減らせない、ということです。

しかもその作業場は、並列で動くスレッドの数だけ増えていきます。同じ検証記事は、スレッドを12に増やすと300MBでも起動直後に失敗したと報告しています。公式ガイドがメモリ不足の対処として、上限を上げることと並べてSET threadsでスレッド数を減らすことを挙げているのは、この理由からです。

だから実務では、上限をぎりぎりに絞るより、スレッド数に見合った余裕を残しておくほうが安全です。「動くけれど、たまに落ちる」がいちばん困る状態だからです。

新社会人エンジニアがDuckDBの外部集約から学べること

この話は、DuckDBを使わない人にも効きます。ディスクへ逃がす仕組みは、多くのデータベースが同じ考え方で持っているからです。

覚えておきたいのは、次の3つです。

  1. 集計が落ちたら、行数ではなくグループの種類数を先に疑う
  2. 急に遅くなったときは、メモリからディスクへ切り替わった可能性を考える
  3. メモリ上限は、同時に動くスレッドの数とセットで決める

とくに2つ目は現場でよく効きます。処理時間が10倍に伸びたという相談の原因が、実は一時ファイルへの書き出しだった、という場面は珍しくありません。壊れているのではなく、逃げ道に切り替わって粘っている状態です。そこまで分かれば、上限を増やすのか、対象を絞るのかを落ち着いて選べます。どこで時間が消えているかを追う方法は、分散トレーシングの記事でも触れました。

考察: 落ちない書き方より、落ち方を知っているほうが強い

ここからは筆者の考えです。若いうちに身につけると差がつくのは、速いコードを書く力より、限界の場所を言葉にできる力だと感じています。

理由は単純で、限界に当たったときの説明が、そのまま次の判断になるからです。「メモリが足りません」で止まる人と、「グループが1億種類あるので、上限を上げるか対象を絞るかの二択です」と言える人では、相談された側の動きが変わります。前者は原因調査から始まりますが、後者はその場で決められます。

メモリはこれから当たり前に潤沢とはいかない資源でもあります。AI向けの需要でメモリの価格が跳ね上がったことは記憶に新しいところです。潤沢な環境を前提にせず、限られた中で成立させる考え方を知っておくことは、これからも古びない土台になります。

まとめ

  • GROUP BYがメモリを使うのは行数ではなく、途中経過を持ち越すグループの種類数が原因
  • DuckDBはハッシュ値でかたまりに分けてディスクへ書き出し、一つずつ読み戻して集計する
  • 退避できない作業領域があるため、メモリ上限はスレッド数に見合った余裕を残して決める

まずは手元の重い集計クエリで、グループの種類数を数えてみてください。その数字が、上限を上げるべきか設計を変えるべきかを教えてくれます。

参考リンク

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

コメント

コメントする

目次