Rust でML に挑戦してみた

| 11 min read
Author: minoru-matsumoto minoru-matsumotoの画像

この記事は夏のリレー連載2023の10日目の記事です。

1. はじめに

#

こんにちは。この記事が初投稿となります、松本です。よろしくお願いします。
さて、C/C++ に代わる言語として注目を浴びている Rust ですが、ML や NN の記事量は圧倒的に Python で、Rust で書いた例はググってもあまり見ない気がします。しかし速度を重視する場合、必ずしもメモリ安全ではない C/C++ を使わざるを得ず、メモリ安全で高速な Rust は魅力があります。
そこで、Rust 界隈でどのような crate があるか調査してみました。

2. Rust + CUDA で使えそうな crate

#

最近 Update されていて、面白そうな crate を挙げてみました。

この中で、革新的な Rust-CUDA/Rust-GPU プロジェクトと PyTorch を使う tch について触れたいと思います。

2.1 Rust-CUDA/Rust-GPU

#
前提条件
LLVM7
CUDA11.4.1以上

Nightly Build が前提。
LLVM 利用で、CUDA 用のコードも(属性がちょっと鬱陶しいが) Rust で書ける点が革新的です。

ただし、Dockerfile を見ればわかりますが、Ubuntu 18.04.01 が前提で、LLVM も version 7 を必要とします。
現状では Docker 環境で動かすのが安全と言えそうです。
是非、Ubuntu 22.04.02 + LLVM15 + CUDA12.2 に対応して欲しいものですが、2022年以来アップデートされていないのが惜しいです。

2.2 tch

#
前提条件
PyTordch == 2.0.0
CUDA11.7以上

libtorch のラッパーなので、PyTorch に可能なことは全部できます。
また、libtorch をわざわざインストールしなくても、PyTorch がインストールされていれば PyTorch の libtorch.so を指定することで利用可能です。
アクセラレータは CUDA の他、Apple Silicon の MPS(Metal Performance Shaders)、Vulkan API にも対応しています。
ただし、PyTorch の安定版最新(2.0.1)には対応していません。

3. 生き残る crate はどれだ

#

crates.io をみると分かりますが、他にも様々な人が crate を作ろうとしていた時期があるようです。けれども、ほとんどの crate は 2023 年になってからメンテナンスされていません。
現時点では、結局 PyTorch とか Tensorflow のコンパイル済みライブラリを呼び出すのが(C++に落ちるのが悔しいけど)現実的のようです。

4. まだ生きていそうな tch crate を使って libtorch を使ってみる

#

この章では、tch crate を使って libtorch を呼び出してみます。
(tensorflow を使わなかったのは、単に私が PyTorch になれていると言う理由だけです。)
お題としては、CIFAR-10 の画像を NN に学習させてみます。
最初に全結合型NN(隠れ層1536)で学習します。
次に CNN→Max Pooling→CNN→Max Pooling で学習します。

4.1 パラメータ学習に使う PC のスペック

#

NN学習に使う PC のスペックは以下のとおりです。Google Colaborator を使えばよかったとの思いもありますが、手持ち資産でどこまでできるか調べたいというのもあったので、ローカルで実行することにしました。

パーツ スペック
CPU Core i7 13700F
GPU Nvidia RTX 4090 GRAM 24GB
RAM DDR5 128GB
SSD NVM.e 1TB+2TB

4.2 CIFAR-10 を全結合型NNで判定する

#

PyTorch では基本データ型として Tensor を使いましたが、tch でも基本的なデータ型は Tensor となります。
従って、読み込んだデータは一旦 Tensor に変換する必要があります。
CIFAR-10 データは幸い Tensor データに変換する専用のローダがありますので、これを利用して Tensor を読み込むことにします。

さて、全結合型NNを tch で表現するには以下のようにします。

use std::time::Instant;
use tch::{no_grad_guard, nn, nn::Module, nn::OptimizerConfig, Device};

const IMAGE_DIM: i64 = 3 * 32 * 32;
const HIDDEN_NODES: i64 = 3 * 32 * 32;
const LABELS: i64 = 10;

fn net(vs: &nn::Path) -> impl Module {
    nn::seq()
      .add(nn::linear(
        vs / "layer1",
        IMAGE_DIM,
        HIDDEN_NODES,
        Default::default()
      ))
      .add_fn(|x| x.relu())
      .add(nn::linear(
        vs / "layer2",
        HIDDEN_NODES,
        LABELS,
        Default::default()
      ))
}

fn main() {
    let m = tch::vision::cifar::load_dir("data").unwrap();
    let vs = nn::VarStore::new(Device::cuda_if_available());
    let net = net(&vs.root());
    let mut opt = nn::Adam::default().build(&vs, 1e-02).unwrap();
    let start = Instant::now();
    println!("epoch,time,acc");
    let images_d = m.train_images.to_device(vs.device()).reshape(&[-1,IMAGE_DIM]);
    let label_d = m.train_labels.to_device(vs.deevice());
    let t_images_d = m.test_images.to_device(vs.device()).reshape(&[-1,IMAGE_DIM]);
    let t_labels_d = m.test_labels.to_device(vs.device());
    loop {
        let loss = net.forward(&images_d)
            .cross_entropy_for_logits(&labels_d);
        opt.backward_step(&loss);
        {
            let _guard = no_grad_guard();
            acc = net.forward(&t_images_d)
                .accuracy_for_logits(&t_labels_d);
            let end = start.elapsed();
            let acc_cpu = acc.to_device(Device::Cpu);
            println!(
                "{:4},{}.{:03},{:8.5},{:?}",
                epoch,
                end.as_secs(),
                end.subsec_nanos() / 1000000,
                &acc_cpu
            );
            epoch += 1;
        }
    }
}

この NN の最終的な正解率は約 41% となりました。

4.3 CIFAR-10 を CNN で判定する

#

せっかくの画像データなので、今度は CNN で学習させてみます。
作成したNNは CNN→Max Pooling→CNN→Max Pooling→linear です。
この NN は tch だと以下のように記述できます。

use std::time::Instant;
use tch::{no_grad_guard, nn, nn::Module, nn::OptimizerConfig, Device};

const LABELS: i64 = 10;

fn net(vs: &nn::Path, config: &nn::ConvConfig) -> impl Module {
    nn::seq()
        .add(nn::conv2d(
            vs / "layer1", 3, 16, 3, config
        ))
        .add_fn(|x| x.relu())
        .add_fn(|x| x.max_pool2d(2))
        .add(nn::conv2d(
            vs / "layer2", 16, 8, 3, config
        ))
        .add_fn(|x| x.relu())
        .add_fn(|x| x.max_pool2d(2))
        .add_fn(|x| x.reshape(&[-1, 512]))
        .add(nn:linear(
            vs / "labels",
            8 * 8 * 8,
            LABELS,
            Default::default()
        ))
}

fn main() {
    let m = tch::vision::cifar::load_dir("data").unwrap();
    let vs = nn::VarStore::new(Device::cuda_if_available());
    let mut config = nn::ConvConfig::default();
    config.padding=1;
    let net = net(&vs.root(), config);
    let mut opt = nn::Adam::default().build(&vs, 1e-03).unwrap();
    let images_d = m.train_images.to_device(vs.device()).reshape(&[-1, 3, 32, 32]);
    let labels_d = m.train_labels.to_device(vs.device());
    let t_images_d = m.test_images.to_device(vs.device()).reshape(&[-1, 3, 32, 32]);
    let t_labels_d = m.test_labels.to_device(vs.device());
    let start = Instant::now();
    let mut epoch: u64 = 1;
    println!("epoch,time,acc");
    loop {
        let loss = net.forward(&images_d)
            .cross_entropy_for_logits(&labels_d);
        opt.backward_step(&loss);
        {
            let _guard = no_grad_guard();
            let acc_d = net.forward(&t_images_d)
                .accuracy_for_logits(&t_labels_d);
            let end = start.elapsed();
            let acc_cpu = acc_d.to_device(Device::Cpu);
            println!(
                "{:4},{}.{:03},{:?}",
                epoch,
                end.as_secs(),
                end.subsec_nanos() / 1000000,
                &acc_cpu
            );
            epoch += 1;
        }
    }
}

最終的な正解率は 67% となりました。

全結合型NNに比べると正解率が向上しています。

4.4 おまけ: Autoencoder の実装

#

CIFAR-10 の画像なので、Autoencoder を構成してみます。
Encoder は 4.3 同様とし、Decoder は ConvTranspose2d×2 を追加しました。
画像ファイルの生成には png crate を使用しました。

use std::time::Instant;
use tch::{nn, IndexOp, Tensor, nn::Module, nn::OptimizerConfig, Device, Reduction, Kind};
use std::path::Path;
use std::fs::File;
use std::io::BufWriter;
use png::Encoder;

fn net(vs: &nn::Path, config: &nn::ConvConfig, conv_config: &nn::ConvTransposeConfig) -> impl Module {
    nn::seq()
        .add(nn::conv2d(
            vs / "layer1", 3, 16, 3, *config
        ))
        .add_fn(|x| x.relu())
        .add_fn(|x| x.max_pool2d(2, 2, 0, 1, false))
        .add(nn::conv2d(
            vs / "layer2", 16, 8, 3, *config
        ))
        .add_fn(|x| x.relu())
        .add_fn(|x| x.max_pool2d(2, 2, 0, 1, false))
        .add(nn::conv_transpose2d(
            vs / "rev_layer2", 8, 16, 2, *conv_config
        ))
        .add(nn::conv_transpose2d(
            vs / "rev_layer1", 16, 3, 2, *conv_config
        ))
}

fn write_image(p: &str, t: &Tensor) {
    let path = Path::new(p);
    let file = File::create(path).unwrap();
    let ref mut wb = BufWriter::new(file);
    let mut encoder = Encoder::new(wb, 32, 32);
    encoder.set_color(png::ColorType::Rgb);
    encoder.set_depth(png::BitDepth::Eight);
    let mut writer = encoder.write_header().unwrap();
    let mut v = Vec::<u8>::new();
    let yb = t.unbind(2);
    for y in 0..32 {
        let xb = yb[y].unbind(1);
        for x in 0..32 {
            let cb = xb[x].unbind(0);
            let r = u8::try_from(&cb[0] * 255.0).unwrap();
            let g = u8::try_from(&cb[1] * 255.0).unwrap();
            let b = u8::try_from(&cb[2] * 255.0).unwrap();
            v.push(r);
            v.push(g);
            v.push(b);
        }
    }
    writer.write_image_data(&v).unwrap();
}

fn main() {
    let m = tch::vision::cifar::load_dir("data").unwrap();
    let vs = nn::VarStore::new(Device::cuda_if_available());
    let mut config = nn::ConvConfig::default();
    config.padding = 1;
    let mut conv_config = nn::ConvTransposeConfig::default();
    conv_config.stride = 2;
    // test_images を訓練に使う
    let train_images = m.test_images;
    let images_d = train_images.to_device(vs.device()).reshape(&[-1, 3, 32, 32]);

    let net = net(&vs.root(), &config, &conv_config);
    let mut opt = nn::Adam::default().build(&vs, 1e-02).unwrap();
    let start = Instant::now();
    println!("epoch,time,test");
    for epoch in 1..10000 {
        let loss = net.forward(&images_d)
                .mse_loss(&images_d, Reduction::Mean);
            opt.backward_step(&loss);
        let loss_cpu = loss.to_device(Device::Cpu);
        let end = start.elapsed();
        println!(
            "{:4},{}.{:03},{:?}",
            epoch,
            end.as_secs(),
            end.subsec_nanos() / 1000000,
            &loss_cpu
        );
    }

    // 任意の5個のテスト画像を選択する
    let indices = Tensor::randint(50000, 5, (Kind::Int64, Device::Cpu));
    let indices = Vec::<i64>::try_from(indices).unwrap();
    let indices = indices.as_slice();
    let test_images = m.train_images.i(indices);
    let tests_d = test_images.to_device(vs.device()).reshape(&[-1, 3, 32, 32]);
    let sample_dst = net.forward(&tests_d);

    // 画像を保存する
    let sample_srcs = tests_d.unbind(0);
    let sample_dsts = sample_dst.unbind(0);
    for c in 0..5 {
        let fname = format!("result/org_{}.png", &c);
        write_image(&fname, &sample_srcs[c]);
        let fname = format!("result/defer_{}.png", &c);
        write_image(&fname, &sample_dsts[c]);
    }
}

この Autoencoder で 1 万枚を学習させ、テストセットからランダムに選んだ 5 枚を出力させたところ、以下のようになりました。

Input Output

色相はあまり再現できていませんが、輪郭は何となく再現されているようです。

5. まとめ

#

Nvidia がネイティブに Rust サポートしてくれないとバージョン対応が遅れるなど厳しい面があります。
推論については Nvidia の TensorRT があるので、あまり旨味がないかも知れません。
Rust で ML するのはまだ時期早尚なのかもしれません。

豆蔵では共に高め合う仲間を募集しています!

recruit

具体的な採用情報はこちらからご覧いただけます。