Pandasのデータフレームは便利だけどメモリ管理よく分からないよね、実際どこにどう配置されているんだっけ、という雑談があって気になったので調べてみました。
調査方法
import pandas as pd
df = pd.DataFrame({'A': [1, 2], 'B': [3.0, 4.0], 'C': [5, 6]})
for block in df._data.blocks:
memory_address = block.values.__array_interface__['data'][0]
memory_hex = block.values.data.hex()
print(f"({id(block)}) {block}")
print(f"<{memory_address}> {memory_hex}")
print()
(4886642416) FloatBlock: slice(1, 2, 1), 1 x 2, dtype: float64
<140474854679968> 00000000000008400000000000001040
(4886642608) IntBlock: slice(0, 4, 2), 2 x 2, dtype: int64
<140474585659872> 0100000000000000020000000000000005000000000000000600000000000000
山カッコ内の数値がメモリアドレス、その後の数値がメモリ値の16進数表現です。
A列とC列はどちらもInt値なのでまとめてメモリ配置されていることが分かりますね。なるほどなー?
データ構造
データフレームはBlockMangerというクラスを通して、データをブロック管理しています。このあたりの思想はPandas作者の記事『A Roadmap for Rich Scientific Data Structures in Python』が分かりやすいです。
上記のコードで表れた変数の型を追っていくと次のようになっていて、
-
df: pandas.core.frame.DataFrame
- df._data: pandas.core.internals.managers.BlockManager
- df._data.blocks: tuple
- df._data: pandas.core.internals.managers.BlockManager
-
block: pandas.core.internals.blocks.Block の子クラス(FloatBlockなど)
- block.values: numpy.ndarray
- block.values.__array_interface__: dict
- block.values.data: memoryview
- block.values: numpy.ndarray
ブロックはNumPyのndarrayを保持していることが分かります。
ということで、ここからはNumPyの世界になっていて『2.2. Advanced NumPy — Scipy lecture notes』の通り ndarray.__array_interface__['data'][0]
でメモリアドレスを取得できます。そして ndarray.data
でmemoryviewが取得できるので、メモリ値も覗くことができます。
なお注意点としてmemoryviewをprintすると <memory at 0x11b6a3ad0>
というふうに表示されるのですが、これはmemoryviewのインスタンスのアドレスであって、値のアドレスとは異なります。詳しくは『Numpy , Python3.6 - not able to understand why address is different? - Stack Overflow』を参照してください。
実験
いくつか簡単なデータフレーム操作を行って、メモリ配置がどう変わるか実験してみます。
df1 = df[0:1]
(4886726416) FloatBlock: slice(1, 2, 1), 1 x 1, dtype: float64
<140474854679968> 0000000000000840
(4886727088) IntBlock: slice(0, 4, 2), 2 x 1, dtype: int64
<140474585659872> 01000000000000000500000000000000
まずは先頭行のスライスです。メモリアドレスは変わらず、参照範囲が短くなっていることが分かりますね。
なおブロックのインスタンスは変わっています。
df2 = df[1:2]
(4886798416) FloatBlock: slice(1, 2, 1), 1 x 1, dtype: float64
<140474854679976> 0000000000001040
(4886798896) IntBlock: slice(0, 4, 2), 2 x 1, dtype: int64
<140474585659880> 02000000000000000600000000000000
2行目のスライスです。メモリアドレスは全て+8されているので、ポインタがズレているだけで同じメモリブロックを参照していることが分かります。
df['D'] = [True, False]
(4886642416) FloatBlock: slice(1, 2, 1), 1 x 2, dtype: float64
<140474854679968> 00000000000008400000000000001040
(4886642608) IntBlock: slice(0, 4, 2), 2 x 2, dtype: int64
<140474585659872> 0100000000000000020000000000000005000000000000000600000000000000
(4886800144) BoolBlock: slice(3, 4, 1), 1 x 2, dtype: bool
<140474855093504> 0100
列の追加です。既存の列は、メモリアドレスだけでなくブロックも変化ありませんね。
df3 = df.append(df)
(4886726224) IntBlock: slice(0, 1, 1), 1 x 4, dtype: int64
<140474855531008> 0100000000000000020000000000000001000000000000000200000000000000
(4509301648) FloatBlock: slice(1, 2, 1), 1 x 4, dtype: float64
<140474585317312> 0000000000000840000000000000104000000000000008400000000000001040
(4509301840) IntBlock: slice(2, 3, 1), 1 x 4, dtype: int64
<140474585630688> 0500000000000000060000000000000005000000000000000600000000000000
(4509301552) BoolBlock: slice(3, 4, 1), 1 x 4, dtype: bool
<140474855008224> 01000100
行を結合してみました。メモリ配置がガラッと変わってますね。あとIntBlockが二つできてしまっています。
これはフラグメンテーションを引き起こすので、適当なタイミングでまとめてほしいところです。
df4 = df3._consolidate()
(4509301552) BoolBlock: slice(3, 4, 1), 1 x 4, dtype: bool
<140474855008224> 01000100
(4509301648) FloatBlock: slice(1, 2, 1), 1 x 4, dtype: float64
<140474585317312> 0000000000000840000000000000104000000000000008400000000000001040
(4886728240) IntBlock: slice(0, 4, 2), 2 x 4, dtype: int64
<140475125920528> 01000000000000000200000000000000010000000000000002000000000000000500000000000000060000000000000005000000000000000600000000000000
プライベートメソッド _consolidate()
を呼びだすと、Int値がまとめられて新しいメモリアドレスに配置されました。