↓快轉到主要內容
  1. 教學文章/

MLX-VLM 解析度實驗:細節辨識、Vision Token 與資源取捨

·9 分鐘· loading · loading · ·
MLX-VLM Qwen2-VL Vision Token Benchmark 圖片解析度 Apple-Silicon
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
科技觀點 - 本文屬於一個選集。
§ 33: 本文

一. 前言:圖片放大,模型就會看得更清楚嗎?
#

同一張截圖,224 像素看不清的小字,換成 896 像素就一定能讀對嗎? 拍拍君今天不靠「感覺比較清楚」回答,而是做一個可以重跑的小實驗。

我們只改圖片的像素預算,固定模型、題目、輸出上限與原始圖。 然後同時看四件事:

  • 六碼小字有沒有辨識正確;
  • processor 實際產生多少影像 token;
  • 每次準備與生成花多久;
  • MLX allocator 的峰值增加多少。

這不是模型排行榜,也不是 OCR 品質保證。 它是一個範圍很小、能幫你設計自己實驗的教學。

圖片問答入門 已介紹聊天與 EXIF/RGB 正規化; 本篇不重做聊天工具,而是研究 解析度改變後,品質和資源是否一起改變。

二. 先分清楚三種尺寸
#

「我傳了一張 1024×1024 圖」只說明原始檔案。 進模型之前,還有兩個尺度:

尺度 本篇如何觀察 不能拿來代替什麼
原始圖片 PNG 尺寸與 SHA-256 不等於 processor 輸入
處理後影像 image_grid_thw × patch size 不等於輸出文字長度
合併後影像位置 網格乘積 ÷ merge size² 不等於完整 prompt token

這裡限定 Qwen2-VL 靜態單圖。 它的 patch、spatial merge 與時間網格都有模型特定意義,不能套到所有 VLM。

實際輸入還會受到取整、長寬比與最低像素預算影響。 所以我們不只記 max_pixels,還要記實際網格。 相關設計可以對照 Qwen2-VL 官方文件。

三. 環境與可重現邊界
#

本次使用 Python 3.12、MLX-VLM 0.7.10、MLX 0.32.4、 Transformers 5.20.2、Pillow 13.0.0,硬體為 Apple M4、64 GiB 統一記憶體。 這是實測環境,不表示這組版本適合所有 checkpoint。

mkdir pypy-resolution
cd pypy-resolution
uv venv --python 3.12
uv pip install 'mlx-vlm==0.7.10' 'mlx==0.32.4' \
  'transformers==5.20.2' 'pillow==13.0.0'
uv pip freeze > requirements.lock.txt

模型使用既有小型範例,不追逐「最新」: mlx-community/Qwen2-VL-2B-Instruct-4bit。

固定 revision:01af461cdb9574acc09084a0ef94e216e142b085。 請先檢查模型卡與授權, 下載時也要保留 revision,不要讓 main 靜默換版本。

from huggingface_hub import snapshot_download
model_path = snapshot_download(
    "mlx-community/Qwen2-VL-2B-Instruct-4bit",
    revision="01af461cdb9574acc09084a0ef94e216e142b085",
    allow_patterns=["*.json", "*.safetensors", "*.txt", "*.jinja", "*.model"],
)
print(model_path)

後面的 CLI 傳入這個已下載的本機目錄。 下載耗時不算推論耗時;本篇沒有要求修改模型或啟用 remote code。

四. 合成三張圖:只問框內的小字
#

真實照片很重要,但第一輪實驗先不要混入光線、JPEG 品質與拍攝角度。 我們建立三張白底 1024×1024 PNG,框內分別放 18、28、42 px 的六碼文字。 上方加入固定大標題,避免畫面只有一個置中的字串。

把以下存成 make_cases.py:

import hashlib
import json
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont

root = Path('cases')
root.mkdir(exist_ok=True)
font_path = '/System/Library/Fonts/Supplemental/Arial.ttf'
font_hash = hashlib.sha256(Path(font_path).read_bytes()).hexdigest()
cases = []
for index, (size, answer) in enumerate(
    [(18, 'K7M4P2'), (28, 'R8D3W5'), (42, 'T6N9H2')]
):
    image = Image.new('RGB', (1024, 1024), 'white')
    draw = ImageDraw.Draw(image)
    draw.text((80, 90), 'PYPY QUALITY LAB', fill='black',
              font=ImageFont.truetype(font_path, 48))
    draw.rectangle((60, 430, 964, 590), outline='black', width=2)
    draw.text((100, 480), answer, fill='black',
              font=ImageFont.truetype(font_path, size))
    path = root / f'case-{index}.png'
    image.save(path)
    cases.append(dict(id=path.stem, path=str(path), answer=answer,
                      font_size=size, font_sha256=font_hash,
                      sha256=hashlib.sha256(path.read_bytes()).hexdigest()))
Path('cases.json').write_text(json.dumps(cases, indent=2))
.venv/bin/python make_cases.py

字型路徑是 macOS 範例;其他系統請換成本機字型。 字型一換,字形與抗鋸齒也可能改變,所以 manifest 同時保留字型及圖片雜湊。

三張圖並不是三種字級的公平因果對照:每張代碼也不同。 我們比較的是同一張圖在三種解析度的配對結果, 不能據此聲稱「18 px 一定比 28 px 難多少」。

五. 評分先寫好,別等看到答案再放寬
#

題目固定成:「讀出框內代碼,只回六碼,不要解釋」。 我們分成兩個分數:

  1. 代碼辨識:擷取獨立六碼英數 token,至少含一個數字;候選清單必須剛好等於標準答案。
  2. 嚴格輸出:去除頭尾空白後,整段回答必須等於標準答案。

因此 The code is K7M4P2. 可以通過第一項,但不能通過第二項。 多報一個不同代碼、重複報相同代碼,也不通過第一項。 這不是「放寬就當對」,而是把辨識能力與格式遵循拆開量。

正式實驗應在看結果之前固定規則。 本次先做 224 預算的探索 smoke,發現模型會加解釋, 才凍結雙指標與 32-token 上限;探索輸出不併入下方正式結果。

輸出達上限時仍保留原文與 finish_reason。 不要自動 retry 到成功,否則你量到的是重試策略,不再是一次推論的辨識率。

六. 一個預算,一個新程序
#

把以下存成 bench.py。 每個 cap 使用新 Python 程序,模型只載入一次;第一筆與暖機後結果分開。 224、448、896 是平方像素預算的邊長標籤,不是通用 resize 指令。

import gc
import hashlib
import json
import random
import re
import sys
import time
from pathlib import Path
import mlx.core as mx
from PIL import Image
from mlx_vlm import generate, load
from mlx_vlm.prompt_utils import apply_chat_template
from mlx_vlm.utils import prepare_inputs

model_path, cap_text = sys.argv[1:]
cap = int(cap_text)
assert cap in (224, 448, 896)
start = time.perf_counter()
model, processor = load(model_path)
mx.eval(model.parameters())
mx.synchronize()
load_s = time.perf_counter() - start
question = ('Read the code inside the outlined rectangle. '
            'Return only the six uppercase letters/digits, without explanation.')
prompt = apply_chat_template(processor, model.config, question, num_images=1)
cases = json.loads(Path('cases.json').read_text())
images = {c['id']: Image.open(c['path']).convert('RGB') for c in cases}

def measure(case, phase, repeat):
    gc.collect()
    mx.synchronize()
    mx.reset_peak_memory()
    start = time.perf_counter()
    inputs = prepare_inputs(
        processor, images=[images[case['id']]], prompts=prompt,
        min_pixels=56**2, max_pixels=cap**2,
    )
    mx.eval(inputs)
    mx.synchronize()
    prepare_s = time.perf_counter() - start
    grid = inputs['image_grid_thw'].tolist()[0]
    merge = processor.image_processor.merge_size
    patch = processor.image_processor.patch_size
    tokens = int(mx.sum(inputs['input_ids'] == processor.image_token_id).item())
    assert tokens == grid[0] * grid[1] * grid[2] // merge**2
    kwargs = {k: v for k, v in inputs.items()
              if k not in ('input_ids', 'pixel_values', 'attention_mask')}
    start_gen = time.perf_counter()
    result = generate(
        model, processor, prompt,
        input_ids=inputs['input_ids'], pixel_values=inputs['pixel_values'],
        mask=inputs.get('attention_mask'), **kwargs,
        max_tokens=32, temperature=0.0, verbose=False,
    )
    mx.synchronize()
    generation_s = time.perf_counter() - start_gen
    answer = result.text.strip()
    codes = re.findall(r'(?<![A-Z0-9])[A-Z0-9]{6}(?![A-Z0-9])', answer)
    codes = [c for c in codes if any(ch.isdigit() for ch in c)]
    row = dict(cap=cap, id=case['id'], phase=phase, repeat=repeat,
               source_sha256=case['sha256'], grid=grid,
               processed_hw=[grid[1]*patch, grid[2]*patch],
               vision_tokens=tokens, prompt_tokens=result.prompt_tokens,
               generation_tokens=result.generation_tokens,
               prepare_s=prepare_s, generation_s=generation_s,
               e2e_s=time.perf_counter()-start, load_s=load_s,
               peak_mlx_gib=mx.get_peak_memory()/2**30,
               answer=answer, expected=case['answer'],
               correct=codes == [case['answer']],
               strict_correct=answer == case['answer'],
               finish_reason=result.finish_reason,
               prompt_sha256=hashlib.sha256(prompt.encode()).hexdigest())
    print(json.dumps(row), flush=True)

measure(cases[0], 'first-generation', -1)
for repeat in range(3):
    order = cases.copy()
    random.Random(100 + repeat).shuffle(order)
    for case in order:
        measure(case, 'warm', repeat)

prepare_inputs() 與預先傳入 tensor 的呼叫方式,對應本篇固定版本。 升級後請重新檢查 MLX-VLM 原始碼,不要假設內部介面永遠不變。 如果網格欄位或 processor 屬性不存在,應停止適配,而不是補一個猜測數字。

這裡不建立 Vision Feature Cache、PromptCacheState 或 APC manager。 暖機指的是 kernel/程序環境暖機,不是重用前一題的答案或視覺特徵。

七. 為什麼直接數影像 token?
#

image_grid_thw 的乘積給出合併前 patch 網格。 在這個 checkpoint,patch_size=14、merge_size=2, 靜態單圖的網格時間維度為 1。

程式再數 input_ids 裡的 image_token_id,與網格公式交叉核對。 這是 processor 展開的影像佔位位置數,不是猜測的 JPEG 大小或文字 token 差。

完整 prompt_tokens 還包括 chat template、問題與特殊符號。 即使 image token 變成四倍,也不能說整體耗時必定四倍。 vision encoder、prefill、decode 與不同 kernel 的成本不是同一條直線。

八. 依序執行,不要把三個模型同時塞進 GPU
#

.venv/bin/python bench.py /path/to/model-snapshot 224 > results-224.jsonl
.venv/bin/python bench.py /path/to/model-snapshot 448 > results-448.jsonl
.venv/bin/python bench.py /path/to/model-snapshot 896 > results-896.jsonl

請把 /path/to/model-snapshot 換成前面印出的路徑。 每個程序 1 筆 first-generation,加上 3 張 × 3 輪 warm,共 10 筆。 三種預算總共 30 次正式模型呼叫。

本次封面生成先完成才跑正式實驗,沒有把繪圖與推論的 GPU 工作重疊。 但這仍是一般桌面環境,不是隔離的效能實驗室。 換順序、換一天、系統壓力不同,延遲都可能變動。

九. 三種時間,不要全叫「冷啟動」
#

  • load_s:本機權重載入與求值,不含套件 import、下載與程序啟動。
  • first-generation:新程序裡的第一個生成呼叫,包含首次 kernel 等成本。
  • warm:第一筆之後的呼叫,沒有共享視覺或 prompt cache。

prepare_s 包含 processor 與輸入 tensor 求值。 generation_s 包含 vision、prefill 與文字 decode,不是純 decode 延遲。 e2e_s 包含兩者與中間網格觀測的少量開銷。

原圖已載入 RAM,因此 e2e 不包含 PNG 讀檔、網路或排隊。 它也不是「使用者按下送出到畫面顯示」的產品端到端時間。 MLX 有 lazy execution,所以量測邊界會明確同步,不只量建立計算圖的 Python 時間。

十. 實測結果:先看實際輸入,再看分數
#

像素預算標籤/實際尺寸 Vision token 代碼辨識 嚴格輸出 warm e2e 中位數 MLX 峰值
224/224×224 64 1/3 0/3 0.287 s 1.401 GiB
448/448×448 256 3/3 0/3 0.577 s 2.107 GiB
896/896×896 1024 3/3 0/3 1.996 s 2.246 GiB

表中延遲是每個預算 9 筆 warm 的中位數;峰值是這 9 筆的最大值。 辨識數以三張獨立圖片計算,且每張三輪都要通過才列為辨識成功。 重跑三次只能觀察這個環境的穩定性,不是把樣本數從 3 變成 9。

224 預算只有 42 px 的那張三輪辨識成功;28 px 被讀成 RBDW5,18 px 沒讀出代碼。 448 與 896 在三張圖都通過代碼辨識,但 全部都未通過嚴格六碼輸出。 所以更高解析度沒有解決「模型一直加解釋」的介面問題。

896 比 448 多四倍影像 token,這組資料卻沒有多辨識任何一張。 它的暖機時間與 MLX 峰值也更高;在這組雙指標與成本下,448 是較合理的下一輪候選, 不是已經證明適用所有圖片的最佳預算。

有些回答在 32 tokens 截斷,但完整正確代碼已出現在前段; 表中的「代碼辨識成功」不代表回答完整結束。這類紀錄不能直接當正式輸出成功。 原始 JSONL 保留 finish_reason,可以分開統計 length 與 stop。

各程序的載入/首筆 e2e:224:load 0.340 s/首筆 e2e 0.291 s;448:load 0.331 s/首筆 e2e 0.618 s;896:load 0.325 s/首筆 e2e 2.077 s。 這是新程序,不是清空 Metal 磁碟編譯快取的真正全冷啟動。 首筆只用同一張 18 px 圖,不拿它跟九筆混合題目的 warm 中位數做因果推論。

原始結果與程式可下載:bench.py、make_cases.py、 測試 manifest、30 筆實測 JSONL、環境紀錄。 完整檔案也提供 實驗資料包,解壓後即可依序執行;合成圖沒有私人資料。

十一. 峰值記憶體不是整台電腦的 RAM
#

mx.reset_peak_memory() 只重設統計,不會卸載模型,也不會清空所有 cache。 mx.get_peak_memory() 讀的是 MLX allocator 指標,本篇以 GiB(2³⁰ bytes)呈現。 它不是 RSS、不是整台機器記憶體,也不包含所有 Python/Pillow 或其他程序的配置。

因此不要把它當成「這個模型只需要這麼多 RAM」的購機承諾。 正式部署還要看程序 RSS、系統壓力、swap、並行請求與保留 cache。 詳見 MLX 記憶體管理 API。

十二. 用 Pareto 邊界選設定,不要只挑最大張
#

如果某設定品質不差、延遲不高、記憶體也不多,且至少一項更好, 它就可能支配另一個設定。 沒有被其他設定支配的候選,才值得放進後續產品實驗。

但這裡只有三張合成圖;不能因為某預算在這組資料全對, 就宣告它在商品照、手寫字、中文表格與模糊收據也全對。

拍拍君會先寫應用需求,再選最小可行預算:

  • 只做大物件分類:較低預算可能足夠,但要另外建立分類資料。
  • 讀 UI 小字:需要真實字型、字級、對比與壓縮樣本。
  • 分析密集圖表:還要檢查細線、圖例與標記位置。
  • 擷取文件欄位:應接上 PDF/OCR 證據檢查,不能只看代碼命中。

圖片含大量留白時,裁切 ROI 可能更有效; 但裁切會改變視野與上下文,應另開一組實驗,不要混在純解析度 sweep。

十三. 下一輪實驗該怎麼擴大?
#

先固定本次設定,再補沒有參與調參的 holdout 圖片。 逐一加入中文、小字、低對比、旋轉、JPEG 壓縮與密集版面, 每個場景保留足夠獨立樣本與人工標註。

之後可輪替解析度執行順序、增加獨立程序重複, 分開量讀檔、processor、首 token 與完整生成時間。 不要用 9 次呼叫的 p95 假裝已經掌握尾端延遲。

比較 checkpoint、量化位元或 prompt 時,一次只改一個因素。 若要建批次資料管線,請接續 MLX-VLM 批次結構化輸出, 本篇的單圖受控量測不取代續跑、失敗隔離與 schema 驗證。

結語:先確認模型真的多看見,再付更多像素的帳單
#

提高解析度是一種資源分配決策,不是品質保證。 先確認 processor 真的產生不同輸入,再確認細節任務是否改善, 最後才用延遲與記憶體判斷這份改善值不值得。

保留原圖雜湊、實際網格、原始答案與清楚的量測邊界, 你得到的就不只是「這張好像比較準」,而是一個可以被重跑、被質疑、被改進的實驗。

延伸閱讀
#

科技觀點 - 本文屬於一個選集。
§ 33: 本文

相關文章

MLX-VLM PDF 文件擷取:逐頁影像、結構化欄位與引用抽查
·9 分鐘· loading · loading
Mlx MLX-VLM PDF Document Extraction OCR PyMuPDF Apple-Silicon
MLX-LM Speculative Decoding:Draft Model、加速條件與品質 Benchmark
·10 分鐘· loading · loading
Mlx MLX-LM LLM Speculative Decoding Draft Model Apple-Silicon Benchmark Local AI
MLX-VLM 影片抽幀分析:時間軸摘要、事件索引與品質檢查
·6 分鐘· loading · loading
Mlx MLX-VLM VLM FFmpeg Video Analysis Timeline Apple-Silicon
MLX-VLM 批次圖片實戰:Caption、結構化擷取與結果驗證
·7 分鐘· loading · loading
Mlx MLX-VLM VLM Structured Output Pydantic Batch Inference Apple-Silicon
MLX-VLM 實戰:Apple Silicon 本地圖片問答與多模態模型
·7 分鐘· loading · loading
Mlx MLX-VLM VLM Multimodal Apple-Silicon Local AI Python
MLX-LM 回歸評測:版本化題庫、逐題差異與 CI Gate
·8 分鐘· loading · loading
MLX-LM LLM Regression-Testing Evaluation Ci Apple-Silicon