はじめに
2026年8月にリリースされたばかりの Qwen3.8-27B を、手元の自作PC(RTX 4070・12GB VRAM)で動かせるか検証してみました。
普段はClaude CodeやOpenCode(さくらのAI Engine経由)のような、クラウドLLMを使ったコーディングエージェントを利用することが多いですが、実はローカルLLMはほぼ触ったことがなく、LLM自体の仕組みにもそこまで詳しくありません。今回は正直に言うと、Qwen3.8-27Bがなんか盛り上がってそうだったので「試しに動かしてみるか、せっかくだし勉強がてら」くらいのノリで始めました。加えて、自分のPC(RTX 4070)だと実際どれくらい動くんだろう、という素朴な興味もありました。厳密なベンチマークというより「雑に動かしながら理解を深めてみた」くらいのトーンで読んでもらえると助かります。
比較対象モデルについて
今回はQwen3.8-27Bだけでなく、以下のモデルも合わせて動かしています。
- Qwen3 8B: Qwen3.8-27Bと同じQwen系の軽量版。サイズ違いでの比較がしやすいだろうと最初から候補に入れていました
- Gemma4:e4b: 検証を進める中で、Qwen系以外の軽量モデルも気になって追加
- Gemma4:12B: 27Bの結果を見て「12Bも精度の割に速度が思ったより出ないんじゃないか」となんとなく予想したので、それを確かめたくて追加(実際に試したら普通に余裕で動きました)
最初から4モデル比較を計画していたわけではなく、検証を進めながら気になって追加していった、という行き当たりばったりな進め方だったことは先に断っておきます。
検証環境
| 項目 | スペック |
|---|---|
| GPU | NVIDIA GeForce RTX 4070 (VRAM 12GB / 12,282 MiB) |
| CPU | Ryzen7 7800X3D |
| メモリ | DDR5 32GB |
| SSD | 2TB |
| OS | Windows 11 + WSL2 (Ubuntu, kernel経由でGPUパススルー) |
| GPUドライバ | Driver Version 572.60 / CUDA Version 12.8 |
| 実行環境 | Ollama 0.32.13 |
WSL2からGPUが正しく認識できているかはnvidia-smiで確認済みです。
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 570.124.03 Driver Version: 572.60 CUDA Version: 12.8 |
|-----------------------------------------+------------------------+----------------------+
| 0 NVIDIA GeForce RTX 4070 On | 00000000:01:00.0 On | N/A |
| 0% 31C P8 11W / 200W | 587MiB / 12282MiB | 2% Default |
+-----------------------------------------+------------------------+----------------------+
Ollamaのインストールはcurl -fsSL https://ollama.com/install.sh | shのみで完了。インストールログにもNvidia GPU detected.と表示され、WSL2側でも問題なくGPUを検出してくれました。
量子化レベルの選定
Qwen3.8-27Bは27B(約270億)パラメータのdenseモデルで(公式モデルカードより)、フル精度(BF16)だと約54GBのVRAMが必要になり、RTX 4070の12GBには到底収まりません。そこで量子化版を検討しました。
Ollama公式レジストリのqwen3.8:27bタグを見ると、最軽量でもQ4_K_M(18GB)しか提供されておらず、これも12GBには収まりません。そこで、OSSモデルの量子化を継続的に公開しているunslothのGGUF版(unsloth/Qwen3.8-27B-GGUF)を採用しました。
選定根拠
Unsloth公式ドキュメント(Qwen3.8: ローカルで実行する方法)には、Qwen3.8-27Bのハードウェア要件表が掲載されています(単位はRAM+VRAMの合計)。
| 2ビット | 3ビット | 4ビット | 6ビット | 8ビット | BF16 |
|---|---|---|---|---|---|
| 11〜13GB | 13〜16GB | 17〜19GB | 24GB | 31GB | 56GB |
目安としては、RAM+VRAM ≈ 量子化サイズです。それ以外でも動作しますが、ディスクオフロードのため大幅に遅くなります。
RTX 4070のVRAM 12GBは「2ビット帯(11〜13GB)」にちょうど収まるラインだったため、この帯域の量子化ファイルから選定しました。
| ファイル | サイズ |
|---|---|
| UD-IQ2_XXS | 9.0GB |
| UD-IQ2_M | 10.3GB |
| UD-Q2_K_XL | 10.7GB ← 採用 |
| UD-IQ3_XXS | 11.9GB |
UD-Q2_K_XLはunslothの"Dynamic"量子化方式で、単純な均一2bit量子化ではなく、重要な層により高い精度を割り当てる方式です。同ドキュメントの量子化分析セクションでは、IQ2_XXS(9GB)でBF16比82.5%の精度を維持しているというデータも公開されています。
実行方法
Ollamaはhf.co/プレフィックスでHuggingFaceのGGUFを直接pull&runできます。
ollama run hf.co/unsloth/Qwen3.8-27B-GGUF:UD-Q2_K_XL --verbose
初回はモデルダウンロード(約10.7GB)が走ります。
ベンチマーク結果
比較条件を揃えるため、Qwen3 8B・Qwen3.8-27B・Gemma4:e4b・Gemma4:12Bの4モデルに同一プロンプト(「プログラミングとか文書作成とかって得意だったりします?」)を複数回投げて計測しました。プロンプト自体に特に意図はなく、動作確認がてらhello world的なノリで適当に投げたものです。
ollama run qwen3:8b --verbose
ollama run hf.co/unsloth/Qwen3.8-27B-GGUF:UD-Q2_K_XL --verbose
ollama run gemma4:e4b --verbose
ollama run gemma4:12b --verbose
実測値
| モデル | ファイルサイズ | 1回目 | 2回目 | 3回目 | 平均 | VRAM使用量 | GPU使用率(生成中) |
|---|---|---|---|---|---|---|---|
| Qwen3 8B | 5.2GB | 79.98 tok/s | 78.42 tok/s | 79.52 tok/s | 約79.3 tok/s | 約6,180 MiB / 12,282 MiB (約50%) | 95% |
| Gemma4:e4b | 9.6GB | 99.92 tok/s | 99.94 tok/s | 101.43 tok/s | 約100.4 tok/s | 約5,053 MiB / 12,282 MiB (約41%) | 91% |
| Gemma4:12B | 7.4GB | 51.89 tok/s | 51.72 tok/s | 51.40 tok/s | 約51.7 tok/s | 約8,963 MiB / 12,282 MiB (約73%) | 96% |
| Qwen3.8-27B | 10.7GB(UD-Q2_K_XL) | 13.83 tok/s | 13.86 tok/s | 13.85 tok/s | 約13.85 tok/s | 11,031 MiB / 12,282 MiB (約90%) | 30〜40%台 |
こうして並べると、ファイルサイズが大きくなるにつれて速度が素直に落ちていく傾向が見えます。VRAM使用量も、5.2GB(8B)→7.4GB(12B)→10.7GB(27B)とサイズが上がるほど当然のように増えていき、27Bはついに約90%まで達しています。
Qwen3.8-27Bとの速度差は、Qwen3 8Bで約5.7倍、Gemma4:12Bで約3.7倍、Gemma4:e4bだと約7.3倍でした。27B(UD-Q2_K_XL)は、モデルファイルサイズ(10.7GB)とほぼ同じ量(11,031 MiB≒10.8GB)がVRAMに収まっており、CPUオフロードなしでVRAMのほぼ限界まで使い切って動いていることが分かります。それでも他の3モデルと比べて生成速度は大きく落ちています。
ここでGPU使用率の欄を見て「あれ?」となりました。8B・e4b・12Bはいずれも生成中90%台まで張り付いているのに、27Bだけ30〜40%台。VRAMはほぼ埋まっているのに、計算コア自体はそこまでフル稼働していないように見えます。
最初は「たまたま生成が終わったタイミングで計測しちゃっただけかも」と思い、1秒ごとに継続計測してみることにしました。
nvidia-smi --query-gpu=utilization.gpu --format=csv -l 1
utilization.gpu [%]
3 %
3 %
5 %
3 %
90 %
0 %
35 %
37 %
38 %
...(生成中はおおむね30〜40%台で推移)...
40 %
0 %
2 %
0 %
6 %
5 %
結果、生成開始前後の3%やアイドル後の0%を除くと、生成中はほぼ一貫して30〜40%台でした。単発の計測ミスではなく、これが27Bの実際の挙動だったようです。
推測ですが、これはLLM推論の一般的な特性が関係していそうです。自分はこのあたり全然詳しくないので、素直に「なんでだろう」と思って少し調べてみました。
調べてみると、バッチサイズ1での生成(今回のような1人でチャットする使い方はまさにこれ)は「メモリ帯域律速(memory-bandwidth bound)」になりやすい、という話がいくつか見つかりました。
Recall that during the autoregressive part of generation, we are memory bandwidth bound if our batch size is 1.
(バッチサイズが1の場合、生成の自己回帰的な部分ではメモリ帯域幅がボトルネックになることを思い出してほしい)
— A guide to LLM inference and performance (Baseten)
他にも似たような記述をこちらの論文などでも見かけたので、割と広く言われている話のようです。1トークン生成するたびに大量のパラメータをGPUメモリから読み出す必要があり、計算そのものより「メモリからどれだけ速くデータを読み出せるか」がボトルネックになりやすいらしく、パラメータ数が多い27Bほどこの影響を受けやすい、というのは感覚的にも筋が通ります。
ただし私は専門家ではないので、これが今回のケースに完全に当てはまるかまでは検証できていません。「ちょっと調べたらそれっぽい説明が見つかった」くらいの温度感で受け取ってください。
4モデルとも3回ずつ同一プロンプトで計測しましたが、eval rateのブレ幅はいずれも小さく、安定した実測値と言えそうです。Gemma4:e4bはQwen系の8Bよりもさらに速い結果になりましたが、e4bはもともと軽量設計を謳っているモデルなので、この結果自体は順当だと思います。
実行ログ(抜粋)
Qwen3 8B
user@wsl-pc:~$ ollama run qwen3:8b --verbose
>>> プログラミングとか文書作成とかって得意だったりします?
Thinking...
(思考過程は省略)
...done thinking.
はい、プログラミングや文書作成のサポートは得意としています!以下に具体的な能力を紹介します:
(回答本文は省略)
total duration: 9.270642597s
load duration: 134.868888ms
prompt eval count: 25 token(s)
prompt eval duration: 380.253ms
prompt eval rate: 65.75 tokens/s
eval count: 700 token(s)
eval duration: 8.752293s
eval rate: 79.98 tokens/s
2回目・3回目も同一プロンプトで試したところ、78.42 tokens/s、79.52 tokens/sとほぼ同水準でした。
Qwen3.8-27B (UD-Q2_K_XL)
user@wsl-pc:~$ ollama run hf.co/unsloth/Qwen3.8-27B-GGUF:UD-Q2_K_XL --verbose
>>> プログラミングとか文書作成とかって得意だったりします?
Thinking...
(思考過程は省略)
...done thinking.
はい、得意です。
プログラミングなら、コード作成・解説・バグ修正・改善・レビュー、アルゴリズムや設計の相談など、かなり幅広くお手伝いで
きます。
(回答本文は省略)
total duration: 33.604927065s
load duration: 202.505487ms
prompt eval count: 63 token(s)
prompt eval duration: 1.555067s
prompt eval rate: 40.51 tokens/s
eval count: 433 token(s)
eval duration: 31.310272s
eval rate: 13.83 tokens/s
2回目・3回目もほぼ同じ値でした(13.86 tok/s / 13.85 tok/s)。
VRAM使用量は以下のコマンドで確認しました。
user@wsl-pc:~$ nvidia-smi --query-gpu=memory.used,memory.total,utilization.gpu --format=csv
memory.used [MiB], memory.total [MiB], utilization.gpu [%]
11031 MiB, 12282 MiB, 32 %
Gemma4:e4b
user@wsl-pc:~$ ollama run gemma4:e4b --verbose
>>> プログラミングとか文書作成とかって得意だったりします?
Thinking...
(思考過程は省略)
...done thinking.
はい、どちらの分野も得意としています。具体的な内容や目的に応じて、全力でお手伝いできます。
(回答本文は省略)
total duration: 8.080288456s
load duration: 343.956732ms
prompt eval count: 29 token(s)
prompt eval duration: 435.614ms
prompt eval rate: 66.57 tokens/s
eval count: 729 token(s)
eval duration: 7.295688s
eval rate: 99.92 tokens/s
2回目・3回目も同一プロンプトで試したところ、99.94 tokens/s、101.43 tokens/sとほぼ同水準でした。VRAM使用量は約5,053 MiB、GPU使用率は生成中91%でした。
Gemma4:12B
user@wsl-pc:~$ ollama run gemma4:12b --verbose
>>> プログラミングとか文書作成とかって得意だったりします?
Thinking...
(思考過程は省略)
...done thinking.
はい、プログラミングと文書作成の両方において、非常に得意としています。
(回答本文は省略)
total duration: 23.240208113s
load duration: 308.866101ms
prompt eval count: 29 token(s)
prompt eval duration: 360.041ms
prompt eval rate: 80.55 tokens/s
eval count: 1171 token(s)
eval duration: 22.568738s
eval rate: 51.89 tokens/s
2回目・3回目も同一プロンプトで試したところ、51.72 tokens/s、51.40 tokens/sとほぼ同水準でした。VRAM使用量は約8,963 MiB、GPU使用率は生成中96%でした。
体感
- Qwen3 8B: 対話が詰まらず、サクサク使える速度感
- Gemma4:e4b: 4モデルの中で一番軽快。8Bよりもさらにキビキビ動く印象で、VRAMにも一番余裕があった
- Gemma4:12B: 8Bとe4bよりワンテンポ遅いが、まだ十分実用的な速度感。VRAMも約73%とまだ余裕がある
- Qwen3.8-27B (Q2_K_XL): 「実用ギリギリ」ライン。読めるスピードではあるが、他の3モデルのような即応性はなく、多少の待ち時間を感じる
精度についても軽く触れておくと、同じ質問への回答を見比べた限りでは、4モデルともどれも要点を押さえた丁寧な回答を返してきて、この程度の質問なら体感で大きな差は感じませんでした。ただしこれは1問しか比べていないので、あくまで参考程度の感想です。
精度も少し見てみる(FizzBuzz)
体感だけだと心許ないので、「FizzBuzzをPythonで書いてください。1から30まで出力するようにしてください。」という1問を4モデルに投げ、実際にコードを実行して確認しました。
Qwen3 8B(eval rate: 79.44 tok/s)
for i in range(1, 31):
if i % 3 == 0 and i % 5 == 0:
print("FizzBuzz")
elif i % 3 == 0:
print("Fizz")
elif i % 5 == 0:
print("Buzz")
else:
print(i)
Qwen3 8Bの回答全文
以下は、1から30までの数字を出力するFizzBuzzのPythonコードです。
for i in range(1, 31):
if i % 3 == 0 and i % 5 == 0:
print("FizzBuzz")
elif i % 3 == 0:
print("Fizz")
elif i % 5 == 0:
print("Buzz")
else:
print(i)
説明:
-
range(1, 31)で1から30までの数字をループします。 - 各数字に対して、以下の条件を順にチェックします:
- 3と5の両方で割り切れる場合(つまり15の倍数)→
"FizzBuzz" - 3で割り切れる場合 →
"Fizz" - 5で割り切れる場合 →
"Buzz" - それ以外 → 数字そのものを出力します。
- 3と5の両方で割り切れる場合(つまり15の倍数)→
このコードは、FizzBuzzの基本的なルールに従って正しく動作します。
Qwen3.8-27B(eval rate: 14.14 tok/s)
for i in range(1, 31):
if i % 15 == 0:
print("FizzBuzz")
elif i % 3 == 0:
print("Fizz")
elif i % 5 == 0:
print("Buzz")
else:
print(i)
Qwen3.8-27Bの回答全文
for i in range(1, 31):
if i % 15 == 0:
print("FizzBuzz")
elif i % 3 == 0:
print("Fizz")
elif i % 5 == 0:
print("Buzz")
else:
print(i)
(このプロンプトへの回答は、コードブロックのみのシンプルな返答でした)
Gemma4:e4b(eval rate: 100.10 tok/s)
def fizzbuzz(start, end):
for i in range(start, end + 1):
output = ""
if i % 3 == 0:
output += "Fizz"
if i % 5 == 0:
output += "Buzz"
if output:
print(output)
else:
print(i)
fizzbuzz(1, 30)
Gemma4:e4bの回答全文(自己申告の実行結果に誤りあり)
PythonでFizzBuzzを実装する最も一般的な方法は、if/elif/elseの構造を使うことです。
特に注意が必要なのは、15の倍数(3でも5でも)を最初にチェックすることです。そうしないと、15が最初に3の倍数として処理され、FizzBuzzが出力されません。
以下にコードと実行結果を示します。
Pythonコード
def fizzbuzz(start, end):
"""
指定された範囲 (startからendまで) でFizzBuzzを処理する関数。
endは含まない(排他的)ものとする。
"""
for i in range(start, end + 1):
output = ""
if i % 3 == 0:
output += "Fizz"
if i % 5 == 0:
output += "Buzz"
if output:
print(output)
else:
print(i)
fizzbuzz(1, 30)
実行結果 (抜粋)
上記のコードを実行すると、以下のような出力がされます。(すべての30個の出力は長すぎるため、重要な部分を抜粋して確認しています。)
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz
Fizz
22
23
24
Buzz
Fizz
30
解説(コードのポイント)
上記のコードでは、複数のelifを使って条件分岐する代わりに、outputという変数を使って文字列を構築する方法を採用しています。この方法が、FizzBuzz問題のロジックをより柔軟かつスッキリ記述できるため、一般的に推奨されます。
-
output = "": ループの最初で空の文字列を初期化します。 -
if i % 3 == 0:: 3の倍数ならoutputに"Fizz"を追記します。 -
if i % 5 == 0:: 5の倍数ならoutputに"Buzz"を追記します。- ★ポイント:15の場合、この2つの
ifが独立して実行されるため、outputは"Fizz"の後に"Buzz"が追記され、最終的に"FizzBuzz"となります。
- ★ポイント:15の場合、この2つの
-
出力判断:
-
if output::outputが空文字列("")ではない場合、outputを出力します。 -
else::outputが空文字列の場合、そのままの数字iを出力します。
-
Gemma4:12B(eval rate: 50.65 tok/s)
for i in range(1, 31):
if i % 3 == 0 and i % 5 == 0:
print("FizzBuzz")
elif i % 3 == 0:
print("Fizz")
elif i % 5 == 0:
print("Buzz")
else:
print(i)
Gemma4:12Bの回答全文
PythonでFizzBuzzを実装する最も標準的で分かりやすいコードは以下の通りです。
# 1から30までループを回す
for i in range(1, 31):
if i % 3 == 0 and i % 5 == 0:
print("FizzBuzz")
elif i % 3 == 0:
print("Fizz")
elif i % 5 == 0:
print("Buzz")
else:
print(i)
解説
-
range(1, 31): Pythonのrange関数は「終わりの数字を含まない」ため、30まで出力したい場合は31を指定します。 -
i % 3 == 0: 「iを3で割った余りが0(=3の倍数)」かどうかを判定しています。 - 条件の順番: 最初に「3でも5でも割り切れる(FizzBuzz)」を判定するのがポイントです。先に「3の倍数」だけを判定してしまうと、15のときに「FizzBuzz」ではなく「Fizz」と判定されてしまうためです。
(おまけ)少しスマートに書く方法
Pythonらしい書き方として、文字列を連結させる方法もあります。この書き方だと「FizzBuzz」の条件を個別に書かなくて済むため、拡張(例えば4の倍数のら「Bazz」を追加するなど)がしやすくなります。
for i in range(1, 31):
result = ""
if i % 3 == 0:
result += "Fizz"
if i % 5 == 0:
result += "Buzz"
print(result if result else i)
| モデル | 結果 | 書き方 |
|---|---|---|
| Qwen3 8B | ✅ 正解 |
if/elif/elseで3・5・15を順にチェックする一般的な書き方 |
| Qwen3.8-27B | ✅ 正解 |
i % 15 == 0を使った簡潔な書き方 |
| Gemma4:e4b | ✅ 正解(コードは) |
output文字列を組み立てる柔軟な書き方。ただし回答内に添えていた「実行結果」の抜粋には誤りがあった |
| Gemma4:12B | ✅ 正解 | 8Bと同じif/elif/else方式。おまけで文字列連結版も提示、実行結果の記載なし |
コード自体はどのモデルも正しく動きました。4モデルとも実行結果は以下の通り一致しています。
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
16
17
Fizz
19
Buzz
Fizz
22
23
Fizz
Buzz
26
Fizz
28
29
FizzBuzz
Gemma4:e4bだけ、回答内で自分なりに実行結果を書き添えていましたが、その一部が上と食い違っていました。
22
23
- 24
+ Fizz
Buzz
Fizz
- 30
+ FizzBuzz
(上: Gemma4:e4bが回答に書いていた内容 / 下: 実際にコードを実行した正しい結果)
コードは合っているのに添えた説明が間違っている、というのは実際に動かして確認しないと気づけないポイントで、今回1問だけでも試してみた価値がありました。
もう少し難しい問題も(回文判定)
FizzBuzzは易しすぎて4モデルとも普通に正解してしまったので、もう少しひねった問題も試しました。プロンプトはこちらです。
文字列を受け取り、それが回文(前から読んでも後ろから読んでも同じ)かどうかを判定するPython関数を書いてください。大文字小文字と空白は無視してください。
「句読点は無視するかどうか」をあえて明示していません。ここが今回一番見たかったところで、仕様が曖昧な指示を投げた時にモデルがどう振る舞うか、というのを雑にでも見てみたかったんです。実務でも最初から仕様がガチガチに固まっていることばかりではなく、多少曖昧な依頼から始まることもあると思うので、そこにどう向き合うかで質の違いが見えるかもと思いました。
Qwen3 8B(eval rate: 74.48 tok/s)
def is_palindrome(s):
s = s.lower().replace(' ', '')
return s == s[::-1]
Qwen3 8Bの回答全文
To determine whether a given string is a palindrome while ignoring case and spaces, we can follow a straightforward approach.
Approach Summary
- Convert to Lowercase: To ensure the comparison is case-insensitive.
- Remove Spaces: Since the user explicitly mentioned ignoring spaces, we remove them.
- Check for Palindrome: A string is a palindrome if it reads the same forwards and backwards.
Python Function
def is_palindrome(s):
s = s.lower().replace(' ', '')
return s == s[::-1]
Example Usage
print(is_palindrome("RaceCar")) # True
print(is_palindrome("A man a plan a canal Panama")) # True
print(is_palindrome("A man, a plan, a canal: Panama")) # False (because punctuation is not removed)
Notes
- This function ignores only spaces and case, not other characters (like punctuation or tabs).
- If the user intended to ignore all whitespace and non-alphanumeric characters, the function would need to be adjusted (e.g., using regular expressions).
Optional Enhancement (Ignoring All Non-Alphanumeric Characters)
import re
def is_palindrome(s):
s = re.sub(r'[^a-z0-9]', '', s).lower()
return s == s[::-1]
This version removes all non-alphanumeric characters (including punctuation and symbols) and is often used in more generalized palindrome checks.
(補足: このプロンプトではthinkingが約4600トークンと非常に長く、「空白のみ無視すべきか、句読点も含めて無視すべきか」を延々と検討していました。最終的に基本版をメイン回答としつつ、拡張版もおまけで提示する形に落ち着いています)
Qwen3.8-27B(eval rate: 13.96 tok/s)
def is_palindrome(s: str) -> bool:
cleaned = ''.join(ch.lower() for ch in s if not ch.isspace())
return cleaned == cleaned[::-1]
Qwen3.8-27Bの回答全文
以下のように実装できます。
def is_palindrome(s: str) -> bool:
"""
文字列が回文かどうかを判定する。
大文字小文字は区別せず、空白は無視する。
"""
cleaned = ''.join(ch.lower() for ch in s if not ch.isspace())
return cleaned == cleaned[::-1]
使用例:
print(is_palindrome("racecar")) # True
print(is_palindrome("Race Car")) # True
print(is_palindrome("hello world")) # False
もし記号や数字以外の文字も無視して判定したい場合は、以下のようにもできます。
import re
def is_palindrome_ignore_symbols(s: str) -> bool:
cleaned = re.sub(r'[^\w]', '', s.lower())
return cleaned == cleaned[::-1]
この場合、"A man, a plan, a canal: Panama" も True になります。
Gemma4:e4b(eval rate: 102.42 tok/s)
def is_palindrome(text: str) -> bool:
cleaned_text = ""
for char in text:
if char.isalnum():
cleaned_text += char.lower()
return cleaned_text == cleaned_text[::-1]
Gemma4:e4bの回答全文(誤りの経緯が分かるので気になる方向け)
はい、承知いたしました。
Pythonで文字列の回文判定を行う関数は、まず「大文字小文字の統一」と「アルファベットや数字以外の文字の除去」という前処理を行い、その後に「文字列を反転させたものとの比較」を行うのが最も効率的です。
以下のコードがそれを実現しています。
def is_palindrome(text: str) -> bool:
"""
文字列が回文(前から読んでも後ろから読んでも同じ)であるか判定します。
大文字小文字と空白、およびその他の非英数字文字は無視します。
Args:
text: 判定したい文字列。
Returns:
回文であればTrue、そうでなければFalse。
"""
cleaned_text = ""
for char in text:
if char.isalnum():
cleaned_text += char.lower()
return cleaned_text == cleaned_text[::-1]
# テストケース
print(f"'Racecar' は回文か?: {is_palindrome('Racecar')}") # True
print(f"'A man, a plan, a canal: Panama' は回文か?: {is_palindrome('A man, a plan, a canal: Panama')}") # True
print(f"'' は回文か?: {is_palindrome('')}") # True (空文字列は回文)
print(f"' ' は回文か?: {is_palindrome(' ')}") # True (除去すると空文字列になるため)
print(f"'Hello World' は回文か?: {is_palindrome('Hello World')}") # False
print(f"'しんぶんし' は回文か?: {is_palindrome('しんぶんし')}") # False (※処理ロジック上、アルファベット・数字のみを対象としています)
実行結果
'Racecar' は回文か?: True
'A man, a plan, a canal: Panama' は回文か?: True
'' は回文か?: True
' ' は回文か?: True
'Hello World' は回文か?: False
'しんぶんし' は回文か?: False
解説ポイント
-
char.isalnum()の役割:
これはPythonの組み込みメソッドで、「その文字がアルファベット(a-z, A-Z)または数字(0-9)であるかどうか」を判定します。これにより、句読点(,、:など)や空白()を自動的に無視することができます。 -
char.lower()の役割:
文字列全体を小文字に統一することで、Racecarもracecarとなり、大文字小文字による比較の違いを排除できます。 -
[::-1]の役割:
Pythonのスライス機能で、[::-1]と記述すると要素を逆順に取得でき、文字列を簡単に反転させることができます。
Gemma4:12B(eval rate: 51.03 tok/s)
def is_palindrome(text: str) -> bool:
cleaned_text = text.lower().replace(" ", "")
return cleaned_text == cleaned_text[::-1]
Gemma4:12Bの回答全文
ご要望通り、大文字・小文字の区別をなくし、空白を無視して回文かどうかを判定するPython関数を作成しました。
サンプルコード
def is_palindrome(text: str) -> bool:
# 1. 全てを小文字に変換し、空白を取り除く
cleaned_text = text.lower().replace(" ", "")
# 2. 文字列と、それを逆転させたものを比較する
return cleaned_text == cleaned_text[::-1]
# --- テスト用コード ---
test_cases = [
"Racecar", # True (大文字が含まれる)
"A man a plan a canal Panama", # True (空白が含まれる)
"hello", # False
"Noon", # True
"12321", # True
"Python" # False
]
for text in test_cases:
result = is_palindrome(text)
print(f"'{text}' -> {result}")
コードの解説
-
.lower(): 「Racecar」を「racecar」のように変換します。これにより、大文字と小文字の差を無視します。 -
.replace(" ", ""): 文字列内のすべての空白(スペース)を削除します。 -
[::-1]: Pythonの「スライス」という機能です。文字列を末尾から先頭に向かって1文字ずつ取り出すことで、簡単に文字列を反転させることができます。 -
==: 元の(加工済みの)文字列と、反転させた文字列が一致するかを比較します。
発展:句読点も無視したい場合
もし「A man, a plan, a canal: Panama!」のように、カンマやピリオドも無視したい場合は、replace の代わりに正規表現などを使って記号を除去するように変更することも可能です。
| モデル | コード動作 | 自己申告の実行結果 | 特筆点 |
|---|---|---|---|
| Qwen3 8B | ✅ 正解 | (テスト提示なし) | thinkingが非常に長かった(約4600トークン、「句読点も無視すべきか」を延々と悩んでいた) |
| Qwen3.8-27B | ✅ 正解 | (テスト提示なし) | thinkingは簡潔(約650トークン)、isspace()を使いタブ・改行にも対応 |
| Gemma4:e4b | ✅ コードは正解 | ❌ 誤りあり |
isalnum()で句読点も自動的に除去する網羅的な実装。ただし自分で添えた実行結果(「しんぶんし」の判定)が誤っていた |
| Gemma4:12B | ✅ 正解 | ✅ 完全一致 | 実装はQwen3 8Bに近いシンプルな方式。自己申告のテスト結果もすべて実際の実行結果と一致 |
4モデルともコード自体は正しく動きましたが、Gemma4:e4bだけ、FizzBuzzに続いてまた自己申告の実行結果に誤りがありました。今回はisalnum()を使ったことで日本語(「しんぶんし」)も判定対象に入り、「日本語は対象外なのでFalse」と説明していたのに実際はTrueが返る、という食い違いです(isalnum()はUnicode対応なので、実はひらがなも英数字扱いになります)。実際に食い違っていた部分はこちらです。
'Racecar' は回文か?: True
'A man, a plan, a canal: Panama' は回文か?: True
'' は回文か?: True
' ' は回文か?: True
'Hello World' は回文か?: False
- 'しんぶんし' は回文か?: False
+ 'しんぶんし' は回文か?: True
(上: Gemma4:e4bが回答に書いていた内容 / 下: 実際にコードを実行した正しい結果)
「しんぶんし」は前から読んでも後ろから読んでも同じ回文なので、Trueが正しい結果です。コードの動作は仕様通りだったのに、モデルが自分で予想した実行結果の方が誤っていた、という珍しいパターンでした。
曖昧さへの向き合い方も、モデルによって結構違いました。Qwen系は「基本版を出しつつ、句読点も無視したいならこちらも」という感じで両方提示してくる一方、Gemma4:e4bは最初からisalnum()で句読点ごと無視する実装を選んでいて、Gemma4:12Bは逆に指示された範囲だけ律儀に実装していました。どれが正解というわけでもないんですが、こういう「指示のグレーゾーンをどう埋めるか」の癖がモデルごとに違うのは、雑に1問投げただけでも見えてきて面白かったです。
普段バックエンドの設計をしていても、後から変更が効きにくい部分はADRやDesign Docsを書いている段階で最初から明確に詰めますが、細かい部分は実装していく中で詰めていくこともよくあります。API仕様書に書かれるようなユーザーから見える部分だけでなく、キャッシュ戦略やリトライ時のべき等性の担保のような裏側の設計は、まさにそうやって実装しながら詰まっていくことが多い印象です。今回のような「言われた範囲だけ律儀にやる」か「一歩踏み込んで拡張案も出す」かという振る舞いの違いは、そういう場面でどちらのスタンスの方が頼りになりそうか、というのを考える上でも参考になりそうだと感じました。
今回の2問(FizzBuzz・回文判定)というごく小さいサンプルの範囲では、Gemma4:12Bが速度と精度のバランスで一番安定していたように見えました。e4bより遅いとはいえ実用上まったく問題ない速度で、精度面でも自己申告の誤りが一度も出ませんでした。ただしあくまで2問しか試していないので、これもたまたまかもしれません。
- RTX 4070(12GB)でも、量子化を選べばQwen3.8-27BをCPUオフロードなしで動かすことができた
- ただし生成速度は他の3モデル(Qwen3 8B・Gemma4:e4b・Gemma4:12B)の約4〜7倍遅く、VRAM使用率も約90%まで張り付く。「動く」ことと「実用的に使える」ことは別問題だと実感した
- 8BとGemma4:e4bという同じ軽量クラス同士で比べても、Gemma4:e4bの方がさらに速く、VRAM消費も少なかった。e4bは軽量設計を謳っているモデルなので順当な結果ではあるものの、「軽量モデル」でもモデルごとに結構差があることは体感できた
今回一番の学びは、このスペック帯(VRAM 12GB)では、無理に27Bクラスを動かすより、8B〜12Bクラスの軽〜中量級モデルで用途を絞った方が結果的に満足度が高いということでした。速度検証と精度検証(FizzBuzz・回文判定)を合わせて振り返ると、その中でもGemma4:12Bが一番バランスが良さそうだったというのが今回の雑な検証での感触です。
- 27Bを2bit量子化まで削ると、モデル本来の精度も目減りする
- その上で速度も1/4〜1/7まで落ちるとなると、「精度も速度も中途半端」になりやすい
- 8Bやe4bはさらに速いが、e4bは今回の2問でどちらも自己申告の実行結果を間違えており、精度面ではやや不安が残った
- 12Bは速度こそ8B・e4bに劣るものの、精度面での取りこぼしがなく、VRAMにもまだ余裕があった
「ハイスペックでなければ大きいモデルを頑張って動かす」よりも、「今のスペックで気持ちよく動く軽〜中量級モデルに、担わせるタスクを絞る」方が現実的な選択肢だと感じました。27Bクラスを本気で使うなら、VRAM 16GB以上(できれば24GB)への投資を検討した方が良さそうです。
もし4bit量子化がストレスなく動くスペック(VRAM 20GB前後)があれば、また違った結果になるはずです。今回の2bitはかなり精度を削った状態なので、4bitまで余裕を持って動かせる環境なら「27Bでも十分実用的」という結論に変わる可能性は高いと思っています。このあたりは今後、環境を変えて試してみたいところです。
なお、当初はGemma4:12Bまでは手を出さず、Gemma4:e4bのような軽いモデルから試すか、というノリで進めていました。27Bの結果を見て「12Bも精度の割に速度が思ったより出ないんじゃないか」となんとなく予想していた部分もあったのですが、調べ直したところQ4_K_M量子化で7.4GB程度と分かり、せっかくなので試してみることに。実際、VRAM使用量は約73%に収まり、余裕を持って動作しました。別に最初から12Bで試しても全然良かったな、というのが今の感想です。
気が向いたらやるかもしれないこと
- 3bit系(UD-IQ3_XXS等)でオフロードが発生したらどれくらい遅くなるのか
- コンテキスト長を伸ばしたらVRAMや速度がどう変わるか
そこまで真剣に計画しているわけではなく、気が向いたら、くらいの温度感です。