端の知識の備忘録

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

【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

Transformers Trainerを使ってみて分かりにくかった仕様など

まえがき

言語モデルを自分でガッツリ使う経験が今まで無かったので、勉強がてら先週火曜日まで開催されていたKaggle - LLM Science Exam というコンペに参加してました。

www.kaggle.com

そこそこ頑張った結果、過去最高の成績(49th/2663, Top2%)を取ることができ、銀メダルを取ることができた。SolutionなどはKaggleの方に書いたのでそちらを。

www.kaggle.com

で、この記事で触れるのは、今まで機械学習モデルのトレーニングにはPyTorchの標準的なやり方(ループ内で推論してloss計算してloss.backward()で逆伝播させて……というやつ)をなるべく使ってきたのだが、最近流行り始めているっぽいHuggingFaceのTransformers Trainer系APIを全面的に利用してみたので、そのときに困ったこととかをまとめてみるというお話。

まとめ

特にHuggingFace Transformers使う上で困ったのは次の4点。

  • wandbなどのロギングサービスとの連携がいらないときは、TrainingArgumentで、report_to="none"("none"が文字列であることに注意)を指定する。
    • 最初ローカルだけでサクッと動かしたいだけなのに、このオプションがデフォルトで有効("all")になっていて少しイラッとした。wandbが使ったら便利なサービスなのはわかるけど、デフォルトは無効になっていてほしい……。
  • モデルの中身で何しているのかを見たいときには、GitHubここから対象のモデルのフォルダの、modeling_XXX.pyを確認する
    • AutoModelForSequentialClassification()で作られるモデルが実際どのようにForward passの処理をしているのか?とかがドキュメントに書いていないので、ソースを見て納得したいようなときの話
  • PEFTのLoRAでモデルのFineTuningを行ったのだが、学習させたモデルを読み込むとClassification層の重みが初期化されてしまう現象に遭遇。
    • 次のGitHub Issueみたいな感じの現象。当然、ランダムな重みのFC層を通った結果が出てくるので、推論結果はナンセンスなものになる。
    • 結局、LoRAの重みを別に読み込んで推論用モデルの重みを上書きするのが一番確実な方法だと思う。

github.com

  • remove_unused_columnsがTrueになっているTrainerにtrain_dataset, eval_datasetを渡すと、モデルのForwardに渡されないdatasetの要素が消し去られてしまい想定しないエラーにつながることがある。
    • remove_unused_columnsはデフォルトTrueである。要注意!
    • 私の場合、使わない要素を自前で省く処理を入れたDataCollatorをTrainerに渡していたのだが、これがコンフリクトしてエラーを起こしてしまった。外で普通のtorch.utils.data.DataLoaderで使う場合エラーが出なかったので、何が原因か分からず数時間浪費した。

HuggingFace系のライブラリは便利は便利なのだが、暗黙的に行われる処理の中身が分からず何かトラブったときの対応が難しいと言うのが難点。エラーメッセージも問題の根本を直接指し示すものとはならないことが多く、GitHubとかで類似のIssueを探したり、アドホックにprint仕込んでデバックしたりする局面が何回かあった。

とはいえ、PyTorchを直接書くよりはコードの量は格段に少なくなるし、PEFTによるファインチューニングやbitsandbytesによる量子化、DeepSpeedによるVRAM使用量削減のような自前でPyTorchと合わせて使うには少し手間がかかるライブラリを使いたいとき、基本的な使い方であればHuggingFaceのオプションに指定するだけで使えたりする利便性もある。

HuggingFaceは簡単そうに見えて色々わかっていないと問題発生時に困るライブラリなので、自前のデータセットでLLMトレーニングさせたい!みたいな場合まずPyTorchを少し勉強してから使うべきものという印象でした。

PEFT関連の注意点をもう少し

まとめで書いた「LoRAの重みを別に読み込んで推論用モデルの重みを上書きする」のやり方は次のような感じ。

peft_model_id = "/kaggle/input/mistral-7b-lora-seq/checkpoint-5000/checkpoint-5000"
base_model = "/kaggle/input/mistral-7b-v0-1-starter/Mistral-7B-v0.1/Mistral-7B-v0.1"
config = LoraConfig.from_pretrained(peft_model_id)
inference_model = AutoModelForSequenceClassification.from_pretrained(base_model, num_labels=5, quantization_config=bnb_config)

# ここでトレーニングさせたLoRAの重みを直接読み込む
adapters_weights = torch.load(os.path.join(peft_model_id, 'adapter_model.bin'))

# PeftModelで学習済みのLoRAのPATHを指定しても、分類層の重みが初期化されてしまう。
model = PeftModel.from_pretrained(inference_model, peft_model_id)

# PEFTモデルの分類層の重みをLoRAの重みで上書きする
model.score.original_module.weight = Parameter(adapters_weights['base_model.model.score.weight'])

また、PEFTでLoRAとかを実際どう学習させるかなどの実装例はここを見ると良い。ドキュメントにはさらっとしか書いていないのであまり役に立たない。

github.com

remove_unused_columnsの罠の例

# こんな感じのpreprocessで、['label', "input_ids", "attention_mask", "prompt"]を要素に持つデータセットを準備する
def preprocess(row, max_seq_length=2048):
    pre_context = tokenizer.bos_token + "[INST] Answer the given multiple choice Question using the Context provided. Contexts are automatically generated and may not be reliable. In such cases, ignore the context and answer! Answers must be a single letter of the alphabet, either A, B, C, D, or E. \nContext:"
    tokenized_pre_context = tokenizer(pre_context)
    len_tokens_pre_context = len(tokenized_pre_context["input_ids"])

    options = '\n Options: A: ' + row['A'] + ' B: ' + row['B'] + ' C: ' + row['C'] + ' D: ' + row['D'] + ' E: ' + row['E'] + "\n[/INST] Answer:"
    
    tokenized_options = tokenizer(options)
    len_tokens_options = len(tokenized_options["input_ids"])
    
    tokenized_prompt = tokenizer('\n Question: ' + row["prompt"])
    len_tokens_prompt = len(tokenized_prompt["input_ids"])
    
    context = row["third_context"] + "\n" + row["second_context"] + "\n" + row["first_context"]
    max_tokens_context = max_seq_length - len_tokens_prompt - len_tokens_pre_context - len_tokens_options
    tokenized_context = tokenizer(context, max_length=max_tokens_context, truncation=True)
    
    
    example_encoded = {
        "input_ids": torch.tensor(tokenized_pre_context["input_ids"] + tokenized_context["input_ids"] + tokenized_prompt["input_ids"] + tokenized_options["input_ids"]),
        "attention_mask": torch.tensor(tokenized_pre_context["attention_mask"] + tokenized_context["attention_mask"] + tokenized_prompt["attention_mask"] + tokenized_options["attention_mask"]),
        "prompt": pre_context + context  + '\n Question: ' + row["prompt"] + options
    }


    example_encoded['label'] = option_to_index[row['answer']]
    
    return example_encoded

#  _ = [feature.pop("prompt") for feature in features] という処理でforward passで利用しない"prompt"を省く処理をCollate Functionに入れているとする
@dataclass
class DataCollatorForMultipleChoice:
    tokenizer: PreTrainedTokenizerBase
    padding: Union[bool, str, PaddingStrategy] = True
    max_length: Optional[int] = 2048
    pad_to_multiple_of: Optional[int] = None
    
    def __call__(self, features):
        labels = [feature.pop("label") for feature in features]
        _ = [feature.pop("prompt") for feature in features]
        batch_size = len(features)

        flattened_features = [[{k: v for k, v in feature.items()} for feature in features]]
        flattened_features = sum(flattened_features, [])
        batch = self.tokenizer.pad(flattened_features, padding=self.padding, max_length=self.max_length,pad_to_multiple_of=self.pad_to_multiple_of,return_tensors="pt",)
        batch = {k: v.view(batch_size, -1) for k, v in batch.items()} 
        batch["labels"] = torch.tensor(labels, dtype=torch.int64)
        return batch

# 通常のtorch.utils.data.DataLoaderでこのCollate Functionを使うのは問題ない。
train_dataloader = torch.utils.data.DataLoader(tokenized_val_dataset, batch_size=2, collate_fn=collate)

# しかし、TrainerでDataCollatorForMultipleChoiceを利用するとエラーになる!暗黙的にモデルが利用する['label', "input_ids", "attention_mask"]以外の要素(今回の場合"prompt")が削除されるため。
trainer = Trainer(
    model=lora_model,
    args=training_args,
    train_dataset=tokenized_train_dataset,
    eval_dataset=tokenized_val_dataset,
    tokenizer=tokenizer,
    data_collator=DataCollatorForMultipleChoice(tokenizer=tokenizer),
    compute_metrics=compute_metrics,
)

#これを防ぐためには、TrainingArgumentsでremove_unused_columns=False を指定する。

training_args = TrainingArguments(
...
    remove_unused_columns=False,
...
)

Ryzen9 7950X3D用にAssassin IVを買ってみた

まえがき

Ryzen9 7950X3Dを買ったのがちょうど半年前になるのだが、CPUクーラーは5600X用に購入したScytheの忍者5を流用して使っていた。

忍者5は非常に静かで安い割にそこそこ冷えるのでお気に入りであったのだが、流石に7950X3Dを冷やすには不十分でCinebenchを回すときにサーマルスロットリングに引っかかっているのが気がかりだった。

今年のComputexでNoctuaの次世代NH-D15が発表されたのでこれが出たら買い換えようと思っていたのだが、発売は来年第2四半期予定とのことでちょっと先の話になりそう。

そろそろPCの掃除がてらクーラーを交換したいというところで、ちょうどこの間DeepCoolの新作CPUクーラー Assassin IVが出たので、とりあえずこっちを買って試してみることにしたというお話。

www.aiuto-jp.co.jp

まとめ

  • Assassin IVとPCケースのTorrentとの組み合わせであれば、空冷でも7950X3Dを十分冷やせる性能がある。

    • 明確に忍者5よりも温度低下が見られ、Cinebench中でも特にサーマルスロットリングに引っかかるようなこともなくなり、しっかり170W近い消費電力を処理できている。
    • ケースの遮音性が低いこともあるが、音はフル回転だとそこそこ気になる。ただ、ちゃんとファンコントロールしてあげて低速回転にすればファン音は気にならない
  • Assassin IVは相当でかいし重い。しかし、ちゃんとマザーのヒートシンクやメモリとの干渉が無いように設計されているので高さにさえ気をつければ取り付けは問題ないと思う。

  • 間違いなく空冷最高レベルの冷却性能だが、値段が現状1.5万前後と非常に高価なので、正直値段に見合っているかといえばノー

    • 安めのAIO 360mm水冷買える値段だし、空冷ならもっとコストと性能のバランスの良いものはあるだろう。それこそ同じDeepCoolのAK500や620、最近出たScytheのFUMA3など、適当なミドルハイ空冷でも7950X3Dであれば十分性能は出ると思う。
    • よほど見た目が気に入ったとかでなければ妥協して別のを買ったほうが幸せになれると思う。
  • Cinebench R23実行時のCPU温度をCore Tempを利用して測定。ログの結果をChatGPTのCode Interpreterにグラフ化させました。

環境

  • PCケース:Fractal Design Torrent
  • 電源: CoolerMaster MWE GOLD 1250 V2
  • マザーボード: MSI X670-P WIFI
  • メモリ: Micron 4800MHz 2x32GB
  • CPU: Ryzen9 7950X3D
  • GPU: ZOTAC RTX4090 Trinity OC
  • SSD1: ADATA XPG GAMMIX S70
  • SSD2: Micron MX500 1TB
  • CPUクーラー: Scythe 忍者5 or DeepCool Assassin IV

ギャラリー

梱包は他のDeepCool製品と似た感じで、茶色のボール箱に白を基調とした外箱のスタイル。

付属品はIntel, AMD用のマウントと工具(ドライバーとレンチ)、DM9というグリス。このDM9に関してはデータシートが見つからなかったものの工業グレードの製品との触れ込み。今回は家で余っていたMX-6を使ったので未使用です。

忍者5との比較。形状自体は忍者5と似た立方体に近い形状。単体で見るとシュッとしているデザインのため僅かに小さく感じるのだが、他のCPUクーラーと並べるとやはりデカいことがわかる。

フルタワーのTorrentに組み込んだらこんな感じ。ほぼマザーのPCIeスロットより上の部分が覆い隠される程の大きさです。買うときはサイズをしっかり確かめましょう。

ちなみに、更にメモリスロット側の方にもう1基ファンを追加することも可能です。やらんけど。

測定結果

ベンチマークとしてはCineBench R23を実行。スコアは次の通り、忍者5では微妙にサーマルスロットリングが発生しておりスコアが落ち込んでいたのだが、Assassin IVではそれが解消されて若干ながら高いスコアを出すことができた。

忍者5 Assassin IV
34815 35208

また、Core TempCineBench実行中の温度のログを取得。結果はCSVで出力されるのだが……

CPUID:,0xA60F12
Processor:,AMD Ryzen 9 7950X3D 16-Core (Raphael) 
Platform:,Socket AM5 (LGA 1718)
Revision:,
Lithography:,5nm

Session start:,20:38:19 - September 04 - 2023

Time,Core 0 Temp. (°),Core 1 Temp. (°),Core 2 Temp. (°),Core 3 Temp. (°),Core 4 Temp. (°),Core 5 Temp. (°),Core 6 Temp. (°),Core 7 Temp. (°),Core 8 Temp. (°),Core 9 Temp. (°),Core 10 Temp. (°),Core 11 Temp. (°),Core 12 Temp. (°),Core 13 Temp. (°),Core 14 Temp. (°),Core 15 Temp. (°),,Core 0,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 1,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 2,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 3,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 4,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 5,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 6,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 7,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 8,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 9,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 10,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 11,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 12,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 13,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 14,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),Core 15,Low temp. (°),High temp. (°),Core load (%),Core speed (MHz),CPU 0 Power,
20:38:29 09/04/23,89,89,89,89,89,89,89,89,89,89,89,89,89,89,89,89,,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4699.87,,43,89,100,4699.87,,43,89,100,4699.87,141.8,
20:38:39 09/04/23,89,89,89,89,89,89,89,89,89,89,89,89,89,89,89,89,,,43,89,100,4699.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4674.87,,43,89,100,4624.87,,43,89,100,4624.87,,43,89,100,4624.87,,43,89,100,4624.87,,43,89,100,4599.87,,43,89,100,4599.87,,43,89,100,4624.87,,43,89,100,4624.87,,43,89,100,4624.87,,43,89,100,4624.87,,43,89,100,4624.87,141.2,

...以下10秒ごとの測定結果...

Session end:,20:49:02 - September 04 - 2023

こんな感じの前後にメタデータが入っててフォーマットめちゃくちゃ、エンコードは何故かWindows 1252、欠損値あり、やけに列多いという面倒なCSVファイルだったので自分でグラフ化する気がなくなってしまった。

ということで、ChatGPTのCode Interpreterに処理させることに。

こんなファイルを渡してもChatGPTは健気に処理してくれまして、次のようなグラフを作成してくれました。縦軸は全コア温度の平均値(℃)、横軸は時間(分)です。

これを見ていただいてわかるように、忍者5ではリミットの90度に張り付いてしまっていたのが、Assassin IVではちゃんと90度以下をキープできています。まあ1.5万円もするクーラーなのでこのくらいやってもらわなければ困るのですが、とりあえず懸念となっていた温度問題も解決できたし久しぶりにPCの掃除もできたしまあ良しとします。

ちなみにChatGPTが作ってくれたCore Tempのログパース用コードは次のような感じ。何度かエラーに遭遇しながらも、方針を示せば後は勝手に試行錯誤してくれるので非常に楽。機密じゃないデータかつある程度枯れたライブラリでできる解析に関しては、もはや自分でやる必要ないなと改めて実感しました。

with open(file_path, 'r', encoding='ISO-8859-1') as file:
    total_lines = sum(1 for line in file)

from datetime import datetime

# 日時のパース関数を更新して非標準的な行を処理
def parse_date(x):
    try:
        return datetime.strptime(x, '%H:%M:%S %m/%d/%y')
    except ValueError:
        return pd.NaT

# 最後の行を除外してデータを読み込む
data = pd.read_csv(file_path, encoding='ISO-8859-1', skiprows=8, nrows=total_lines-9, parse_dates=[0], date_parser=parse_date)

data.set_index('Time', inplace=True)

# スタート時を0として、時間を分単位で計算
data.index = (data.index - data.index[0]).total_seconds() / 60

【続・続】鯖落ちGPUを使った安価な機械学習用マシンの作り方 ~憧れのEPYCを購入して機械学習用マシンを完全体にする編~

前記事:

hashicco.hatenablog.com

前書き

前記事において、P40を買い増して機械学習用マシンをマルチGPUにした。

しかし、普通のRyzenで構成していたがために、PCIeの帯域が足りず片方のGPUがx4接続になってしまっていたのが若干心残りとなっていた。

そこで、EPYCとSupermicroのマザーを中古で買ってみたという話。

まともな帯域とセッティングで2GPUを接続してベンチを取ってみたところ、そこそこマルチGPUの良さが見えてきたので本記事に記しておく。

まとめ

  • 今回購入したのは1ソケット用32Coreの初代Zen 7551Pと対応マザーSupermicro H11SSL-i、そして8x32GBのECC REGメモリのセット。
    • ebayでtugm4470という出品者から$653.60で購入できました。32コアCPU、マザー、メモリ256GBでも10万切るのは素晴らしい。もう少し円高ならなお良かったのだが……。
    • 本当はZen2にしたかったが、コアあたりの単価が倍くらいしたので今回はコスト重視で初代Zenを選択
    • 本題からずれるが、最近異常に中国からの物流が速く驚かされる。前回の2枚目のP40も5日くらいで届いたのだが、今回の商品も注文してから4日程度で届いてびっくりした。

www.ebay.com

  • 本記事の主題である、安価な機械学習用マシンに対する回答としてコストを書いておくと、2xP40, 32core 7551P, 256GB RAMというかなりリッチなマシンではあるものの、電源やSSD代含めてでも18万円程度でこのマシンは組めるはず。

    • 内訳は、CPUマザーメモリとクーラーセット: 10万、GPU: 3万x2、1000W電源: 1.5万、1TB SSD: 5000円くらいを想定。
    • まあ普通に考えればGoogle Colab (Pro)やKaggleのノートブック機能で安価or無料のリソースを有効活用する方が良いと思うが、自宅にEPYCやら256GBのメモリやらマルチなサーバー用GPUがあるのは何事にも代えがたいという同士の方であれば、自己責任で組んでみるのにも現実的な価格と言えよう。
  • H11SSL-iのようなサーバーマザーでメモリが一部認識されないような場合、PCケースのスペーサー(マウントネジ)の位置を確認すること。通常のATXマザーとネジ止めの位置が異なる場合があり、基板裏の端子がスペーサーによってショートしている可能性がある。

    • 原因が分からず小1時間格闘していたとき、Redditでそのものずばりのスレッドがあり解決。まじで助かった。
    • マザーのサイズを変えたときにスペーサーの位置を変え忘れるとかで通常の自作用マザーでも稀にこういうことはあるので注意。
  • 平積みマシンにはサーバー用マシン、というかSupermicro製マザーがとても便利なことが判明。

    • ASPEEDのグラフィック出力がついており余計に画面出力用GPUをセットアップ時に付ける必要がない
    • なによりSupermicroのIPMIという管理機能が使えるためブラウザからマシンの起動停止、温度センサー情報の確認、リモートコンソール経由でブラウザ上でもBIOS設定やUbuntuのデスクトップの表示ができ、半端なく便利。
    • トラブルシューティング時モバイルモニターをいちいち接続する手間がなくなるので最高。
  • 前回記事においては、片方のGPUのみECC機能が有効になっており、利用可能なVRAM量が少なく性能にも影響があったことが判明ECC機能をオフにしてまともなマルチGPUのベンチを取り直した。

  • 前回と同様、GPT2トレーニングとllama.cppでマルチVSシングルのGPU性能比較を実施。十分GPUを2枚にした効果が見られたと思う。

    • 結果的に、GPT2トレーニングにおいては、レーニング時間はシングルの方が短いがtrain_samples_per_secondはマルチのほうが1.9倍ほど高い結果が得られた。
    • llama.cppにおいてはマルチGPUにすることで約1.2倍のトークン生成速度となった。

外観など

こんな箱で届きました。中にはEPYCがセットされたマザーと、個々にブリスターパックされたDDR4メモリが8枚入っていました。

中身というと、緑色の無骨なPCB基板にたくさんのDIMM、PCIeスロットが並び余すことなくチップと配線が敷き詰められたマザー、SP3ソケットに収められたヒートスプレッダ上に踊る輝かしきEPYCの文字、両面いっぱいに2列でDRAMチップが取り付けられた32GBのECC REG DIMMモジュールと、自作趣味の心が踊らずにはいられないセットである。

これにメルカリで中古で入手した適当な240mmのCPUクーラーを取り付け、適当にATX電源を接続することで普通の自作PCと同じく起動可能な状態となります。

一点普通の自作PCと異なるのは、マザーに管理用チップ(BMC)が別付けで取り付けられているため、マシンを起動せずとも様々な操作をLAN経由で実施可能なこと。これの素晴らしさはセットアップの項にて後述します。

元々EPYCをいつか自宅で動かしたいという憧れを持っていたので、大人になってようやくその願望が叶えられた形である。海外通販で中古商品を買うのに10万近い金額を出すのはだいぶ躊躇いがありましたが、学生の時からの夢ですし財布の紐が多少緩むのは仕方ないと度重なる出費に理由をつけて買ってしまいました。

セットアップ

とても便利なIPMI

このマザーではIPMIを利用することで、全くディスプレイを繋ぐことなくBIOSをいじったりOSインストールをすることが可能です!

www.fanatic.co.jp

厳密に言えば最初のIPMIのIPアドレスだけはDHCPで払い出されたものを予想して当てるか、一旦ディスプレイに繋いでPOST画面右下で確認するか、同じくディスプレイに一旦繋いでBIOS設定で静的IPアドレスを与える必要がありますが、一回IPMIにアクセスできてしまえば次のような管理画面で様々な操作が可能です。

ちなみに、IPMIログインのための初期ユーザーはADMINで、パスワードはマザーボード上のシールに印字されたPWDの文字列となります。

本マシンのように基本ディスプレイを繋がずリモートで使うマシンにおいて、初期設定やトラブルシューティングのためにいちいちKVMを物理で繋いで対応するのは非常に面倒であるが、IPMIがあればLANケーブルだけ繋げば後はいつものPCのブラウザからほぼすべての作業が行えるので大変便利です。

今回はこのIPMIのバーチャルKVM機能を使ってUbuntuのインストールや静的IPアドレス設定まで実施して、残りはSSH接続でCUDAやPython関連のセットアップを実施することができました。

ちなみにCUDAのセットアップは特にマルチGPUだから何か違うということはなく、普通にNVIDIAの公式サイト通りに実施して問題なくできました。

https://docs.nvidia.com/cuda/cuda-installation-guide-linux/index.html

DRAMが1枚認識されないトラブル

また、しばらく何故かメモリが1枚認識されず、計224GBとして認識される問題に小一時間悩まされました。

何度DRAMを入れ替え刺し直しても状況は変わらずマザーの故障を疑い始めた頃、Redditでドンピシャなスレッドを発見。どうやらPCケースのマザーボードマウント用の突起がマザー裏の端子と接触することでこういう症状が起こることがあるという内容。

https://www.reddit.com/r/homelab/comments/zi0xph/epyc_not_detecting_all_ram/

自分の環境でも正しく同じ状況となっており、8つ目のDIMMスロット裏に接触していたマウント用ネジを外したところ問題なく8枚のメモリが認識され、計256GBとしてBIOSからもOSからも見えるようになりました!マジでRedditには感謝です。

GPUECC機能が片方だけオンになっていた件

前回の記事で気づいていなかったのですが、nvidia-smiの結果でみると2枚のP40のVRAM量が異なるように表示されていたようである。

ubuntu@ubuntu-Super-Server:~$ nvidia-smi
Fri Aug 11 15:37:26 2023
+---------------------------------------------------------------------------------------+
| NVIDIA-SMI 535.86.10              Driver Version: 535.86.10    CUDA Version: 12.2     |
|-----------------------------------------+----------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |         Memory-Usage | GPU-Util  Compute M. |
|                                         |                      |               MIG M. |
|=========================================+======================+======================|
|   0  Tesla P40                      On  | 00000000:06:00.0 Off |                  Off |
| N/A   28C    P8              10W / 250W |      4MiB / 24576MiB |      0%      Default |
|                                         |                      |                  N/A |
+-----------------------------------------+----------------------+----------------------+
|   1  Tesla P40                      On  | 00000000:21:00.0 Off |                    0 |
| N/A   28C    P8               9W / 250W |      4MiB / 23040MiB |      0%      Default |
|                                         |                      |                  N/A |
+-----------------------------------------+----------------------+----------------------+

GPU毎のnvidia-smi -aの結果をdiffしてみると、あとから買った方のP40のみECC Modeが有効になっていることが判明。

天下り的に考えてみると、23040MB/24576MB=0.9375=15/16なので、16bitのうち1bitをパリティビットとして利用しているのだと思われる。

また、nvidia-smiでnvidia-smi -g GPU番号 --ecc-config=0/1とすることで、ecc機能の有効無効を切り替えることができると判明(再起動が必要)。

https://thelinuxcluster.com/2013/07/24/turning-off-and-on-ecc-ram-for-nvidia-gp-gpu-cards/

ということで、#1 GPUecc機能をオフにしてみると、ちゃんとVRAM量が同じになりました!

ubuntu@ubuntu-Super-Server:~$ nvidia-smi
Sat Aug 12 13:10:17 2023
+---------------------------------------------------------------------------------------+
| NVIDIA-SMI 535.86.10              Driver Version: 535.86.10    CUDA Version: 12.2     |
|-----------------------------------------+----------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id        Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |         Memory-Usage | GPU-Util  Compute M. |
|                                         |                      |               MIG M. |
|=========================================+======================+======================|
|   0  Tesla P40                      On  | 00000000:06:00.0 Off |                  Off |
| N/A   24C    P8               9W / 250W |      4MiB / 24576MiB |      0%      Default |
|                                         |                      |                  N/A |
+-----------------------------------------+----------------------+----------------------+
|   1  Tesla P40                      On  | 00000000:21:00.0 Off |                  Off |
| N/A   23C    P8               9W / 250W |      4MiB / 24576MiB |      0%      Default |
|                                         |                      |                  N/A |
+-----------------------------------------+----------------------+----------------------+

後ほど言及しますが、ECC機能がオンになっていると性能が約1割ほど下がる模様。前回測定したときには片方がECCオン、もう片方がオフになっているアンバランスな状態で、マルチGPUでの性能測定時にも当然遅い方に結果が引っ張られますので、前記事の結果は参考程度にみていただければと思います(その旨すでに記事に反映済みです)。

今回のマルチGPUの性能測定は、ちゃんと両GPUともECCをオフにして実行しました。

Transformers公式のDDPチュートリアル

前回同様、次のページのDDPを利用したGPT2トレーニングのサンプルを利用して、トレーニングにかかる時間とtrain_samples_per_secondの値を比較。詳細なセットアップに関しては前記事参照。

https://huggingface.co/docs/transformers/perf_train_gpu_many

- 2GPU 1GPU(w/o ECC) 1GPU(w ECC)
Training Time(s) 186 175 195
train_samples_per_second 8.6 4.57 4.097

結果、Training TimeはマルチGPUでは改善しないものの、train_samples_per_second1GPUのときより1.9倍近い値となっていた。前回同様予測の域を出ないが、やはりGPU間におけるGradientsの値の共有に余計な時間がかかっているせいでTraining時間が改善しないのであろうか。

マルチGPUでモデルトレーニングを高速化するのは今後の宿題としたい。あまり詳しくないが、ZeROのような別の分散学習方法を採用したり、DeepSpeedのようなライブラリの使用でなんとかなる気もする。

また、先程言及したように、ECCを有効化した状態でのシングルGPUの性能もみてみる。すると、演算性能も1割程度低いことが判明。特にECC機能が必要ない場合はオフにするのが良さそう。

マルチGPU

NCCL_P2P_DISABLE=1 CUDA_VISIBLE_DEVICES=0,1 torchrun --nproc_per_node 2 examples/pytorch/language-modeling/run_clm.py --model_name_or_path gpt2 --dataset_name wikitext --dataset_config_name wikitext-2-raw-v1 --do_train --output_dir ~/tmp/test-clm --per_device_train_batch_size 4 --max_steps 200

***** train metrics *****
  epoch                    =       0.69
  train_loss               =      3.279
  train_runtime            = 0:03:06.03
  train_samples            =       2318
  train_samples_per_second =        8.6
  train_steps_per_second   =      1.075

シングルGPU(w/o ECC)

NCCL_P2P_DISABLE=1 CUDA_VISIBLE_DEVICES=0 torchrun --nproc_per_node 1 examples/pytorch/language-modeling/run_clm.py --model_name_or_path gpt2 --dataset_name wikitext --dataset_config_name wikitext-2-raw-v1 --do_train --output_dir ~/tmp/test-clm --per_device_train_batch_size 4 --max_steps 200
***** train metrics *****
  epoch                    =       0.34
  train_loss               =     3.3276
  train_runtime            = 0:02:54.99
  train_samples            =       2318
  train_samples_per_second =      4.571
  train_steps_per_second   =      1.143

シングルGPU(w/ ECC)

CUDA_VISIBLE_DEVICES=1 torchrun --nproc_per_node 1 examples/pytorch/language-modeling/run_clm.py --model_name_or_path gpt2 --dataset_name wikitext --dataset_config_name wikitext-2-raw-v1 --do_train --output_dir ~/tmp/test-clm --per_device_train_batch_size 4 --max_steps 200
***** train metrics *****
  epoch                    =       0.34
  train_loss               =     3.3276
  train_runtime            = 0:03:15.25
  train_samples            =       2318
  train_samples_per_second =      4.097
  train_steps_per_second   =      1.024

llama.cpp でマルチGPUとシングルGPUを比較する

前回同様、CUBLASを有効化してllama.cppをコンパイルし、8bit量子化されたLLaMa2 13b(llama-2-13b-chat.ggmlv3.q8_0.bin)のモデルをCPUのみ、GPU1枚、GPU2枚で動作させたときのトークン生成速度(eval time)を比較しました。

CPUのみにするときは-nglの値を0とし、GPU枚数のコントロールはコマンド実行時のCUDA_VISIBLE_DEVICESへ渡す値を変えることで行います。

ついでにCPUの性能も比較したいので、前回測定したCPU(5900X)の結果も併記します。

CPU(5900X) CPU(7551P) 2GPU 1GPU(w/o ECC) 1GPU(w ECC)
2.76 1.24 8.51 7.01 6.68

結果、マルチGPUのほうが1.2倍くらい1GPUよりも速く推論できるとの結果に。

また、やはりGPUECCを有効化すると若干性能が下がるというのはllama.cppにおいても同様の結果が得られた。

CPUに関してはコア数が増えた上(12コア→32コア)、メモリチャネルも8チャネルとなり帯域が増したはず(3200MHz * 2 channel * 8B = 51.2GB/s → 2133MHz * 8 channel * 8B = 136.5GB/s)であるが、やはり世代の差は埋められないのか、あまりマルチコアの性能をllama.cppが出し切れないのか、残念ながら7551Pでは5900Xの半分以下のトークン生成速度しか出ない結果となった。

マルチGPU

(llama2) ubuntu@ubuntu-Super-Server:~/Documents/llama.cpp$ ./build/bin/main -m ./models/llama-2-13b-chat.ggmlv3.q8_0.bin --temp 0.1 -p "### Instruction: Create poetry about Mount Fuji in 200 words
### Response:" -ngl 40 -b 512
main: build = 975 (9ca4abe)
main: seed  = 1691814871
ggml_init_cublas: found 2 CUDA devices:
  Device 0: Tesla P40, compute capability 6.1
  Device 1: Tesla P40, compute capability 6.1
llama.cpp: loading model from ./models/llama-2-13b-chat.ggmlv3.q8_0.bin
llama_model_load_internal: format     = ggjt v3 (latest)
llama_model_load_internal: n_vocab    = 32000
llama_model_load_internal: n_ctx      = 512
llama_model_load_internal: n_embd     = 5120
llama_model_load_internal: n_mult     = 256
llama_model_load_internal: n_head     = 40
llama_model_load_internal: n_head_kv  = 40
llama_model_load_internal: n_layer    = 40
llama_model_load_internal: n_rot      = 128
llama_model_load_internal: n_gqa      = 1
llama_model_load_internal: rnorm_eps  = 5.0e-06
llama_model_load_internal: n_ff       = 13824
llama_model_load_internal: freq_base  = 10000.0
llama_model_load_internal: freq_scale = 1
llama_model_load_internal: ftype      = 7 (mostly Q8_0)
llama_model_load_internal: model size = 13B
llama_model_load_internal: ggml ctx size =    0.11 MB
llama_model_load_internal: using CUDA for GPU acceleration
ggml_cuda_set_main_device: using device 0 (Tesla P40) as main device
llama_model_load_internal: mem required  =  698.16 MB (+  400.00 MB per state)
llama_model_load_internal: allocating batch_size x (640 kB + n_ctx x 160 B) = 360 MB VRAM for the scratch buffer
llama_model_load_internal: offloading 40 repeating layers to GPU
llama_model_load_internal: offloaded 40/43 layers to GPU
llama_model_load_internal: total VRAM used: 13218 MB
llama_new_context_with_model: kv self size  =  400.00 MB

system_info: n_threads = 32 / 64 | AVX = 1 | AVX2 = 1 | AVX512 = 0 | AVX512_VBMI = 0 | AVX512_VNNI = 0 | FMA = 1 | NEON = 0 | ARM_FMA = 0 | F16C = 1 | FP16_VA = 0 | WASM_SIMD = 0 | BLAS = 1 | SSE3 = 1 | VSX = 0 |
sampling: repeat_last_n = 64, repeat_penalty = 1.100000, presence_penalty = 0.000000, frequency_penalty = 0.000000, top_k = 40, tfs_z = 1.000000, top_p = 0.950000, typical_p = 1.000000, temp = 0.100000, mirostat = 0, mirostat_lr = 0.100000, mirostat_ent = 5.000000
generate: n_ctx = 512, n_batch = 512, n_predict = -1, n_keep = 0


 ### Instruction: Create poetry about Mount Fuji in 200 words
### Response:

Mount Fuji, a majestic sight
A symbol of Japan, pure and bright
Its snow-capped peak, a beacon of light
Against the blue sky, a wondrous sight

In spring, cherry blossoms bloom
Around its base, a colorful boom
Summer brings green forests alive
Autumn paints the mountain with gold and red
Winter's snow, a serene delight

Mount Fuji, a constant presence
A source of inspiration, a sacred presence
Its beauty, a reflection of the divine
A reminder of nature's splendor and design [end of text]

llama_print_timings:        load time =  2580.63 ms
llama_print_timings:      sample time =   157.57 ms /   142 runs   (    1.11 ms per token,   901.20 tokens per second)
llama_print_timings: prompt eval time =   355.85 ms /    22 tokens (   16.18 ms per token,    61.82 tokens per second)
llama_print_timings:        eval time = 16562.81 ms /   141 runs   (  117.47 ms per token,     8.51 tokens per second)
llama_print_timings:       total time = 17134.59 ms

シングルGPU(w/o ECC)

(llama2) ubuntu@ubuntu-Super-Server:~/Documents/llama.cpp$ CUDA_VISIBLE_DEVICES=0 ./build/bin/main -m ./models/llama-2-13b-chat.ggmlv3.q8_0.bin --temp 0.1 -p "### Instruction: Create poetry about Mount Fuji in 200 words
### Response:" -ngl 40 -b 512
main: build = 975 (9ca4abe)
main: seed  = 1691809687
ggml_init_cublas: found 1 CUDA devices:
  Device 0: Tesla P40, compute capability 6.1
llama.cpp: loading model from ./models/llama-2-13b-chat.ggmlv3.q8_0.bin
llama_model_load_internal: format     = ggjt v3 (latest)
llama_model_load_internal: n_vocab    = 32000
llama_model_load_internal: n_ctx      = 512
llama_model_load_internal: n_embd     = 5120
llama_model_load_internal: n_mult     = 256
llama_model_load_internal: n_head     = 40
llama_model_load_internal: n_head_kv  = 40
llama_model_load_internal: n_layer    = 40
llama_model_load_internal: n_rot      = 128
llama_model_load_internal: n_gqa      = 1
llama_model_load_internal: rnorm_eps  = 5.0e-06
llama_model_load_internal: n_ff       = 13824
llama_model_load_internal: freq_base  = 10000.0
llama_model_load_internal: freq_scale = 1
llama_model_load_internal: ftype      = 7 (mostly Q8_0)
llama_model_load_internal: model size = 13B
llama_model_load_internal: ggml ctx size =    0.11 MB
llama_model_load_internal: using CUDA for GPU acceleration
llama_model_load_internal: mem required  =  698.16 MB (+  400.00 MB per state)
llama_model_load_internal: allocating batch_size x (640 kB + n_ctx x 160 B) = 360 MB VRAM for the scratch buffer
llama_model_load_internal: offloading 40 repeating layers to GPU
llama_model_load_internal: offloaded 40/43 layers to GPU
llama_model_load_internal: total VRAM used: 13218 MB
llama_new_context_with_model: kv self size  =  400.00 MB

system_info: n_threads = 32 / 64 | AVX = 1 | AVX2 = 1 | AVX512 = 0 | AVX512_VBMI = 0 | AVX512_VNNI = 0 | FMA = 1 | NEON = 0 | ARM_FMA = 0 | F16C = 1 | FP16_VA = 0 | WASM_SIMD = 0 | BLAS = 1 | SSE3 = 1 | VSX = 0 |
sampling: repeat_last_n = 64, repeat_penalty = 1.100000, presence_penalty = 0.000000, frequency_penalty = 0.000000, top_k = 40, tfs_z = 1.000000, top_p = 0.950000, typical_p = 1.000000, temp = 0.100000, mirostat = 0, mirostat_lr = 0.100000, mirostat_ent = 5.000000
generate: n_ctx = 512, n_batch = 512, n_predict = -1, n_keep = 0


 ### Instruction: Create poetry about Mount Fuji in 200 words
### Response:

Majestic Mount Fuji, a snow-capped wonder,
Rises high above the clouds and thunder.
Its peak soars to the sky, serene and still,
A symbol of strength, beauty, and will.

In springtime, cherry blossoms bloom around,
Painting the mountain with colors profound.
Summer skies are bright and clear,
Reflecting the mountain's grandeur here.

Autumn leaves change to shades of gold,
As nature's canvas, stories untold.
Winter snows blanket the peak,
A peaceful sight, a season to seek.

Through every season, Mount Fuji stands,
A constant presence, a sacred land.
Its beauty inspires and humbles the soul,
A source of wonder, a goal to strive for whole. [end of text]

llama_print_timings:        load time =  2654.84 ms
llama_print_timings:      sample time =   220.07 ms /   195 runs   (    1.13 ms per token,   886.08 tokens per second)
llama_print_timings: prompt eval time =   540.22 ms /    22 tokens (   24.56 ms per token,    40.72 tokens per second)
llama_print_timings:        eval time = 27682.73 ms /   194 runs   (  142.69 ms per token,     7.01 tokens per second)
llama_print_timings:       total time = 28521.56 ms

シングルGPU(w/ ECC)

(llama2) ubuntu@ubuntu-Super-Server:~/Documents/llama.cpp$ CUDA_VISIBLE_DEVICES=1 ./build/bin/main -m ./models/llama-2-13b-chat.ggmlv3.q8_0.bin --temp 0.1 -p "### Instruction: Create poetry about Mount Fuji in 200 words
### Response:" -ngl 40 -b 512
main: build = 975 (9ca4abe)
main: seed  = 1691810359
ggml_init_cublas: found 1 CUDA devices:
  Device 0: Tesla P40, compute capability 6.1
llama.cpp: loading model from ./models/llama-2-13b-chat.ggmlv3.q8_0.bin
llama_model_load_internal: format     = ggjt v3 (latest)
llama_model_load_internal: n_vocab    = 32000
llama_model_load_internal: n_ctx      = 512
llama_model_load_internal: n_embd     = 5120
llama_model_load_internal: n_mult     = 256
llama_model_load_internal: n_head     = 40
llama_model_load_internal: n_head_kv  = 40
llama_model_load_internal: n_layer    = 40
llama_model_load_internal: n_rot      = 128
llama_model_load_internal: n_gqa      = 1
llama_model_load_internal: rnorm_eps  = 5.0e-06
llama_model_load_internal: n_ff       = 13824
llama_model_load_internal: freq_base  = 10000.0
llama_model_load_internal: freq_scale = 1
llama_model_load_internal: ftype      = 7 (mostly Q8_0)
llama_model_load_internal: model size = 13B
llama_model_load_internal: ggml ctx size =    0.11 MB
llama_model_load_internal: using CUDA for GPU acceleration
llama_model_load_internal: mem required  =  698.16 MB (+  400.00 MB per state)
llama_model_load_internal: allocating batch_size x (640 kB + n_ctx x 160 B) = 360 MB VRAM for the scratch buffer
llama_model_load_internal: offloading 40 repeating layers to GPU
llama_model_load_internal: offloaded 40/43 layers to GPU
llama_model_load_internal: total VRAM used: 13218 MB
llama_new_context_with_model: kv self size  =  400.00 MB

system_info: n_threads = 32 / 64 | AVX = 1 | AVX2 = 1 | AVX512 = 0 | AVX512_VBMI = 0 | AVX512_VNNI = 0 | FMA = 1 | NEON = 0 | ARM_FMA = 0 | F16C = 1 | FP16_VA = 0 | WASM_SIMD = 0 | BLAS = 1 | SSE3 = 1 | VSX = 0 |
sampling: repeat_last_n = 64, repeat_penalty = 1.100000, presence_penalty = 0.000000, frequency_penalty = 0.000000, top_k = 40, tfs_z = 1.000000, top_p = 0.950000, typical_p = 1.000000, temp = 0.100000, mirostat = 0, mirostat_lr = 0.100000, mirostat_ent = 5.000000
generate: n_ctx = 512, n_batch = 512, n_predict = -1, n_keep = 0


 ### Instruction: Create poetry about Mount Fuji in 200 words
### Response:

Oh, majestic Mount Fuji, so serene and divine,
Your snow-capped peak touches the sky, so fine.
In the distance, a haze of blue,
A gentle breeze rustles through.

Your slopes are blanketed in green,
A carpet of life, so serene.
The trees stand tall and proud,
Their leaves rustling, a gentle sound.

In the shadow of your greatness,
I am but a small part,
A tiny speck in your grand design,
Yet, I am filled with awe and wonder at your heart.

Oh, Mount Fuji, you are a symbol of strength and grace,
A beacon of hope, a source of light,
In your presence, I find peace,
And my spirit takes flight. [end of text]

llama_print_timings:        load time =  2480.95 ms
llama_print_timings:      sample time =   204.16 ms /   185 runs   (    1.10 ms per token,   906.13 tokens per second)
llama_print_timings: prompt eval time =   605.07 ms /    22 tokens (   27.50 ms per token,    36.36 tokens per second)
llama_print_timings:        eval time = 27541.31 ms /   184 runs   (  149.68 ms per token,     6.68 tokens per second)
llama_print_timings:       total time = 28424.62 ms

CPU(EPYC 7551P)

(llama2) ubuntu@ubuntu-Super-Server:~/Documents/llama.cpp$ ./build/bin/main -m ./models/llama-2-13b-chat.ggmlv3.q8_0.bin --temp 0.1 -p "### Instruction: Create poetry about Mount Fuji in 200 words
### Response:" -ngl 0 -b 512
main: build = 975 (9ca4abe)
main: seed  = 1691810099
ggml_init_cublas: found 2 CUDA devices:
  Device 0: Tesla P40, compute capability 6.1
  Device 1: Tesla P40, compute capability 6.1
llama.cpp: loading model from ./models/llama-2-13b-chat.ggmlv3.q8_0.bin
llama_model_load_internal: format     = ggjt v3 (latest)
llama_model_load_internal: n_vocab    = 32000
llama_model_load_internal: n_ctx      = 512
llama_model_load_internal: n_embd     = 5120
llama_model_load_internal: n_mult     = 256
llama_model_load_internal: n_head     = 40
llama_model_load_internal: n_head_kv  = 40
llama_model_load_internal: n_layer    = 40
llama_model_load_internal: n_rot      = 128
llama_model_load_internal: n_gqa      = 1
llama_model_load_internal: rnorm_eps  = 5.0e-06
llama_model_load_internal: n_ff       = 13824
llama_model_load_internal: freq_base  = 10000.0
llama_model_load_internal: freq_scale = 1
llama_model_load_internal: ftype      = 7 (mostly Q8_0)
llama_model_load_internal: model size = 13B
llama_model_load_internal: ggml ctx size =    0.11 MB
llama_model_load_internal: using CUDA for GPU acceleration
ggml_cuda_set_main_device: using device 0 (Tesla P40) as main device
llama_model_load_internal: mem required  = 13555.97 MB (+  400.00 MB per state)
llama_model_load_internal: offloading 0 repeating layers to GPU
llama_model_load_internal: offloaded 0/43 layers to GPU
llama_model_load_internal: total VRAM used: 360 MB
llama_new_context_with_model: kv self size  =  400.00 MB

system_info: n_threads = 32 / 64 | AVX = 1 | AVX2 = 1 | AVX512 = 0 | AVX512_VBMI = 0 | AVX512_VNNI = 0 | FMA = 1 | NEON = 0 | ARM_FMA = 0 | F16C = 1 | FP16_VA = 0 | WASM_SIMD = 0 | BLAS = 1 | SSE3 = 1 | VSX = 0 |
sampling: repeat_last_n = 64, repeat_penalty = 1.100000, presence_penalty = 0.000000, frequency_penalty = 0.000000, top_k = 40, tfs_z = 1.000000, top_p = 0.950000, typical_p = 1.000000, temp = 0.100000, mirostat = 0, mirostat_lr = 0.100000, mirostat_ent = 5.000000
generate: n_ctx = 512, n_batch = 512, n_predict = -1, n_keep = 0


 ### Instruction: Create poetry about Mount Fuji in 200 words
### Response:

Mount Fuji, a majestic sight
A symbol of Japan, pure and bright
Snow-capped peak, serene and still
A challenge to climb, but the view is thrilling

In the distance, the sun sets low
Painting the sky with hues of gold
The mountain's shadow stretches far and wide
As the stars begin to twinkle inside

The mountain's beauty is a work of art
A masterpiece that touches the heart
A source of inspiration, a symbol of strength
A reminder of nature's grand design

In the silence, I find peace
A sense of calm, a world to cease
From the summit to the base
Mount Fuji, a wonder of grace. [end of text]

llama_print_timings:        load time =  1813.56 ms
llama_print_timings:      sample time =   163.44 ms /   169 runs   (    0.97 ms per token,  1034.04 tokens per second)
llama_print_timings: prompt eval time =  1460.09 ms /    22 tokens (   66.37 ms per token,    15.07  tokens per second)
llama_print_timings:        eval time = 135809.05 ms /   168 runs   (  808.39 ms per token,     1.24   tokens per second)
llama_print_timings:       total time = 137504.02 ms

【続】鯖落ちGPUを使った安価な機械学習用マシンの作り方 ~夢のマルチGPU環境にしてみる編~

!!注意!!: この記事で取ったベンチマークは、片方のGPUのみECC有効となっていたことが判明しました。PCIeのレーン数も双方のGPUで異なっており、非常にアンバランスな条件で測定した結果となっておりますので、マルチGPUの効果を見るのにはあまり適しておりません。後記事にてEPYCを購入し、ちゃんとECCを無効化した上で両GPUともPCIe x16で接続した結果を公開しましたので、こちらも合わせて見ていただければと思います。

前記事:

hashicco.hatenablog.com

後記事 :

hashicco.hatenablog.com

前書き

きっかけは、llama2の13BモデルがシングルGPUでは動かなかったこと。

以前記事にした鯖落ちP40を利用して作った機械学習用マシンですが、最近分析界隈でも当たり前のように使われ始めているLLMを動かすことを考えると、GPUはなんぼあってもいい状況です。

また、私自身マルチGPUを扱った経験が薄く、精々SageMaker上でPyTorchのDPでお手軽マルチGPUレーニングをやったことがある程度。今後大きなモデルを扱っていくことを考えると、お家でマルチGPUを試せる環境を準備しとくのは悪くないと思ってしまった。

そこで、P40をもう一台買い増してみることにしたので、色々とベンチやマルチGPUの使い勝手を書いておこうと思います。

まとめ

  • 私の使っているB550マザーでは1本目のPCIeがx16, 2本目がx4で分割もできないようなので、仕方無しに2枚目のP40はx4接続となりました。
    • フルスピードで動かすならThreadripperかEPYCがほしいところ。昔持ってたやつ売らなければ良かった......。
  • テスト項目は次の3つとしました。
    • 1.Transformers公式のDDPチュートリアル(GPT2のトレーニング): 一番実用に近いベンチ。 結果的にシングルGPUでもトレーニングできてしまったのですが、マルチのほうが約25%遅い結果に。 結果をちゃんと見てみたところ、train_samples_per_secondの値は約1.6倍となっていることが判明。Gradientsを共有するのに時間がかかっているのであろうか?
    • 2.Stable Diffusion SDXL base 1.0 画像生成速度(シングルGPU性能): 512x512の画像生成が大体11.6 secでした。StableDiffusion-webuiがマルチGPUに対応しているわけではないですが、別プロセスで並列して生成させることはできるはず。
    • 3.llama.cppでシングルVSマルチGPU性能比較: llama.cppをcuBLAS有効でコンパイルし、llama-2-13b-chat.ggmlv3.q8_0.binGPUなし/1GPU/2GPUで動作させて実行時間を測りました。結果、5%ほど速いスピードで推論できることがわかりました。

PCIeの帯域が問題なのか、あまりうまくスケールしていない感じです。これは中古EPYC買うフラグが立ってしまったかもしれません。

とはいえとりあえずマルチGPUの意味は確認できたので、あとは実用的にKaggleコンペなどで使ってメリットを出していきたいと思います。

直近では次のコンペに出てみようと思ってますので、ここでP40x2を有効活用することができれば嬉しいなあ。

www.kaggle.com

セットアップ

すでに前の記事で構築済みのマシンに、お手製ブラケットでファンを取り付けたP40を増設しました。特にOS上は設定変更などいらずに2枚目のGPUが認識されるようになりました。

1. Transformers公式のDDPチュートリアル

準備

こちらのチュートリアルに、TransformersでマルチGPUを使ってトレーニングを行う方法が紹介されています。ここのコードをベンチマークに利用しました。

huggingface.co

ここのチュートリアルのコードを実行するためには、ソースからTransformersをインストールしなければならないなど少し工夫が必要です。新たなPython仮想環境上で次の手順にて準備を行います(最後のPyTorchインストールは各々のCUDAバージョン等に合わせて変更してください)。

pip install git+https://github.com/huggingface/transformers
pip install evaluate datasets
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu118

ベンチマークに用いるrun_clm.pyは、transformersの公式レポジトリからクローンしてきます。

git clone https://github.com/huggingface/transformers.git

で、あとは次のコードを実行するのですが、

NCCL_P2P_DISABLE=1 CUDA_VISIBLE_DEVICES=0,1 \
python -m torch.distributed.launch --nproc_per_node 2 examples/pytorch/language-modeling/run_clm.py \
--model_name_or_path gpt2 --dataset_name wikitext --dataset_config_name wikitext-2-raw-v1 \
--do_train --output_dir ~/tmp/test-clm --per_device_train_batch_size 4 --max_steps 200

torch 2.0ではtorch.distributed.launchからtorchrunへの移行が推奨されているようなので、エラーが発生してしまいました。

ValueError: Some specified arguments are not used by the HfArgumentParser: ['--local-rank=0']

これを回避するため、torch.distributed.launchからtorchrunへコマンドを単純に差し替えたところ、問題なく動いてくれました。

NCCL_P2P_DISABLE=1 CUDA_VISIBLE_DEVICES=0,1 \
torchrun --nproc_per_node 2 examples/pytorch/language-modeling/run_clm.py \
--model_name_or_path gpt2 --dataset_name wikitext --dataset_config_name wikitext-2-raw-v1 \
--do_train --output_dir ~/tmp/test-clm --per_device_train_batch_size 4 --max_steps 200

結果

マルチGPUの結果

***** train metrics *****
  epoch                    =       0.69
  train_loss               =      3.279
  train_runtime            = 0:03:37.12
  train_samples            =       2318
  train_samples_per_second =      7.369
  train_steps_per_second   =      0.921
[INFO|modelcard.py:452] 2023-08-05 16:27:29,020 >> Dropping the following result as it does not have all the necessary fields:
{'task': {'name': 'Causal Language Modeling', 'type': 'text-generation'}, 'dataset': {'name': 'wikitext wikitext-2-raw-v1', 'type': 'wikitext', 'config': 'wikitext-2-raw-v1', 'split': 'train', 'args': 'wikitext-2-raw-v1'}}

結果は217sとのこと。Titan RTX V x2のベンチ結果がTransformers公式に載っているのですが、それだと131sらしいのでまあ早くも遅くもない?

シングルGPUで同じトレーニングを実施。むしろ若干早く(173s)でトレーニングできてしまっているので、2GPUの恩恵を感じるほどの結果ではなくなってしまった……。やはり片方のPCIeの帯域がx4なのが悪いのか。

ただ、train_samples_per_secondの値は約1.6倍となっているので、1秒間に処理できているサンプル数は増加している模様。NVLinkがないことから、GPU間でのパラメータ共有に時間がかかっていたりするのだろうか?まあ、train_samples_per_secondの算出方法がわからないのでなんとも言えないが……。

続いて、同じベンチマークコードをシングルGPUで実行します。単にCUDA_VISIBLE_DEVICES=0を頭につけて1枚だけGPUを認識できるようにし、nproc_per_nodeを1にしただけです。

NCCL_P2P_DISABLE=1 CUDA_VISIBLE_DEVICES=0 torchrun --nproc_per_node 1 examples/pytorch/language-modeling/run_clm.py \
--model_name_or_path gpt2 --dataset_name wikitext --dataset_config_name wikitext-2-raw-v1\
 --do_train --output_dir ~/tmp/test-clm --per_device_train_batch_size 4 --max_steps 200

***** train metrics *****
  epoch                    =       0.34
  train_loss               =     3.3276
  train_runtime            = 0:02:53.38
  train_samples            =       2318
  train_samples_per_second =      4.614
  train_steps_per_second   =      1.154

ちなみに、ちゃんと2GPU使用時には、どちらのGPUの利用率も高くなっていることが確認できました。

2. Stable Diffusion SDXL base 1.0でのP40の性能計測

準備

インストールは次のページを参照。唯一、異なるPCのWebUI環境にアクセスする必要があるので、launch.pyの引数に--listenを追加しました。

pc.watch.impress.co.jp

sudo apt install curl gnupg2 git python-is-python3 python3.10-venv python3-pip
git clone https://github.com/AUTOMATIC1111/stable-diffusion-webui
cd stable-diffusion-webui
python launch.py --listen

また、モデルファイルとしては次のURLから対象のsafetensorsをダウンロードして、models/Stable-diffusionに配置しました。

https://huggingface.co/stabilityai/stable-diffusion-xl-refiner-1.0/tree/main https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0/tree/main

sd_xl_base_1.0.safetensors sd_xl_refiner_1.0.safetensors

結果

txt2imgで512x512のイメージを生成した際の生成時間を確認したところ、 11.6 secとのこと。

当然2枚GPUが刺さっていれば別プロセスとして違うGPUを使うように設定してあげて倍の枚数生成できると思うので、悪くないのではないだろうか。

3. llama.cpp でマルチGPUとシングルGPUを比較する

準備

llama.cppでGPUを使う方法に関しては次のページを参照しました。

note.com

次の手順でcuBLASを有効化したllama.cppをコンパイルします。

git clone https://github.com/ggerganov/llama.cpp
cd llama.cpp
mkdir build
cd build
cmake .. -DLLAMA_CUBLAS=ON
cmake --build . --config Release

モデルとしてはllama2のGGML 8bit量子化モデル(llama-2-13b-chat.ggmlv3.q8_0.bin)を次のページからダウンロード

https://huggingface.co/TheBloke/Llama-2-13B-chat-GGML/tree/main

結果

とりあえず200文字程度の詩を生成させてみて、その際のトークン生成速度(eval time)を比較しました。

CPU(5900X) 1GPU 2GPU
2.76 14.83 15.59

結果、5%ほど2GPUのほうが速くなりました。llama.cppの場合多分層ごとに異なるGPUに配置するような形になるため、あんまり効率は良くないのかもしれません。シンプルに1GPUに乗り切らないモデルを動かしたりできるという面で2GPUの価値はあるでしょう。

multi GPU

./build/bin/main -m ./models/llama-2-13b-chat.ggmlv3.q8_0.bin --temp 0.1 -p "### Instruction: Create poetry about Mount Fuji in 200 words
### Response:" -ngl 40 -b 512
main: build = 952 (3323112)
main: seed  = 1691226913
ggml_init_cublas: found 2 CUDA devices:
  Device 0: Tesla P40, compute capability 6.1
  Device 1: Tesla P40, compute capability 6.1
llama.cpp: loading model from ./models/llama-2-13b-chat.ggmlv3.q8_0.bin
llama_model_load_internal: format     = ggjt v3 (latest)
llama_model_load_internal: n_vocab    = 32000
llama_model_load_internal: n_ctx      = 512
llama_model_load_internal: n_embd     = 5120
llama_model_load_internal: n_mult     = 256
llama_model_load_internal: n_head     = 40
llama_model_load_internal: n_head_kv  = 40
llama_model_load_internal: n_layer    = 40
llama_model_load_internal: n_rot      = 128
llama_model_load_internal: n_gqa      = 1
llama_model_load_internal: rnorm_eps  = 5.0e-06
llama_model_load_internal: n_ff       = 13824
llama_model_load_internal: freq_base  = 10000.0
llama_model_load_internal: freq_scale = 1
llama_model_load_internal: ftype      = 7 (mostly Q8_0)
llama_model_load_internal: model size = 13B
llama_model_load_internal: ggml ctx size =    0.11 MB
llama_model_load_internal: using CUDA for GPU acceleration
ggml_cuda_set_main_device: using device 0 (Tesla P40) as main device
llama_model_load_internal: mem required  =  698.16 MB (+  400.00 MB per state)
llama_model_load_internal: allocating batch_size x (640 kB + n_ctx x 160 B) = 360 MB VRAM for the scratch buffer
llama_model_load_internal: offloading 40 repeating layers to GPU
llama_model_load_internal: offloaded 40/43 layers to GPU
llama_model_load_internal: total VRAM used: 13218 MB
llama_new_context_with_model: kv self size  =  400.00 MB

system_info: n_threads = 12 / 24 | AVX = 1 | AVX2 = 1 | AVX512 = 0 | AVX512_VBMI = 0 | AVX512_VNNI = 0 | FMA = 1 | NEON = 0 | ARM_FMA = 0 | F16C = 1 | FP16_VA = 0 | WASM_SIMD = 0 | BLAS = 1 | SSE3 = 1 | VSX = 0 |
sampling: repeat_last_n = 64, repeat_penalty = 1.100000, presence_penalty = 0.000000, frequency_penalty = 0.000000, top_k = 40, tfs_z = 1.000000, top_p = 0.950000, typical_p = 1.000000, temp = 0.100000, mirostat = 0, mirostat_lr = 0.100000, mirostat_ent = 5.000000
generate: n_ctx = 512, n_batch = 512, n_predict = -1, n_keep = 0


 ### Instruction: Create poetry about Mount Fuji in 200 words
### Response:

Mount Fuji, a majestic sight
Rising high into the morning light
Snow-capped peak, a work of art
A symbol of Japan's heart

Its beauty is beyond compare
A sacred mountain, pure and fair
In spring, cherry blossoms bloom
Summer brings green forests, autumn's hue
Winter's snow, a peaceful sight

The mountain's grace, a source of pride
For Japan, a treasure inside
A place of wonder, awe and dreams
Where nature's beauty, forever gleams

Its majesty, a symbol of strength
A reminder of life's fleeting length
Let us cherish this gift of nature
And honor Mount Fuji's pure creation. [end of text]

llama_print_timings:        load time =  3770.04 ms
llama_print_timings:      sample time =    72.51 ms /   174 runs   (    0.42 ms per token,  2399.74 tokens per second)
llama_print_timings: prompt eval time =   481.74 ms /    22 tokens (   21.90 ms per token,    45.67 tokens per second)
llama_print_timings:        eval time = 11099.33 ms /   173 runs   (   64.16 ms per token,    15.59 tokens per second)
llama_print_timings:       total time = 11679.77 ms

1GPU

CUDA_VISIBLE_DEVICES=0 ./build/bin/main -m ./models/llama-2-13b-chat.ggmlv3.q8_0.bin --temp 0.1 -p "### Instruction: Create poetry about Mount Fuji in 200 words
### Response:" -ngl 40 -b 512
main: build = 952 (3323112)
main: seed  = 1691227096
ggml_init_cublas: found 1 CUDA devices:
  Device 0: Tesla P40, compute capability 6.1
llama.cpp: loading model from ./models/llama-2-13b-chat.ggmlv3.q8_0.bin
llama_model_load_internal: format     = ggjt v3 (latest)
llama_model_load_internal: n_vocab    = 32000
llama_model_load_internal: n_ctx      = 512
llama_model_load_internal: n_embd     = 5120
llama_model_load_internal: n_mult     = 256
llama_model_load_internal: n_head     = 40
llama_model_load_internal: n_head_kv  = 40
llama_model_load_internal: n_layer    = 40
llama_model_load_internal: n_rot      = 128
llama_model_load_internal: n_gqa      = 1
llama_model_load_internal: rnorm_eps  = 5.0e-06
llama_model_load_internal: n_ff       = 13824
llama_model_load_internal: freq_base  = 10000.0
llama_model_load_internal: freq_scale = 1
llama_model_load_internal: ftype      = 7 (mostly Q8_0)
llama_model_load_internal: model size = 13B
llama_model_load_internal: ggml ctx size =    0.11 MB
llama_model_load_internal: using CUDA for GPU acceleration
llama_model_load_internal: mem required  =  698.16 MB (+  400.00 MB per state)
llama_model_load_internal: allocating batch_size x (640 kB + n_ctx x 160 B) = 360 MB VRAM for the scratch buffer
llama_model_load_internal: offloading 40 repeating layers to GPU
llama_model_load_internal: offloaded 40/43 layers to GPU
llama_model_load_internal: total VRAM used: 13218 MB
llama_new_context_with_model: kv self size  =  400.00 MB

system_info: n_threads = 12 / 24 | AVX = 1 | AVX2 = 1 | AVX512 = 0 | AVX512_VBMI = 0 | AVX512_VNNI = 0 | FMA = 1 | NEON = 0 | ARM_FMA = 0 | F16C = 1 | FP16_VA = 0 | WASM_SIMD = 0 | BLAS = 1 | SSE3 = 1 | VSX = 0 |
sampling: repeat_last_n = 64, repeat_penalty = 1.100000, presence_penalty = 0.000000, frequency_penalty = 0.000000, top_k = 40, tfs_z = 1.000000, top_p = 0.950000, typical_p = 1.000000, temp = 0.100000, mirostat = 0, mirostat_lr = 0.100000, mirostat_ent = 5.000000
generate: n_ctx = 512, n_batch = 512, n_predict = -1, n_keep = 0

 ### Instruction: Create poetry about Mount Fuji in 200 words
### Response:

Mount Fuji, a majestic sight
Rising high into the sky so bright
Snow-capped peak, a work of art
A symbol of Japan, a treasure to the heart

Its base, a lush green forest
Home to wildlife, a peaceful nest
The mountain's beauty, a source of pride
For the Japanese people, it abides

In spring, cherry blossoms bloom
A delicate pink, a sweet perfume
Summer brings warmth and vibrant hues
Autumn leaves, a kaleidoscope of views
Winter's snow, a serene landscape
Year-round, Mount Fuji's grandeur expands

A sacred mountain, a spiritual place
Where nature's beauty, grace and pace
Bring solace to the soul and heart
A journey to Mount Fuji, a work of art. [end of text]

llama_print_timings:        load time =  5271.96 ms
llama_print_timings:      sample time =    80.64 ms /   199 runs   (    0.41 ms per token,  2467.67 tokens per second)
llama_print_timings: prompt eval time =   596.81 ms /    22 tokens (   27.13 ms per token,    36.86 tokens per second)
llama_print_timings:        eval time = 13346.94 ms /   198 runs   (   67.41 ms per token,    14.83 tokens per second)
llama_print_timings:       total time = 14053.59 ms

CPU

./build/bin/main -m ./models/llama-2-13b-chat.ggmlv3.q8_0.bin --temp 0.1 -p "### Instruction: Create poetry about Mount Fuji in 200 words
### Response:" -ngl 0 -b 512
main: build = 952 (3323112)
main: seed  = 1691227261
ggml_init_cublas: found 1 CUDA devices:
  Device 0: Tesla P40, compute capability 6.1
llama.cpp: loading model from ./models/llama-2-13b-chat.ggmlv3.q8_0.bin
llama_model_load_internal: format     = ggjt v3 (latest)
llama_model_load_internal: n_vocab    = 32000
llama_model_load_internal: n_ctx      = 512
llama_model_load_internal: n_embd     = 5120
llama_model_load_internal: n_mult     = 256
llama_model_load_internal: n_head     = 40
llama_model_load_internal: n_head_kv  = 40
llama_model_load_internal: n_layer    = 40
llama_model_load_internal: n_rot      = 128
llama_model_load_internal: n_gqa      = 1
llama_model_load_internal: rnorm_eps  = 5.0e-06
llama_model_load_internal: n_ff       = 13824
llama_model_load_internal: freq_base  = 10000.0
llama_model_load_internal: freq_scale = 1
llama_model_load_internal: ftype      = 7 (mostly Q8_0)
llama_model_load_internal: model size = 13B
llama_model_load_internal: ggml ctx size =    0.11 MB
llama_model_load_internal: using CUDA for GPU acceleration
llama_model_load_internal: mem required  = 13555.97 MB (+  400.00 MB per state)
llama_model_load_internal: offloading 0 repeating layers to GPU
llama_model_load_internal: offloaded 0/43 layers to GPU
llama_model_load_internal: total VRAM used: 360 MB
llama_new_context_with_model: kv self size  =  400.00 MB

system_info: n_threads = 12 / 24 | AVX = 1 | AVX2 = 1 | AVX512 = 0 | AVX512_VBMI = 0 | AVX512_VNNI = 0 | FMA = 1 | NEON = 0 | ARM_FMA = 0 | F16C = 1 | FP16_VA = 0 | WASM_SIMD = 0 | BLAS = 1 | SSE3 = 1 | VSX = 0 |
sampling: repeat_last_n = 64, repeat_penalty = 1.100000, presence_penalty = 0.000000, frequency_penalty = 0.000000, top_k = 40, tfs_z = 1.000000, top_p = 0.950000, typical_p = 1.000000, temp = 0.100000, mirostat = 0, mirostat_lr = 0.100000, mirostat_ent = 5.000000
generate: n_ctx = 512, n_batch = 512, n_predict = -1, n_keep = 0


 ### Instruction: Create poetry about Mount Fuji in 200 words
### Response:

Mount Fuji, a majestic sight
Rising high into the sky so bright
Snow-capped peak, a work of art
A symbol of Japan, a treasure to the heart

Its beauty is beyond compare
A sacred mountain, pure and fair
In spring, cherry blossoms bloom
Summer brings green forests, autumn's hue
Winter's snow, a blanket of white

The mountain's grace, a source of pride
For the Japanese people, it abides
A spiritual journey, a pilgrimage too
Climbing Mount Fuji, a dream come true

The sun sets, the moon glows bright
A night of tranquility, pure delight
The stars twinkle, like diamonds in the sky
Mount Fuji, a wonder why

In the morning light, it stands tall
A beacon of hope, for one and all
A symbol of strength, a source of pride
Mount Fuji, forever by our side. [end of text]

llama_print_timings:        load time =  5799.78 ms
llama_print_timings:      sample time =    91.96 ms /   224 runs   (    0.41 ms per token,  2435.82 tokens per second)
llama_print_timings: prompt eval time =  2474.66 ms /    22 tokens (  112.48 ms per token,     8.89 tokens per second)
llama_print_timings:        eval time = 80750.70 ms /   223 runs   (  362.11 ms per token,     2.76 tokens per second)
llama_print_timings:       total time = 83352.26 ms

最近SSDが安いのでオールフラッシュNASを導入した(ASUSTOR FS6712X)

前書き

我が家には10GbE搭載のQNAP TS-332XというNASを導入済みである。8TBのHDD2枚でRAID1を組んでおり、10Gの速度を活かすためにリード/ライトのSSDがキャッシュを設定している。

https://www.qnap.com/ja-jp/product/ts-332x

hashicco.hatenablog.com

単なるデータ倉庫としての用途だったので特に性能的にも不満はなく、HDDも稼働時間を1000日超えても快調に動いてくれていたのですが、最近あまりにもSSDが安すぎてオールフラッシュにしたい欲が浮き上がっては消えていく。

そんな折、ASUSUTORから興味深い製品の発表が。最大12枚ものM.2 SSDを搭載できるというオールフラッシュ専用の10GbE対応NAS、FS6712Xである。

https://www.asustor.com/product/FS67

NASにしては小型の筐体で置き場に困らなさそうなところとか、(実際にはやらないにしても)8TBのSSDを使えば96TBもの容量を積めるところとか、クアッドコアのN5105搭載で最大16GBまでメモリ積めるとかスペック的にも申し分ないところとか、興味をそそられるポイントが随所に存在する。

ということで、SSDの金額が下落していくのを横目で見ながらボーナスが出るのを待ち、東京に帰省するタイミングでアキバでFS6712Xを調達してきたという話。

まとめ

  • 今メモリ・SSDが激安。今回は店員さんのお勧めでSamsungの980 Pro 2TB 5枚でRaid5を組んだのだが、1枚あたり17,580円で買えてしまった

    • このNASはPCIe 3.0x1接続なので970EVO PLUSでも良かったのだが、あまりにも安かったのでProにしてしまった。ちなみに、970EVOで良ければ13,680円で買えた。
  • NAS本体はオリオスペックで購入。129,000円

  • 更に、アークに寄ってバルク品のDDR4 2x8GBのRAMも購入。こちらもまた衝撃的な安さでなんとたったの3,300円!しかもバルクとはいえMicronの品。
  • トータル金額は、本体、980 Pro 2TB 5枚(RAID5, 8TB), メモリで220,200円となりました。オールフラッシュの10GbE NASと考えれば安いのではないでしょうか。

なんか今めちゃくちゃメモリ・SSDが安いので、買うなら今です。

バルク品メモリが激安なのを見つけたりできたので、久々にアキバで買い物して得した気分になりました。

組み立てなど

箱とSSD。こんなにSSDが並ぶとテンションが上がります。ちなみにSSDが6枚ありますが、一枚は保守用です。

本体は大きいオライリー本を一回り大きくしたくらいのサイズ感。最悪災害時にも抱えて逃げられるかもと思えるサイズ。 上蓋と下蓋の2つの蓋があって、それぞれから6本ずつのM.2スロットにアクセスできます。上蓋側にDRAMスロットがあるので、DRAM増設時にはそこから増やします。

設定は画面通りに進めていけば特に詰まるポイントはありませんでした。Web管理画面はQNAPのものと似ている印象。メモリはバルク品かつデフォルトの2933MHzよりも早い3200MHzでしたが問題なく認識してくれました。

結構本体はアチアチです。4cm角程度の小さなファンが底面と上面にあったと思うのですが、旧NASからのデータ転送中にシステム温度は60度近い温度となっています。本格的に運用するならノートPC用の冷却台などに載せたほうが良いかもしれません。

速度計測

残念ながら今の私のメインPCの10G NICがPCIe2.0 x2接続になってしまっているので、あんまりまともなベンチを取ることができておりません……。RTX4090がでかすぎるのが悪い。

サブのPCのGPUを外して10G NICを刺すか、QNAPのNASの方にLinuxコンテナ立ててdiskspdで計測するか、など色々手段は考えられるのですが面倒くさいのでとりあえずはPCIe2.0 x2(実効8.0Gbps上限)での測定結果でお茶を濁しておきます。

明らかにネットワーク速度がネックになっていますが、Writeの速度が過去のNASよりも速いのがわかります。これがオールフラッシュストレージのちからでしょうか。

いつかもう少しまともな計測環境が揃ったらまた測り直して見ようと思います。

【IMC2023】KaggleでなんやかんやあってExpertになれた話

概要

最近久しぶりにKaggleに復帰して、Image Matching Challenge 2023というコンペに参加しました。結果、92位/464グループ という成績でギリギリ銅メダルを獲得でき、晴れてKaggle Competitions Expert(コンペで銅メダル以上2個獲得)となることができました。

アイコン周りの表示も紫色になってちょっと嬉しい

Kaggleを始めたのは2022年の4月くらいですが、ここ半年くらい何故かTypeScriptの勉強と称してLeetCodeに時間を割り当てていた時期があったので、実質半年くらいのKaggle歴です。

実は1つ目の銅メダルも昨年のImage Matching Challenge 2022にて獲得したのであまり幅広いKaggleの経験があるわけではないのですが、なんかネット上のKaggleに関する記事はKaggle MasterとかGrand Masterによる立派な記事ばかりな気がするので、平凡Kagglerの視点から感想を書いてみます。

ちなみに、タイトルの「なんやかんや」という部分に関しては、期せずKaggle悪徳業者の被害者になりかけたのでそのことを指してます。詳しくは続きをどうぞ。

まとめ

  • コンペをうまく選べばCompetition Expertになるのはそこまで難しくないと思う。コツは次の通り

    • 比較的上位のスコアがばらついているコンペに狙いを定める
    • スコアが最も高い公開ノートブックを追いかける。Disscussionにも一通り目を通して拾えそうな改善案を見つける。
    • 一通りみんなが試してそうな改善案をベースラインノートブックに加える
    • さらなる差別化として、モデルのアンサンブルやTTA(Test Time Augmentation)などを行ってチマチマ精度を上げてみるといいと思います。運が良ければそのまま100位以内に残れます。
  • Kaggleではコードを裏で売買したり、チームを組む機能を悪用してメダルの獲得を斡旋するなど不正行為を働く不届き者がいるため、コンペ終了後の不正チェックの結果順位が上がることがある。メダルギリギリの順位でもワンチャンあるかも。

無事に漠然と目標としていた1年位でKaggle Expertになるという目標を達したので、後はたまに興味のあるコンペに参加してみる感じで続けられればと思います。

また、銅メダルはこんなもんで取れてしまうので、"最低限他人の書いたコードが読めて、単純・定番な改善策を加えることができる"程度の証明にしかならないとも思いました。頑張っていつか銀以上のメダルを取ってみたいと思います。

Image Matching Challenge 2023について

www.kaggle.com

コンペの概要

大した順位ではないしこのコンペで使う技術に専門性があるわけでもなく、提出したのもベースラインのノートブックにちょっとした変更を加えただけのものなので真面目なSolution紹介をするつもりはないですが、一応どんなことをやったか書いておきます。

このコンペでは、同じ建物や物体を色々な場所から撮影された画像(以下同一シーン画像と呼びます)をもとに立体構造の復元を行う、Structure from Motion(SfM)を行います。具体的な提出内容としては、SfMの結果より得られる画像パラメータである3x3rotation_matrixと3次元のtranslation_vectorを各画像ごとに求め、flattenして次のようなCSVファイルとして提出します。

image_path,dataset,scene,rotation_matrix,translation_vector
da1/sc1/images/im1.png,da1,sc1,0.1;0.2;0.3;0.4;0.5;0.6;0.7;0.8;0.9,0.1;0.2;0.3
da1/sc2/images/im2.png,da1,sc1,0.1;0.2;0.3;0.4;0.5;0.6;0.7;0.8;0.9,0.1;0.2;0.3

SfMの原理的なところは他のサイトをご参照ください。ちなみに、私は次の本でさらってなんとなくわかった気になっています。この本は深くはないものの幅広く画像処理の基礎知識が載っているので辞書的な使い方ができて良き。

Amazon.co.jp: ディジタル画像処理[改訂第二版] : ディジタル画像処理編集委員会: 本

rotation_matrixtranslation_vectorをどのように求めるか、というところですが、実際のところはcolmapというパッケージ(と、それをPython用にラップしたpycolmap)が難しいところを全部やってくれます。

つまるところ我々が機械学習でやるべきことは、①同一シーン画像のペアを作り、②そのペア毎に学習済みのFeature DetectorとMatcherを使って一致するポイントをなるべくたくさん正確に見つけ出すことです。次のノートブックの図がわかりやすいかも。

https://www.googleapis.com/download/storage/v1/b/kaggle-forum-message-attachments/o/inbox%2F5065877%2F68911db7c4cc430dec05670cd196a960%2Fslide_architecture.png?generation=1687202186098466&alt=media

https://www.kaggle.com/competitions/image-matching-challenge-2023/discussion/416873

このとき、結構複雑なデータ処理パイプラインを構築する必要があるわりに、自前でモデルのトレーニングなどは行わず既存の特徴量抽出機やマッチングモデル(LoFTRやSuperGlue, DISCなど)を使うため、普通の機械学習コンペっぽくないデータエンジニアリングが重要なコンペでした。

自分のやった工夫

ノートブックはここ。

https://www.kaggle.com/code/bobfromjapan/imc-2023-submission-92nd-solution

  • ベースにしたのはこちらのノートブック: imc-2023-submission-example | Kaggle
    • 非常に良くできたノートブックで、本データセットにおけるSfMの処理を一通り行うことができるうえ、3つの特徴点抽出&マッチング方法(LoFTR, KeyAffHardNet, DISC)を実装している。
  • これのパラメータ(画像サイズ、ペア画像数の最低数、画像から取得する特徴点の数、特徴点の選択Threshold)を最適化するため、ローカルマシンで実行環境を整えてTrainデータを利用してグリッドサーチ
    • モデルを自分でトレーニングしないコンペであっても、やはりローカルでそこそこ良いマシンを持っているとこういうところで有利です。
  • 画像の明るさを調整するためにCLAHEを施す
    • これは明確に数%前半の精度向上があったと思う。同じ被写体をいろんな条件で撮った写真のマッチングなので、これはリーズナブルな結果かな?
  • 元の実装では最終的に構築された3次元モデルの中から最も利用された画像枚数が多いもののみを選択し一部の画像のみ結果を出すようになっていたが、これを全モデル利用するように変更。
    • Trainデータにおいての改善は微々たるものだったので、これが本当に意味があったかはわからん。まあサルでも思いつくような変更なので、理由があって最良モデルのみを選択していたのだと思う。
  • LoFTRとKeyAffHardNetのアンサンブル
    • これは今回精度向上には役立たなかった。KeyAffHardNet単体のほうが数%精度が良かった。

言い訳でしかないですが、眼精疲労が酷すぎたり、仕事関連でモチベが下降気味だったり、出張が重なったり、TrainデータとTestデータでかなりスコアが違っており改善効果が読みにくかったり、そもそもSfMに関する理解不足であまり根本的な改善案を入れられなかったりで、途中からまあワンチャン銅メダル取れればいいや的なスタンスでパラメータのチューニングだけしかやらなくなってしまいました。

しかし、今回のコンペに関してはこのパラメータ設定が争点の一つだったため、結果的に大したことはしてないものの銅メダルを取得できました。

反省

画像の対応付けに参考ノートブックの元実装のtorch.cdistから変更してFAISS使いたいとか、明らかに画像の向きが揃っていなかったのでこれを揃える改良をしたいとかアイデアはあったものの、やる気と時間がなく(改めてこれは言い訳で単なる実力不足だが)実装をやめてしまった。

結果的に上位陣の解法でもこの画像の対応付けと回転に対する対応が結構大きなウェイトを占めていたので、ここをもう少し頑張れていたら銀メダルくらいは取れたかもしれないとちょっと後悔……。

Kaggleのちょっとした闇の話

また、今回のコンペに限らずKaggle界隈で蔓延る不正行為の犠牲になりかけたので、その話も少し。実は最初コンペ終了時私の順位は101位/502グループ(!!) と、ギリギリ銅メダルを取れない順位にいました。

あまりにギリギリの順位だった(しかもPublicもPrivateも101位という奇跡)ので、最後頑張らなかったツケが回ってきたかと半笑いでスクショを撮っていたのだが、こんなDisscussionを発見。

Medals sellers are always there

どうやら中国のネットマーケット上でこのIMC2023のメダルの販売が行われているという内容。

今まで参加したコンペでもちょくちょくこういう不正の噂は聞いていたものの、今回はまさに当落線上にいる身としてこの話は無視できない。

今回は諸々の事情(おそらく、ここのディスカッションで議論されてたロシアのウクライナ侵攻に関連した、上位者のCVPR参加可否の件?)でだいぶ順位確定まで時間がかかったのですが、提出締切から1週間ほど経った頃に不正のチェックが終わり、順位が9位くらい上昇し、無事に銅メダル圏内となりました。

まあ、こんな不正業者に負けるようなスコアで喚くのも情けない話ですが、銅であってもメダルを貰えるに越したことはないので、スタッフの方々の頑張りには大変感謝しております。