FashionMNISTのCNNをCPUとRTX 5070 Tiで学習する:小さいNNでは負けたGPUが逆転するまで

実験日: 2026-08-24 / PyTorch 2.13.0+cu130
CPU: Core i7-14700F / GPU: RTX 5070 Ti 16 GB

前回、4,810パラメータの2層ニューラルネットをCPUとGPUで実行したところ、GPUはCPUより約4.38倍遅くなりました。GPUが速いかどうかは「AIだから」ではなく、渡す仕事の大きさで決まります。

前回の結果は少し意外でした。そこで、もう少し計算量のあるCNNでも同じ比較を行います。

今回はFashionMNIST全60,000学習画像を使い、105,866パラメータのCNNを3 epoch学習します。行列積だけのベンチマークではなく、DataLoader、CPU→GPU転送、forward、backward、optimizer更新を含むend-to-end時間を測りました。

GPUが逆転した

  • CPU 3 epoch合計:12.68秒
  • CUDA 3 epoch合計:6.29秒
  • end-to-endでGPUが約2.02倍高速
  • 温間epoch 3:CPU 4.24秒、CUDA 1.82秒(約2.34倍)
  • 最終テスト精度:CPU 86.74%、CUDA 86.44%
  • CUDAのピークPyTorch割当:116.5 MiB
  • CPU:約14,139 images/s、CUDA:約33,038 images/s(epoch 3)
  • pytest:出力shapeとパラメータ数の1件合格

小さい全結合NNではCPUが勝ち、FashionMNIST CNNではGPUが逆転しました。ただしRTX 5070 Tiの性能を使い切ったとは言えません。batch 256、num_workers=0、約10万パラメータでは、まだデータ供給や固定費の割合が大きいからです。

FashionMNIST CNNをCPUとRTX 5070 Tiで3epoch学習したテスト精度とend-to-end時間
FashionMNIST CNNをCPUとRTX 5070 Tiで3epoch学習したテスト精度とend-to-end時間

FashionMNISTとは

FashionMNISTは28×28グレースケールの衣類画像を10クラスへ分類するデータセットです。

ラベル クラス
0 T-shirt/top
1 Trouser
2 Pullover
3 Dress
4 Coat
5 Sandal
6 Shirt
7 Sneaker
8 Bag
9 Ankle boot

学習60,000枚、テスト10,000枚です。Digitsの8×8画像より大きく、T-shirt、Shirt、Pullover、Coatのように形が似たクラスがあります。

transform = transforms.ToTensor()

train_set = datasets.FashionMNIST(
    data_dir, train=True, download=True, transform=transform
)
test_set = datasets.FashionMNIST(
    data_dir, train=False, download=True, transform=transform
)

ToTensor()により画素を0~1のfloat Tensorへ変換しました。今回は平均・標準偏差による正規化やdata augmentationを使っていません。CPU/GPU速度比較の条件を単純にし、精度を最大化する実験とは分けています。

CNNが画像の空間構造を使う仕組み

Digitsの全結合NNでは画像を1列に並べ、各画素へ独立した重みを持たせました。CNNの畳み込み層は小さなkernelを画像上で移動し、同じ重みを位置ごとに共有します。

今回の構造です。

input: 1 × 28 × 28
  ↓ Conv 3×3, 1→16 + ReLU
16 × 28 × 28
  ↓ MaxPool 2×2
16 × 14 × 14
  ↓ Conv 3×3, 16→32 + ReLU
32 × 14 × 14
  ↓ MaxPool 2×2
32 × 7 × 7
  ↓ Flatten
1568
  ↓ Linear 1568→64 + ReLU
  ↓ Linear 64→10
logits

PyTorchコードです。

class SmallCNN(nn.Module):
    def __init__(self):
        super().__init__()
        self.features = nn.Sequential(
            nn.Conv2d(1, 16, 3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(2),
            nn.Conv2d(16, 32, 3, padding=1),
            nn.ReLU(),
            nn.MaxPool2d(2),
        )
        self.classifier = nn.Sequential(
            nn.Flatten(),
            nn.Linear(32 * 7 * 7, 64),
            nn.ReLU(),
            nn.Linear(64, 10),
        )

自動テストでは(4,1,28,28)を入力し、出力が(4,10)、全パラメータ数が105,866であることを確認しました。

. [100%]
1 passed in 3.45s

CPUとGPUで何を揃えたか

項目 条件
初期重み 同じstate_dictをコピー
shuffle 同じgenerator seed 42
batch size 256
epoch 3
optimizer Adam、lr 1e-3
loss CrossEntropyLoss
num_workers 0
CPU pin_memory False
CUDA pin_memory True

CPU用モデルを先に初期化してstate_dictを保存し、CPU/CUDAの両モデルへ読み込みました。

torch.manual_seed(42)
base_model = SmallCNN()
initial_state = copy.deepcopy(base_model.state_dict())

model = SmallCNN().to(device)
model.load_state_dict(initial_state)

同じ条件を揃えても、CPUとGPUで全epochの精度が完全一致するとは限りません。並列演算の順序、浮動小数点の丸め、backend実装が違うため、小さな数値差がoptimizer更新を通じて蓄積します。

何を時間に含めたか

各epochの時計は次を含みます。

  • DataLoaderからbatchを取り出す
  • Tensorをdeviceへ送る
  • forward
  • loss計算
  • backward
  • Adamによるparameter更新

テスト評価はepoch時間から除外しました。

torch.cuda.synchronize()
started = time.perf_counter()

for images, labels in train_loader:
    images = images.to(device, non_blocking=device == "cuda")
    labels = labels.to(device, non_blocking=device == "cuda")
    optimizer.zero_grad(set_to_none=True)
    logits = model(images)
    loss = loss_function(logits, labels)
    loss.backward()
    optimizer.step()

torch.cuda.synchronize()
epoch_seconds = time.perf_counter() - started

CUDAの非同期実行を考慮し、計測前後で同期しています。転送を含めたのは、人間がこの学習コードを実行したときの待ち時間に近い比較にするためです。

epochごとの実測

epoch CPU時間 CUDA時間 CPU test CUDA test
1 4.265 s 2.633 s 80.68% 80.63%
2 4.173 s 1.840 s 85.82% 86.06%
3 4.244 s 1.816 s 86.74% 86.44%

CUDAのepoch 1だけ2.63秒と長く、epoch 2以降は約1.82秒です。初回にはkernel選択、ライブラリ初期化、キャッシュなどのコールドスタート要素が含まれます。

そのため、合計6.29秒だけでなく、温間epochの値も併記します。都合のよいepochだけ選ぶのではなく、「初回を含む人間の待ち時間」と「温まった定常状態」を分けるためです。

小さいNNではCPU、CNNではGPUが勝った

これまでの同PC実測を並べます。

処理 CPU GPU 勝者
4,810 parameterのDigits NN学習 0.251 s 1.098 s CPU 約4.38倍
FashionMNIST CNN 3 epoch 12.68 s 6.29 s GPU 約2.02倍
4096² FP32行列積 177.845 ms 4.262 ms GPU 約41.7倍

仕事が大きくなるほどGPUの並列演算を活かしやすくなっています。ただし3つは計測範囲が違います。行列積は配列生成・転送を除外、Digits NNはデータ事前配置、FashionMNISTはDataLoaderと転送込みです。倍率だけを直接比較せず、損益分岐の傾向として読みます。

ピークVRAM 116.5 MiBの意味

torch.cuda.max_memory_allocated()で、PyTorchがTensorへ割り当てたピークを測りました。

peak CUDA allocated: 116.5 MiB

これはnvidia-smiのプロセスVRAM表示とは一致しません。CUDA context、library workspace、allocatorが予約した未使用領域など、測る対象が違うからです。本記事の116.5 MiBはPyTorchの「allocated」であり、GPU全体の消費量ではありません。

16 GB GPUに対して非常に小さいため、batchを増やす余地があります。batch sizeごとの速度・VRAM・OOMを測れば、余ったVRAMがthroughputへ寄与する範囲を切り分けられます。

精度差を性能差と断定しない

最終精度はCPU 86.74%、CUDA 86.44%で0.30ポイント差でした。しかし1回の学習だけで「CPUの方が高精度」とは言えません。

  • 初期重みとshuffle seedは同じ
  • backendの数値演算順は異なる
  • 学習は確率的で、小さな丸め差が蓄積する
  • 複数seedを回していない

精度比較を主目的にするなら複数seedの平均と分散が必要です。今回はend-to-end速度と、同条件でも軌道が少し分かれる事実を観測する実験です。

混同行列からCNNの弱点を見る

CPU版の最終混同行列では、クラス6のShirtが1,000枚中468枚しか正解していませんでした。

  • Shirt→Pullover:170枚
  • Shirt→Coat:145枚
  • Shirt→T-shirt/top:131枚

一方、Bagは973/1,000、Sneakerは969/1,000、Trouserは963/1,000正解です。全体正解率86.74%だけを見ると、Shirtの弱さを見落とします。

次に改善するなら、学習epochを増やす前に誤分類画像を観察し、augmentation、正規化、モデル容量、クラス別指標のどれを試すか仮説を立てます。

num_workers=0を明記する理由

DataLoaderのworker数を増やすと、CPUでのデータ準備を学習と並列化できます。しかしWindows/WSL、CPU core数、ストレージ、変換処理によって最適値が変わります。

今回は変数を増やさないため0へ固定しました。GPUが入力待ちしている可能性を残しています。これは欠陥を隠すのではなく、次の「DataLoader bottleneck」記事で独立変数にするための基準値です。

この結果から言えること/言えないこと

言えること

  • 全60,000枚・転送込みのCNN学習でRTX 5070 TiがCPUより約2.02倍短時間だった
  • 温間epochではGPUが約2.3倍の画像/秒を処理した
  • 約10万parameter、batch 256のCNNでGPU優位へ逆転した
  • PyTorch allocatedのピークは116.5 MiBで、VRAMには大きな余裕があった
  • Shirtが主な誤分類クラスだった

言えないこと

  • RTX 5070 Tiの最大性能が2倍しかない
  • CPUとGPUの0.3ポイント差が統計的に有意
  • num_workers=0が最速設定
  • batch sizeやモデルを変えても同じ倍率
  • 3 epochで到達可能な最高精度

損益分岐を詳しく調べる条件

同じCNNで次の条件を一つずつ変えると、速度差の原因を分離できます。

  1. batch size 32、64、128、256、512、1024
  2. DataLoader num_workers 0、2、4、8
  3. FP32、TF32、FP16、BF16
  4. VRAMとimages/s
  5. OOMが起きたときの安全な復旧

前の記事「同じ2層NNをPyTorchへ移植する」でGPUが負けた条件とセットで読むと、GPUの損益分岐が見えます。

再現性メモ

  • 実験:work/ai_lab/experiment_fashion_cnn.py
  • 自動テスト:work/ai_lab/test_fashion_cnn.py
  • 生データ:work/ai_lab/results/fashion_cnn_cpu_gpu.json
  • データ保存:/opt/ai-lab/data/FashionMNIST
  • 図:outputs/fashion-cnn-cpu-gpu.png
  • PyTorch 2.13.0+cu130、torchvision 0.28.0+cu130
  • GPU:RTX 5070 Ti、CUDA runtime 13.0

参考:

関連記事