はじめに

IQuest Coder 系 40B モデルを aider に入れて回していると、最初に感じるのは「prompt はすぐ飲むのに、出力だけが妙に重い」という違和感だった。今回のメモはまさにそこを切り分けたもので、prefill は十分速い一方、decode が 0.6〜8 tok/s まで落ち、編集と反復を繰り返す実運用では待ち時間が大きすぎる、という結論に至っている。

同時期の関連ノートでは、IQuest-Coder-V1-40B-Instruct-nvfp425〜28 tok/s を出していた。なので今回の話は単純なモデル性能ランキングではなく、「aider のような編集ループに何が噛み合わないのか」を見る記録として読むのが正確だ。

背景・動機

aider のようなコーディング支援では、1 回の応答で長文を美しく出すことよりも、短い返答を速く何度も返せることの方が重要になる。特に whole edit 系の運用では、モデルがファイル全体や大きな差分を毎回吐きやすく、単純な「平均 tok/s」よりも体感待ち時間が支配的になる。

今回のメモはその観点でかなり率直で、評価は次の一文に集約されている。

Prefill は十分速いが、decode が致命的に遅い。

この時点で、問題の軸はかなり明確だった。GPU の搭載量や KV cache の不足を疑う前に、まず生成フェーズそのものを疑うべき状況だ。

現状の評価

まず、元メモの観測をそのまま整理する。

  • Prefill(prompt 処理)は数百〜900 tok/s で十分速い
  • Decode(生成)は 0.6〜8 tok/s と遅い
  • GPU や KV cache 不足ではなく、生成フェーズそのものがボトルネック
  • aider の実運用、特に編集と反復では体感的に厳しい

ここで重要なのは、「入力処理が遅い」のではなく「出力が遅い」という点だ。prefill が速いなら、リクエストを送った直後に重さを感じる原因は prompt ingestion ではなく、デコードの進み方にあると見てよい。

実際、埋め込まれていた vLLM ログでもその傾向が見えている。

  (APIServer pid=1) INFO 01-09 19:15:06 [loggers.py:257] Engine 000: Avg prompt throughput: 0.0 tokens/s, Avg generation throughput: 8.0 tokens/s, Running: 1 reqs, Waiting: 0 reqs, GPU KV cache usage: 12.2%, Prefix cache hit rate: 6.3%
(APIServer pid=1) INFO 01-09 19:15:16 [loggers.py:257] Engine 000: Avg prompt throughput: 0.0 tokens/s, Avg generation throughput: 8.0 tokens/s, Running: 1 reqs, Waiting: 0 reqs, GPU KV cache usage: 12.5%, Prefix cache hit rate: 6.3%
(APIServer pid=1) INFO 01-09 19:16:16 [loggers.py:257] Engine 000: Avg prompt throughput: 251.4 tokens/s, Avg generation throughput: 4.0 tokens/s, Running: 1 reqs, Waiting: 0 reqs, GPU KV cache usage: 7.0%, Prefix cache hit rate: 6.4%
(APIServer pid=1) INFO 01-09 19:17:06 [loggers.py:257] Engine 000: Avg prompt throughput: 73.4 tokens/s, Avg generation throughput: 1.1 tokens/s, Running: 0 reqs, Waiting: 0 reqs, GPU KV cache usage: 0.0%, Prefix cache hit rate: 6.8%
  

KV cache usage は 7.0%〜13.8% の範囲で、キャッシュが詰まっている感じではない。Prefix cache hit rate も 6.3%〜6.8% と高くはないものの、主因をそこだけで説明できる数字でもない。やはり decode の遅さが中心だ。

主因

元ノートが挙げていた主因は 4 つで、どれも aider では効きやすいものだ。

1. 40B モデル + whole edit 形式で出力が長い

whole edit は、人間が読むには分かりやすい一方で、モデルには長い出力を要求しがちだ。編集対象が 1 箇所でも、返答は全文置換に近い長さになりやすく、結果として decode の総量が膨らむ。

今回のメモに含まれていた Django モデル断片を見ると、モデル自体はまとまったコードを素直に返せている。

  from django.db import models
from django.utils.translation import gettext_lazy as _
from core.models import TimeStampedModel, SoftDeleteModel


class Comment(TimeStampedModel, SoftDeleteModel):
    class Status(models.TextChoices):
        PENDING = 'pending', _('Pending')
        APPROVED = 'approved', _('Approved')
        SPAM = 'spam', _('Spam')
        TRASH = 'trash', _('Trash')

    site = models.ForeignKey('core.Site', on_delete=models.CASCADE)
    post = models.ForeignKey('content.Post', on_delete=models.CASCADE)
    parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True)
    author_user = models.ForeignKey('auth.User', on_delete=models.SET_NULL, null=True, blank=True)
    author_name = models.CharField(max_length=255)
    author_email = models.EmailField()
    author_url = models.URLField(blank=True)
    body = models.TextField()
    status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING)
    ip_hash = models.CharField(max_length=64)
    user_agent = models.TextField(blank=True)

    class Meta:
        indexes = [
            models.Index(fields=['post', 'status', 'created_at']),
        ]

    def __str__(self):
        return f"Comment by {self.author_name} on {self.post}"

    def approve(self):
        self.status = self.Status.APPROVED
        self.save(update_fields=['status'])

    def mark_as_spam(self):
        self.status = self.Status.SPAM
        self.save(update_fields=['status'])

    def move_to_trash(self):
        self.status = self.Status.TRASH
        self.save(update_fields=['status'])

    def restore(self):
        self.status = self.Status.PENDING
        self.save(update_fields=['status'])

    def is_approved(self):
        return self.status == self.Status.APPROVED
  

ただ、こういうまとまったコードを毎回長く返す運用は、デコード速度が 1 桁 tok/s のときに一気に重くなる。

2. コンテキストが肥大している

repo-map と複数ファイルをまとめて入れると、モデルにとっては状況が分かりやすくなる。一方で、編集ループのたびに参照情報が膨らみ、出力の前提条件も複雑になる。今回のメモでも、repo-map/add 対象を減らすべきだと明示されていた。

これは単に入力トークン数だけの問題ではない。入力が大きいほどモデルが返そうとする説明や差分も長くなりやすく、結果として decode 側の遅さがさらに目立つ。

3. サンプリングありの生成で decode が重い

temperature=0 に落として greedy 固定にする、という対策が即効策として書かれていたのは妥当だ。コーディング用途で再現性と速度を優先するなら、サンプリングの多様性よりも安定した終端の方が価値が高い場面が多い。

4. 40B クラス自体が単発 decode に弱い可能性

関連ノートでは同じ 40B クラスでも nvfp4 構成が 25〜28 tok/s を出していた。そのため、40B というサイズ自体が絶対悪というより、whole edit・長い文脈・サンプリング込みの編集ループという条件が悪い方向に重なっている、と読むのが自然だ。

即効性のある対策

メモに書かれていた対策は、そのまま優先順位として使える。

  1. whole edit をやめて diff/patch 形式にする
  2. temperature=0 で回す
  3. repo-map/add 対象を減らす
  4. max_model_len を必要最小にする
  5. 可能なら量子化やより軽いコード特化モデル(20B〜32B)に変更する

特に 1 と 2 は効果が出やすいはずだ。今回の観測では prefill はすでに足りているので、改善の中心は decode の総量と decode の分岐コストを減らすことになる。

関連ノートとの対比

今回、関連ファイルとして読み込んだ Firworks-IQuest-Coder-V1-40B-Instruct-nvfp4.md では、同系統の IQuest Coder 40B が以下のように評価されていた。

  • Prompt throughput: 1100〜2300 tok/s
  • Generation throughput: 25〜28 tok/s
  • KV cache usage: 2〜12%
  • Prefix cache hit rate: 20〜45%

この差は大きい。25〜28 tok/s なら 200 tokens でも数秒台に収まりやすく、日常のエージェントやテスト生成で十分に使えるレンジに入る。逆に、今回のメモの 0.6〜8 tok/s では、短い修正の反復ですら待ちが積み上がっていく。

つまり、「IQuest Coder 40B は使えるか」という問いへの答えは一枚岩ではない。今回の条件では遅い。しかし別条件では十分速い。だから見るべきなのはモデル名よりも、どういう出力形式で、どれだけの文脈を持たせ、どのデコード設定で回すかだ。

結論

元メモの結論はかなり実務的だった。

  • この t/s は「GPU だから許容」ではなく、実務では遅い
  • aider 用途では賢さより「短く速く出す」設計に振らないと生産性が出ない
  • 設定次第で多少改善はするが、40B + whole edit は根本的に相性が悪い

この整理に同意する。今回の観測だけを見るなら、問題は「モデルが賢くない」ことではなく、「編集ループの単位あたりで返すものが重すぎる」ことだ。aider を快適にしたいなら、まずは diff/patch・greedy・小さめのコンテキストに寄せる。そのうえで、まだ遅いなら 20B〜32B 級に落とす、という順番が現実的だ。

少なくともこのメモから言えるのは、prefill の速さだけでは実運用は決まらないということだった。編集支援では decode の遅さがすべてを壊す。そのため、モデル評価も「どれだけ賢いか」ではなく、「どれだけ短く、安定して、速く返せるか」で見るべきだと感じている。