packed-rows — 宣言済みの行を 1 回の確保に収める
TABLE.DECLARE はテーブルの形をまるごとサーバーに伝えている。packed-rows を有効にすると、そのテーブルの prefix 配下の行は行ごとのハッシュ表を持つのをやめ、各列の値を宣言順に、小さなオフセット表の後ろへ並べて保持する。
既定では無効であり、その理由は下の「順序」の節にある。
こういうときに要る
列が 3〜12 本で、メモリが効いてくるテーブル。ここが汎用ハッシュのいちばん無駄な帯域で、インライン形式から昇格したハッシュは kevy-map が出せる最小の表——16 スロット——を要求する。行が 3 列でも 12 列でも同じように要求する。
その徴候は、行の固定コストが列数に依存しないこと。実測、5 万行の 1 行あたり RSS は 3 列で 1,699 バイト、7 列で 1,694、12 列で 1,713 ——ペイロードは 91 バイト違うのに。
中核の考え方
宣言済みの行は 1 回の確保になる:
[ncol u16][存在ビットマップ][end_1 u16] … [end_n u16][各列のバイト …]列の順序は宣言から来るので、フィールド名は保存しない——カタログに既にある。欠けている列は 1 ビットで済む。ここまでのどれもワイヤ上には見えない:HGET / HSET / HDEL / HGETALL / HRANDFIELD / HSCAN、そしてフィールド TTL 系の動詞は、以前とまったく同じに応答する。
その行が汎用形式に戻る条件は 2 つで、どちらもエラーにならず値も落とさない:テーブルが宣言していない列への書き込みと、u16 オフセットで届く範囲を超える値。パック行は型ではなく、サイズクラスと宣言である。
有効化
[server]
packed_rows = trueあるいは KEVY_PACKED_ROWS=1、あるいは実行中に:
CONFIG SET packed-rows yesCONFIG SET が書き換えるのは動作中の設定であってファイルではない。再起動はファイルを読むので、実行中にパックを指示されたサーバーは再起動後はパックしない。
テーブル宣言時に既にある行は、shard tick 上で分割されたバッチによって変換される。稼働中のキー空間に対して宣言してもそこで止まらない。
何を節約し、何を払うか
リリース機での実測。200 万行、7 列(うち 1 列は 400 バイト)、3 パスを交互に、同一バイナリでフラグ 1 つだけの差:
| AOF モード | 無効 | 有効 | |
|---|---|---|---|
| off | 5,504 | 4,760 | −13.5% |
everysec | 5,824 | 5,527 | −5.1% |
always | 5,882 | 5,137 | −12.7% |
| tiering 有効 | 5,221 | 5,460 | +4.6% |
単位は元 CSV 1 MB あたりの常駐メモリ KB。ロードのスループットは変わらない(差は 0.1% 未満)。点読みとインデックス参照も変わらない。
リストページは約 7% 高くなる(p50 145 → 155 µs)。パック行は宣言された列名を走査して列を見つける——短いスライスがひと握り——のが、行ごとのハッシュ表を持たないことの代価であり、ページという形は 20 行それぞれから 1 列を取り出す。
順序。上のどれよりも効く
そもそも節約が現れるかどうかは、テーブルをいつ宣言したかで決まる。
| 順序 | 無効 | 有効 | |
|---|---|---|---|
| 宣言してからロード | 1,663 | 1,274 | −23.4% |
| ロードしてから宣言 | 1,663 | 1,721 | +3.5% |
単位は 1 行あたりの常駐バイト、50 万行、インデックスなし。先に宣言するとは、すべての行が最初からパック形式で作られ、汎用形式が一度も確保されないということ。後から宣言するとは、すべての行がまず汎用形式で作られ、バックフィルが置き換える対象の表の隣にパック済みバッファを確保してから表を解放するということ——そしてプロセスに戻ってくるのは、アロケータが返す気になった分だけである。
ピークはどちらの順序でも同一で、そこが要点だ。違うのは表現ではなく、履歴のほうである。
このページの 2 つの測定は食い違っており、それに与えた説明は測って否定された。 順序の表の上にある AOF モードの表も「ロードしてから宣言」の順序であり、そちらは 13.5% 節約する——こちらは 3.5% かかる。真っ先に疑われたのはインデックスだった。ベンチマーク側のテーブルはそれも宣言しており、そのバックフィルはパックのバックフィルが解放した直後に大量に確保する。アロケータが解放された表を抱え込まず再利用するなら、差は説明がつく。
同じプローブにインデックス宣言を足して測った、3 回の中央値:
| ロードしてから宣言 | 無効 | 有効 | |
|---|---|---|---|
| インデックスなし | 1,663 | 1,721 | +3.4% |
| インデックスあり | 1,828 | 2,186 | +19.6% |
インデックスは事態を悪くし、規模でもなく(200 万行でも同じ +3.5%)、シャード数と AOF 設定は両者で同一、一括ロードを 1 件ずつに変えても差はない(+3.6% 対 +3.5%)。
答えはクエリ段階だった。 ベンチマークはロード後に 4 つのクエリ形状を各 5,000 回走らせるが、どのプローブも 1 度も走らせていなかった。それを足して、3 回の中央値:
| ロード後宣言・インデックスあり | 無効 | 有効 | |
|---|---|---|---|
| クエリなし | 1,848 | 2,077 | +12.4% |
| クエリあり | 2,207 | 2,133 | −3.4% |
符号が反転する。理由は非対称性が語っている:クエリ段階は汎用形式に 1 行あたり 359 バイトを足し、パック形式には 56 しか足さない。後者は 3 回とも 2,133 バイトちょうどに着地し、前者は 244 バイトの幅に散る。
節約は書くときではなく、読むときに回収される。 パック行への書き込みは代価を払う——置き換える表の隣に確保されるバッファと、アロケータが返さない分だ。一方、汎用ハッシュから 1 フィールドを読むには表を辿って SmallBytes を 1 つ返す必要がある。パック行の値は連続した 1 本で、その読み出しはほとんど確保しない。1 万回の読み出しでは、これが 1 行あたり 300 バイトの残留 arena になる。
行は 1 度書かれて何度も読まれる——それがこの形式の対象であり、ロードだけを測る測定が決して見ない側面である。
−23.4% は表現そのものの効果として読んでほしい。そして「ロード先行」の数字はいずれも、バックフィル経路の結果がこのページの特定できていない何かに依存すること、そして 50 万行の規模では試した両方の構成でメモリを食うことを述べている。
したがって:
- 順序を自分で決められるなら、ロードの前にテーブルを宣言すること。 −23% はそこにある。
- すでにあるキー空間へ後から導入するのがもう一方の行で、バックフィルが解放したものをアロケータが返すまでは、節約ではなく出費になりうる。どれだけ返すかは kevy ではなくプラットフォームの性質で、同じ実験は macOS では +3.5% ではなく +55% に落ち着く。
トレードオフ
- tiering。 降格の予算はストア自身の会計で表現されており、パックはその数字を下げる。したがって tiering を有効にしたストアは自分が予算内だと早く判断し、より多くの行を常駐させる——これは予算が言われたとおりに働いているのであって、上の表で tiering の行だけが逆を向く理由でもある。書き込みが大幅に速くなるのは同じことの裏側で、熱いまま残った行は書き込み時に tier の経路を通らない。
- 宣言されていないキーは今日の表現をすべてそのまま保つ。 パック形式は宣言によって得るものであり、押し付けられるものではない。
- ごく小さいハッシュにはインライン形式のほうが有利で、それはこの形式の手前に残る。
FAQ
AOF やスナップショットの形式は変わる? 変わらない。AOF はコマンドログなので、パック行は 5.3 のログに既に入っている同じ HSET フレームの再生で作られるし、5.3 のバイナリは 5.4 のログをそのまま読む。スナップショットも同じペイロード形状を書き出す。
レプリカはプライマリと揃えるべき? 実務上は揃える。行を別の方式で保持するレプリカへフェイルオーバーすると、配置全体のメモリ像が変わる。この設定がホットに変更できるのはそのためである。
ある行がパックされているか知るには? その行に MEMORY USAGE を前後で撃つ。保存形式を報告する動詞は意図的に用意していない:それで分岐するクライアントは、依存してはならないものに依存することになる。