Mac M2 チップで lm-evaluation-harness
はじめに
来たるSLM全盛時代に向けて、Mac mini (2023) の Apple M2 チップ(24GB unified memory)という物足りない環境で言語モデルを評価したいと試行錯誤した経緯を記事に記録しようと思って記事を書き始めました。
対象読者
以下のいずれかの方々を対象にとって参考になれば幸いです。
- 言語モデルを評価してみたい MacのM2チップ(24GB unified memory)ユーザ。
- nvidia様GPUの大量購入が困難だけど言語モデルを開発したい個人開発者。
- 会社に言語モデル開発を命じられるも、環境構築の予算が足りないand/or購入のための稟議書作成に疲弊しきってしまった企業研究者。
結論
言語モデルの評価フレームワークであるlm-evaluation-harnessを用いて日本語能力を評価してみましたが、そのまま使用するだけでは評価が難しいこと分かりました...。
Mac M2 チップで lm-evaluation-harness
Small language model
しばらくキャッチアップをサボっていたら 5Bパラメータ以下の小型言語モデル(Small Language Model: SLM)の性能が思ったよりも発達してきていることを知りました(SLM survey paper)。これはnvida様の高級GPUの用意が困難(少なくとも私のお財布事情ではムリです...)な個人の環境でも、使い物になるオリジナルの言語モデルの開発!なんてことが可能になる世界が数年後にはくるかも知れない等と期待してしまいます。
言語モデルの評価
開発したからには、開発したモデルがどの程度の性能なのか、他のモデルと比べて賢いのかポンコツなのか等を評価したくなるのが人(研究者)というものです。とはいうものの、言語モデルの評価については、一つ二つのクエリを投げてチャットするだけでは、賢いのかポンコツなのかの判断が難しいところです。そのためにベンチマークデータを用いて評価するわけです。しかし、各ベンチマークごとに評価手法やデータフォーマットが異なり、個別に評価環境を整えることは手間です。そこで、登場するのがlm-evaluation-harnessです。
lm-evaluation-harness
本家のGitHubのOverviewをgpt-4oに要約させると、以下のようになります。
本プロジェクトは、生成系言語モデルの多様な評価タスクに対応する統一フレームワークです。60以上の学術ベンチマークや多様なモデル(transformers、GPT-NeoX、Megatron-DeepSpeed など)、高速推論(vLLM)、商用API、アダプター評価(PEFT対応)、ローカル評価など、多彩な機能を備え、再現性のある評価環境を提供します。また、Hugging Face の Open LLM Leaderboard のバックエンドとしても利用されています。
こういったものがあるおかげで、世界中のみんなが同様の評価を実施することができるので、異なる論文の比較が容易なります。それに、研究の再現性が促進されるので分野の健全は発展にはとても重要だと考えます。
というわけで前置きが長くなりましたが、この記事では、言語モデルの開発には物足りない環境で、どのようにしてlm-evaluation-harnessを用いて言語モデルを評価していったら良いかを試行錯誤した内容を紹介します。
環境
環境は以下の通りです。いろいろギリギリです。
項目 | 値 |
---|---|
Mac | Mac mini (2023) |
Chip | Apple M2 |
Memory | 24 GB Unified Memory |
OS | macOS Sonoma 14.6.1 |
Python | 3.12.6 |
lm-eval | 0.4.7 |
モデル
この記事を書くきっかけでもあるサーベイ論文(SLM survey paper)にも記載されていたニイハオ系言語モデルの
Qwen2.5-0.5B-Instructを使用しました。huggingface からダウンロードしてもファイルサイズは合計953 MBと、1 GBを下回る軽量なモデルです。これなら私の環境でもなんとかなりそうだと期待させてくれます。
Instructではないのですが、Qwen2.5-0.5Bの実力の程を確認するためにollamaで動かしてみます。
3秒ほどで生成してくれました。内容については「ホントにカレーの作り方なのかなぁ?」などと思いましたが、肉を入れただけのジ○ワーカレーしか作ったことのない私が正しいカレーの作り方を知らないだけなのかも知れません。とまぁ、内容には一抹の不安が残るものの、パッと見はなんかちゃんと日本語っぽくて、「このサイズでここまで出来るか!」と感動しました。Llama-2-7bとかはもっと意味わかんない日本語を出力していたし。
実行コマンド
lm-eval
では、さまざまな推論バックエンドを簡単に利用できます。
本当は vLLM
を試したいところですが、Mac 環境では対応が難しいため、まずは Hugging Face のtransformers
ライブラリを使って試してみます。
lm-eval
をインストール後、以下のコマンドを実行してください。
$ lm-eval \
--model hf \
--model_args pretrained=${path_to_model} \
--tasks japanese_leaderboard \
--device mps \
--output_path ${path_to_output} \
--log_sample \
--batch_size ${batch_size} \
--gen_kwargs ${gen_kwargs} \
--limit ${limit}
引数の概要は下記の通りです。詳細は本家を参照してくださいinterface.md。
引数 | 値 |
---|---|
model | 使用する推論バックエンドをしています(例:hf, vllm) |
model_args | modelで指定したバックエンドに応じた引数 |
tasks | 評価対象のベンチマークタスク(標準でヤック170程度用意) |
output_path | 出力先 |
log_sample | 言語モデルに入力したサンプルを出力するフラグ |
batch_size | 推論時のバッチサイズ |
gen_kwargs | 推論時のパラメータ(top_kとか) |
limit | 評価対象のサンプル数を制限 |
model_args
や gen_kwargs
は、指定したmodel
の種類によって内容が異なります。
いまいち確認方法が不明だったのでlm-evaluation-harness/lm_eval/models
にあるファイルを直接確認しました。例えばhfを指定するとhuggingface.py
中のclass HFLM
が呼び出されるので、それを確認します。
@register_model("hf-auto", "hf", "huggingface")
class HFLM(TemplateLM):
...
def __init__(
self,
pretrained: Union[str, transformers.PreTrainedModel],
backend: Literal["default", "causal", "seq2seq"] = "default",
...
def generate_until(
self, requests: List[Instance], disable_tqdm: bool = False
) -> List[str]:
...
このように、たとえば HFLM
を使う場合は、--model_args pretrained=${path_to_model}
のように、__init__
メソッドの引数に該当する値を model_args
に指定します。
一方、gen_kwargs
はgenerate_until
メソッドに渡されるキーワード引数であり、生成時の挙動(例:top_k
, top_p
, temperature
など)を制御するために使用されます。
japanese_leaderboard
日本語能力に興味があったので標準で用意されているjapanese_leaderboard
で評価することにしました。
このtask
は下記の表にあるように8つのベンチマークデータセットから構成されます。
benchmark | metric | output type | total samples |
---|---|---|---|
ja_leaderboard_jaqket_v2 | exact match | generate until | 1164 |
ja_leaderboard_jsquad | exact match | generate until | 4442 |
ja_leaderboard_mgsm | acc | generate until | 250 |
ja_leaderboard_xlsum | rouge2 | generate until | 766 |
ja_leaderboard_jcommonsenseqa | acc | multiple choices | 5595 |
ja_leaderboard_jnli | acc | multiple choices | 7302 |
ja_leaderboard_marc_ja | acc | multiple choices | 11308 |
ja_leaderboard_xwinograd | acc | multiple choices | 1918 |
metric にある exact match, acc, rouge2 などは自然言語処理や機械学習で用いられる指標です。
output type がgenerate_until
はLLMに文章を生成してその文章を評価対象としており、推論バックエンドクラスの generate_until
メソッドで出力を生成します。
一方で、multiple_choices
は、loglikelihood
メソッドで出力を生成します。これは
LLMに多肢選択問題を回答してもらう際に実施する処理と基本的には同じで、答えとなる各トークのlogit値もしくはsoftmaxの値を入手して尤度を計算します。詳細は文章を書くのが面倒なのでここでの記載は避けますが、この論文とかが参考になると思います。
hfで評価
まずは huggingface の transformers を用いる hf
で評価を検討してみます。limitと
batch_size の値を変化させながら、
japanese_leaderboard` を評価する場合に、どの程度の時間がかかりそうかを推定してみました。結果は以下の通りです。
limit |
batch_size |
'generate_until' | loglikelihood |
---|---|---|---|
1 | 1 | 4/4 [00:34<00:00, 8.63s/it] | 12/12 [00:06<00:00, 1.87it/s] |
10 | 1 | 40/40 [23:54<00:00, 35.86s/it] | 120/120 [03:48<00:00, 1.90s/it] |
10 | 2 | 40/40 [40:16<00:00, 60.41s/it] | 20/120 [04:11<00:00, 2.10s/it] |
100 | 2 | 389/400 [10:04:39<14:37, 79.77s/it] | - |
3 | 3 | RuntimeError | - |
まず、--batch_size 1
で実行した場合、最初の 10 サンプルにおける平均処理時間は以下の通りでした。
-
generate_until
: 35.86 sec/it -
loglikelihood
: 1.90 sec/it
この処理時間をもとに、すべてのタスクに対して評価を行った場合のおおよその総所要時間を試算すると:
(1164 + 4442 + 250 + 766) × 35.86 ≒ 66 時間
(5595 + 7302 + 11308 + 1918) × 1.90 ≒ 14 時間
それぞれ66時間と14時間かかる計算になります。
loglikelihood
の14時間はまだしもgenerate_until
の66時間という数字はとてもじゃないけど許容できません。そのためバッチサイズを増やして高速化を図りたいところですが、実際に --batch_size 2 で実行してみたところ、処理時間は 逆に悪化していました。さらに、--batch_size 3
では、下記の通り完全に MPS のメモリサイズを超えるようです。
RuntimeError: MPS backend out of memory (MPS allocated: 25.60 GB,
other allocations: 222.78 MB, max allowed: 27.20 GB).
Tried to allocate 1.47 GB on private pool.
Use PYTORCH_MPS_HIGH_WATERMARK_RATIO=0.0 to disable upper
limit for memory allocations (may cause system failure).
この結果から、私の環境ではMac環境(MPS)では安易にバッチサイズを増やすことで高速化できるとは限らないことがわかります。
ggufで評価
残念ながら私の環境ではhf
を使って評価することは現実的でないことが分かりました。そこで次は Mac ユーザーの味方である llama.cpp を用いる gguf
でトライすることにしました。この推論バックエンドが定義されている lm-evaluation-harness/lm_eval/models/gguf.py
には
@register_model("gguf", "ggml")
class GGUFLM(LM):
def __init__(self, base_url=None, max_length=2048, **kwargs):
super().__init__()
self.base_url = base_url
assert self.base_url, "must pass `base_url` to use GGUF LM!"
self.logprobs = 10
self.temperature = 0.0
self.max_length = max_length
def gguf_completion(
self, context, continuation=None, stop=None, retries=3, delay=5, **kwargs
):
...
と記載されています。モデルの指定はbase_url
に gguf サーバのURLを指定するようです。いまいち何を言っているのかが分からなかったのですが、そういえば ollama はサーバを起動して動かすし、gguf ファイルを読み込むし、ということを思い出しました。
そこで、ollama を起動して、
% lsof -iTCP -sTCP:LISTEN -n -P | grep ollama
でエンドポイントを取得しました。調べるとollamaのポートはデフォルトで、11434
らしいので、ほとんどの場合がbase_url=127.0.0.1:11434
で良いのだと思います。
適切かどうかは別としてモデルの指定方法もわかったので実行してみます。しかし、私のollama のバージョン(8.3.6)では、 GGUFLM
の gguf_completion
メソッドが期待通りに動作せず、一部の修正が必要でした。
def gguf_completion(
self, context, continuation=None, stop=None, retries=3, delay=5, **kwargs
):
for _ in range(retries):
try:
prompt = context
request = {
"prompt": prompt,
"logprobs": self.logprobs,
"temperature": self.temperature,
}
if continuation:
prompt += continuation
request.update({"prompt": prompt, "max_tokens": 1, "echo": True})
if stop is not None:
request["stop"] = stop
response = requests.post(
f"{self.base_url}/v1/completions", json=request
# f"{self.base_url}/api/generate", json=request
)
response.raise_for_status()
return response.json()
except RequestException as e:
logger.error(f"RequestException: {e}")
time.sleep(delay) # wait before retrying
else:
raise RuntimeError(
f"Failed to get a valid response after {retries} retries."
)
def gguf_completion(
self, context, continuation=None, stop=None, retries=3, delay=5, **kwargs
):
for _ in range(retries):
try:
prompt = context
request = {
"model": model_name, # 追加: モデル名を明示的に指定
"prompt": prompt,
"logprobs": self.logprobs,
"temperature": self.temperature,
"stream": False, # 追加: 明示的に False にする
}
if continuation:
prompt += continuation
request.update({"prompt": prompt, "max_tokens": 1, "echo": True})
if stop is not None:
request["stop"] = stop if isinstance(stop, list) else [stop]
response = requests.post(
f"{self.base_url}/api/generate", json=request
)
response.raise_for_status()
return response.json()
except RequestException as e:
logger.error(f"RequestException: {e}")
time.sleep(delay) # wait before retrying
else:
raise RuntimeError(
f"Failed to get a valid response after {retries} retries."
)
重要なのは /v1/completions
から /api/generate
に変更したことです。この修正により、API の呼び出し自体は可能になりました。
しかし、gguf_completion 自体は正常に呼び出せるようになりましたが、数十分経っても生成が終わりませんでした。Qwen2.5-0.5B などの SLM では同じトークンがひたすら繰り返されたりするので、その影響なのかと考え、max_length
を設定してみましたが、それでも数十分経っても生成が終わりませんでした。llama-cpp-pythonでは、repeat_penalty
を設定するなどして対応できるのですが...。
Qwen2.5-0.5B と違って生成が永遠に終わらないことが少なく、日本語も得意な phi-3を使って試験的に実行したところ、一応は処理が完了しました。動作する使い方はできているようでした。ただし、以下のように非常に処理時間がかかる上、ログ尤度(loglikelihood)の出力はうまくいかず、デバッグが必要な状態でした。
-
generate_until
: 4/4 [04:25<00:00, 66.34s/it] -
loglikelihood
: 0/12 [02:03<?, ?it/s]
つまり、generate_until
は極端に遅く、loglikelihood
に至っては全く成功していないという結果でした。
まとめ
- 5Bパラメータ以下の小型言語モデル(Small Language Model: SLM)の性能が向上中。
- 来たる個人環境での SLM 開発に向けて LLM を評価したい。
- lm-eval-harness を使って評価をトライしたが、実行速度の観点で現状手づまり。
とまぁ、色々と前書きを垂れた割には残念な結果となりました。が、このままではあまりにも情けないのでもう少しもがいてみたいと思います...。
Discussion