Mac M2 チップで lm-evaluation-harness 2
はじめに
来たるSLM全盛時代に向けて、Mac mini (2023) の Apple M2 チップ(24GB unified memory)という物足りない環境で言語モデルを評価したいと試行錯誤した経緯を記事に記録しようと書き始めました。今回は、前回の記事の続編になります。
対象読者
以下のいずれかの方々を対象にとって参考になれば幸いです。
- 言語モデルを評価してみたい MacのM2チップ(24GB unified memory)ユーザ。
- nvidia様GPUの大量購入が困難だけど言語モデルを開発したい個人開発者。
- 会社に言語モデル開発を命じられるも、環境構築の予算が足りないand/or購入のための稟議書作成に疲弊しきってしまった企業研究者。
結論
前回の記事では、言語モデルの評価フレームワークであるlm-evaluation-harnessを用いて日本語能力を評価してみましたが、そのまま使用するだけでは困難であることが分かりました。今回は、Mac ユーザの強い味方である llama-cpp-python を用いた推論手段を正攻法(?)で実施して、 「Mac でも実用的な評価ができそう」と感じた内容を紹介します。
gguf で評価
実行方法
前回の記事では、ollama を起動してから lm-eval --model gguf
を実行していましたが、調べてみると lm-evaluation-harness の issues GGUF Local Model #1254 ですでに議論されていました。
やり方は簡単で、llama-cpp-python をインストールして、下記コマンドを実行します。
% poetry run python -m llama_cpp.server\
--n_gpu_layers 25\
--n_ctx 2048 \
--model /path_to_model/model.gguf
このとき使用できる引数は、Llama
クラスのコンストラクタ引数と同様です。詳細は API reference を参照してください。
サーバを起動すると、以下のような出力が表示されます:
INFO: Started server process [42561]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://localhost:8000 (Press CTRL+C to quit)
この状態で、出力されたURL(例: http://localhost:8000)を lm-eval 実行時の引数に指定します。以下は実行例です:
% poetry run lm-eval
--model gguf
--model_args base_url=http://localhost:8000
--tasks japanese_leaderboard
--device mps
--output_path path_to_out
--log_sample
--batch_size 1
--limit 10
修正
requests
まずはそのまま実行してみたところ、以下のようなエラーが発生しました:
File "lm-evaluation-harness/lm_eval/models/gguf.py", line 138, in generate_until
response = self.gguf_completion(context=inp, stop=until)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "lm-evaluation-harness/lm_eval/models/gguf.py", line 96, in gguf_completion
raise RuntimeError(
RuntimeError: Failed to get a valid response after 3 retries.
要はgguf_completion
内で3回リトライしても応答が得られなかったことを示しています。
このメソッドは、起動した llama-cpp サーバにプロンプトを投げてテキスト生成を試みる処理であり、リクエストの形式が llama_cpp.Llama
の __call__
メソッドの仕様に沿っていないのが原因のようです。API仕様 を確認のうえ、以下のように修正しました:
def gguf_completion(
self, context, continuation=None, stop=None, retries=3, delay=5, **kwargs
):
for _ in range(retries):
try:
prompt = context
request = {
"max_tokens": self.max_tokens,
"temperature": self.temperature,
"repeat_penalty": self.repeat_penalty,
"logprobs":self.logprobs,
"echo": False,
}
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
)
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."
)
get_results
修正
gguf_completion
を修正して再度実行しました。次は以下のようなエラーです。
File "lm-evaluation-harness/lm_eval/models/gguf.py", line 97, in loglikelihood
logprob, is_greedy = get_result(logprobs, len(context))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "lm-evaluation-harness/lm_eval/models/gguf.py", line 24, in get_result
continuation_logprobs = sum(tokens_logprobs[idx:-1])
^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: unsupported operand type(s) for +: 'int' and 'NoneType
これは logprobs["token_logprobs"]
に None が混在しているためです。logprobs の上位N件のみを返すよう制限されているため(例えば logprobs=10
)、対象のトークンの確率が含まれていない場合 None となるようです。この処理は、gguf.py
中の get_result
という関数によって実施されているので、その関数を下記のように修正します。
def get_result(logprobs, context_length):
is_greedy = True
offsets = logprobs.get("text_offset", [])
tokens = logprobs.get("tokens", [])
tokens_logprobs = logprobs.get("token_logprobs", [])
top_logprobs = logprobs.get("top_logprobs", [])
idx = 0
while offsets[idx] < context_length:
idx += 1
# None を除外して合計(念のため float にもしておく)
continuation_logprobs = sum(float(lp) for lp in tokens_logprobs[idx:-1] if lp is not None)
for i in range(idx, len(tokens)):
token = tokens[i]
# top_logprobs[i] が存在しない or None → is_greedy = False にして終了
if i >= len(top_logprobs) or not isinstance(top_logprobs[i], dict):
is_greedy = False
break
top_tokens = top_logprobs[i]
# top_tokens が空なら is_greedy 判定不能なので False に
if not top_tokens:
is_greedy = False
break
top_token = max(top_tokens.keys(), key=lambda x: top_tokens[x])
if top_token != token:
is_greedy = False
break
return continuation_logprobs, is_greedy
そもそもなぜNoneになるのか、
API reference を確認すると、
logprobs (Optional[int], default: None ) – The number of logprobs to return. If None, no logprobs are returned.
と記載されています。つまり logprobs=10
のように指定した場合、確率上位10トークンのみが返され、それ以外のトークンについては None になる可能性があります。
なぜ制限しているのかというと、語彙サイズが大きく(サーベイ論文 をみるとSmall Language Model でもおおよそ 150,000 語彙)、例えば500トークンを生成する場合、
と、6000万件以上の出力が発生するため、処理コストや通信量の観点から制限していると考えられます(多分)。
結果
以上の修正をして実行した結果が以下のとおりです。コードはここを確認してください。
model | 'generate_until' | loglikelihood |
---|---|---|
hf | 40/40 [23:54<00:00, 35.86s/it] | 120/120 [03:48<00:00, 1.90s/it] |
gguf | 40/40 [09:16<00:00, 13.91s/it] | 120/120 [18:59<00:00, 9.49s/it] |
'generate_until'はだいぶ高速化しましたが、'loglikelihood'は逆だいぶ遅くなりました。
まとめ
- llama-cpp-python を正攻法(?)で使用して日本語ベンチマークを実行。
- gguf_completion 関数と get_result 関数に対して、API仕様との整合性を取るためのコード修正を実施。
- Mac環境でもある程度の工夫で言語モデルの自動評価が可能であることを確認。
前回の記事からは、光明が見えた気もしますが、もう少し工夫したいと思います。
Discussion