MNIST なんか遅くない？

本記事は Python Advent Calendar 2024 シリーズ2 における11日目の記事になります．

https://qiita.com/advent-calendar/2024/python

## はじめに
MNISTデータセットでMLPの学習をしていたところ，GPU使用率が全然上がっていないことに気づきました．ボトルネックと思われるものを発見したので共有します．

## 環境
* Ryzen 5 5600X
* RTX 3060Ti
* Python 3.12.7
* torch 2.5.1+cu118
* CUDA 11.8
* cuDNN 8.7.0

## 状況の確認
とりあえず以下の典型的なプログラムで実行時間を確認します．
```Python: main.py
import torch
import torch.nn as nn
from torch.optim import Adam
from torchvision import datasets, transforms
from torch.utils.data import DataLoader
from torch.nn.functional import relu

# ハイパーパラメータの設定
batch_size = 4096
learning_rate = 0.01
num_epochs = 10
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

# データの前処理とデータローダーの作成
transform = transforms.Compose([
    transforms.ToTensor(),
    transforms.Normalize((0.5,), (0.5,))
])

train_dataset = datasets.MNIST(root='./downloads', train=True, transform=transform, download=True)
test_dataset = datasets.MNIST(root='./downloads', train=False, transform=transform, download=True)

train_loader = DataLoader(dataset=train_dataset, batch_size=batch_size, shuffle=True)
test_loader = DataLoader(dataset=test_dataset, batch_size=batch_size, shuffle=False)

# 3層のMLPモデルを定義
class MLP(nn.Module):
    def __init__(self):
        super().__init__()
        self.lin1 = nn.Linear(28*28, 256)  # 入力層から中間層（256ユニット）
        self.lin2 = nn.Linear(256, 128)    # 中間層からもう一つの中間層（128ユニット）
        self.lin3 = nn.Linear(128, 10)      # 出力層（10クラス）

    def forward(self, x):
        x = x.view(-1, 28*28)  # 2次元画像を1次元ベクトルに変換
        x = self.lin1(x)
        x = relu(x)
        x = self.lin2(x)
        x = relu(x)
        x = self.lin3(x)
        return x

model = MLP().to(device)

criterion = nn.CrossEntropyLoss()
optimizer = Adam(model.parameters(), lr=learning_rate)

# モデルのトレーニング
for epoch in range(num_epochs):
    model.train()
    for data, target in train_loader:
        data = data.to(device)
        target = target.to(device)
        optimizer.zero_grad()
        output = model(data)
        loss = criterion(output, target)
        loss.backward()
        optimizer.step()
        print(f"train loss: {loss:.4f}")

    # モデルの評価
    model.eval()
    correct = 0
    test_loss = 0
    with torch.no_grad():
        for data, target in test_loader:
            data = data.to(device)
            target = target.to(device)
            pred = model(data)
            test_loss += criterion(pred, target).item()
            correct += (pred.argmax(1) == target).type(torch.float).sum().item()
    
        test_loss /= len(test_loader)
        correct /= len(test_loader.dataset)
        print(f"test loss: {test_loss:.4f}, acc: {correct}, {epoch+1}/{num_epochs}")

```
10 エポックの学習を行ったところ，おおよそ実行時間は85秒くらいになりました．
GPUの使用率は10%以下で全然使用できていません．

## 遅さの原因
明らかにGPU以外でボトルネックが存在します．
今回の調査では `torchvision.datasets.MNIST` の `__getitem__` メソッドで，Tensor 配列の  `img` を numpy 配列 → PIL Image → Tensor 配列 にする変換を挟んで出力していることがわかりました．
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/642842/434c96ba-a1e2-9762-f5ff-21b18d6a434d.png)

https://github.com/pytorch/vision/blob/229d8523bfa9a2696872d76b1cdb6815028f1e03/torchvision/datasets/mnist.py#L131-L151

上記の変換を `__getitem__` メソッドではなく `__init__` メソッドでやってしまい，すべてのデータを一気にGPUへ転送した方が早そうです．

## 対策をやってみた
面倒なので今回いじるところ以外は継承を使ってしまいます．
`__init__` メソッドも元々のものを実行しておきます．その後に付け足したプログラムでデータの前処理をします．

```Python 
from PIL import Image

class MyDataset(datasets.MNIST):
    def __init__(
        self,
        root,
        train = True,
        transform = None,
        target_transform = None,
        download = False,
    ):
        super().__init__(root, train, transform, target_transform, download)

        # 変換後のデータを保存する
        self.data_transformed = torch.empty_like(self.data, dtype=torch.float)

        for index, (img, target) in enumerate(zip(self.data, self.targets)):
            target = int(target)
            img = Image.fromarray(img.numpy(), mode="L")
            
            if self.transform is not None:
                self.data_transformed[index] = self.transform(img)

            if self.target_transform is not None:
                self.targets[index] = self.target_transform(target)
        
    def __getitem__(self, index):
        img, target = self.data_transformed[index], self.targets[index]
        return img, target

```
作成したデータセットに合わせて，プログラムを変更します．
```Python
train_dataset = MyDataset(root='./downloads', train=True, transform=transform, download=True)
test_dataset = MyDataset(root='./downloads', train=False, transform=transform, download=True)
train_dataset.data_transformed = train_dataset.data_transformed.to(device)
train_dataset.targets = train_dataset.targets.to(device)
test_dataset.data_transformed = test_dataset.data_transformed.to(device)
test_dataset.targets = test_dataset.targets.to(device)

model = MLP().to(device)

criterion = nn.CrossEntropyLoss()
optimizer = Adam(model.parameters(), lr=learning_rate)

# モデルのトレーニング
for epoch in range(num_epochs):
    model.train()
    for batch_idx, (data, target) in enumerate(train_loader):
        # すでにGPUに転送済みなので不要
        # data = data.to(device)
        # target = target.to(device)
        optimizer.zero_grad()
        output = model(data)
        loss = criterion(output, target)
        loss.backward()
        optimizer.step()
        print(f"train loss: {loss:.4f}")

    # モデルの評価
    model.eval()
    correct = 0
    test_loss = 0
    with torch.no_grad():
        for data, target in test_loader:
            data = data.to(device)
            target = target.to(device)
            pred = model(data)
            test_loss += criterion(pred, target).item()
            correct += (pred.argmax(1) == target).type(torch.float).sum().item()
    
        test_loss /= len(test_loader)
        correct /= len(test_loader.dataset)
        print(f"test loss: {test_loss:.4f}, acc: {correct}, {epoch+1}/{num_epochs}")

```
変更したプログラムでは実行時間が16秒になりました．
GPUの使用率は25%になり，先ほどよりも使用できています．

![results.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/642842/eab5f535-e24b-7c8d-580d-72f4888de7c6.jpeg)

他にもボトルネックがありそうですが，今回は満足したのでここまで．

## 結論
* `torchvision.datasets.MNIST` の `__getitem__` メソッドでボトルネックがある
* ボトルネックを修正したら実行時間が85秒から16秒に改善