端の知識の備忘録

技術メモになりきれない、なにものか達の供養先

48コアで300ドルの謎のZen2 CPU, EPYC 7K62を買ってみた

まえがき

2020年のK40m購入から始まり、GPUをP40にしたりサーバー用マザボを導入したりMI50を試してみたりと徐々に進化を続けてきたホームラボ環境。

hashicco.hatenablog.com

またふとebayを見ていると、なんと300ドルで48コア、しかもZen2のCPUが売られているのを発見。

型番はEPYC 7K62と見たことのない命名ルールで、どうやらAMDのEPYC 7642のOEM版のようだ。

https://www.reddit.com/r/homelab/comments/1dssl0m/which_motherboards_support_epyc_rome_7k62/

普通のEPYC 7642や競合となりそうな3000番代ThreadripperのCPUと比べても数割安い金額なので、これはかなりお買い得。

Epyc 7642 for sale | eBay

どっかのデータセンターでリプレースされたものが放出されたのか、この数ヶ月でそれなりの数出回っているっぽい。

現在利用しているマザボであるH11SSL-iでもZen2 CPUはサポートされているので、これは試してみる価値ありと考えてポチってみた。

まとめ

  • 300ドル(2024年8月末購入時)で48コアのZen2 EPYC 7K62 CPUを購入。以前7551Pと使っていたマザボH11SSL-iとの組み合わせで無事に起動を確認
  • ついでにCPUクーラーとしてDynatronのA35を米尼で購入。
  • Phoronix Test Suiteでいくつかベンチマークを実行。今まで使っていた7551Pと比べて1世代分の進化と1.5倍のコア数があることもあり、かなりの性能向上が見られた
  • コア単価で見ると群を抜いて安く、安めのマザボでも動くので、高コスパなホームサーバーを構築する際には2024年現在かなりおすすめできるといえよう

しかしマザボ、256GBメモリ、48コアCPU合わせて10万ちょっとで買えるのだからすごい時代になったものだ。重たいマルチコア処理を日常的に行う人であれば簡単に元が取れるだろうし、鯖向けCPUを自宅で動かすのに抵抗のないパワーユーザーであれば購入検討してみてください。

ギャラリー

CPUはこんな感じ。SP3ソケットなのはZen1と変わらず。7K62という印字が確かにある。

もともと中古の水冷クーラーを使っていたが取り回しが悪く使いにくかったので、ちょっと気になっていた産業用クーラーメーカーのDynatronの製品を試してみた。無骨でなかなか格好いい。

システム情報

  • openbenchmarkのシステム情報

  • lshwの結果
ubuntu@ubuntu-Super-Server:~$ sudo lshw -C cpu
  *-cpu
       description: CPU
       product: AMD EPYC 7K62 48-Core Processor
       vendor: Advanced Micro Devices [AMD]
       physical id: 2c
       bus info: cpu@0
       version: 23.49.0
       serial: Unknown
       slot: CPU
       size: 1500MHz
       capacity: 3300MHz
       width: 64 bits
       clock: 100MHz
       capabilities: lm fpu fpu_exception wp vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ht syscall nx mmxext fxsr_opt pdpe1gb rdtscp x86-64 constant_tsc rep_good nopl nonstop_tsc cpuid extd_apicid aperfmperf rapl pni pclmulqdq monitor ssse3 fma cx16 sse4_1 sse4_2 movbe popcnt aes xsave avx f16c rdrand lahf_lm cmp_legacy svm extapic cr8_legacy abm sse4a misalignsse 3dnowprefetch osvw ibs skinit wdt tce topoext perfctr_core perfctr_nb bpext perfctr_llc mwaitx cpb cat_l3 cdp_l3 hw_pstate ssbd mba ibrs ibpb stibp vmmcall fsgsbase bmi1 avx2 smep bmi2 cqm rdt_a rdseed adx smap clflushopt clwb sha_ni xsaveopt xsavec xgetbv1 cqm_llc cqm_occup_llc cqm_mbm_total cqm_mbm_local clzero irperf xsaveerptr rdpru wbnoinvd arat npt lbrv svm_lock nrip_save tsc_scale vmcb_clean flushbyasid decodeassists pausefilter pfthreshold avic v_vmsave_vmload vgif v_spec_ctrl umip rdpid overflow_recov succor smca sev sev_es amd_ppin cpufreq
       configuration: cores=48 enabledcores=48 microcode=137367674 threads=96

ベンチマーク

Phoronix Test Suiteでいくつかベンチマークを実行。7551Pの結果を自分で取るのを忘れてしまったのだが、OpenBenchmarking.orgで公開されている7551か7551Pの結果と比較してみる。あと、参考値として7950Xの結果も転載して載せておく。

インストールは次の記事を参考にして、phoronix-test-suite list-recommended-testsProcessor Testsからいくつか選んで実行している。

dev.classmethod.jp

結果としては、旧世代の7551Pと比べて1世代分の進化と1.5倍のコア数があることもあり、かなりの性能向上が見られた。

また、一部Cache系ベンチマークなどではアーキテクチャの違いもあるのか7950Xに大きく劣るものの、概ね現世代の1桁後半万円、16コアのコンシューマCPUと比較してもほとんどのベンチで上回る性能を発揮している。もちろん、48コアをしっかり使い切れるアプリケーションでないと意味がないですが、5万でこの性能が出るなら十分だろう。

注: 本環境では若干遅めのメモリ(2133MHz)を使っているのでその点ご了承ください。

Timed Linux Kernel Compilation(Fewer is better)

https://openbenchmarking.org/result/2409115-NE-BUILDLINU47

項目 7551 7950X 7K62
defconfig 52 53 51.31
allmodconfig 1027 651 516.18

compress-7zip(Higher is better)

https://openbenchmarking.org/result/2409114-NE-COMPRESS736

項目 7551 7950X 7K62
compression 115838 182389 214261
decompression 133753 177177 175451

openssl(Higher is better)

https://openbenchmarking.org/result/2409111-NE-OPENSSLLO27

注: 7551Pの結果がなかった。

項目 7950X 7K62
SHA256 34748143805 33841133080
SHA512 11279544014 15466609297
RSA4096-sign 14454 11022.7
RSA4096-verify 380133 716442.8
ChaCha20 132915172864 129956744397
AES-128-GCM 258049517394 182569143227
AES-256-GCM 221082106458 162018783597
ChaCha20-Poly1305 94272191214 84309747797

c-ray(Fewer is better)

https://openbenchmarking.org/result/2409113-NE-CRAYLOG5569

項目 7551 7950X 7K62
Resolution 4K 142 113 77.02
Resolution 5K 254 209 136.82
Resolution 1080p 36 28 19.54

GraphicsMagick(Higher is better)

https://openbenchmarking.org/result/2409110-NE-GRAPHICSM18

項目 7551 7950X 7K62
Swirl 216 251 295
Rotate 118 176 103
Sharpen 71 60 123
Enhanced 87 96 128
Resizing 171 342 295
Noise-Gaussian 114 120 109
HWB Color Space 283 295 204

cachebench(Higher is better)

https://openbenchmarking.org/result/2409114-NE-CACHEBENC24

項目 7551 7950X 7K62
Read 7628 14090 8391.21
Write 37987 82631 46623.97
Read/Modify/Write 41606 149088 90643.11

pbzip2(Fewer is better)

https://openbenchmarking.org/result/2409114-NE-CACHEBENC24

項目 7551 7950X 7K62
BZIP2 Compression 6 3 4.054023

Transformers高速化ライブラリvLLMのAsyncLLMEngineを利用した非同期高速文章生成

概要

先日までKaggleのAIMOコンペ(数学の問題をLLMに解かせて正答率を競う)に参戦していました。結果は初のチーム参加でメンバーに助けられつつ運もあり、なんとか銀メダルを取れました!これでMasterにリーチがかかりましたが、金メダルは未だ取れる気がしないので遠い道のりです……。

www.kaggle.com

このコンペについて、近い内に同様のコンペが開催予定なこともあり上位解法があまり出ていない状態なので、どのような手法が良かったのかまだわかっていないのですが、とりあえず公開されている情報を元にすると、

  • LLMとしてはほぼほぼ全員が数学問題に特化したLLMであるDeepseek-Math-7Bを利用している
  • LLMが出力したPythonコードを実行するインタープリターを実装することで、LLMのハルシネーションによる計算ミスを防ぐパイプラインが有力であった
  • LLMの出力を比較的高い温度で複数回サンプリングすることで、多様な解答を得つつ最も回答数が多いものを選択することで、精度を高めることができる

という点がベースラインとなっているコンペでした。

(2024/7/11 追記) 1st ソリューション公開されました。H100x8を使ったフルパラメータチューニングという常人には真似できない凄まじい解法!どうやらHFとMistral AIの人たちのチームらしい。

www.kaggle.com

その中で、最後の点について、通常のHugging Face Transformersではなく、vLLMというライブラリを利用することで文章生成を高速化し、サンプリング回数を増やすことができるというディスカッションがありました。

www.kaggle.com

全くこのvLLMの存在を知らなかったのですが、使ってみるとたしかに速い。更に、非同期推論用のラッパーであるAsyncLLMEngineを利用することで、GPUを遊ばせることなくフルに動かすことができ、通常のTransformersの最大20倍以上の速度で文章生成を行うことができました。

ただ、vLLMのドキュメントが少なく、特に日本語情報は殆どなさそう。更に、AsyncLLMEngineの使い方に至っては公式ドキュメントにもわかりやすい動作例がないため、少しvLLMの使い方をまとめることとします。

まとめ

ここに動作サンプル(Kaggle Notebook)を用意しましたので、単に使うだけならこれをご参照ください。

www.kaggle.com

いま私のソリューションは書いている途中なので、これもできたら共有します。

(20240707追記)ソリューション公開しました。

www.kaggle.com

今後、KaggleでTransformersを使うときには、推論時にvLLMを使うことで大幅に時間短縮が可能になるかもしれませんので、試す価値アリだと思います。

vLLMについて

Geminiに作らせた要約を一部訂正したものです。

高速: 最先端のサービングスループット、PagedAttentionによる効率的なアテンションキーと値メモリの管理、受信リクエストの連続バッチング、CUDA/HIPグラフと量子化による高速なモデル実行により、高速な処理を実現します。

柔軟性と使いやすさ: 人気のHugging Faceモデルとのシームレスな統合、並列サンプリング、ビームサーチなどのさまざまなデコーディングアルゴリズムを備えた高スループットサービング、分散推論用のテンソル並列サポート、ストリーミング出力、OpenAI互換APIサーバー、NVIDIA GPUAMD GPUIntel CPUとGPUのサポート、接頭辞キャッシュサポート(実験的)、マルチloraサポート(実験的)など、柔軟で使いやすい設計になっています。

「人気のHugging Faceモデルとのシームレスな統合」ということで、ほとんどHuggingFaceのTransformersと同じAPIを持ちつつ、裏側でPagedAttention等の高速化技術を使って推論速度を上げてくれるような機能も持っており、最小限の変更でノートの実行時間を減らすこともできます。

公式Exampleより抜粋。一番シンプルなLLM APIを使うことで対応しているモデルであれば、"facebook/opt-125m"のようにHuggingFace Hubのパス指定で当該のモデルを呼び出し、HF transformersのgenerateメソッドっぽく文章生成をすることができます。

from vllm import LLM, SamplingParams
sampling_params = SamplingParams(temperature=0.8, top_p=0.95)

llm = LLM(model="facebook/opt-125m")
outputs = llm.generate(prompts, sampling_params)

近いライブラリというと、Deepspeedが挙げられるかもしれません。

インストール

活発にアップデートされているライブラリなのですぐにインストール方法が変わりそうですが、とりあえず20240706現在、0.4.0post1をKaggleでインストールする方法を載せておきます。

Kaggle DatasetにWhlがあるので、これを使ってインストールします

!pip uninstall -y torch
!pip install --no-index --find-links=/kaggle/input/vllm-whl -U vllm
!pip install /kaggle/input/vllm-additional-packages/grpcio-1.60.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl

一般的なインストールのガイダンスは次を参照してください。

docs.vllm.ai

AsyncLLMEngineについて

今回フォーカスするのは、非同期推論用のAPIであるAsyncLLMEngineです。

とりあえず公式ドキュメントの記載は以下にあります。

docs.vllm.ai

非同期推論なので、ここに載っているExampleをそのまま使うことはできず、async/awaitを使ったコードを書く必要があります。

そこで、簡単に動作確認ができるサンプルを作成しました。このサンプルは、2つのプロンプトに対して100回ずつサンプリングを行い、その結果をファイルに書き出すというものです。

from vllm import AsyncLLMEngine, SamplingParams
from vllm.engine.arg_utils import AsyncEngineArgs

import asyncio
import time
import uuid

st = time.time()


example_inputs = [
    {
        "prompt": "About 200 words, please give me some tourist information about Tokyo.",
        "temperature": 0.9,
    },
    {
        "prompt": "About 200 words, please give me some tourist information about Osaka.",
        "temperature": 0.9,
    },
]


async def gen(engine, example_input, id):
    results_generator = engine.generate(
        example_input["prompt"],
        SamplingParams(temperature=example_input["temperature"],max_tokens=300, min_tokens=200,),
        id,
    )

    final_output = None

    async for request_output in results_generator:
        final_output = request_output

    prompt = final_output.prompt
    text_output = [output.text for output in final_output.outputs]
    return text_output[0]


async def main():
    engine = AsyncLLMEngine.from_engine_args(
        AsyncEngineArgs(
            model="/kaggle/input/llama-2/pytorch/7b-chat-hf/1",
            dtype="half",
            enforce_eager=True,
            gpu_memory_utilization=0.99,
            swap_space=3,
            max_model_len=1024,
            kv_cache_dtype="fp8_e5m2",
            tensor_parallel_size=2,
            disable_log_requests=True
        )
    )

    results = []

    for example_input in example_inputs:
        tasks = []
        for i in range(100):
            tasks.append(asyncio.create_task(gen(engine, example_input, uuid.uuid4())))

        res = [await task for task in tasks]

        results.append(res)

    with open("async_res.txt", "w") as f:
        for r in results:
            f.writelines(r)


if __name__ == "__main__":
    asyncio.run(main())

    print("Async vLLM inference time: ", time.time() - st)

パラメータについて

AsyncEngineArgsというクラスでLLMの設定、プロンプトとともに渡すSamplingParamsでサンプリングの設定を行うことが可能です。

詳しくは公式ドキュメントを参照いただければと思いますが、とりあえずKaggle上でT4x2を利用する際に、私は次のような設定を利用していました。

engine = AsyncLLMEngine.from_engine_args(
    AsyncEngineArgs(
        model=MODEL_NAME,
        dtype="half",  # ここは指定しなくてもturing世代なら自動的にfp16になるらしい。より新しいGPUならbfloat16をデフォルトでは使ってくれる
        enforce_eager=True,  # Pytorchのeager modeを強制するかどうか。これは参考にしたノートブックがTrueにしていたのでそのまま利用している。
        gpu_memory_utilization=0.99,  # KVキャッシュ含めGPUのメモリをどこまで使うか。Kaggleであれば0.99でも問題ないが、もし映像出力も行っているGPUを使う場合はここを減らさないとマシン自体が落ちるので注意。
        swap_space=3,  # デフォルトは4だが、メインメモリのOOMで落ちたことがあったので3にしている
        kv_cache_dtype="fp8_e5m2",  # auto, fp8, fp8_e5m2, fp8_e4m3から選べる。fp8_e5m2のほうが<s>仮</s>指数部が大きいので<s>精度が高いらしい</s>数値のレンジが大きい。 (20240708)訂正。fp8_e5m2を使っているというディスカッションがあったのでそれに則ってこちらを使っているが、どっちが良いかはケース次第?
        tensor_parallel_size=2,  # ここの数を変えるだけで複数GPUを利用してくれる。
        max_model_len=1024,
        disable_log_requests=True,  # これをTrueにしないとログがめちゃくちゃ出る
    )
)

AIMOコンペでは、コードフェンス内のPythonコードをcode.pyというファイルに書き出して、そのコードをsubprocessで実行するという手法が取られていたので、stop_wordsによる生成停止を行います。

Transformersと似たようにリスト型でstop_wordsが可能ですが、include_stop_str_in_outputをTrueにすることでstop_wordsを含めた文章を返してくれるようになり、Transformersの標準的な出力と互換性を保つことができます。

SamplingParams(
    temperature=0.9,
    top_p=1,
    max_tokens=2048,
    stop=[
        "```output",
        "```python",
        "```\nOutput",
        ")\n```",
        "``````output",
    ],
    include_stop_str_in_output=True,
)

句読点のない日本語の文章に句読点を挿入するBERTモデルを作る

概要

Whisperなどで日本語の文字起こしをした後の文章にほとんど句読点が入っておらず、そのまま文字起こし文章を結合すると利用しにくいことがありました。

そこで何らかの手法で句読点を挿入したいと思ったのですが意外と手軽な古典的な手法が見つかりませんでした。このようなタスクはpunctuation restorationと呼ばれるタスクのようです。

ちょっと調べて見つかったのは次のような日本語BERTモデルのMask Fillを逐語的に適応していく方法でした。

qiita.com

この手法でもできなくはないのですが思ったほど精度が良くないこと、さらに処理時間が文章の長さに比例してだんだん無視できないレベルになるので、もう少し効率的な方法がないかと考えました。

実のところChatGPTに適当に「次の句読点の入っていない文章に適当に句読点を挿入してください」という指示を出せばいい感じに句読点を入れてくれるのですが、わざわざこれだけのためにChatGPTを使うのもなぁと思い、BERTモデルを自分で作ってみることにしたというお話。

作成したコード、Weightは下記リポジトリにあります。

huggingface.co

ちょっとした解説

学習データとして次のKaggle データセットを利用しました。夏目漱石の「明暗」の読み上げを行った音声データと文章データが含まれています。このうち文章データのみを利用してBERTモデルを学習しました。

https://www.kaggle.com/datasets/bryanpark/japanese-single-speaker-speech-dataset/data?select=transcript.txt

基本的なコンセプトとしては、句読点がちゃんと入っている文章において、句読点の前の文字であるか否かをラベルとして付与します。

例えば、「今日は、いい天気。」という文章に対して、

[[0,0,1,0,0,0,0],[0,0,0,0,0,0,1]]

のようなラベルを付与し、BERTに対しては「今日はいい天気」という文章を入力として与え、句or読点の前の文字であるか否かを予測させるという流れです。実際にはTokenizerによりスペシャトークンが付与されるので位置がずれますが、基本的な考え方はこのような感じです。

ベースのモデルとしてはtohoku-nlp/bert-base-japanese-char-v3を利用し、出力のhidden layerを取り出して、その後に全結合層を追加したモデルを作ります。

huggingface.co

それぞれのトークンのhidden stateに対し共通のlinear layerを適応し、「、」の前であるか「。」の前であるかを意味する2次元の出力を得る形となり、最終的な出力形状は(num_tokens, 2)となります。

class punctuation_predictor(torch.nn.Module):
    def __init__(self, base_model):
        super().__init__()
        self.base_model = base_model
        self.linear = torch.nn.Linear(768, 2)

    def forward(self, input_ids, attention_mask):
        last_hidden_state = self.base_model(
            input_ids=input_ids, attention_mask=attention_mask
        ).last_hidden_state
        # get last hidden state token by token and apply linear layer
        return self.linear(last_hidden_state)

で、出来上がったものとしては、次のようにprocess_long_textという関数に生で句読点の入っていない日本語を入力すると、適当に句読点を入れてくれるもの。

process_long_text(
        "句読点ありバージョンを書きました句読点があることで僕は逆に読みづらく感じるので句読点無しで書きたいと思います"
    )

先ほどのQiitaの記事で紹介されていた文章で比較してみます。

本実装

句読点ありバージョンを書きました。句読点があることで僕は逆に読みづらく感じるので、句読点無しで書きたいと思います。

学習データは地の文にあまり点が入っていないうえ近代文学なこともあり、結構かけ離れた文章のはずですがそこそこ上手く句読点を入れてくれているように見えます。

自分で使う分には対象の文字起こし文章10000文字くらいをChatGPTに投げて教師データを作成し、専用モデルを学習させることで割と実用的な精度が出ることを確認しています。

今回の文章の量(2000センテンス)であればRTX4090 1枚でだいたい1epoch 45秒, 10epochで10分くらいで学習が終わるので、そこまで時間がかからないのも良いところです。

GPTを使うには文が多すぎる場合などに活用できるモデルというイメージです。

ChatGPT4

句読点ありバージョンを書きました。句読点があることで、僕は逆に読みづらく感じるので、句読点無しで書きたいと思います。

Qiitaで紹介されていた"cl-tohoku/bert-base-japanese-char"のMask Fillを利用する方法

句読点ありバージョンを書きました句読点があることで僕は逆に、読みづらく感じるので、句読点無しで書きたいと思います。

【ベンチマーク編】 Radeon Instinct MI50を買って機械学習用に使ってみる【ROCm】

まえがき

ROCmを試すためにRadeon Instinct MI50を買ってみて、PyTorchで使えるようにセットアップをしたのが前回。

hashicco.hatenablog.com

今回は取ったベンチマークの結果をご紹介!

まとめ

  • ROCmは本当にほぼコード変更無しでCUDA用のTensorFlow、PyTorch、Transformerのコードが動く。素晴らしい。
  • 1GPUであればMI50のほうがP40よりも速い。特にFP16を利用する場合は4割くらい早く、結構実用的。
  • 2GPUだとあまりまともな速度が出ない。処理速度に波があるような挙動を示すため、Gradientの集約に時間がかかっているのかも。
    • Infinity fabric connectorをつけるとか、PCIe4であればもう少しマシになるのかも。
    • 2GPUをうまく使うならDDPでトレーニングするよりも、別プロセスで別のモデルを同時に2つトレーニングするように使うほうが実用的と思われる。
  • SDXL1.0でも512x512で7.3 sec/枚(FP16)の画像生成速度と、P40の10.9 sec/枚よりも4割以上速い。画像生成で遊ぶならMI50は悪くないと思う。

ということで、MI50は結構使えるGPUと言える。ただ、2GPUでDDPトレーニングをするような場合はあまり速度が出ないので買うなら1枚がオススメ。

P40と比べると、MI50のほうが速度は速いがVRAMが少ない。LLMで遊ぶ用ならメモリ量が第一なのでP40、Diffusion系の画像生成なら必要VRAMも比較的少ないモデルも多いし原理的に計算が重いと思うのでMI50のが良いかもしれません。

ROCm起因のトラブルもないわけではないのでちょっと玄人向けかもしれませんが、性能も3万円とは思えないほどなので、MI50は機械学習で遊ぶ用としてお家に1枚あっても損はないGPUかもしれない。

VRAM 32GB版が安く買えるなら間違いなくコスパ最強と言えると思うが、入手しやすい16GBに関しては必要なVRAM量を考えてP40とMI50のどちらか選ぶのが悩ましいところである。

ちなみに、Redditに「MI50機械学習用にどうよ?」的なスレッドもあったので、購入検討時にはこちらも確認するといいだろう(私は読まずに買ってしまいましたが)

https://www.reddit.com/r/LocalLLaMA/comments/1b5ie1t/interesting_cheap_gpu_option_instinct_mi50/

P40とMI50のスペック

www.techpowerup.com

www.techpowerup.com

ベンチマーク

行ったベンチマークは以下の通り。P40に関しても新しくベンチを取り直し、なるべく公正な比較になるようにしています。

  • FP32 画像分類モデルトレーニング(transformers pytorch exampleのrun_image_classification.py)
  • FP16 言語モデルレーニング(transformers pytorch exampleのrun_clm.py)
  • SDXL1.0 画像生成

環境

P40, MI50

  • CPU: EPYC 7551P 32Cores
  • MEM: DDR4 2133 256GB
  • M/B: H11SSL-i
  • SSD: SATA 1TB
  • OS: Ubuntu22.04

RTX4090

  • CPU: R9 7950X3D 16Cores
  • MEM: DDR5 4800 64GB
  • M/B: MSI X670-P WIFI
  • SSD: M.2 PCIe4 2TB
  • OS: Win11 Pro(WSL2 Ubuntu 22.04)

FP32 画像分類モデルトレーニン

transformersをGitHubからクローンしてくることで利用可能なexamplesを実行します。とりあえずROCmでも問題なく実行できました。

https://github.com/huggingface/transformers/blob/main/examples/pytorch/image-classification/run_image_classification.py

GPU train_samples_per_second eval_samples_per_second train ratio(MI50x1=1) eval ratio(MI50x1=1)
MI50x1 35.686 90.745 1 1
MI50x2 33.933 69.598 0.9509 0.7000
P40x1 31.508 76.755 0.8829 0.8458
P40x2 37.566 74.594 1.0532 0.8219
RTX4090 101.406 197.71 2.8416 2.1787

結果、MI50は1枚のほうが速いという結果に。

1枚勝負ならP40よりもTrainでもEvalでも速いことがわかった。P40x2のTrainでは流石に負けるがその差は僅か。

FP16 言語モデルレーニン

使ったExampleは次のURLのもの。VRAM量の関係からBatch sizeを4に落として実行しました。

https://github.com/huggingface/transformers/blob/main/examples/pytorch/language-modeling/run_clm.py

GPU train_samples_per_second eval_samples_per_second train ratio(MI50x1=1) eval ratio(MI50x1=1)
MI50x1 7.015 19.792 1 1
MI50x2 5.595 11.597 0.7976 0.5859
P40x1 4.582 12.63 0.6532 0.6381
P40x2 6.485 12.787 0.9244 0.6461
RTX4090 19.049 52.211 2.7155 2.6380

結果、1枚のMI50がP40x2よりも速いことがわかった。

レーニングのベンチマーク結果からは、少なくともMI50 1枚は同価格帯のP40よりも性能が高く、VRAM量を気にしなければコスパが高いと言えるだろう。

なんなら30万するRTX4090の1/3~1/2くらいの性能は出ているので、コンペなどでも意外と使えなくもないかもしれない。

SDXL1.0 画像生成

automatic1111のWebUIから、sd_xl_base_1.0による512x512の画像を1枚生成するときの速度を比較。5回生成したときの実行時間の平均を取りました。

P40におけるセットアップは前回ブログでも紹介した次の記事を参照。

https://pc.watch.impress.co.jp/docs/column/ubuntu/1493536.html

ROCmにおけるセットアップは公式のAMD用ガイドを参考にしました。

https://github.com/AUTOMATIC1111/stable-diffusion-webui/wiki/Install-and-Run-on-AMD-GPUs#running-natively

基本的には前回のセットアップ編で記載したvenv環境で./webui.sh --precision full --no-half --listenまたは./webui.sh --upcast-sampling --listenのどちらかのコマンドを実行すれば良いです。

  • MI50(--precision full --no-half): 8.4s
  • MI50(--upcast-sampling): 7.3s
  • P40(オプション無し): 10.9s

いずれにおいてもP40よりも早く推論できています。VRAMの使用量は--precision full --no-halfで88%、--upcast-samplingで47%と、SDXLをなんのオプションも付けずに動かす分にはFP32でもメモリは足りています。

よく画像生成用に買われているRTX3060よりも安く、メモリも多く、しかも結構速いということで、冷却の問題を解決できるパワーユーザーならMI50はアリだと思いました

【セットアップ編】Radeon Instinct MI50を買って機械学習用に使ってみる【ROCm】

まえがき

しばらく大きな買い物をしていなかったが、仕事からの現実逃避をしているとふと思う。「ROCm、試してみたいなー」

ということで、ebayを開きめぼしいGPUを探してみたところ、$200以下のお手頃価格で一応ギリギリ最新のROCm 6.0.2にもメンテナンスサポートされているMI50を発見。

送料も考えて2枚まとめて買ってみたので、数回に分けてROCmのインストールから機械学習用の環境構築、以前に買った同価格帯の中古サーバー用GPUの先輩であるNVIDIA P40との比較までを書いていこうと思う。

今回はセットアップ編ということで、取り付けとROCmインストールでrocm-smiが動くところ、PyTorch上でGPUが認識されるまでを書いていく。

hashicco.hatenablog.com

  • P40の記事

hashicco.hatenablog.com

  • 取り付け時のXポストたち

まとめ

  • ebayでMI50 16GBが1枚$190で買えました。2つ買って送料、消費税込でだいたい70,000円くらい

    • MI25も似たようなスペックですがMI50のほうがアーキテクチャが少し新しく(MI25がgfx900, MI50がgfx906)、メモリバス幅が倍なので、大した金額差ではないしMI50を選択しました。
  • 今回冷却用にArcticの新しいファン(S8038-10K)を買いました。こいつが中々のモンスターで、80mmファンで500~10000rpmのPWM制御が可能、MAX102cfmというスペック。

    • 10000rpmだとノイズもやばいXに動画を上げときましたが、見るときは音量注意
    • 5000rpmくらいに抑えると結構な風量の割に音も耐えられる感じなので、ipmitoolを使ってファンコントロールしました。
    • 1個$16ですが、米アマで4個まとめ買いして大体送料込み12000円くらい
  • ROCmのインストールは拍子抜けするほど簡単!コマンド数行でつつがなく終了したので、下手するとNVIDIAのドライバとCUDAインストールよりも簡単だったかもしれない。

  • 買ってしばらくして気づいたのですが、私の買ったMI50はどうやらRadeon VIIのVBIOSが焼かれているようで、PCからはRadeon VIIとして認識されてるっぽい

    • 購入先はebayのcomputewarehouseというショップ。現在MI50のVBIOSをもらえないか問い合わせ中
    • (20240410 追記) computewarehouseの担当者と連絡が取れ、MI50のVBIOS Flasherをいただくことができた。まだ試せていないが、もしうまくいったらなにか書く予定。
    • MI50をRadeonVIIにする改造は結構広く行われているようなのでまあこのまま使ってもいいのだが、FP64の性能が半分になるらしい。
    • オリジナルをそのまま使いたい気持ちもあるので可能ならVBIOSを書き換えたいのですが、MI50のものはどこを探しても発見できず……。
  • マシンとしては前回作成したEPYCマシンのP40を載せ替え、SSDをサラのに交換したものを使ったのだが、M/BのH11SSL-iでのファン制御は結構苦戦した。

    • 結果的にBMCを最新にアップデートすることでipmitoolによるファンの固定値制御がうまくワークするようになった
  • ちょっとしたトラブルとして、GPU2枚のうち1枚、付いていないはずのGPUファンの表示上の速度が過負荷時に異常に下がって報告されてしまい、システム異常を示すと思われる凄まじいビープ音を発する現象が確認された

    • おそらくRadeonVIIのVBIOSが焼かれていることが原因と思われる。
    • 結局rocm-smiでファン回転数を固定値とすることで問題は解消した。

次から詳しい説明です。興味のある方はどうぞ。

カードの取り付け

  • MI50

今回はアメリカからの発送で受取まで約3週間くらいかかりました。

カードの見た目が中々カッコよくていい感じ。P40と並べて撮影してみても、Radeon Instinct MI50のほうがモダンで高級感があります。

  • S8038-10K

80mm, 38mm厚のマッシブなファンです。まるで120mmファンのような102CFM、強力な51mmH2Oというものすごい静圧ファン。

  • ファンを取り付ける

いつも通り段ボールを使って無理くりブラケットを作ります。カードの上部に出っ張りがあり加工が中々難しかった……。

本当は上部にInfinity fabric connectorの繋ぐ場所があるのですが、肝心のコネクタ自体が買える値段で売ってなかったので使いません。

  • 出来上がったもの

前回作ったEPYCマシンに2枚のMI50を刺します。段ボールの見栄えが悪いし下手すると燃えそうなので真似する場合はあくまで自己責任で!

OS準備とファン制御

今回OSはUbuntu 22.04を使います。ROCm 6.0.2は20.04または22.04対応とのこと。

OSインストールについては、いつも通りEtcherにISOを焼いて新しいSSDをにインストールします。

で、あとはROCmをセットアップ……と行きたいところですが、ファン制御をしないことには10000RPM近くでGPUファンが回るのでとても煩くて作業できません!

ということでファン制御の方法を調べてみるとipmitoolというコマンドで制御できるらしい。

hassiweb.gitlab.io

指定する0x30や0x45などのHexコードの意味はよくわかりませんが、とりあえずファンモードをFullにしたうえで、ファン回転数を手動設定すればいいらしい。

GPU用とその他のファンで制御を分けたかったので、本来の使い方とは違いますがFANA, BコネクタにGPU用のS8038-10Kを接続し、他のファンはFAN1~5に接続します。こうすることで、GPUファンはペリフェラル用のファンゾーン(0x01)で制御できるようになります。

# ファンモードの変更。0x01がFullを意味するらしい
sudo ipmitool raw 0x30 0x45 0x01 0x01

# ファン回転数の手動設定。後ろから2つ目のHexがファンゾーン。今回はペリフェラルの方を制御したいので0x01を指定。
# ファンの回転数は一番うしろのHexで指定。0x00~0xFFの256段階で指定できる。今回は約5000RPMをターゲットにするので0x32を指定
sudo ipmitool raw 0x30 0x70 0x66 0x01 0x01 0x32

……が、何度このコマンドを実行しても一時的にファン回転数が下がるもののまた最大に戻るという挙動が発生。煩すぎてまともに作業できないので一旦電源を落として情報を探したところ、次のフォーラムでBMCのアップデートを行うことが推奨されていた。

forums.servethehome.com

結局こちらの内容がドンピシャで、BMCをアップデートしたらちゃんと上記コマンドでファン回転数を制御できるようになりました

ちなみに、BMCのアップデートファイルはこちらで配布されています。H11SSL-Iで検索したら出てきたv2.09に更新しました。

www.supermicro.com

ROCmのインストールとPyTorchセットアップ

で、ここが本題になるはずだったのだが、あまりにも簡単にインストールできてしまったので取り立てて書くことはなく、基本的に公式ページの内容通りに作業すれば良い

最終的にTransformersのExamplesで性能検証をしていきたいので、huggingfaceのAMDページにあるインストラクションに従って作業していきます。

huggingface.co

1. Confirm you have a supported AMD hardware platform

私の買ったMI50はギリギリサポート中。Deprecated - Support will be removed in a future release.だそうです。

rocm.docs.amd.com

2. Install ROCm driver, libraries and tools

次のページを参考にインストール。

rocm.docs.amd.com

今回はAMDGPU Installerという方法を試します。基本は上記のページのコマンドを利用しますが、特にグラフィック出力は使わないので--usecaserocmのみにしておきます。

sudo apt update
wget https://repo.radeon.com/amdgpu-install/6.0.2/ubuntu/jammy/amdgpu-install_6.0.60002-1_all.deb
sudo apt install ./amdgpu-install_6.0.60002-1_all.deb
sudo amdgpu-install --usecase=rocm

20GBくらいダウンロードが行われますが、特にエラーもなく正常終了。

3. Install Machine Learning Frameworks

pipからのインストールがおすすめされています。

とりあえずvenvで仮想環境を切ったうえで、下記コマンドを実行していきます。

PyTorchは専用の--index-urlを指定し、TensorFlowはtensorflow-rocmという別名パッケージを指定してインストールするだけ。

pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm5.7
pip install tensorflow-rocm
pip install transformers

これも特に問題なく実行できました。

4. ユーザーのグループ追加

rocm系のコマンドの実行にはユーザーをrendervideoというグループに参加させる必要があります。

sudo usermod -aG render USER名
sudo usermod -aG video USER名

で、最後にマシンをリブートします。リブートしないとPyTorchからGPUが見えませんでした。

5. rocm-smiの内容確認

で、ここまでやれば普通にrocm-smiというnvidia-smi的なコマンドを実行できるようになります。

表示内容は次のスクショみたいな感じ。GPU使用率、VRAM使用率など確認できます。

6. PyTorchからのGPU認識確認

で、PyTorchからどのようにROCmデバイスを利用するかといえば、基本的には普通にcudaデバイスと同じ扱いで良いようです。つまり、すでにNVIDIA GPUで動いているコードであれば特に変更する必要はなく、modelとデータを全部.to("cuda")で同じデバイス上に送っておけばOK。

ということで、GPU認識の確認もいつもどおりの次のコマンドでOK。次のスクショのような感じで確認できます。

import torch
print(torch.cuda.is_available())
print(torch.cuda.device_count())
print(torch.cuda.get_device_name())

拍子抜けするほど簡単に、なんの問題もなく2枚のMI50(RadeonVII)が認識されました。

ファンスピードを固定する

まとめにも書いたが、RadeonVIIのVBIOSが焼かれているからかもしれないが、高負荷時に存在しないはずの表示上のファンスピードが極端に遅くなり、下限に達したためかけたたましいビープ音を鳴らしたことがありました。

結果、rocm-smiの --setfan でファンスピードを固定値にしたところ問題が解消。どのみちファンはGPUカードに刺さっていないのでそこそこ高め(私はとりあえず100に設定。255段階っぽい。)に設定しておけばOKだろう。

manpages.debian.org

次回予告

次回はP40とガチンコベンチマークバトルの予定!全部データは取ってあり記事を書くだけなので、なるべく早めに上げたいと考えています。

とりあえず簡潔に言うとMI50はP40に対してそんなに性能的優位は示せていない。とはいえ、VRAMいらず単にStableDiffusionを動かすだけとかならもしかしたら最もコスパの良いGPUになるかも?といった感じ。詳細は乞うご期待。

PyTorch/TensorFlowのトレーニング時にWSL2がハングする現象

結論

今回は結論のみ。

WSL2上でPyTorch/TensorFlowで画像モデルのトレーニングを行う際に、データローダーをマルチスレッドで動かすと、トレーニングがハングする現象が発生しました。

ある程度のイテレーションは学習が進むのですが、途中で突然プログレスバーが全く進まなくなるという感じの挙動です。

私のケースではWSL2がWindowsディレクトリ(/mnt/c 以下)にアクセスする際のパフォーマンス問題によって起こった現象のようでした。なんとなくキャッシュと言うかバッファみたいなのがあって、それを超えた読み書きが行われると止まる、みたいな挙動に見えます。

類似の現象は以下のIssueにも報告されており、これを参考にLinux側のディレクトリ(/home 下など)にデータをコピーしてからトレーニングを行うことで解決しました。

github.com

github.com

おそらく最近言語モデルしかトレーニングしてなかったので問題になっていなかったのだと思うのですが、WSL2である程度大きなデータを並列読込みするようなケースでは注意が必要そうです。

環境情報

wsl --version
WSL バージョン: 2.0.9.0
カーネル バージョン: 5.15.133.1-1
WSLg バージョン: 1.0.59
MSRDC バージョン: 1.2.4677
Direct3D バージョン: 1.611.1-81528511
DXCore バージョン: 10.0.25131.1002-220531-1700.rs-onecore-base2-hyp
Windows バージョン: 10.0.22621.2283

【PyTorch小ネタ】 複数モデルを組み合わせたモデルにおいて別々のPretrained Weightを読み込む方法

まえがき

時の流れは早いもので、もう2024年になってしまったようです。

最近はKaggleやら実践的な個人活動は一先ずお休みし、社会人5年目になる前に一旦基礎固めし直そうと線形代数/解析学/統計の勉強をしたり新しく出たBishop本を読んだりしており、あまりアウトプットするものがありませんでした。

bishopbook.com

物買ったネタもEPYC買ったりオールフラッシュNASを作ったり好き勝手した結果欲しい物が無くなってしまったので、本当にブログに書く内容がない状態。

とはいえたまには書いとかないとアレなので、ちょっとした小ネタで更新しておきます。

最近ちょっとあったケースで、既存の2つのモデルのEmbeddingモデルの出力をくっつけて、FC層に流して何らかの出力を得るようなモデルを作りたいという相談を受けました。

例えば、画像とキャプションテキストから記事のジャンルを分類する課題を考えたとき、画像に関してはEfficientNetで、テキストはBERTで埋め込みを取得して、その出力をconcatして分類するみたいな。

こういうとき、既存のPretrained Weightを正しく読み込むのにどうすればいいのか、PyTorchのStatedictをいじりながら見ていきます。

たまにモデルのパラメータを直接さわりたいときがあると思いますが、意外と柔軟に弄れるので経験しておくと役に立つかもしれません。この辺頑張ると下の記事みたいな応用例もあるかも。

logmi.jp

まとめ

下ではうだうだと例を以て説明を書いておりますが、モデル内の一部でウェイトを読み込みたい場合、モデルのインスタンス変数として対象のレイヤーを呼び出し、load_state_dictメソッドを利用します。

model.embedding1.load_state_dict(state_dict_a)

メソッドの公式ソースは下

pytorch.org

モデル定義

デモ用に簡単な2つのモデルを作ります。若干出力サイズだけ変えてありますがほぼ同じモデルです。

実際には片方が画像用、もう片方がテキスト用のモデルみたいなケースを想像しておいてください

import torch
import torch.nn as nn
import torch.nn.functional as F

class ModelA(nn.Module):
    def __init__(self):
        super(ModelA, self).__init__()
        self.conv1 = nn.Conv2d(3, 6, 5)
        self.pool = nn.MaxPool2d(2, 2)
        self.fc1 = nn.Linear(6 * 13 * 13, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 10)

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = x.view(-1, 6 * 13 * 13)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x
    
class ModelB(nn.Module):
    def __init__(self):
        super(ModelB, self).__init__()
        self.conv1 = nn.Conv2d(3, 6, 5)
        self.pool = nn.MaxPool2d(2, 2)
        self.fc1 = nn.Linear(6 * 13 * 13, 120)
        self.fc2 = nn.Linear(120, 84)
        self.fc3 = nn.Linear(84, 20)

    def forward(self, x):
        x = self.pool(F.relu(self.conv1(x)))
        x = x.view(-1, 6 * 13 * 13)
        x = F.relu(self.fc1(x))
        x = F.relu(self.fc2(x))
        x = self.fc3(x)
        return x
    

で、この2つのモデルを結合して利用するモデルを次のように定義します。

ここで設定するインスタンス変数名のembedding1embedding2が後々大事になります。

class CombinedModel(nn.Module):
    def __init__(self):
        super(CombinedModel, self).__init__()
        self.embedding1 = ModelA()
        self.embedding2 = ModelB()

        self.activation = nn.ReLU()
        self.fc1 = nn.Linear(30, 10)
        self.fc2 = nn.Linear(10, 1)

    def forward(self, x, y):
        x = self.embedding1(x) # [bs, 10]
        y = self.embedding2(y) # [bs, 20]

        z = torch.cat((x, y), 1) # [bs, 30]

        z = self.activation(self.fc1(z))
        z = self.fc2(z)
        return z

パラメータの操作

ModelAとModelBに関して、Weightを手動で割り当てるためにstate dictを作っていきます。

名前の通り、state dictはdict型でモデルの重みやOptimizerなどの状態を保存します。

今回は重みのみを保存しますので、単にLayer名をキーとして重みをバリューに持つDictを用意します。

本来は学習によって有意義な重みを学習するところですが、今回はシンプルにmodel_aは全部0,model_bは全部1の重みを設定します。

model_a = ModelA()
model_b = ModelB()

state_dict_a = {}
for param_tensor in model_a.state_dict():
    print(param_tensor, "\t", model_a.state_dict()[param_tensor].size())
    state_dict_a[param_tensor] = torch.zeros(model_a.state_dict()[param_tensor].size())

state_dict_b = {}
for param_tensor in model_b.state_dict():
    print(param_tensor, "\t", model_b.state_dict()[param_tensor].size())
    state_dict_b[param_tensor] = torch.ones(model_b.state_dict()[param_tensor].size())

printの出力は次のようになります。

conv1.weight     torch.Size([6, 3, 5, 5])
conv1.bias   torch.Size([6])
fc1.weight   torch.Size([120, 1014])
fc1.bias     torch.Size([120])
fc2.weight   torch.Size([84, 120])
fc2.bias     torch.Size([84])
fc3.weight   torch.Size([10, 84])
fc3.bias     torch.Size([10])
conv1.weight     torch.Size([6, 3, 5, 5])
conv1.bias   torch.Size([6])
fc1.weight   torch.Size([120, 1014])
fc1.bias     torch.Size([120])
fc2.weight   torch.Size([84, 120])
fc2.bias     torch.Size([84])
fc3.weight   torch.Size([20, 84])
fc3.bias     torch.Size([20])

作成したstate dictをモデルに読み込ませるには、load_state_dictメソッドを利用します。自前で作ったDictでも、ちゃんとキーとバリューのShapeが合っていれば読み込んでくれます。

また、保存の際にはtorch.saveを利用します。

model_a.load_state_dict(state_dict_a)
model_b.load_state_dict(state_dict_b)

torch.save(model_a.state_dict(), 'model_a.pth')
torch.save(model_b.state_dict(), 'model_b.pth')

CombinedModelにそれぞれの子モデルの重みを読み込む

CombinedModelのインスタンスを作成し、先程保存した.pthファイルを改めて読み込みます。

model = CombinedModel()
state_dict_a = torch.load('model_a.pth')
state_dict_b = torch.load('model_b.pth')

で、本題である子モデルの重みを読み込む方法ですが、単にモデルのインスタンス変数を読み出し、load_state_dictメソッドを利用します。

model.embedding1.load_state_dict(state_dict_a)
model.embedding2.load_state_dict(state_dict_b)

ちゃんと重みが更新されたかどうかを見てみます。embedding1には0、embedding1には1が格納されており、特に重みをロードしていないfc1などではランダムな値が入っており、意図通りの操作ができたことが確認できます。

print(next(model.embedding1.parameters())[0][:1])
print(next(model.embedding2.parameters())[0][:1])
print(next(model.fc1.parameters())[0])
tensor([[[0., 0., 0., 0., 0.],
         [0., 0., 0., 0., 0.],
         [0., 0., 0., 0., 0.],
         [0., 0., 0., 0., 0.],
         [0., 0., 0., 0., 0.]]], device='cuda:0', grad_fn=<SliceBackward0>)
tensor([[[1., 1., 1., 1., 1.],
         [1., 1., 1., 1., 1.],
         [1., 1., 1., 1., 1.],
         [1., 1., 1., 1., 1.],
         [1., 1., 1., 1., 1.]]], device='cuda:0', grad_fn=<SliceBackward0>)
tensor([-0.0896, -0.0968, -0.1777, -0.1720, -0.1266, -0.0351,  0.0431, -0.1412,
         0.1106,  0.0788, -0.0594,  0.1563,  0.0192, -0.1271,  0.0526, -0.1182,
        -0.0506, -0.1199, -0.0888,  0.0479, -0.0823,  0.1511,  0.1532,  0.0241,
         0.0064,  0.1728,  0.0547,  0.0753, -0.0233,  0.0545], device='cuda:0',
       grad_fn=<SelectBackward0>)

公式の説明は下。nn.Moduleのパラメータ以外、Optimizerなどのstate dict読み込みなどに関しても言及があります。

pytorch.org