ディープラーニングの学習が「遅い!」と感じたとき、誰もが通る高速化のショートカット。それが PyTorchの DataLoader の引数にある num_workers(並列読み込みプロセス数) を 0 から 4 や 8 に引き上げることです。
「よし、これでメインスレッドは学習に集中し、裏で労働者(ワーカー)たちがガンガン画像を読み込んでくれるぞ!」と意気込んで実行した瞬間、信じられないほど長いエラーメッセージとともに学習が強制終了します。
_pickle.PicklingError: Can't pickle
あるいはAttributeError: Can't pickle local object 'MyDataset.__init__.
「ピクル?漬物?なんのことですか?」
実はPyTorchの並列処理の裏側では、Python特有の「オブジェクトのシリアライズ(直列化)」という、非常にデリケートで複雑なデータの受け渡しが行われています。
本記事では、この「PicklingError」の正体を暴き、WindowsとMac/Linuxの仕様の違い、そしてエラーを出さないDataLoaderの書き方を徹底解説します!
1. Pickle(漬物)とは一体何なのか?
Pythonにおける pickle とは、メモリ上に存在しているオブジェクト(私たちが作ったClassのインスタンスや、関数、辞書などのデータ)を、「ただのバイト列(0と1の連続)」に変換してファイルに保存したり、別のプロセスに送信したりするための標準モジュールです。
この「メモリ上の立体的なデータを、平べったい文字列にする作業」をシリアライズ(直列化)と呼び、Python界隈ではこれを「ピクリング(Pickling:漬物作り)」と呼びます。
PyTorchの num_workers > 0 を設定したとき、内部では以下のようなことが起きています。
- 親プロセス(メインの学習ループ)が、
Datasetクラスのインスタンスを作る。 - 親プロセスは、画像読み込み用の「別プロセス=労働者(ワーカー)」を新たに立ち上げる。
- 親プロセスは、ワーカーに「このDatasetを使ってね!」と渡すため、Datasetを一度
pickleで漬物(バイト列)にする。 - ワーカープロセスが、受け取った漬物(バイト列)を元のDatasetに 復元(Unpickle)する。
そう、この「漬物にして渡す」というステップで、Datasetの中に「漬物にできない成分」が混ざっていると、大爆発を起こすのです!
2. なぜ「漬物にできない」のか?(エラーの原因)
PythonのPickleはとても便利ですが、万能ではありません。
例えば、「その空間(ローカルスコープ)にしか存在しない無名関数(lambda式)」や、「ファイルポインタ(現在開いているファイルの状態)」、あるいは一部の「外部モジュールそのもの」などは、別のプロセスにそのまま転送することができないため、シリアライズに失敗します。
# 【PicklingErrorを引き起こすダメなDatasetの実装例】
from torch.utils.data import Dataset
class DangerousDataset(Dataset):
def __init__(self, data_list):
self.data_list = data_list
# ❌ ダメな例1:無名関数(lambda)をインスタンス変数に持たせている
# lambdaはPickleできない!
self.transform = lambda x: x / 255.0
# ❌ ダメな例2:データベースのコネクションや開いているファイルを保持している
# ファイルポインタは別プロセスにコピーできない!
self.db_conn = open("my_database.sqlite", "r")
def __getitem__(self, idx):
# 処理...
return data上記のようなDatasetを num_workers=4 のDataLoaderに渡した瞬間、「lambda式はピクルにできません!(PicklingError)」と怒られてシステムが停止します。
3. エラーを回避する「防弾仕様の実装法」
この問題を回避するためのルールは極めてシンプルです。
ルール1:lambda は使わず、通常の関数(def)かクラスを使う
画像変換の前処理(Transforms)を定義する際は、横着して lambda を使うのをやめましょう。必ず def で外側のスコープで関数を定義するか、PyTorch標準の torchvision.transforms に用意されているクラス群を使用してください。
これらは全て「Pickle可能」に設計されています。
ルール2:ファイルを開くのは「__getitem__」の瞬間にする
データベースのコネクションや、巨大なHDF5ファイルなどを __init__ の中で開いて保持し続けるのはNGです。
ファイルへのアクセスは、労働者(ワーカープロセス)が実際に読み込み作業を命じられた瞬間、つまり __getitem__ が呼ばれた時に毎回都度開くか、ワーカーごとに初期化される worker_init_fn という特殊な関数を利用して接続を確立するのがベストプラクティスです。
# 【安全な実装例】
class SafeDataset(Dataset):
def __init__(self, file_paths):
self.file_paths = file_paths
# 変数にはファイルの中身やコネクションではなく、「パス(文字列)」だけを保持する
def __getitem__(self, idx):
# 必要な時に、必要な分だけファイルを開いて読む!
# これならワーカープロセス側で完全に独立して実行できる。
path = self.file_paths[idx]
with open(path, "r") as f:
data = f.read()
return dataまとめ:裏側の「プロセス」を意識するエンジニアへ
PicklingErrorが出たとき、「とりあえず num_workers=0 に戻して遅いまま我慢しよう…」と諦めてしまう初心者は非常に多いです。(特にWindows環境では、プロセスの作り方(spawn方式)がMacやLinux(fork方式)と異なるため、このエラーがより発生しやすいという厄介な特徴があります。)
しかし、そこで一歩踏み込んで「シリアライズとは何か」「マルチプロセス間でメモリはどう共有されるのか」を理解すれば、GPUの使用率を100%に保ちながら爆速で学習回すことができるようになります。
Pythonの「並行処理・並列処理」の仕組みは、AI開発における強力な武器になります。高度なPythonプログラミングの本で、GIL(Global Interpreter Lock)やマルチプロセッシングの章をぜひ学んでみてください!


コメント