LLM推理優(yōu)化實錄:從0.4到18 token/s的80倍提升路徑)
上周我把一個開源對話模型部署到手機上打算做一個完全離線的個人助手。模型選了一款7B級別、量化后不到4GB的那種按說以現(xiàn)在主流的旗艦級移動平臺跑這個規(guī)模的端側(cè)推理應(yīng)該不至于太難堪。結(jié)果一測生成速度傻眼了穩(wěn)定輸出時每秒鐘只能吐出不到半個token短一點的回復(fù)都要等一兩分鐘體驗完全是上世紀(jì)撥號上網(wǎng)的感覺。而同一時間我在開發(fā)者社區(qū)里看到有位老哥用同款手機跑類似模型每秒能出三十多個token。我掐指一算正好差了80倍。這個差距大到讓人沒法接受。我把這次排查的全過程記錄下來包括一開始的誤判、拆解出來的各類瓶頸以及每步優(yōu)化對應(yīng)的實測數(shù)據(jù)。如果你也在做端側(cè)LLM部署或者只是好奇為什么別人的手機能跑飛快、自己的手機卻在原地轉(zhuǎn)圈這篇實錄應(yīng)該能幫你省不少時間。排查到最后我發(fā)現(xiàn)所謂80倍差距里真正屬于硬件差異的部分其實很小大部分是被配置細節(jié)和測量口徑放大的誤會。1. 現(xiàn)象復(fù)盤同一個模型、同一部手機慢80倍的落差從哪來1.1 先把“慢”拆成兩個指標(biāo)LLM推理的耗時大多數(shù)人只知道一個籠統(tǒng)的“生成速度”實際上必須分成兩個階段來看。prefill階段是處理你輸入的prompt它要一次性對所有詞元做并行自注意力計算這個階段更吃算力decode階段是逐詞生成回復(fù)每生成一個token都要把整個權(quán)重過一遍這個階段極度依賴內(nèi)存帶寬。如果你只記錄“首字延遲”或者“平均速度”很容易被某一個階段拖垮從而誤判原因。我一開始只記錄了“從按下發(fā)送到收到完整回復(fù)”的總時間這其實是個很粗糙的指標(biāo)。后來我改成分別計時輸入固定長度的prompt后到第一個token到達的時間首token延遲以及后續(xù)穩(wěn)態(tài)生成階段的每秒token數(shù)。這才發(fā)現(xiàn)慢的不只是decodeprefill更是慢得離譜300字左右的prompt光處理就要將近半分鐘這在別人那里通常只需要1到2秒。1.2 實測基線我的設(shè)備、我的配置先把基線交代清楚。設(shè)備是一臺8GB內(nèi)存的某主流旗艦手機芯片是當(dāng)前時間點的高端移動平臺。模型本身沒問題量化后體積符合預(yù)期推理引擎用的是某個開源項目配置方面基本都是默認(rèn)值全部算子走CPU模型以FP16格式在內(nèi)存里跑沒有額外的上下文優(yōu)化。測量工具是一個簡單的計時腳本在流式輸出的回調(diào)里打時間戳取三次運行的中位數(shù)。import time start_time None first_token_time None token_count 0 timestamps [] def on_token(text): global start_time, first_token_time, token_count now time.time() if start_time is None: start_time now if first_token_time is None: first_token_time now - start_time token_count 1 timestamps.append(now - start_time) # 推理結(jié)束后穩(wěn)態(tài)速度取中間段 # token_count / (timestamps[-1] - timestamps[中間點])基線數(shù)據(jù)是這樣的首token延遲約75秒穩(wěn)態(tài)解碼約0.4 token/s參考那位開發(fā)者的數(shù)據(jù)首token延遲約1秒出頭穩(wěn)態(tài)解碼約32 token/s。穩(wěn)態(tài)速度差距正好80倍。注意我這里對齊參考配置后發(fā)現(xiàn)他跑的是一個更小的3B級別混合量化模型還有主動散熱和外接供電的加持。但即便如此我自己這臺設(shè)備在正常情況下也遠不該這么慢。1.3 三個容易混淆的對比陷阱復(fù)盤過程中我發(fā)現(xiàn)很多“慢80倍”的案例根本不是配置問題而是測量口徑不同。第一用首token延遲對比別人的穩(wěn)態(tài)生成速度這是最離譜的因為首token包含了模型加載、預(yù)熱、prompt prefill整個過程第二prompt長度差異喂500字和喂50字首token時間可能差出幾十倍第三流式輸出和整體等待的差異別人可能只是邊生成邊刷新而你非要等全部輸出完成才看到結(jié)果。我當(dāng)時為了快速對齊參考數(shù)據(jù)特意統(tǒng)一了測量點都用一段固定200字的中文prompt開頭從引擎收到請求開始計時首token延遲取流式輸出的第一個字符時間戳穩(wěn)態(tài)速度取中間100個token的平均耗時。這樣才敢說“慢80倍”不是測量誤差。2. 第一次誤判我一開始以為模型裝錯了其實是加速單元根本沒干活2.1 為什么手機上的默認(rèn)配置會這么坑先說結(jié)論移動平臺上的開源推理引擎裝完之后默認(rèn)很可能根本沒啟用NPU或GPU。很多引擎為了兼容性優(yōu)先在CPU上執(zhí)行全部算子。桌面平臺問題不大因為CPU核多、主頻高散熱也好但手機的CPU是“大核少、小核多”的混合架構(gòu)推理這種持續(xù)高負(fù)載任務(wù)很難長時間維持峰值頻率。更要命的是如果模型內(nèi)部某些算子不被加速庫支持調(diào)度器可能直接放棄整條執(zhí)行鏈路的加速靜默回退到CPU而不是只把那幾個算子挪到CPU跑。我當(dāng)時完全沒有懷疑這一層。因為模型能正常加載、能出字我的第一反應(yīng)是“模型文件的量化是不是壞了”或者“是不是prompt設(shè)置有問題”白白浪費了半天。2.2 確認(rèn)過程監(jiān)控、日志、算子級觀測要確認(rèn)“加速單元有沒有干活”我用了幾種手段按從粗到細排列先看設(shè)備資源監(jiān)控。跑推理的時候切到后臺觀察CPU和NPU占用曲線。如果是純CPU回退能看到CPU各核心幾乎全部滿載或者交替滿載而NPU部分始終是0%如果啟用了NPUNPU占用會拉滿甚至頂?shù)?00%CPU占用反而會低一些。這一步是最快的。再翻推理引擎日志。把引擎日志級別調(diào)到verbose重點搜索幾個特征詞fallback、unsupported、not optimized、partition。如果有大量“算子回退到CPU”的記錄基本可以實錘。很多情況下原因就是引擎版本和芯片加速庫不匹配或者模型導(dǎo)出的格式帶了不支持的算子組合。ENGINE_LOG_LEVELverbose your_app 21 | grep -Ei fallback|unsupported|not supported|partition最后做算子級觀測。讓引擎把每個算子的執(zhí)行設(shè)備打印出來或者干脆把模型拆開看算子列表。我在日志里看到模型的主要計算圖里約七成算子走了CPU路徑尤其是Attention里的Softmax、LayerNorm和一些非標(biāo)準(zhǔn)形狀的矩陣乘都沒有被加速庫接受。2.3 修復(fù)動作和第一次提速找到原因之后修復(fù)其實不復(fù)雜。我把推理引擎升級到帶對應(yīng)芯片加速插件的新版本重新導(dǎo)出了模型文件打開層融合選項讓QKV線性層合并、LayerNorm融合成單個算子最后在初始化代碼里顯式聲明設(shè)備綁定到NPU。另外還有個意外收獲升級后發(fā)現(xiàn)引擎默認(rèn)會做一次權(quán)重布局轉(zhuǎn)換恰好把之前“按需解包”的問題也順帶解決了模型被整體預(yù)加載到內(nèi)存里。修復(fù)后同一段prompt重測首token延遲從75秒降到約10秒穩(wěn)態(tài)解碼從0.4 token/s提升到5 token/s提升了12倍左右。這一步讓我意識到10倍級的差距往往不是模型問題而是硬件加速根本沒有參與。不過距離32 token/s還差得遠接下來才是真正要啃的硬骨頭。3. 真正拉開差距的是“內(nèi)存搬運”不是算力標(biāo)稱值3.1 手動算一遍每生成一個token要搬多少GB數(shù)據(jù)很多人看到芯片廠商宣傳的TOPS算力就覺得只要算力夠大跑LLM就快。這是一個非常普遍的誤解。拿數(shù)據(jù)來算一遍就清楚了假設(shè)一個7B參數(shù)的模型FP16存儲的話權(quán)重大小約14GB。decode階段每次生成一個token理論上都需要把這14GB權(quán)重從內(nèi)存搬進計算單元因為Transformer的權(quán)重是逐層復(fù)用、按順序參與計算的沒法提前緩存全部?,F(xiàn)代手機的內(nèi)存帶寬大約在50GB/s上下那么14GB除以50GB/s約等于0.28秒也就是說推理的理論極限也就3.5 token/s左右。如果換成INT4量化權(quán)重大約3.5GB理論極限可以到14 token/s左右。這才是為什么端側(cè)跑LLM幾乎必須要量化的原因——不是精度不夠才量化而是不量化根本喂不飽計算單元。這就解釋了為什么“CPU看起來好像沒多忙但速度就是上不去”decode階段本來就是內(nèi)存負(fù)載型任務(wù)CPU只是在那等著數(shù)據(jù)送過來。如果發(fā)現(xiàn)推理時CPU占用率不高但耗時很長大概率不是CPU不行而是訪存等待占據(jù)了絕大部分時間。3.2 同一個手機為什么還能差出好幾倍理論計算解釋的是“上限”但現(xiàn)實里還會出現(xiàn)很多離譜的慢。我那個0.4 token/s就是典型。正常情況下即使純CPU加FP16也不該這么慢。后來我發(fā)現(xiàn)問題出在三個疊加項上。第一模型文件一開始放在一個需要按需解包的路徑里。推理引擎默認(rèn)惰性加載每遇到一個權(quán)重塊就現(xiàn)場解包一次一個token要觸發(fā)成百上千次小塊讀取時間全耗在I/O上。這聽起來很蠢但很多直接從壓縮包解壓出來就用的部署方式都會踩這個坑。第二8GB內(nèi)存被大量占用系統(tǒng)把模型的部分頁換到了swap區(qū)。decode階段每個token都會隨機訪問權(quán)重只要有幾頁在swap里就可能出現(xiàn)災(zāi)難性的隨機磁盤讀取速度直接掉兩個數(shù)量級。第三Attention的計算里存在大量內(nèi)存重排。某些算子會對KV cache做按token維度的拷貝和拼接連續(xù)讀變成了散列讀。同一份數(shù)據(jù)連續(xù)讀和隨機讀的性能差距可以達到5到10倍。3.3 怎么判斷瓶頸到底是不是內(nèi)存帶寬這里有個實用經(jīng)驗在推理過程中持續(xù)觀察CPU頻率和內(nèi)存壓力。如果是算力瓶頸CPU會長期保持高頻率滿載如果是內(nèi)存帶寬瓶頸CPU占用率反而不太高可能只有20%到40%但耗時并沒有變短。另一個信號是關(guān)閉后臺應(yīng)用、清空內(nèi)存之后速度立刻提升而增加CPU核心數(shù)反而沒有明顯變化。我修掉了按需解包路徑、把模型預(yù)加載到內(nèi)存并讓KV cache連續(xù)分配之后穩(wěn)態(tài)速度從5 token/s提升到了約9 token/s。這中間的提升沒有改任何計算邏輯純粹是減少內(nèi)存和I/O層面的浪費。# 粗略觀察CPU頻率是否維持在高位 while true; do grep cpu MHz /proc/cpuinfo | head -n 8; sleep 1; done4. 從“能跑”到“快跑”把每個環(huán)節(jié)的隱藏開銷逐個挖掉4.1 量化不是無腦INT4混合精度才是端側(cè)常態(tài)在內(nèi)存帶寬受限的前提下量化位寬直接決定decode速度。我用INT4把全模型重新量化一遍速度從5提升到9 token/s。但性能提升的同時我也得盯住精度指標(biāo)。實測下來直接全模型INT4在某些長文本任務(wù)上回答質(zhì)量有明顯下降尤其是最后的輸出層量化誤差會被放大。后來我改成混合精度量化Attention里的QKV投影這類結(jié)構(gòu)規(guī)整、冗余度高的層用INT4最后的lm_head輸出層和部分embedding層保留INT8其余按敏感度選擇。校準(zhǔn)樣本不需要太多幾百條就行關(guān)鍵在于量化工具能不能按層給出誤差貢獻。這個調(diào)整讓速度幾乎不變但回答質(zhì)量基本回到可用水平。順帶提醒一句端側(cè)推理的速度優(yōu)化一定要和精度評估綁定不能只盯著tokens/s。我在后面加了一組固定的問答集做回歸測試每次量化策略改動都跑一遍防止“快得很但滿嘴胡話”。4.2 KV Cache復(fù)用長對話變慢的隱形殺手如果只是單輪短對話KV cache優(yōu)化感覺不明顯。但做多輪對話時很多實現(xiàn)會犯一個錯誤每一輪都把整個歷史prompt重新prefill一遍。歷史越長每次prefill的處理量越大速度越來越慢到后面幾輪幾乎等于卡死。排查方式是在日志里找cache命中和未命中的計數(shù)如果未命中率極高說明緩存沒有正確復(fù)用。對我這個場景我在引擎里顯式開啟了上下文緩存并且把最大上下文長度從默認(rèn)的8K收緊到4K。這么做的一個額外好處是預(yù)分配KV cache緩沖變小內(nèi)存占用降低系統(tǒng)更不容易觸發(fā)swap。調(diào)整后長對話場景的首token延遲從十幾秒降到了三秒左右。4.3 線程綁核、性能模式和散熱手機不是服務(wù)器桌面服務(wù)器上“線程數(shù)越大越好”在手機上完全不成立。手機的核分大小核如果讓推理引擎把線程撒給所有核心一部分線程會被調(diào)度到小核上反而拖慢整體。我試過把線程數(shù)量全部打開然后觀察結(jié)果速度反而下降了接近20%。正確做法是啟用手工綁核把推理線程只綁定到大核集群或者至少設(shè)置線程親和性。不同平臺API差異很大這個需要查具體引擎的文檔。功耗策略的影響也很大。我曾經(jīng)在省電模式下測過一次速度只有性能模式的一半左右插電狀態(tài)下又比電池供電更穩(wěn)。手機端做長時間推理時溫控降頻幾乎不可避免典型表現(xiàn)是剛開始速度正常跑幾十秒后越來越慢。解決辦法有兩個方向一個是從配置上限制batch大小和上下文長度降低瞬時功耗另一個是物理手段我用一個普通散熱背夾把溫度壓住之后連續(xù)三分鐘推理的速度穩(wěn)定度明顯改善。4.4 優(yōu)化過程中每一階段對應(yīng)的實測數(shù)據(jù)把整個優(yōu)化過程的數(shù)字匯總一下方便大家對照參考階段首token延遲穩(wěn)態(tài)速度累計倍數(shù)初始基線純CPUFP16按需加載約75秒約0.4 token/s1x啟用NPU加速并預(yù)加載模型約10秒約5 token/s12.5xINT4量化約6秒約9 token/s22.5xKV cache復(fù)用短上下文約3秒約12 token/s30x混合量化線程綁核性能模式約2.5秒約18 token/s45x從0.4提到18累計45倍。剩下那點差距主要是我的模型是7B級別而參考配置用的是3B級別的小模型再加上參考環(huán)境里有外接供電和主動散熱。如果真用同一模型、同一環(huán)境對齊差距大概只有1.5倍左右這屬于硬件平臺內(nèi)存帶寬的正常差異不是配置問題。5. 復(fù)盤清單以后再遇到端側(cè)推理慢按這個順序查5.1 排查順序和每步的關(guān)鍵信號第一看加速單元是否真的參與。設(shè)備監(jiān)控里NPU占用是否為0日志里有沒有大量fallback。這一步?jīng)]做好后面所有優(yōu)化都可能在錯誤的地基上瞎使勁。第二看模型位寬和存儲路徑。模型是不是FP16硬跑有沒有做量化模型權(quán)重是不是每次都在從壓縮包或慢速存儲按需讀取。第三看內(nèi)存和swap。8GB內(nèi)存的手機跑7B模型本來內(nèi)存就緊一旦觸發(fā)換頁速度會急劇惡化??梢杂迷O(shè)備自帶的內(nèi)存監(jiān)控先確認(rèn)可用內(nèi)存。第四看KV cache復(fù)用和上下文長度。多輪對話慢主要在這一層。第五看線程調(diào)度和功耗模式。確認(rèn)推理線程沒被調(diào)度到小核、性能模式是否打開、溫度是否導(dǎo)致降頻。5.2 哪些“慢”其實不是模型的鍋一些常見的“看起來慢80倍”的情況查到最后和模型本身都沒關(guān)系。例如只測首token延遲但prompt長度是別人的二十倍后臺有一堆應(yīng)用占著內(nèi)存系統(tǒng)頻繁swap把模型跑在沒插電且省電模式的手機上和別人插電加性能模式比還有的是因為流式輸出設(shè)置不對引擎全部生成完才一次性返回體驗上極其卡頓。這些情況只要對齊測量條件所謂的80倍差距往往會瞬間縮水到幾倍以內(nèi)。5.3 幾條建議總結(jié)讓我少走彎路的東西如果你剛接觸端側(cè)LLM部署我的建議是先用一兩天時間把完整的性能測量腳本做好把首token延遲、穩(wěn)態(tài)速度、內(nèi)存占用、芯片溫度幾個指標(biāo)固定下來每次改動配置都跑一遍所有優(yōu)化項目都建立在這個基準(zhǔn)之上。沒有基準(zhǔn)就做優(yōu)化等于閉眼開車。另外心態(tài)上要有個預(yù)期端側(cè)推理的性能優(yōu)化不是一個開關(guān)就能解決的事通常是內(nèi)存、量化、調(diào)度三個層面各擠一點合起來才有效果。單項優(yōu)化可能只帶來10%到20%的提升但疊起來很容易追回幾十倍的差距。我自己這次排查最大的收獲是真正理解了端側(cè)LLM的瓶頸不是“算力不夠”而是“數(shù)據(jù)運不過來”。以后看到芯片廠商宣傳某個平臺算力有多強我都會先問一句它的內(nèi)存帶寬支持每秒搬幾GB這個問題想明白了端側(cè)推理的很多怪現(xiàn)象就都有了答案。