メモリ構造#
Polars の DataFrame の裏側には、Apache Arrow 形式の配列があります。これにより、メモリ効率を保ちながら、高速にデータへアクセスして操作できます。Apache Arrow は、データ分析や処理に特化した列指向のメモリ形式で、次の特徴があります。
メモリ効率 — データを連続したメモリブロックに格納するため、キャッシュ効率が高くなります。大量のデータを扱っても、メモリ使用量を最小限に抑えられます。
互換性 — 多くのデータ分析ツールやライブラリが Arrow 形式に対応しています。異なるツール間でも、データをそのまま受け渡せます。
高速なデータアクセス — 列指向のため、特定の列へのアクセスや操作が速くなります。大規模なデータセットで一部の列だけを繰り返し操作する場合に有効です。
豊富なデータ型 — NULL、リスト、構造体、文字列など、さまざまなデータ型を扱えます。複雑な構造もそのまま表現できます。
この章では、NULL、文字列、配列、リスト、構造体などのデータ型が、メモリ上でどのように表現されるかを見ていきます。
import struct
import polars as pl
データの管理#
Polars は、もっとも低いレベルのデータ管理に pyarrow の Buffer を使っています。次のコードでは df['A'] を2回実行し、得られた Series が同一かどうかを id() で確かめます。
df = pl.DataFrame({
"A": [1, 2, 3, 4],
"B": [5, 6, 7, 8]
})
# Seriesオブジェクトを2回取得
s1 = df['A']
s2 = df['A']
print(id(s1), id(s2))
1915379603472 1914274714448
結果として、取得した2つの Series は同一ではありません。Series の内部では、Rust で実装された PySeries が使われていますが、この PySeries もそれぞれ別のオブジェクトです。
print(id(s1._s), id(s2._s))
1915371803904 1914275844704
各 Series を PyArrow の配列に変換しても、得られる配列は同一ではありません。
a1 = s1.to_arrow()
a2 = s2.to_arrow()
print(id(a1), id(a2))
1914276271328 1915380386592
PyArrow 配列が内部で使う Buffer を取り出すと、ID が異なるので Buffer 自体も同一ではありません。しかし address 属性でデータのアドレスを見ると、アドレスは同一です。次の図は、各オブジェクトの関係を示します。
flowchart LR
s1(Series s1) -->|_s| s1s(PySeries)
s2(Series s2) -->|_s| s2s(PySeries)
s1s -->|"to_arrow(False)"| s1sa(pyarrow.Array)
s2s -->|"to_arrow(False)"| s2sa(pyarrow.Array)
s1 -->|"to_arrow()"| s1sa
s2 -->|"to_arrow()"| s2sa
s1sa -->|"buffers()[1]"| s1sab(pyarrow.Buffer)
s2sa -->|"buffers()[1]"| s2sab(pyarrow.Buffer)
s1sab -->|address| memory(Data Memory)
s2sab -->|address| memory(Data Memory)
b1 = a1.buffers()[1]
b2 = a2.buffers()[1]
print(id(b1), id(b2))
print(b1.address, b2.address)
1915387531696 1915387531760
1914300075008 1914300075008
次に、インデックス操作で Series の要素を変更したあと、Buffer のデータアドレスをもう一度確認します。
s1[1] = 100
print(s1.to_arrow().buffers()[1].address)
1914300074368
結果として、データのアドレスが変わっていることが分かります。同じアドレスのデータを上書きしたのではなく、新しいアドレスにデータを保存したためです。そのため s2 と df は元のデータを保ち、s1 の変更は反映されません。
NULL の保存方法#
Polars は PyArrow のデータ型でデータを保存します。NULL を含む配列では、2つのバッファを使います。これにより、NULL を効率よく管理しながら、実際のデータも保持できます。
マスクバッファ: このバッファは、各データ要素が NULL かどうかをビット単位で記録します。具体的には、各ビットがデータ要素に対応し、ビットが1の場合その要素は非 NULL、0の場合は NULL を示します。この方法はメモリ効率が良く、大量のデータでも NULL 情報を最小限のメモリで管理できます。
データバッファ: こちらのバッファには実際のデータが格納されます。NULL 値の代わりに、一般的には0やその他のプレースホルダーが用いられ、実際のデータ要素が連続して保存されます。
次に、具体的な例を示します。
s = pl.Series([1, None, None, 3, 4, None, 8, 9], dtype=pl.Int16)
a = s.to_arrow()
buf_mask, buf_data = a.buffers()
buf_mask は NULL を表すフラグをビット列で持ちます。0b11011001 は、各ビットが対応する要素の NULL 状態を示しています。右端のビットから順に 1, 0, 0, 1, 1, 0, 1, 1 であるため、2、3、6番目の要素が NULL です。
bin(bytes(buf_mask)[0])
'0b11011001'
struct.unpack('<8h', buf_data)
(1, 0, 0, 3, 4, 0, 8, 9)
次のコードでは、buf_data と s が同じメモリ領域を共有していることを確かめます。ctypes を使って buf_data.address をポインタ pbuf に変換し、pbuf 経由で4番目の値を99に書き換えます。最後に s を出力して、変更が反映されているか確認します。
from ctypes import POINTER, c_int16, cast
pbuf = cast(buf_data.address, POINTER(c_int16))
pbuf[3] = 99
print(s)
shape: (8,)
Series: '' [i16]
[
1
null
null
99
4
null
8
9
]
String#
Polars の文字列列は、メモリ効率を高めるために3つのバッファでデータを保存します。次の3つです。
マスクバッファ — 各文字列要素が NULL かどうかをビット単位で記録します。1なら NULL でなく、0なら NULL です。
インデックスバッファ — 各文字列要素の開始位置と終了位置を保存します。これにより、連続した文字列データから各要素をすぐに取り出せます。i 番目の要素の開始位置は buf_index[i]、終了位置は buf_index[i+1] です。
データバッファ — すべての文字列を連続して保存します。連続して並べることで、メモリ使用量を抑え、アクセスも速くなります。
次に具体的な例を示します。
s = pl.Series(["abc", "defghi", "xyz", None, "123"])
a = s.to_arrow()
buf_mask, buf_index, buf_data = a.buffers()
buf_mask の各ビットが、文字列要素の NULL 状態を示します。最初の3つは NULL でなく、4番目が NULL です。
bin(bytes(buf_mask)[0])
'0b11110111'
buf_index の中身は [0, 3, 9, 12, 12, 15] です。これは、各文字列要素の開始位置と終了位置を表しています。
"abc"の開始位置は0、終了位置は3"defghi"の開始位置は3、終了位置は9NULL 要素のため、開始位置と終了位置は同じ12
struct.unpack('<6q', buf_index)
(0, 3, 9, 12, 12, 15)
buf_data には、すべての文字列が連続して保存されています。インデックスバッファを使えば、この連続したデータから各要素をすぐに取り出せます。
buf_data.to_pybytes()
b'abcdefghixyz123'
たとえば s[2] の開始位置は9、終了位置は12のため、次のように buf_data から s[2] のデータを取り出します。
buf_data[9:12].to_pybytes()
b'xyz'
Array#
Array 列は固定長のリストを格納するデータ構造で、メモリ構造には他の列と共通する部分と固有の部分があります。とくに list_size 属性が重要です。次のコードで、Array 列のメモリ構造を説明します。
s = pl.Series([
[1, None, 3 ],
[4, 5, None],
[6, 7, 8 ],
[9, 10, 11 ]],
dtype=pl.Array(pl.Int16, 3))
a = s.to_arrow()
list_size 属性は各要素のサイズを表します。
print(f"{a.type.list_size=}")
a.type.list_size=3
buf_mask と buf_data は、それぞれ NULL とデータを保存するバッファです。この2つは一般の列と同じです。
_, buf_mask, buf_data = a.buffers()
bin(bytes(buf_mask)[0])
'0b11011101'
struct.unpack('<12h', buf_data)
(1, 0, 3, 4, 5, 0, 6, 7, 8, 9, 10, 11)
List#
List 列は可変長のリストを格納するため、独自のバッファ構成を持ちます。文字列列(str 列)とよく似ています。次のコードと出力で説明します。
s = pl.Series([
[1, None, 3],
[10, 20],
None,
[100, 200, 300]],
dtype=pl.List(pl.Int16))
a = s.to_arrow()
buf_mask1, buf_index, buf_mask2, buf_data = a.buffers()
buf_mask1 の各ビットが、リスト要素の NULL 状態を記録します。1なら NULL でなく、0なら NULL です。3ビット目が0のため、3番目の要素が NULL です。
bin(bytes(buf_mask1)[0])
'0b11111011'
buf_index は、各リスト要素の開始位置と終了位置を記録します。文字列列(str 列)と同じく、このバッファで連続したデータから各要素を取り出します。
s[0] のデータはインデックス0〜3、つまり buf_data の [1, 0, 3] です。s[1] は3〜5、buf_data の [10, 20] です。
struct.unpack('<5Q', buf_index)
(0, 3, 5, 5, 8)
次のバッファは、リスト内の各要素が NULL かどうかをビット単位で記録します。2ビット目が0のため、2番目の要素(None)が NULL です。
bin(bytes(buf_mask2)[0])
'0b11111101'
すべてのリスト要素のデータは、buf_data に連続して格納されています。NULL はプレースホルダー(この例では0)で表されます。
struct.unpack('<8h', buf_data)
(1, 0, 3, 10, 20, 100, 200, 300)
Struct#
Struct 列は、複数のフィールドを持つデータを格納する構造です。各フィールドは独自のデータ型とメモリ構造を持ちます。次のコードで説明します。
s = pl.Series([{"A": 1, "B": None}, {"A": None, "B": 20}, {"A": 3, "B": 30}, None])
a = s.to_arrow()
a.buffers()
[<pyarrow.Buffer address=0x1bdb5230a50 size=1 is_cpu=True is_mutable=False>,
<pyarrow.Buffer address=0x1bdb5230a48 size=1 is_cpu=True is_mutable=False>,
<pyarrow.Buffer address=0x1bdb52d04e0 size=32 is_cpu=True is_mutable=False>,
<pyarrow.Buffer address=0x1bdb5230a40 size=1 is_cpu=True is_mutable=False>,
<pyarrow.Buffer address=0x1bdb52d04a0 size=32 is_cpu=True is_mutable=False>]
1つのフィールドにつき、マスクと値の2つのバッファを使います。各フィールドの値がメモリ上で連続していることが分かります。
buf_mask_A, buf_value_A = a.buffers()[1:3]
print(bin(bytes(buf_mask_A)[0]))
print(struct.unpack('<4Q', buf_value_A))
0b101
(1, 0, 3, 0)
buf_mask_B, buf_value_B = a.buffers()[3:5]
print(bin(bytes(buf_mask_B)[0]))
print(struct.unpack('<4Q', buf_value_B))
0b110
(0, 20, 30, 0)