Zenn
🐃

Mac M2 チップで lm-evaluation-harness

2025/03/29に公開
1

はじめに

来たる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_argsgen_kwargsは、指定したmodelの種類によって内容が異なります。
いまいち確認方法が不明だったのでlm-evaluation-harness/lm_eval/modelsにあるファイルを直接確認しました。例えばhfを指定するとhuggingface.py中のclass HFLMが呼び出されるので、それを確認します。

huggingface.py
@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_kwargsgenerate_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 で評価を検討してみます。limitbatch_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 には

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)では、 GGUFLMgguf_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 を使って評価をトライしたが、実行速度の観点で現状手づまり。

とまぁ、色々と前書きを垂れた割には残念な結果となりました。が、このままではあまりにも情けないのでもう少しもがいてみたいと思います...。

1

Discussion

ログインするとコメントできます