MySQL8.0.27や8.0.28あたりのmemory/innodb/hash0hashやut0new.hなどの話

こんにちわ。せじまです。

先日、プライベートで新しいPCを買ったので試しにVisual Studio 2022(version 17.7.2)でMySQL8.0.34をデバッグビルドしようと思ったら、sql_main がC2678でビルドできなくなってました(2023-08-29時点)。

Hyper-V上でen_USなWindows11開発環境を立ち上げて試してもC2678が発生したので、日本語版のWindowsに限った問題でもなさそうな気がします。 https://developercommunity.visualstudio.com/ を見ると、コンパイラ周りで "Fixed - Pending Release" な件がいくつか見受けられたので、しばらく様子見しようかなと思っています。

MySQL8.1.0や8.0.34をデバッグビルドする話を書こうかと思っていたのですが、今回は別の話にします。

はじめに

performance_schema.memory_summary_global_by_event_name

みたいなqueryを投げて、「memory/innodb/buf_buf_poolはともかくmemory/innodb/hash0hashは何だ?」と思ったことはないでしょうか。私はあります。

innodb_buffer_pool_sizeが100GBくらいのDBになると、memory/innodb/hash0hashのCURRENT_NUMBER_OF_BYTES_USEDがGB単位になってることもあります。

それでいて、mysqldのResident set sizeは memory/innodb/hash0hash のCURRENT_NUMBER_OF_BYTES_USED分、追加で割り当てられてるように見えないこともあります。mysqldを長期間稼働させ続ける間、memory/innodb/hash0hashのCURRENT_NUMBER_OF_BYTES_USEDの分がmysqldのResident set sizeに積み上がってくる可能性があるなら、それがどういったケースになるのか知りたいところであります。

というわけで、memory/innodb/hash0hash で割り当てられてるメモリを調べるために、ソースコードを読んでいきましょう。

対象となるバージョンは(具体的な理由は後述しますが)8.0.27 とします。

今回のお話は、mallocとかページフォルトとかデマンドページングとか、そういった単語がでてきます。mallocについてはかの高名なmalloc動画を視聴していただけばよいかと存じますが、malloc以外についても何か日本語の情報があった方が良いかと思いましたので、最後に少しReferencesの方に記載しておきました。興味のある方はそちらも読んでいただくとよろしいかもしれません。

予め雑なまとめ

書いてるうちに「けっこう長くなってきたな」と思ったので、最初にざっくりまとめておきます。

  • MySQL8.0.27や8.0.28あたりのmemory/innodb/hash0hashのCURRENT_NUMBER_OF_BYTES_USEDは、手元のインスタンスで確認したところ、innodb_buffer_pool_sizeの3%くらいになってました。
  • memory/innodb/hash0hashに割り当てられているメモリはstd::malloc()で割り当てられているため、デマンドページングで物理メモリが割り当てられることもあります。
    • 故に、mysqldのResident set sizeがmemory/innodb/hash0hashのCURRENT_NUMBER_OF_BYTES_USED分だけ常に増加するとは限りません。ワークロードによります。
  • MySQL8.0.30以降、memory/innodb/hash0hashに計上されないメモリの領域が増えています。

では詳細に入ります。

hash0hash

まず hash0hash がどこで定義されているかというと storage/innobase/include/ut0new.h で、PSI_memory_key auto_event_keysなる配列があります。

そして、PSI_memory_key auto_event_keysは、 hash_create()hash_create_sync_obj()からut::malloc_withkey()を呼ぶ際に、UT_NEW_THIS_FILE_PSI_KEYで対応するKEYを引いているわけですね。

次に、hash_create()やhash_create_sync_obj()でut::malloc_withkey()してるものが該当するなら、hash_create()やhash_create_sync_obj()はどこから呼ばれているかが気になります。

hash_create() は次のような #define があるのですが、ここではいったん hash_create() と呼称します。

hash_create_sync_obj() は ib_create() から呼ばれます。hash_create() や ib_create() は buffer pool の初期化やリサイズのときなどで呼ばれており、 hash_create() や ib_create() に渡される引数は、buffer pool size 次第で増減します。

例えば次のような箇所です。

https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/dict/dict0dict.cc#L1026-L1030
https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/buf/buf0buf.cc#L1291-L1295

https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/buf/buf0buf.cc#L1514
https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/btr/btr0sea.cc#L215-L216

hash_create()では、渡された引数より大きい素数をもとにhash_table_t::cellsを初期化するようなので、 innodb_buffer_pool_size の影響は受けつつも、DBAが事前にサイズを見積もるなどはやりにくそうです。

ut_find_prime() の実装など気になる方はこのあたり参照してください。

https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/ha/hash0hash.cc#L109
https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/ut/ut0rnd.cc#L47

ut::malloc_withkey()

8.0.27のソースコードからut::malloc_withkey()を引用します。

https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/include/ut0new.h#L563-L578

余談ですが、MySQL8.0以降のInnoDBのソースコードは、IDE使って読んだ方がいいなと再認識しました。DISABLE_PSI_MEMORY のデフォルトはOFFなので、PFS memory instrumentationはデフォルトでは有効なわけですが、このソースコードだけ読んで malloc_impl の型がなんなのか理解したいなら、IDEの力を借りるのが無難だなと思います。例えば Visual Studio では、select_malloc_impl_tにマウスポインタを合わせると、implはut::detail::Alloc_pfsだとわかります。

Ubuntu 20.04 LTS on WSL2上で MySQL 8.0.27 をデバッグビルドして vscode 上でgdb使ってステップ実行したところ、hash_create()やib_create()から想定通り

https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/include/detail/ut/alloc.h#L435-L439
https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/include/detail/ut/alloc.h#L273-L276

に来て、最終的に

https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/include/detail/ut/allocator_traits.h#L45-L48

でstd::malloc()が呼ばれていることが確認できました。
例えば、vscodeでのコールスタックは次のようになります。

こういった確認がWSL2とvscodeでできるようになったのを見るたびに、便利な時代になったなとつくづく実感させられます。

memory/innodb/hash0hash などについてここまでで雑なまとめ

  • memory/innodb/hash0hashのCURRENT_NUMBER_OF_BYTES_USEDは、InnoDBの初期化時やbuffer poolのリサイズの際、 storage/innobase/ha/hash0hash.cc で hash table に割り当てられたメモリのサイズの合計である
  • hash table のhash_table_t::cellsに割り当てられるメモリのサイズは、 innodb_buffer_pool_size 次第で増減する
  • memory/innodb/hash0hashでhash_table_t::cellsに割り当てられているメモリは、 std:malloc() で割り当てられている。

std::malloc() である程度まとまった領域を割り当てているのであれば、その領域はデマンドページングで割り当てられることになるでしょう。memory/innodb/hash0hashのCURRENT_NUMBER_OF_BYTES_USEDが、mysqldのResident set sizeに積み上がってないことがあるのも道理ですね。

では、どういったケースでmysqldのResident set sizeに、これらの領域が積み上がっていくのでしょうか。
その一つに、「たくさんテーブルを開いたとき」というものが挙げられます。

たくさんテーブルを開くとmemory/innodb/hash0hashで確保した領域の一部に、デマンドページングでメモリが割り当てられる

具体的にmemory/innodb/hash0hashでどういったものがhash tableで管理されているかといいますと、dict_sys->table_hashやdict_sys->table_id_hashなどが含まれます。

innodb_buffer_pool_size=16Gで試してみたところ、dict_sys->table_hash.cellsへのut::malloc_withkey()は、size=35402344で、ut::detail::Alloc_pfs:alloc()内でtotal_lenは35402376になっていました。

dict_sys->table_hashだけでそれだけ割り当てられるのですから、dict_sys->table_id_hashも合わせると67.5MBくらいになります。innodb_buffer_pool_sizeが100GBを超えたりすると、dict_sys->table_hashとdict_sys->table_id_hashだけで数百MBくらいはmallocされそうだな、となります。

dict_sys->table_hashやdict_sys->table_id_hashがhash tableであることを考えると、dict_sys->table_hash->cellsやdict_sys->table_id_hash->cellsが連続して埋まっていくわけではなく、歯抜けのような状態で使われるでしょう。
雑な言い方をすると、実際にオープンしたテーブルの数と比較して、dict_sys->table_hash->cellsやdict_sys->table_id_hash->cellsにデマンドページングで割り当てられるメモリは、多めになるんじゃないかと考えられます。

次に、dict_sys->table_hashにHASH_INSERTされていく様をha_innobase::open() から見ていくと

https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/handler/ha_innodb.cc#L6978-L7077
https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/dict/dict0dd.cc#L4524-L4530
https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/dict/dict0dd.cc#L4153-L4184
https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/dict/dict0dd.cc#L2745-L2876

dict_table_tのオブジェクトをここで割り当てて、それをdict_sys->table_hashやdict_sys->table_id_hashにHASH_INSERTします。

https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/dict/dict0dd.cc#L4183-L4361
https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/dict/dict0dict.cc#L1193-L1243

新規にテーブルをオープンして、いままで使ってなかったfoldやid_foldでHASH_INSERT()した際、hash_table_t::cellsで今までアクセスしたことのないアドレスにアクセスしてページフォルトが発生し、Anonymous Pageが割り当てられるわけですね。Anonymous Pageが割り当てられた結果、mysqldのResident set sizeが積み上がっていきます。(ただ、 innodb_buffer_pool_sizeが小さいとdict_sys->table_hashに割り当てられるメモリのサイズも小さくなって、 glibc malloc は ARENA から割り当てようとするため、このあたりの挙動はまた変わってくるでしょう)

HASH_INSERTされるdict_table_tのオブジェクトもヒープに割り当てられているので、これもちょっとずつmysqldのResident set sizeに反映されていきます。

dict_mem_table_create()からut::malloc_withkey()に至るまで長いのでちょっと割愛しますが、最終的にmem_heap_create_block_func()なので、memory.ccで呼び出されたut::malloc_withkey()は performance schema では memory/innodb/memory で集計され、memory/innodb/hash0hash には含まれません。

私がmysqldで8.0.27で稼働しているインスタンスをいくつか見てみたところ、innodb_buffer_pool_sizeが100GBくらいあるようなところは、memory/innodb/hash0hashのCURRENT_NUMBER_OF_BYTES_USEDは3GBくらいあったので、dict_sys->table_hashやdict_sys->table_id_hashがmemory/innodb/hash0hashで占める割合はそこまで大きくないかもしれません。

具体的に挙げていくと、btr_search_sys_create()から呼ばれるib_create()や、

https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/handler/ha_innodb.cc#L4908

srv_lock_table_sizeのサイズを踏まえると、lock_sys_create()などの方が memory/innodb/hash0hashのCURRENT_NUMBER_OF_BYTES_USED で占める割合は大きいでしょう。

https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/srv/srv0start.cc#L2149
https://github.com/mysql/mysql-server/blob/mysql-8.0.27/storage/innobase/lock/lock0lock.cc#L308-L332

ただ、

  • innodb_buffer_pool_size に応じて InnoDB 内部の hash table に割り当てられるメモリの量は増減する
  • たくさんテーブルを開くようなワークロードでは、InnoDB 内部の hash table に、デマンドページングでメモリが割り当てられることがある

といったことは(たしか)MySQLの公式ドキュメントでは見かけた記憶が無く、ワークロード次第で変わってきたりする部分なので、このあたりは知っておいて損はないかなと思います。

InnoDBでのメモリアロケータ周りで、MySQL8.0.26~8.0.30にかけて変更されたこと

8.0.27でBug #33159210 IMPLEMENT PFS-AWARE CUSTOM MEMORY ALLOCATORがマージされました。また、8.0.30でBug #16739204 IMPROVE THE INNODB HASH FUNCTIONがマージされました。(余談ですが、8.0.30のこの修正は性能上の問題があったらしく、8.0.34でBug#34870256: New hash function causes performance regressionという修正が入りました。)

InnoDBはPFS対応したカスタムメモリアロケータを8.0.27で新しい実装に切り替えました。8.0.30でハッシュ関数や乱数を生成する関数を置き換える際、hash_create()は削除され、 ut::new_<hash_table_t>() で置き換えられました。ut::new_<>()はPSI_NOT_INSTRUMENTEDなので、8.0.27や8.0.28のhash_crete()でmemory/innodb/hash0hashに計上されていたものは、performance schema上で計上されなくなりました。
詳しい内容はわかりませんが、8.0.27より前のInnoDBはカスタムメモリアロケータ周りに課題があったらしく8.0.27で修正されたようです。Bug #33159210 IMPLEMENT PFS-AWARE CUSTOM MEMORY ALLOCATORには次のような記述がありました。

This patch introduces a fresh implementation of standard-compliant custom memory allocator that addresses the many issues found in the old implementation, all of which are more thoroughly explained in Bug #32716067 UT0NEW ALLOCATOR IS TRYING TO FIT TOO MANY ORTHOGONAL CONCEPTS TOGETHER and several other related bug reports. Increased complexity in the old implementation is what has lead to creating unnoticed subtle bugs but also imposing limitations to the code design.

また、8.0.30になると、かつてperformance schemaに計上されていたメモリの使用量が一部計上されなくなるなど、このあたりはいろいろと過渡期なような気がしています。

私はたまたま8.0.27や8.0.28でmemory/innodb/hash0hashについて調べる機会があったので、「memory/innodb/hash0hashで確保された領域において、ワークロード次第ではResident set sizeの増え方に違いが出てきそうだ」ということに気づきましたが、8.0.26以前や8.0.30以降で同じことを調べようとしたら、そこまでわからなかったかもしれません。

2021年以降のcommit logをさらっと見ても、8.0.26や8.0.27で、メモリアロケータ周りではかなり修正が入っているように見えます。

驚いたことに、ut::malloc_withkey()がInnoDBに導入されたのは、8.0.27からのようです。8.0.26のhash_create()を眺めてみると、ut::malloc_withkey()ではなくut_malloc_nokey()が使われています。また、ut_malloc_nokey()あたりのコメントを読むと

One should avoid using the macros below when writing new code in general, and try to remove them when refactoring existing code (in favor of using the UT_NEW).

とありましたので、念願かなってリファクタリングが進んだのでしょう。

いまのInnoDBのメモリ管理周りはたぶん過渡期で、今後また整理されることもあるんじゃないかなぁと個人的には感じていますが、最新のソースコードだけでなくちょっと古いソースコードも併読することで理解が深まることもあるのだなぁと、再認識した次第です。

おわりに

現代のmysqldにおけるメモリの用途は、多くの場合InnoDBのbuffer poolが大半を占めるかと思います。そのため、クライアントからどれくらいコネクションが張られ、そのコネクションがsort bufferなどスレッドごとのバッファをどれくらい使うか試算し、binary logやInnoDBのredo logがページキャッシュなどで使うメモリも考慮しつつ、innodb_buffer_pool_sizeをどれくらいまで引き上げようか、といったチューニングが行われたりもするわけです。しかし、実のところ考慮すべき要素はこれらだけでなく、「buffer poolやスレッドごとのバッファ以外にも、けっこうメモリ食ってるところあるな?」と数年前から気になっていました。今回、innodb_buffer_pool_size次第で大きく増減する要素があって、それを調べる術を知ることができたので、個人的には良い学びになりました。

とりあえず、innodb_buffer_pool_sizeの数%分くらいは、InnoDBが内部的にhash tableで確保して使ったりもするのだ、ということを頭の片隅に置いておくと良いんじゃないかと思います。

あと、MySQLのソースコードをIDEで読めるとやはり有益だなと思いましたので、MySQL8.1.0をデバッグビルドしてIDEでソースコード読めるようにするための記事など、そのうちまた書きたいなと思います。

References