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