はじめに
この実験では、Python のコンテナとメモリ管理について学びます。Python が組み込みデータ構造のメモリをどのように扱うかを調べ、メモリ効率の良いカスタムコンテナクラスを作成する方法を見つけます。
この実験の目的は、Python のリストと辞書のメモリ割り当て動作を調べ、メモリ使用量を最適化するためのカスタムコンテナクラスを作成し、列指向データストレージの利点を理解することです。
リストのメモリ割り当ての理解
Python では、リストは非常に便利なデータ構造であり、特に要素を追加する必要がある場合に便利です。Python のリストは、要素の追加操作に効率的に設計されています。必要なメモリ量を正確に割り当てる代わりに、Python は将来の追加を見越して余分なメモリを割り当てます。この戦略により、リストが拡張される際に必要なメモリの再割り当て回数が最小限に抑えられます。
sys.getsizeof() 関数を使用して、この概念をもっとよく理解しましょう。この関数は、オブジェクトのサイズをバイト単位で返し、リストが異なる段階でどれだけのメモリを使用しているかを確認するのに役立ちます。
- まず、ターミナルで Python の対話型シェルを開く必要があります。これは、Python コードをすぐに実行できるプレイグラウンドのようなものです。開くには、ターミナルに以下のコマンドを入力して Enter キーを押します。
python3
- Python の対話型シェルに入ったら、
sysモジュールをインポートする必要があります。Python のモジュールは、便利な関数が入ったツールボックスのようなものです。sysモジュールには、必要なgetsizeof()関数があります。モジュールをインポートした後、itemsという名前の空のリストを作成します。以下はそのコードです。
import sys
items = []
- では、空のリストの初期サイズを確認しましょう。
sys.getsizeof()関数をitemsリストを引数として使用します。Python の対話型シェルに以下のコードを入力して Enter キーを押します。
sys.getsizeof(items)
64 バイトのような値が表示されるはずです。この値は、空のリストのオーバーヘッドを表しています。オーバーヘッドは、リストに要素がない場合でも、Python がリストを管理するために使用する基本的なメモリ量です。
- 次に、リストに要素を 1 つずつ追加して、メモリ割り当てがどのように変化するかを観察します。
append()メソッドを使用して要素をリストに追加し、再度サイズを確認します。以下はそのコードです。
items.append(1)
sys.getsizeof(items)
96 バイト程度の大きな値が表示されるはずです。このサイズの増加は、Python が新しい要素を収容するためにより多くのメモリを割り当てたことを示しています。
- リストにさらに要素を追加し、各追加後にサイズを確認しましょう。以下はそのコードです。
items.append(2)
sys.getsizeof(items) ## サイズは同じまま
items.append(3)
sys.getsizeof(items) ## サイズはまだ変わらない
items.append(4)
sys.getsizeof(items) ## サイズはまだ変わらない
items.append(5)
sys.getsizeof(items) ## サイズが大きく増加する
追加操作のたびにサイズが増加するわけではないことに気づくでしょう。代わりに、定期的にサイズが増加します。これは、Python が各新しい要素に個別にメモリを割り当てるのではなく、チャンク単位でメモリを割り当てていることを示しています。
この動作は設計上のものです。Python は、リストが拡張される際に頻繁な再割り当てを避けるために、最初に必要以上のメモリを割り当てます。リストが現在の容量を超えるたびに、Python はより大きなメモリチャンクを割り当てます。
リストはオブジェクト自体ではなく、オブジェクトへの参照を格納することを忘れないでください。64 ビットシステムでは、各参照には通常 8 バイトのメモリが必要です。これは、リストが異なるタイプのオブジェクトを含む場合に、リストが実際に使用するメモリ量に影響を与えるため、理解するのが重要です。
辞書のメモリ割り当て
Python では、リストと同様に辞書も基本的なデータ構造です。辞書に関して理解すべき重要な点の 1 つは、メモリの割り当て方法です。メモリ割り当てとは、Python がコンピュータのメモリに辞書内のデータを格納するための領域を確保する方法を指します。リストと同様に、Python の辞書もチャンク単位でメモリを割り当てます。辞書のメモリ割り当てがどのように機能するかを探ってみましょう。
- まず、操作対象の辞書を作成する必要があります。同じ Python シェルで(もし閉じてしまった場合は新しく開いて)、データレコードを表す辞書を作成します。Python の辞書はキーと値のペアのコレクションで、各キーは一意であり、対応する値にアクセスするために使用されます。
import sys ## 新しいセッションを開始する場合は sys をインポートする
row = {'route': '22', 'date': '01/01/2001', 'daytype': 'U', 'rides': 7354}
ここでは、Python インタープリターが使用または維持するいくつかの変数や、インタープリターと強く相互作用する関数にアクセスできる sys モジュールをインポートしています。そして、4 つのキーと値のペアを持つ row という名前の辞書を作成しました。
- 辞書ができたので、その初期サイズを確認しましょう。辞書のサイズとは、コンピュータのメモリ上で占めるメモリ量を指します。
sys.getsizeof(row)
sys.getsizeof() 関数は、オブジェクトのサイズをバイト単位で返します。このコードを実行すると、240 バイト程度の値が表示されるはずです。これにより、辞書が最初にどれだけのメモリを占有するかがわかります。
- 次に、辞書に新しいキーと値のペアを追加し、メモリ割り当てがどのように変化するかを観察します。辞書にアイテムを追加することは一般的な操作であり、それがメモリにどのような影響を与えるかを理解することは重要です。
row['a'] = 1
sys.getsizeof(row) ## サイズは同じままかもしれない
row['b'] = 2
sys.getsizeof(row) ## サイズが増加するかもしれない
最初のキーと値のペア ('a': 1) を追加すると、辞書のサイズは同じままになることがあります。これは、Python がすでにある程度のメモリチャンクを割り当てており、そのチャンク内に新しいアイテムを収容するのに十分なスペースがあるからです。しかし、2 番目のキーと値のペア ('b': 2) を追加すると、サイズが増加することがあります。一定数のアイテムを追加した後、辞書のサイズが突然増加することに気づくでしょう。これは、辞書もリストと同様に、パフォーマンスを最適化するためにチャンク単位でメモリを割り当てるからです。チャンク単位でメモリを割り当てることで、Python がシステムからより多くのメモリを要求する回数が減り、新しいアイテムを追加するプロセスが高速化されます。
- 辞書からアイテムを削除して、メモリ使用量が減少するかどうかを試してみましょう。辞書からアイテムを削除することも一般的な操作であり、それがメモリにどのような影響を与えるかを見るのは興味深いです。
del row['b']
sys.getsizeof(row)
興味深いことに、アイテムを削除しても通常はメモリ割り当てが減少しません。これは、Python が再度アイテムが追加された場合の再割り当てを避けるために、割り当てられたメモリを保持するからです。メモリの再割り当てはパフォーマンス面で比較的コストがかかる操作なので、Python はできるだけ避けようとします。
メモリ効率に関する考慮事項:
多数のレコードを作成する必要がある大規模なデータセットを扱う場合、各レコードに辞書を使用することは必ずしも最もメモリ効率の良いアプローチではないかもしれません。辞書は非常に柔軟で使いやすいですが、特に大量のレコードを扱う場合、かなりの量のメモリを消費することがあります。以下は、より少ないメモリを消費する代替案です。
- タプル:単純な不変のシーケンス。タプルは作成後に変更できない値のコレクションです。キーを格納したり、関連するキーと値のマッピングを管理する必要がないため、辞書よりも少ないメモリを使用します。
- 名前付きタプル:フィールド名を持つタプル。名前付きタプルは通常のタプルに似ていますが、名前で値にアクセスできるため、コードが読みやすくなります。また、辞書よりも少ないメモリを使用します。
__slots__を持つクラス:インスタンス変数に辞書を使用しないように属性を明示的に定義するクラス。クラスで__slots__を使用すると、Python はインスタンス変数を格納するための辞書を作成しないため、メモリ使用量が削減されます。
これらの代替案は、多数のレコードを扱う際にメモリ使用量を大幅に削減することができます。
列指向データによるメモリ最適化
従来のデータストレージでは、各レコードを個別の辞書として格納することが多く、これは行指向アプローチと呼ばれます。しかし、この方法はかなりの量のメモリを消費する可能性があります。別の方法は、データを列で格納することです。列指向アプローチでは、各属性に対して個別のリストを作成し、各リストにはその特定の属性のすべての値が格納されます。これにより、メモリを節約することができます。
- まず、プロジェクトディレクトリに新しい Python ファイルを作成する必要があります。このファイルには、列指向でデータを読み取るためのコードが含まれます。ファイル名を
readrides.pyとします。これを実現するには、ターミナルで以下のコマンドを使用できます。
cd ~/project
touch readrides.py
cd ~/project コマンドは、現在のディレクトリをプロジェクトディレクトリに変更し、touch readrides.py コマンドは readrides.py という名前の新しい空のファイルを作成します。
- 次に、WebIDE エディタで
readrides.pyファイルを開きます。そして、以下の Python コードをファイルに追加します。このコードは、read_rides_as_columns関数を定義しており、CSV ファイルからバスの乗車データを読み取り、それを 4 つの個別のリストに格納します。各リストはデータの列を表します。
## readrides.py
import csv
import sys
import tracemalloc
def read_rides_as_columns(filename):
'''
Read the bus ride data into 4 lists, representing columns
'''
routes = []
dates = []
daytypes = []
numrides = []
with open(filename) as f:
rows = csv.reader(f)
headings = next(rows) ## Skip headers
for row in rows:
routes.append(row[0])
dates.append(row[1])
daytypes.append(row[2])
numrides.append(int(row[3]))
return dict(routes=routes, dates=dates, daytypes=daytypes, numrides=numrides)
このコードでは、まず必要なモジュール csv、sys、および tracemalloc をインポートしています。csv モジュールは CSV ファイルを読み取るために使用され、sys はシステム関連の操作に使用できます(ただし、この関数では使用されていません)。tracemalloc はメモリプロファイリングに使用されます。関数内では、データの異なる列を格納するために 4 つの空のリストを初期化します。その後、ファイルを開き、ヘッダー行をスキップし、ファイル内の各行を反復処理して、対応する値を適切なリストに追加します。最後に、これら 4 つのリストを含む辞書を返します。
- では、列指向アプローチがなぜメモリを節約できるのかを分析しましょう。これは Python シェルで行います。以下のコードを実行します。
import readrides
import tracemalloc
## Estimate memory for row-oriented approach
nrows = 577563 ## Number of rows in original file
dict_overhead = 240 ## Approximate dictionary overhead in bytes
row_memory = nrows * dict_overhead
print(f"Estimated memory for row-oriented data: {row_memory} bytes ({row_memory/1024/1024:.2f} MB)")
## Estimate memory for column-oriented approach
pointer_size = 8 ## Size of a pointer in bytes on 64-bit systems
column_memory = nrows * 4 * pointer_size ## 4 columns with one pointer per entry
print(f"Estimated memory for column-oriented data: {column_memory} bytes ({column_memory/1024/1024:.2f} MB)")
## Estimate savings
savings = row_memory - column_memory
print(f"Estimated memory savings: {savings} bytes ({savings/1024/1024:.2f} MB)")
このコードでは、まず先ほど作成した readrides モジュールと tracemalloc モジュールをインポートします。そして、行指向アプローチのメモリ使用量を見積もります。各辞書のオーバーヘッドが 240 バイトであると仮定し、これを元のファイルの行数で乗算して、行指向データの総メモリ使用量を求めます。列指向アプローチの場合、64 ビットシステムで各ポインタが 8 バイトを占めると仮定します。4 つの列があり、各エントリに 1 つのポインタがあるため、列指向データの総メモリ使用量を計算します。最後に、行指向のメモリ使用量から列指向のメモリ使用量を引いて、メモリ節約量を計算します。
この計算により、列指向アプローチは辞書を使用した行指向アプローチに比べて約 120MB のメモリを節約できることがわかります。
- これを
tracemallocモジュールを使用して実際のメモリ使用量を測定することで検証しましょう。以下のコードを実行します。
## Start tracking memory
tracemalloc.start()
## Read the data
columns = readrides.read_rides_as_columns('ctabus.csv')
## Get current and peak memory usage
current, peak = tracemalloc.get_traced_memory()
print(f"Current memory usage: {current/1024/1024:.2f} MB")
print(f"Peak memory usage: {peak/1024/1024:.2f} MB")
## Stop tracking memory
tracemalloc.stop()
このコードでは、まず tracemalloc.start() を使用してメモリの追跡を開始します。次に、read_rides_as_columns 関数を呼び出して ctabus.csv ファイルからデータを読み取ります。その後、tracemalloc.get_traced_memory() を使用して現在のメモリ使用量とピークメモリ使用量を取得します。最後に、tracemalloc.stop() を使用してメモリの追跡を停止します。
出力結果から、列指向データ構造の実際のメモリ使用量がわかります。これは、行指向アプローチの理論上の見積もりよりも大幅に少ないはずです。
大幅なメモリ節約は、何千もの辞書オブジェクトのオーバーヘッドを排除することによって実現されます。Python の各辞書には、含まれるアイテムの数に関係なく固定的なオーバーヘッドがあります。列指向ストレージを使用することで、何千もの辞書の代わりに数個のリストだけが必要になります。
カスタムコンテナクラスの作成
データ処理において、列指向アプローチはメモリを節約するのに優れています。しかし、既存のコードがデータを辞書のリスト形式で期待している場合、問題が発生することがあります。この問題を解決するために、カスタムコンテナクラスを作成します。このクラスは行指向のインターフェースを提供します。つまり、コードから見ると辞書のリストのように見え、動作します。ただし、内部的には列指向の形式でデータを格納し、メモリを節約するのに役立ちます。
- まず、WebIDE エディタで
readrides.pyファイルを開きます。このファイルに新しいクラスを追加します。このクラスがカスタムコンテナの基礎となります。
## Add this to readrides.py
from collections.abc import Sequence
class RideData(Sequence):
def __init__(self):
## Each value is a list with all of the values (a column)
self.routes = []
self.dates = []
self.daytypes = []
self.numrides = []
def __len__(self):
## All lists assumed to have the same length
return len(self.routes)
def __getitem__(self, index):
return {'route': self.routes[index],
'date': self.dates[index],
'daytype': self.daytypes[index],
'rides': self.numrides[index]}
def append(self, d):
self.routes.append(d['route'])
self.dates.append(d['date'])
self.daytypes.append(d['daytype'])
self.numrides.append(d['rides'])
このコードでは、Sequence を継承した RideData という名前のクラスを定義しています。__init__ メソッドは、それぞれがデータの列を表す 4 つの空のリストを初期化します。__len__ メソッドは、コンテナの長さを返します。これは routes リストの長さと同じです。__getitem__ メソッドは、インデックスで特定のレコードにアクセスできるようにし、それを辞書として返します。append メソッドは、各列のリストに値を追加することで、新しいレコードをコンテナに追加します。
- 次に、バスの乗車データをカスタムコンテナに読み込む関数が必要です。
readrides.pyファイルに以下の関数を追加します。
## Add this to readrides.py
def read_rides_as_dicts(filename):
'''
Read the bus ride data as a list of dicts, but use our custom container
'''
records = RideData()
with open(filename) as f:
rows = csv.reader(f)
headings = next(rows) ## Skip headers
for row in rows:
route = row[0]
date = row[1]
daytype = row[2]
rides = int(row[3])
record = {
'route': route,
'date': date,
'daytype': daytype,
'rides': rides
}
records.append(record)
return records
この関数は、RideData クラスのインスタンスを作成し、CSV ファイルからのデータでそれを埋めます。ファイルから各行を読み取り、関連する情報を抽出し、各レコードに対して辞書を作成し、それを RideData コンテナに追加します。重要なのは、辞書のリストと同じインターフェースを維持しながら、内部的にはデータを列で格納することです。
- Python シェルでカスタムコンテナをテストしましょう。これにより、期待通りに動作することを確認できます。
import readrides
## Read the data using our custom container
rows = readrides.read_rides_as_dicts('ctabus.csv')
## Check the type of the returned object
type(rows) ## Should be readrides.RideData
## Check the length
len(rows) ## Should be 577563
## Access individual records
rows[0] ## Should return a dictionary for the first record
rows[1] ## Should return a dictionary for the second record
rows[2] ## Should return a dictionary for the third record
カスタムコンテナは Sequence インターフェースを正常に実装しています。つまり、リストのように振る舞います。len() 関数を使ってコンテナ内のレコード数を取得でき、インデックスを使って個々のレコードにアクセスできます。各レコードは辞書のように見えますが、内部的にはデータは列で格納されています。これは、辞書のリストを期待する既存のコードが、何の変更もせずにカスタムコンテナで動作し続けるので、非常に便利です。
- 最後に、カスタムコンテナのメモリ使用量を測定しましょう。これにより、辞書のリストと比較してどれだけのメモリを節約できているかがわかります。
import tracemalloc
tracemalloc.start()
rows = readrides.read_rides_as_dicts('ctabus.csv')
current, peak = tracemalloc.get_traced_memory()
print(f"Current memory usage: {current/1024/1024:.2f} MB")
print(f"Peak memory usage: {peak/1024/1024:.2f} MB")
tracemalloc.stop()
このコードを実行すると、メモリ使用量が列指向アプローチと同程度であり、辞書のリストが使用するメモリよりもはるかに少ないことがわかるはずです。これは、メモリ効率の面でカスタムコンテナの利点を示しています。
スライシングに対応したカスタムコンテナの拡張
私たちのカスタムコンテナは個々のレコードへのアクセスには便利です。しかし、スライシングに関しては問題があります。カスタムコンテナをスライスしようとすると、結果は通常期待するものと異なります。
これが起こる理由を理解しましょう。Python では、スライシングはシーケンスの一部を抽出するためによく使われる操作です。しかし、私たちのカスタムコンテナの場合、Python はスライスされたデータだけを持つ新しい RideData オブジェクトを作成する方法を知りません。その代わり、スライス内の各インデックスに対して __getitem__ を呼び出した結果を含むリストを作成します。
- Python シェルでスライシングをテストしましょう。
import readrides
rows = readrides.read_rides_as_dicts('ctabus.csv')
r = rows[0:10] ## Take a slice of the first 10 records
type(r) ## This will likely be a list, not a RideData object
print(r) ## This might look like a list of numbers, not dictionaries
このコードでは、まず readrides モジュールをインポートします。次に、ctabus.csv ファイルからデータを読み込み、rows 変数に格納します。最初の 10 レコードをスライスして結果の型を確認すると、RideData オブジェクトではなくリストになっていることがわかります。結果を出力すると、辞書ではなく数値のリストのように見えるかもしれません。
RideDataクラスを修正して、スライシングを適切に処理できるようにしましょう。readrides.pyを開き、__getitem__メソッドを更新します。
def __getitem__(self, index):
if isinstance(index, slice):
## Handle slice
result = RideData()
result.routes = self.routes[index]
result.dates = self.dates[index]
result.daytypes = self.daytypes[index]
result.numrides = self.numrides[index]
return result
else:
## Handle single index
return {'route': self.routes[index],
'date': self.dates[index],
'daytype': self.daytypes[index],
'rides': self.numrides[index]}
この更新された __getitem__ メソッドでは、まず index がスライスかどうかを確認します。スライスであれば、result という名前の新しい RideData オブジェクトを作成します。そして、元のデータ列 (routes、dates、daytypes、numrides) のスライスでこの新しいオブジェクトを埋めます。これにより、カスタムコンテナをスライスすると、リストではなく別の RideData オブジェクトが得られます。index がスライスでない場合(つまり、単一のインデックスである場合)、関連するレコードを含む辞書を返します。
- 改善されたスライシング機能をテストしましょう。
import readrides
rows = readrides.read_rides_as_dicts('ctabus.csv')
r = rows[0:10] ## Take a slice of the first 10 records
type(r) ## Should now be readrides.RideData
len(r) ## Should be 10
r[0] ## Should be the same as rows[0]
r[1] ## Should be the same as rows[1]
__getitem__ メソッドを更新した後、再度スライシングをテストできます。最初の 10 レコードをスライスすると、結果の型は readrides.RideData になるはずです。スライスの長さは 10 で、スライス内の個々の要素にアクセスすると、元のコンテナ内の対応する要素にアクセスした場合と同じ結果が得られるはずです。
- 異なるスライスパターンでもテストできます。
## Get every other record from the first 20
r2 = rows[0:20:2]
len(r2) ## Should be 10
## Get the last 10 records
r3 = rows[-10:]
len(r3) ## Should be 10
ここでは、異なるスライスパターンをテストしています。最初のスライス rows[0:20:2] は最初の 20 レコードから 1 つおきのレコードを取得し、結果のスライスの長さは 10 であるはずです。2 番目のスライス rows[-10:] は最後の 10 レコードを取得し、その長さも 10 であるはずです。
スライシングを適切に実装することで、私たちのカスタムコンテナは標準の Python リストにさらに似た動作をするようになり、同時に列指向ストレージのメモリ効率を維持します。
このように、Python の組み込みコンテナを模倣しながらも内部表現が異なるカスタムコンテナクラスを作成するアプローチは、コードがユーザーに提示するインターフェースを変更せずにメモリ使用量を最適化する強力な手法です。
まとめ
この実験では、いくつかの重要なスキルを学びました。まず、Python のリストと辞書におけるメモリ割り当ての動作を調べ、データの格納方法を行指向から列指向に変更することでメモリ使用量を最適化する方法を学びました。次に、元のインターフェースを維持しながらメモリ使用量を削減するカスタムコンテナクラスを作成し、スライシング操作を適切に処理できるように拡張しました。
これらの手法は、大規模なデータセットを扱う際に非常に有用です。コードのインターフェースを変更することなく、メモリ使用量を大幅に削減できるからです。Python の組み込みコンテナを模倣しながらも内部表現が異なるカスタムコンテナクラスを作成する能力は、Python アプリケーションの強力な最適化ツールです。これらの概念は、特に大規模で規則的な構造のデータセットを扱うメモリに敏感な他のプロジェクトにも適用できます。