與傳統的CNN 模型推理不同,大語言模型的推理通常會分成prefill 和decoding 兩個階段。每一個請求發起後產生的推理過程都會先經歷一個Prefill 過程,prefill 過程會計算用戶所有的輸入,並產生對應的KV 緩存,再經歷若干個decoding 過程,每一個decoding 過程,伺服器都會產生一個字符,並將其放入KV 快取當中,之後依序迭代。
由於解碼過程是逐字產生的,每個答案片段的產生都需要大量時間,並且會產生大量字元。因此,解碼階段的數量非常龐大,佔了整個推理過程的絕大部分,超過了90%的比例。
在Prefill過程中,雖然需要處理大量的計算,因為要同時計算使用者輸入的所有詞,但這只是一個一次性的過程。因此,在整個推理過程中,Prefill僅佔不到 10% 的時間。
在大型語言模型推理中,通常會專注於四個關鍵指標:吞吐量、首字延遲、總體延遲和每秒請求數(QPS)。這些性能指標從不同角度評估系統的服務能力。吞吐量衡量系統處理請求的速度和效率,而首字延遲則指系統產生第一個標記的時間。總體延遲是系統完成整個推理任務所需的時間。最後,QPS表示系統每秒處理的請求次數。這些指標在評估模型效能和系統最佳化方面起著關鍵作用,幫助確保系統能夠有效率地應對各種推理任務。
首先來介紹 Throughput(吞吐量)。從模型推理層面來看,最先關注的就是吞吐量。吞吐量是指當系統的負載達到最大的時候,在單位時間內,能夠執行多少個 decoding,即產生多少個字元。測試吞吐量的方法是,假設所有用戶都會在同一時刻到來,並且這些用戶問的都是一樣的問題,這些用戶可以同時啟動和結束,且他們生成的文本的長度和輸入的文本長度都是一樣的。透過使用完全相同的輸入,組成一個完整的 batch。在這種情況下,系統的吞吐量會達到最高。但這種情況是不合實際的,所以這是一個理論的最大值。我們會測量在一秒鐘之內,系統能夠執行多少個獨立的 decoding 階段。
另一個關鍵指標是首字延遲(First Token Latency),也就是使用者進入推理系統後完成Prefill階段所需的時間。這指的是系統產生第一個字元的回應時間。許多使用者期望在系統輸入問題後,能在2-3秒內得到回答。
另一個重要的度量指標是延遲(Latency)。延遲表示每次解碼操作所需的時間,它反映了大型語言模型系統在即時處理過程中產生每個字元所需的時間間隔,以及生成過程的流暢程度。通常,我們希望延遲保持在50毫秒以下,這意味著每秒可以產生20個字元。這樣一來,大型語言模型的生成過程將會更加流暢。
最後一個指標是 QPS(每秒請求數)。反映了在線上系統的服務當中,一秒鐘能夠處理多少個使用者的請求。這項指標的測量方式比較複雜,後面會展開介紹。
對於 First Token Latency 和 Latency 這兩個指標,我們都進行了相對完善的測試。這兩個指標會因為使用者輸入的長度不同、batch size 的不同而發生非常大的變化。
在上表中可以看到,對於同樣的7B 模型,如果使用者的輸入長度從8 變成2048,Prefill 的時間將從6.78 毫秒,直到變成2078 毫秒,即2 秒的時間。如果有 80 個用戶,每個用戶都輸入 1,024 個字,那麼 Prefill 在服務端就要跑 2 秒左右,這個時間已經超出了可以接受的範圍。但如果使用者輸入長度都很短,例如每次存取只輸入 8 個字,即使有 768 個使用者同時到來,首字延遲也只有 165 毫秒左右。
與首字延遲最相關的就是使用者的輸入長度,使用者輸入的長度越長,首字延遲也會越高。使用者輸入長度如果很短,那麼首字延遲在整個大語言模型推理過程中都不會成為瓶頸。
而後面的 decoding 延遲,通常只要不是千億等級的模型,decoding 的延遲都會控制在 50 毫秒以內。它主要受到 batch size 的影響,batch size 越大,推理延遲也會越大,但基本上增加的幅度不會很高。
吞吐量其實也會受到這兩個因素的影響。如果使用者輸入的長度和產生的長度很長,那麼系統吞吐量也不會很高。如果使用者輸入長度和產生長度都不是很長,那麼系統吞吐量可能會達到一個非常離譜的程度。
再來看 QPS。 QPS 是一個非常具體的指標,它表示系統中每秒可以處理多少個請求,在進行這個測試的時候,我們會使用實際的資料。 (關於這份數據,我們已經做好了取樣,並且放在了github 上。)
QPS 的測量跟吞吐量不太一樣,因為在實際使用大語言模型系統的時候,每個使用者到來的時間是不確定的。有的用戶可能早來,有的用戶可能會晚來,並且每個用戶做完 Prefill 之後的生成長度也是不確定的。有的使用者可能生成 4 個字就退出,有的使用者可能要產生 20 多個字。
在Prefill 階段,在實際線上推理當中,因為使用者實際生成長度不一樣,所以會遇到一個問題:有些使用者會提前生成完,而有些使用者要生成很多長度之後才會結束。在這樣的生成過程中,有許多地方的 GPU 會空閒。因此在實際的推理過程中,我們的 QPS 並不能夠發揮完全的吞吐量優勢。我們的吞吐量可能很大,但實際的處理能力可能會很差,因為在這個處理過程中充滿了無法使用顯示卡的空洞。所以在 QPS 指標上,我們會有非常多的具體的最佳化方案,避免計算的空洞或無法有效利用顯示卡的現象存在,使得吞吐量能夠完全服務到用戶上。
#接下來進入大語言模型的推理流程當中,看看我們究竟做了哪些優化,使得系統在QPS 以及吞吐量等指標上都達到較優的狀況。
#首先來詳細介紹大語言模型的推理過程,前文中提到了每個請求都要經歷prefill 和decoding 兩個階段,在prefill 階段,至少要做四件事情:
第一件事情是把使用者的輸入進行向量化,tokenize 的過程指的是將使用者輸入的文字轉換為向量,相對於prefill 整個階段來說,大概要佔掉10% 的時間,這是有代價的。
之後就會進行真正的 prefill 計算,這個過程會佔掉大概 80% 的時間。
計算之後會進行 sampling,這個過程在 Pytorch 裡面一般會用 sample、top p。在大語言模型推理當中會用 argmax。總而言之,是根據模型的結果,產生最後一個字的一個過程。這個過程會佔掉 10% 的時間。
最後將 refill 的結果回傳給客戶,這所需的時間會比較短,大概佔 2% 到 5% 的時間。
Decoding 階段不需要tokenize,每一次做decoding 都會直接從計算開始,整個decoding 過程會佔掉80% 的時間,而後面的sampling,也就是採樣生成詞的過程,也要佔掉10% 的時間。但它會有一個detokenize 的時間,detokenize 是指生成了一個詞之後,這個生成的詞是個向量,需要把它解碼回文本,這一操作大概會佔掉5% 的時間,最後將這個生成的詞返回給用戶。
新的請求進來,在進行完 prefill 之後,會不斷迭代進行 decoding,每一個 decoding 階段結束之後,都會將結果當場回傳給客戶。這樣的生成過程在大語言模型裡面是很常見的,我們稱這樣的方式為串流。
這裡要介紹的第一個優化是管線優化,其目的是盡可能讓顯示卡利用率佔滿。
在大語言模型推理過程中,tokenize、fast sample 和 detokenize 這些過程都與模型的計算無關。我們可以把整個大語言模型的推理想像成這樣一個過程,在執行prefill 的過程中,當我拿到了fast sample 的詞向量之後,就可以立刻開始下一個階段decoding,不用等到結果返回,因為結果已經在GPU 上了。而當完成了一次 decoding 後,不用等待 detokenize 的完成,可以立刻開始下一次的 decoding。因為 detokenize 是個 CPU 過程,後面這兩個過程,只涉及到使用者的結果返回,不涉及任何 GPU 的運算。而在執行完採樣過程之後,就已經知道下一個生成的詞是什麼了,我們已經拿到了所需的所有數據,可以立刻開始下一次運算,不需要再等待後面兩個過程的完成。
在PPL.LLM 的實作當中使用了三個執行緒池:
第一個執行緒池負責執行tokenize 過程;
第三個執行緒池負責執行後面的fast sample 以及傳回結果的過程和detokenize;
中間的執行緒池用來執行computing 的過程。
這三個執行緒池互相異步地把這三部分的延遲相互隔離,從而盡可能地將這三部分的延遲掩蔽掉。這將為系統帶來 10% 到 20% 的 QPS 提升,這就是我們所做的第一項優化。
#在這之後,PPL.LLM 還可以執行一項更有意思的最佳化,叫做動態批次。
前文提到,在實際的推理過程當中,使用者的生成長度不同,使用者到達的時間也不一樣。因此會存在這樣一種情況,如果當前的GPU 在推理過程當中,已經有一個請求在線上進行推理,在推理進行到一半時,第二個請求插入進來,這時第二個請求的生成過程會跟第一個請求的生成過程相衝突。因為我們只有一個 GPU,這個 GPU 上只能夠串形地跑任務,所以不能簡單地把它們在 GPU 上做並行。
我們的做法是,在第二個請求進入的時間點,把它的prefill 階段和第一個請求對應的decoding 階段進行混合,產生一個新的階段稱為Merge Step。在這個 Merge Step 中,不僅會進行第一個要求的 decoding,同時會進行第二個請求的 Prefill。這項功能在許多大語言模型推理系統中都會存在,它的實現使得大語言模型的 QPS 提升達到了 100%。
具體過程為,第一個請求產生過程進行了一半,表示它在進行decoding 時會有一個長度為1 的輸入,而第二個請求是新進入的,在進行Prefill 的過程當中,會有一個長度為48 的輸入。將這兩個輸入沿著第一個維度相互拼接,拼接完的輸入長度為 49,而 hidden dimension 是 4096 的輸入。在這長度為 49 的輸入當中,第一個字是第一個請求的,剩下的 48 個字是第二個請求的。
由於在大模型推理當中,所需要經歷的算子,例如 RMSNorm、矩陣乘和 attention 等算子,不論是做 decoding 還是做 prefill,它們的結構都是不變的。因此拼接完的輸入可以直接放入到整個網路中去跑。我們只需要在一個地方加以區分,那就是 attention。在attention 的過程當中或在執行self attention 算子的過程當中,我們會做一次資料分流,將所有做decoding 的請求分流成一波,把所有做prefill 的請求分流到另一波,執行兩個不同的運算。所有做 prefill 的請求,將會執行 Flash Attention;所有做 decoding 的用戶,將會執行一個非常特殊的算子,叫做 Decoding Attention。在分流執行完 attention 算子之後,這些使用者的輸入會再一次被拼接到一起,完成其它算子的計算。
對於Merge Step,實際上當每個請求到來的時候,我們都會把這個請求跟系統上現在已有的所有請求的輸入拼接在一起,完成這次計算,然後繼續往下不停地做decoding,這是動態批次在大語言模型中的實作。
Decoding Attention 算子,不像Flash Attention 算子那麼有名,但其實在處理decoding 任務上比Flash Attention 快得多。
這是專為 decoding 任務設計的算子,完全依賴 Cuda Core,不會依賴Tensor Core 來完成計算。它非常靈活且容易修改,但它有一個限制,因為其特徵是在 decoding 的 tensor 的運算當中,所以會要求輸入的 q 的長度必須是 1,但 k 和 v 的長度是可變的。這是 Decoding Attention 的限制,在這個限制下,我們可以做一些特定的最佳化。
這種特定的最佳化使得在 decoding 階段的 attention 算子的實現,會比 Flash Attention 更快。這個實作目前也已經開源,大家可以到上圖的網址存取。
#另一項最佳化是Virtual Memory Allocator,對應Page Attention 優化。當請求來到之後,要進行 prefill 階段,又要進行 decoding 階段,它所有輸入的 token 會產生一個 KV 緩存,這個KV 緩存記錄了這個請求所有的歷史資訊。那麼要給這樣一個請求分配多長的 KV 快取空間,才能滿足它完成這次生成任務呢?如果分的太多,顯存會有浪費,如果分的太少,在 decoding 階段,碰到了 KV 快取的截止位置,就沒有辦法繼續往下生成。
為了解決這個問題,有 3 種方案。
Pytorch 的顯存管理方式是為每個請求預留一片足夠長的空間,通常是 2048 或 4096,能夠保證完成 4096 個字的生成。但大部分使用者實際的生成長度不會有那麼長,所以會有大量的記憶體空間被浪費掉。
Page Attention 採用的是另一個記憶體管理方式。允許生成過程中不斷為用戶追加顯存。類似於作業系統中的頁式儲存或記憶體分頁。當一個請求來到之後,系統會為這個請求分配一小塊顯存,這一小塊顯存通常只夠生成8 個字符,當請求生成了8 個字符之後,系統會追加一塊顯存,可以把結果再寫到這塊顯存裡面,同時系統會維護一個顯存塊和顯存塊之間的鍊錶,使得算符可以正常地進行輸出。當產生的長度不斷變長時,會不斷地給用戶追加顯存區塊的分配,並且可以動態維護顯存區塊分配的列表,使系統不會存在大量浪費的資源,不需要為這個請求保留太多的顯存空間。
PPL.LLM 使用的是 Virtual Memory 的管理機制,為每個請求預測一個它所需的生成長度。每個請求進來之後,都會直接為其分配一個連續的空間,這個連續空間的長度是預測出來的。但理論上可能難以實現,尤其到了線上推理階段,不太可能清楚知道每個請求究竟要產生多長的內容。因此我們推薦訓練一個模型去做這件事。因為即使我們採用了 Page Attention 這樣的模式,還是會遇到問題。 Page Attention 在運作的過程中,具體到一個特定的時間點,例如目前系統上已經有了四個請求,系統裡面還剩餘 6 塊顯存沒有被分配。這時我們無法知道是否會有新的請求進來,能否為其繼續提供服務,因為當前的四個請求還沒有結束,可能未來還要繼續為它們追加新的顯存塊。所以即使是 Page Attention 機制,還是需要預測每個使用者實際的生成長度。這樣才知道在具體的一個時間點上能不能接受一個新的用戶的輸入。
這是我們目前所有的推理系統都沒有做到的事情,包括 PPL 目前也沒有實現。但 Virtual Memory 的管理機制,還是讓我們很大程度上避免了顯存的浪費,從而使系統整體的 QPS 提升達到 200% 左右。
PPL.LLM 在做的另一個最佳化,就是KV 快取的量化,在服務端推理的過程當中,KV 快取會佔據絕大部分的顯存空間,這會嚴重限制系統的並發請求數量。
可以看到,在服務端,特別是A100、H100 這樣的大顯存的伺服器上運行如7B 模型這樣的大語言模型時,它的KV 快取將占到84% 的顯存空間,而對於如176B 這樣的千億級大模型,它的KV 快取也將佔用50% 以上的快取空間。這會嚴重限制模型的並發數量,每一個請求到來後,都需要給它一個很大的顯存。這樣請求數量就無法提升上去,繼而使得 QPS 以及吞吐量都無法提升。
PPL.LLM 使用了一種非常特殊的量化方式,分組量化對 KV 快取的資料進行壓縮。也就是原來 FP16 的數據,會試著把它量化到 INT8。這樣會使 KV 快取的體積縮小 50%,並使得服務端能夠容納的請求數量增加 100%。
之所以相比 Faster Transformer 能夠提升約 50% 的吞吐量,正是得益於 KV 快取量化所帶來的 batch size 的提升。
#在KV 快取量化之後,我們進行了更細力度的矩陣乘法的量化。在整個服務端推理的過程當中,矩陣乘法占到整個推理時間的 70% 以上,PPL.LLM 使用了一種動態的 per-channel/per-token 交替的混合量化方式來加速矩陣乘法。這些量化同樣是精度極高的,並且能夠提升接近 100% 的性能。
具體做法是,在RMSNorm 算子的基礎之上,融合一個量化算子,這個量化算子會在RMSNorm 算子的功能基礎之上統計其Token 信息,統計每一個token 的最大最小值,並且沿著token 的維度,把這個數據進行量化。也就是說經過了RMSNorm 之後的資料將會從 FP16 轉成 INT8,而這次量化是全動態的,不需要做 calibration。而在後面的 QKV 矩陣乘當中,這三個矩陣乘都會進行 per-channel 量化。它們接收的資料是 INT8 的,同樣它們的權重也是 INT8 的,所以這些矩陣乘可以完整地執行 INT8 的矩陣乘法。它們的輸出將會被 Soft Attention 接受,但在接受之前會執行一次解量化過程,這次解量化過程將會和 soft attention 算子融合。
而後面的 O 矩陣乘法是不做量化的,Soft Attention 本身的計算過程也不做任何量化。在後續的 FeedForward 過程當中,這兩個矩陣同樣採用一樣的方式進行量化,和上面的 RMSNorm 進行融合,或者與上面的 Silu 和 Mul 這樣的激活函數進行融合。它們的解量化算子將和其下游算子進行融合。
#目前學術界對於大語言模型的量化關注點可能主要集中在INT4 上,但是在服務端推理的過程中,其實更適合使用INT8 的量化。
INT4 的量化也叫Weight Only 的量化,這種量化方式出現的意義在於,當大語言模型推理過程中batch 比較小時,在矩陣乘法的計算過程中, 90% 的時間都會用來載入權重。因為權重的體積非常大,而載入輸入的時間很短,它們的輸入,即activation 也非常短,計算的時間也不會很長,寫回結果的時間同樣不會很長,這意味著這個算子是訪存密集型的算子。在這種情況下,我們會選用 INT4 的量化,前提是 batch 足夠的小,使用 INT4 的量化每一次加載權重之後,會緊接著進行一個解量化的過程。這次解量化會把權重從INT4 解量化成FP16,經歷解量化過程之後,後面的計算和FP16 是完全一樣的,也就是說INT4 Weight Only 的量化適用於訪存密集性的矩陣乘法,其計算過程還是由FP16 的運算元件去完成的。
當 batch 夠大,例如 64 或 128 時,INT4 的 Weight Only 量化將不會帶來任何效能提升。因為如果 batch 夠大,那麼計算時間就會拉得很長。而INT4 Weight Only 量化有一個非常不好的點,它的解量化過程所需的計算量是會隨著batch 的(GEMM Batch)提升而提升的,隨著輸入batch 的提升,解量化的時間也會越來越長。當 batch 達到 128 的時候,解量化所帶來的時間損耗和負載權重所帶來的效能優勢,就已經相互抵銷了。也就是說當 batch 達到 128 之後,INT4 的矩陣量化不會比 FP16 矩陣量化快,效能優勢極小。大概在 batch等於 64 的時候,INT4 的 Weight Only 量化只會比 FP16 的快 30%,等到 128 的時候,大約只會快 20% 甚至更小。
但對於INT8 來說,INT8 的量化與INT4 量化最不同的一點,是它不需要任何解量化的過程,而且它的計算是可以壓縮一倍時間的。在 batch 等於 128 時,從 FP16量化到 INT8,載入權重的時間將會減半,計算的時間也會減半,這會帶來百分之百的加速。
在服務端場景下,特別是因為會有不斷的請求湧入,大部分的矩陣乘,都會是計算密集型的。在這種情況下,如果為了追求極限的吞吐量,INT8 的效率其實是高於 INT4 的。這也是為什麼我們目前已經完成的實現裡面,在服務端上主推 INT8 的一個原因。
在H100、H800、4090 上面,我們可能會執行FP8 的量化。 FP8 這樣的資料格式,在 Nvidia 最新一代的顯示卡當中被引入。 INT8 的精度從理論上是要高於 FP8 的,但是 FP8 會更好用,效能會更高一些。我們在後續服務端的推理過程的更新當中也會推進 FP8 的落地。上圖可以看到,FP8 的誤差相比 INT8 會大約 10 倍。 INT8 會有一個量化的尺寸因子,可以透過調整尺寸因子,降低 INT8 的量化誤差。而 FP8 的量化誤差跟尺寸因子基本上是無關的,它不受尺寸因子的影響,這使得我們基本上不需要對它做任何的 calibration。但是它的誤差總體來講是要高於 INT8 的。
PPL.LLM 在後續的更新中,也會更新 INT4 的矩陣量化。這種 Weight Only 的矩陣量化主要是為端側服務的,為了手機端行動端等 batch 固定為 1 的裝置。在後續的更新當中會從 INT4 逐漸轉變為非線性量化。因為在Weight Only 的計算過程當中,會存在一個解量化的過程,這個解量化過程實際上是可定制的,未必是一個線性的解量化過程,其使用其它解量化過程以及量化過程,會使得這一次計算的精度更高。
一個比較典型的例子,就是在一篇論文當中所提到的NF4 的量化,這種量化實際上會透過一種打表的方式進行量化及解量化,是一種非線性的量化。 PPL.LLM 的後續更新當中會嘗試使用這樣的量化來完成端側推理的最佳化。
#最後,介紹大語言模型處理的硬體。
模型結構一旦確定,我們就會知道它具體的計算量,具體需要多少個訪存,需要多少計算量。同時也會知道每張顯示卡的頻寬、算力、價格等。在確定了模型的結構以及確定了硬體指標之後,我們就可以透過這些指標去計算出在這張顯示卡上推理大模型的最大吞吐量會是多少、計算延遲是多少、訪問存取時間需要多少,可以算出一個非常具體的表。我們把這個表格公開在後續的資料當中,大家可以訪問這個表格,查看最適合大語言模型推理的顯示卡型號有哪些。
對於大語言模型推理來說,因為大部分算子都是存取密集型的,而訪存的延遲總是比計算延遲更高。因為大語言模型的參數矩陣確實太大了,所以即使是在 A100/80G 上,batch size 開到 272 的時候,它的計算延遲都是較小的,訪存延遲反而會更高。因此,我們的許多優化都是從訪存上著手的。而進行硬體選擇時,我們主要的方向就是選擇頻寬比較高、顯存比較大的設備。從而使得大語言模型在推理時,可以支撐更多的請求,支撐更快的訪存,相應的吞吐量也會更高。
以上就是這次分享的內容。所有相關資料都放在了網盤中,連結請參閱上圖。我們所有的程式碼也已經開源在 github 上了。歡迎大家隨時與我們溝通。
A1:Decoding Attention 這個算子非常特殊,它的 Q 的長度永遠是 1,所以它不會像Flash Attention 那樣面臨 Softmax 裡有非常大的訪問存量。實際上在 Decoding Attention 的執行過程當中,就是完整地執行這次 Softmax 的過程,並不需要像 Flash Attention 那樣更快執行。
A2:這是個好問題,首先這個解量化不是像大家想的那樣,只需要把權重從INT4 塞回FP16 就行了,如果只做這件事情,那權重有多少就要解多少。其實不是這樣的,因為這是一個融合在矩陣乘法裡面的解量化,不能在執行矩陣乘法之前,把所有權重解量化出來,放在那裡然後再去讀。這樣我們所做的 INT4 的量化就沒有意義了。它是在執行過程當中不停地去解量化,因為我們會執行分塊的矩陣乘,每一個權重所要讀寫的次數並不是1,需要不停地拿過來計算,這個次數實際上跟batch 有關。也就是區別於之前那些優化量化的手段,會有單獨的量化的算子和解量化算子。兩個算子的插入,解量化還是直接融合在算子中的。我們執行的是矩陣乘法,所以要解量化的次數並不是一次。
A3:根據我們的測試是可以被掩蓋的,而且其實還遠遠有剩餘。 KV 計算中的反量化以及量化都會融合進 self attention 算子當中,具體來說就是 Decoding Attention。根據測試,這個算子即使在 10 倍的計算量,可能都可以掩蓋掉。就是訪存的延遲都掩蓋不了它,它主要的瓶頸在於訪存,它計算量還遠遠達不到可以掩蓋掉它訪存的那個程度。所以 KV cache 當中的反量化計算,對於這個算子來說,基本上是一個很好被掩蓋的東西。
以上是高性能 LLM 推理框架的設計與實現的詳細內容。更多資訊請關注PHP中文網其他相關文章!