ارزانکردن Inference: KV cache، Batching و Quantization
یک مدل، خروجی byte-identical در 8.8 و 78.9 ثانیه؛ سپس INT4 با سه اندازهگیری واقعی، نه ادعا.
در این صفحه
همان مدل، روی همان ماشین، در پاسخ به همان پرسش با همان 48 token. دو خروجی token به token یکساناند — بررسیشده، نه فرضشده.
with a key-value cache: 8.85 s ( 6.01 tokens/second)
without a key-value cache: 78.95 s ( 0.60 tokens/second)فقط یک argument تغییر کرد: use_cache=False. هیچچیز درباره مدل، prompt، sampling یا محاسبات فرق ندارد، و اجرای دوم هم بهخاطر این زحمت دقیقتر نیست. بیهیچ دلیلی نه برابر کندتر است.
شکل این فصل همین است. هر چیزی در آن — cache، batch، وزنهای quantized — تلاشی است برای اینکه هزینه کاری را نپردازیم که پاسخ را تغییر نمیدهد، یا بفهمیم پاسخ ارزانتر چه هزینهای دارد. فصل 10 فهرست قیمت training را مشخص کرد. این فهرست قیمت بخشی است که تا ابد برایش پول میدهید: یک مدل deployشده برای هر token که تولید میکند، در هر request، تا پایان عمرش تقریباً FLOPs خرج میکند.
زمان اجرای دوم کجا رفت
لینک به بخش: زمان اجرای دوم کجا رفتبرای generate کردن یک token، یک decoder-only transformer کل sequence تا این لحظه را میگیرد، آن را از همه layerها عبور میدهد و probability distribution را از position آخر میخواند. بعد token انتخابشده را append میکند و دوباره همین کار را انجام میدهد. این توصیف درست است، و اجرای کند دقیقاً همین کار را میکند.
اما بهشدت اتلافی هم هست، و دلیلش causal mask از فصل 9 است. بردارهای key و value برای position 7 از input همان position 7 و positionهای قبل از آن محاسبه میشوند. وقتی position 8 میرسد، position 7 نمیتواند آن را ببیند — causal یعنی همین — پس key و value در position 7 دقیقاً همان اعداد قبلیاند. اجرای کند بااینحال در هر step دوباره آنها را محاسبه میکند.
پس آنها را ذخیره کنید. آن ذخیره همان key-value cache است؛ مهمترین optimisation در serving مدلهای زبانی:
out = model(prompt_ids, use_cache=True) # prefill: the whole prompt
past = out.past_key_values
nxt = out.logits[:, -1].argmax(-1, keepdim=True)
for _ in range(n - 1):
out = model(nxt, past_key_values=past, use_cache=True)
past = out.past_key_values
nxt = out.logits[:, -1].argmax(-1, keepdim=True)به چیزی که داخل loop به مدل داده میشود نگاه کنید: nxt، یک token. نه کل sequence. query مربوط به token جدید به همه keyهای cached شده attention میدهد، و keyهای cached هرگز قرار نبود تغییر کنند. این یک approximation نیست — نکته همان بررسی خروجی یکسان در بالا است. cache کیفیت را با سرعت معامله نمیکند؛ محاسبات تکراری را حذف میکند.
برای دیدن scaling بهشکل تمیز، transformer را کنار بگذارید و زمان یک attention head با را بگیرید؛ یک step از generation که به هر دو روش محاسبه شده است:
| tokenها در context | محاسبه دوباره همهچیز | با cache | نسبت | ماتریس score |
|---|---|---|---|---|
| 128 | 0.59 ms | 0.062 ms | 10x | 65,536 B vs 512 B |
| 256 | 1.20 ms | 0.163 ms | 7x | 262,144 B vs 1,024 B |
| 512 | 7.03 ms | 0.078 ms | 90x | 1,048,576 B vs 2,048 B |
| 1024 | 17.31 ms | 0.114 ms | 152x | 4,194,304 B vs 4,096 B |
| 2048 | 59.83 ms | 0.214 ms | 279x | 16,777,216 B vs 8,192 B |
| 4096 | 236.18 ms | 0.284 ms | 832x | 67,108,864 B vs 16,384 B |
ستون سمت راست علت است. محاسبه دوباره، در هر step کل ماتریس attention با اندازه را میسازد — همان از جعبه asymptotic notation فصل 9، که برای هر token یک بار پرداخت میشود. با cache بهجای آن یک ردیف میسازید: در 4,096 token، 67 MB از scoreها در برابر 16 KB.
شمردن multiply-accumulateها بهجای میلیثانیه، ماشین را از بحث حذف میکند. برای generate کردن token از cold start:
| tokenهای generateشده | با cache | محاسبه دوباره | نسبت |
|---|---|---|---|
| 128 | 2.6 M | 192.0 M | 73x |
| 512 | 23.1 M | 7.36 G | 318x |
| 2048 | 293.7 M | 392.6 G | 1,336x |
در هر step نسخه cached نسبت به context خطی است و نسخه بدون cache درجهدو؛ وقتی روی یک generation جمع زده شود، در برابر است، با نسبتی که بینهایت رشد میکند. تفاوت نهبرابری ابتدای فصل روی 48 token اندازهگیری شده بود — حتی کمتر از ردیف اول این جدول.
cache همچنین چیزی را که باید در memory باشد تغییر میدهد. روی یک GPU لپتاپی 8 GB که 256 token در fp16 generate میکند، اگر peak allocator را بگیرید و وزنهای resident را کم کنید:
| peak working memory | |
|---|---|
| با cache | 21.8 MB |
| محاسبه دوباره | 181.7 MB |
8.3 برابر memory بیشتر، خرجشده برای تولید همان tokenها با سرعت کمتر. این همان وعدهای است که در فصل 5 داده شد، اما از جهتی غیرمنتظره میرسد: آنجا reverse-mode autodiff باید هر intermediate را برای backward pass زنده نگه میداشت، و activationها بر memory training غالب بودند. در inference هیچ backward passی وجود ندارد و چیزی برای نگهداشتن بهخاطر آن نیست — پس چیزی که بر memory غالب میشود cache است، و این یک انتخاب آگاهانه است نه هزینهای اجتنابناپذیر.
Prefill و decode دو ماشین متفاوتاند
لینک به بخش: Prefill و decode دو ماشین متفاوتانددوباره به اجرای سریع نگاه کنید: token اولش شبیه چهلوهفت token دیگر رفتار نکرد.
prefill, 40 prompt tokens : 1.0224 s -> 25.6 ms per token
decode, 47 steps : 0.1665 s mean per stepprompt بهازای هر token 25.6 ms هزینه داشت و هر token generateشده 166 ms. همان مدل، همان hardware، همان وزنها، تفاوت ششبرابری بهازای هر token — و جهتش همان چیزی نیست که بیشتر مردم انتظار دارند. prompt بخش ارزان است. Generation به دو phase با فیزیک واقعاً متفاوت تقسیم میشود:
Prefill
لینک به بخش: Prefillیک forward pass روی کل prompt. هر token بهصورت parallel پردازش میشود، بنابراین هر ماتریس وزن یک بار از memory load میشود و در ماتریسی از صدها بردار token ضرب میشود — یک matrix-matrix product، با محاسبات زیاد بهازای هر byte جابهجاشده؛ دقیقاً همان چیزی که GPU برایش ساخته شده است. Prefill compute-bound است، و هزینهاش تقریباً با طول prompt خطی است.
Decode
لینک به بخش: Decodeیک forward pass بهازای هر token، batch یک و sequence یک. هر ماتریس وزن همچنان بهطور کامل از memory load میشود و در یک بردار منفرد ضرب میشود — یک matrix-vector product، با تقریباً هیچ محاسبهای بهازای هر byte جابهجاشده. Decode memory-bandwidth-bound است، و هزینه هر token تقریباً به طول context وابسته نیست.
هر دو نیمه قابل اندازهگیریاند. Prefill، یک pass روی token:
| tokenهای prompt | ثانیه | ms بهازای هر token |
|---|---|---|
| 16 | 0.3515 | 21.97 |
| 32 | 0.5254 | 16.42 |
| 64 | 1.0491 | 16.39 |
| 128 | 1.6552 | 12.93 |
| 256 | 3.0965 | 12.10 |
Decode، یک token در برابر cache با اندازه :
| tokenهای cached | ms برای یک token |
|---|---|
| 16 | 110.05 |
| 64 | 97.57 |
| 256 | 108.53 |
| 1024 | 103.86 |
جدول دوم را دو بار بخوانید. رفتن از 16 token context به 1,024 — شصتوچهار برابر history بیشتر برای attention — هزینه یک step را بهقدر قابل اندازهگیری تغییر نداد. attention روی cache کار واقعی است، اما زیر سایه هزینه ثابت کشیدن نیممیلیارد وزن از memory bus برای تولید یک بردار محو میشود. همین هزینه ثابت دلیل همهچیز در بخش بعدی است.
این دو phase منشأ دو عددیاند که هر سیستم serving گزارش میکند. Time to first token اساساً prefill است، و با prompt رشد میکند؛ برای همین یک conversation طولانی کند شروع میشود. Tokens per second برابر است، و تقریباً ثابت میماند؛ برای همین reply بعد از شروع، یکنواخت جریان پیدا میکند. chatی که کند شروع میشود و بعد روان stream میشود ترفند rendering نیست. همین دو جدول است.
cache خودش هم صورتحساب است
لینک به بخش: cache خودش هم صورتحساب استcache محاسبات را با memory معامله میکند، و memory که میخواهد کم نیست. برای هر token در context، هر layer برای هر key-value head یک بردار key و یک بردار value نگه میدارد:
عدد 2 برای keyها و valueهاست؛ بقیه معماری است. برای مدلی که در سراسر این فصل اندازهگیری شده — 24 layer، 14 query head، 2 key-value head، head dimension برابر 64 — در fp16 این مقدار byte بهازای هر token است.
فرمولها در این حوزه عادت دارند با ضریب دو اشتباه باشند، پس بهجای باورکردن، آن را با allocator چک کنید:
KV cache tensors per layer: (1, 2, 295, 64) float16
measured: 3,624,960 bytes for 295 tokens = 12,288 bytes/token
formula : 2 * 24 * 2 * 64 * 2 = 12,288 bytes/tokenدقیق است، و در همه shapeهای امتحانشده دقیق میماند:
| batch | context | cache اندازهگیریشده | پیشبینیشده | peak working memory |
|---|---|---|---|---|
| 1 | 512 | 6.0 MB | 6.0 MB | 15.4 MB |
| 1 | 16,384 | 192.0 MB | 192.0 MB | 207.3 MB |
| 1 | 65,536 | 768.0 MB | 768.0 MB | 793.7 MB |
| 8 | 4,096 | 384.0 MB | 384.0 MB | 401.5 MB |
| 32 | 2,048 | 768.0 MB | 768.0 MB | 794.2 MB |
| 64 | 1,024 | 768.0 MB | 768.0 MB | 797.0 MB |
| 128 | 512 | 768.0 MB | 768.0 MB | 816.4 MB |
سه ردیف آخر ارزش نگاه دوباره دارند. سیودو user با 2,048 token برای هرکدام، شصتوچهار user با 1,024، صدوبیستوهشت user با 512 — cache در همه موارد 768 MB است، چون هر سه 65,536 token را نگه میدارند. cache فقط به تعداد کل tokenهای resident وابسته است، نه به اینکه بین userها چطور توزیع شدهاند. همین واقعیت پایه بخش batching است.
MQA و GQA از کجا میآیند
لینک به بخش: MQA و GQA از کجا میآیندفصل 9 multi-query و grouped-query attention را معرفی کرد و دلیلش را به این فصل موکول کرد. دلیل همان فرمول است، و بهطور خاص داخل آن.
attention چندسری استاندارد به هر query head، key و value head مخصوص خودش را میدهد. مدل اینجا 14 query head دارد؛ با multi-head attention کامل، cache آن byte بهازای هر token میشد — 84 KB بهجای 12 KB، دقیقاً هفت برابر بیشتر، یعنی نسبت query headها به key-value headها.
Multi-query attention1 این را تا حد نهایی میبرد: همه query headها یک key-value head مشترک دارند. Grouped-query attention2 مصالحهای است که برنده شد — چند key-value head، که هرکدام توسط گروهی از query headها share میشوند — چون افت کیفیت MQA واقعی بود و افت کیفیت GQA نیست. هیچکدام arithmetic نمیخرند. وجود دارند تا آن فرمول را بر یک عدد صحیح تقسیم کنند، و همان لحظهای که contextهای طولانی cache را به constraint اصلی تبدیل کردند، در سراسر صنعت پخش شدند.
و این اتفاق سریع میافتد. برای یک مدل 7B-class با 32 layer و 8 key-value head با dimension 128، cache در fp16 برابر 128 KB بهازای هر token است:
| tokenهای context | یک user | 8 user | 64 user |
|---|---|---|---|
| 4,000 | 0.49 GB | 3.91 GB | 31.2 GB |
| 32,000 | 3.91 GB | 31.25 GB | 250.0 GB |
| 128,000 | 15.62 GB | 125.00 GB | 1,000.0 GB |
| 1,000,000 | 122.07 GB | 976.56 GB | 7,812.5 GB |
وزنهای خود آن مدل در fp16 برابر 13.0 GB است، همان عددی که در جدول انتهای این فصل میآید. پس در context با 128,000 token، cache یک user از خود مدل بزرگتر است. این همان arithmeticی است که فصل 16 به پول تبدیل میکند، و دلیل اینکه یک conversation طولانی فقط کند نیست — تا وقتی request زنده است، یک slice ثابت از یک ماشین را اشغال میکند.
Batching: عددی که بالا میرود و عددی که پایین میآید
لینک به بخش: Batching: عددی که بالا میرود و عددی که پایین میآیدDecode memory-bound است: وزنها از bus کشیده میشوند تا یک token تولید شود، و واحدهای arithmetic idle میمانند. پس در همان step کار بیشتری بگذارید. چند request را همزمان اجرا کنید، و وزنهایی که یک بار خوانده شدهاند به همه آنها خدمت میکنند. اندازهگیری روی همان مدل، با cache 64-token برای هر request و decoding یک token:
| batch | latency هر step | throughput | latency نسبت به B=1 |
|---|---|---|---|
| 1 | 0.1286 s | 7.78 tok/s | 1.00x |
| 2 | 0.1839 s | 10.88 tok/s | 1.43x |
| 4 | 0.1909 s | 20.95 tok/s | 1.49x |
| 8 | 0.2781 s | 28.76 tok/s | 2.16x |
| 16 | 0.3430 s | 46.64 tok/s | 2.67x |
| 32 | 0.6302 s | 50.78 tok/s | 4.90x |
دو ستون سمت راست را در برابر هم بخوانید، چون کل نکته همین است. رفتن از یک request به شانزده، throughput را 6.0 برابر میکند و انتظار برای هر request منفرد را 2.67 برابر. batch سرور را بهتر کرد و هر user را بدتر.
این bugی نیست که با tuning از بین برود؛ خود tradeoff است، و هر سمتش نامی دارد. Latency چیزی است که شخص منتظر reply تجربه میکند. Throughput چیزی است که invoice بر آن تقسیم میشود. هیچ تنظیمی هر دو را بهتر نمیکند.
همچنین توجه کنید کجا متوقف میشود. از 16 به 32، throughput فقط 9 % بهتر میشود درحالیکه latency تقریباً دو برابر میشود: step از memory-bound بودن خارج شده و compute-bound شده است، و بعد از آن knee، batch چیزی نمیخرد. هر deployment چنین kneeای دارد؛ جای آن باید روی سیستم خودتان اندازهگیری شود، اما وجودش محل تردید نیست.
Static batching بیشتر چیزی را که میبرد هدر میدهد
لینک به بخش: Static batching بیشتر چیزی را که میبرد هدر میدهدروش naive برای batch این است که request جمع کنید، آنها را با هم اجرا کنید، و وقتی همه تمام شدند برگردانید. اما آنها با هم تمام نمیشوند: بعضی replyها بیست token هستند و بعضی پانصد. یک batch ثابت تا زمانی اجرا میشود که طولانیترین عضو آن تمام شود، و هر request تمامشده تا آن زمان slot خود را اشغال نگه میدارد و padding اضافه میکند.
64 request را با skew واقعگرایانه در طول خروجی بگیرید — median برابر 18 token، طولانیترین 231، مجموعاً 1,874 — و هر دو policy را با هزینه per-step اندازهگیریشده برای هشت slot شبیهسازی کنید:
| policy | wall clock | throughput | میانگین latency هر request | slot-stepهای هدررفته |
|---|---|---|---|---|
| static batches of 8 | 176.9 s | 10.6 tok/s | 83.2 s | 3,214 |
| continuous, 8 slots | 109.0 s | 17.2 tok/s | 8.1 s | 0 |
Throughput بهاندازه 1.6x بهتر میشود. میانگین latency بیش از ده برابر بهتر میشود، چون در static batching، requestی که در چهار step تمام شده هنوز باید منتظر همسایه 231-token بماند تا کسی چیزی بشنود.
Continuous batching3 راهحل است، و به همان سادگیای است که به نظر میرسد: batch یک گروه نیست، بلکه مجموعهای از slotهاست، و slotی که آزاد میشود در همان step بعدی request بعدی queue را میپذیرد. scheduler با granularity یک token کار میکند نه یک request. اکنون هر serving stack در production همین کار را میکند.
نیمه دومش cache است. slotهایی که میآیند و میروند cache memory را fragmented میکنند، و رزروکردن حداکثر context ممکن برای هر slot بیشتر reservation را هدر میدهد. PagedAttention4 پاسخ را از operating systemها قرض میگیرد: cache را در blockهای fixed-size با یک block table برای هر sequence ذخیره کنید، تا cache یک sequence بتواند از نظر physical پراکنده باشد اما از نظر logical contiguous بماند — و همچنین به دو sequence با prefix مشترک اجازه دهد blockهای نگهدارنده آن را share کنند. vLLM بر همین ساخته شده، و به همین دلیل یک serving engine در اصل memory allocatorی است که یک transformer به آن وصل شده است.
Quantization، و اولین چیزی که خراب میشود
لینک به بخش: Quantization، و اولین چیزی که خراب میشودنیمه دیگر صورتحساب خود وزنها هستند. نیممیلیارد parameter با چهار byte برای هرکدام 1.98 GB است؛ با دو byte، 0.99 GB؛ با یک byte، 0.49 GB. bitهای کمتر بهازای هر وزن، مدل را روی disk کوچکتر میکند، در memory کوچکتر میکند، و — چون decode bandwidth-bound است — هر step را سریعتر میکند، چون byteهای کمتری باید جابهجا شوند.
سادهترین scheme، symmetric absolute-maximum quantization است، و در سه خط جا میشود:
qmax = 2 ** (bits - 1) - 1
scale = W.abs().max() / qmax
Wq = torch.round(W / scale).clamp(-qmax - 1, qmax)
W_hat = Wq * scale # dequantizedیک scale انتخاب کنید تا بزرگترین وزن به بزرگترین integer map شود، تقسیم کنید، round کنید، integerها و scale را ذخیره کنید. بازسازی با ضربکردن دوباره انجام میشود. هیچچیز هوشمندانهای در آن نیست، و کار میکند — درست تا جایی که دیگر کار نمیکند.
اندازهگیری روی وزنهای واقعی مدل: همه 168 ماتریس projection، 357.8 میلیون parameter، relative error برابر :
| scheme | میانگین relative error | بدترین ماتریس |
|---|---|---|
| INT8، یک scale برای کل ماتریس | 0.0400 | 0.1487 |
| INT8، یک scale برای هر output row | 0.0100 | 0.0149 |
| INT4، یک scale برای کل ماتریس | 0.6026 | 0.9931 |
| INT4، یک scale برای هر output row | 0.1790 | 0.2589 |
| INT4، یک scale برای هر گروه 128تایی | 0.1323 | 0.1992 |
| NF4، یک scale برای هر block 64تایی | 0.0952 | 0.1205 |
| INT3، یک scale برای هر گروه 128تایی | 0.3044 | 0.4123 |
| INT2، یک scale برای هر گروه 128تایی | 0.7790 | 0.8076 |
ردیف چهارم collapse است. relative error برابر 0.99 روی بدترین ماتریس یعنی reconstruction عملاً هیچچیزی از original را نگه نداشته — ماتریس با noiseی تقریباً در magnitude درست جایگزین شده است. علت در همان آزمایش روی یک ماتریس منفرد دیده میشود:
model.layers.12.mlp.down_proj.weight (896 x 4864)
mean |w| 0.01386 std 0.01822 max |w| 0.43945 max/std 24.1
weights beyond 6 sigma: 692 of 4,358,144 (0.016 %)یک وزن از هر شش هزار فراتر از شش standard deviation مینشیند، و بزرگترینشان 24 تا دورتر است. با یک scale برای کل ماتریس، همان یک وزن step size را برای همه 4.3 میلیون وزن تعیین میکند. در 8 bit، 256 step وجود دارد و وزن معمولی هنوز روی step معناداری میافتد. در 4 bit، 16 step وجود دارد، بیرونیترینشان برای مقداری رزرو شده که تقریباً هیچچیز ندارد، و وزنهای معمولی — یعنی همه آنها — به دو یا سه level متمایز round میشوند.
هر چیزی بعد از آن ردیف، همان repair در granularityهای متفاوت است: به scale قلمرو کوچکتری بدهید. Per output row خطا را بر 3.4 تقسیم میکند؛ per group of 128 consecutive weights دوباره آن را تقسیم میکند. هزینه bookkeeping است — یک scale 16-bit برای هر گروه 128تایی برابر bit بهازای هر وزن است نه 4 — و بیشتر شکاف را برمیگرداند.
NF4 از سمت دیگر سراغ مسئله میرود.5 لازم نیست levelها فاصله برابر داشته باشند. وزنها داخل یک block تقریباً normal distributed هستند، پس شانزده level را بهصورت quantileهای یک normal distribution انتخاب کنید: متراکم نزدیک zero که وزنها واقعاً آنجا هستند، پراکنده در tailها که نیستند. همان چهار bit، همان block scaling، در blockی کوچکتر — 4.25 bit بهازای هر وزن در برابر 4.125 برای group-128 — و خطای اندازهگیریشده از 0.1323 به 0.0952 میافتد، 28 % کمتر. بخشی از آن block ریزتر است و بقیه گذاشتن levelها جایی که mass هست؛ جداکردن این دو به ردیف سوم نیاز دارد.
outlier featureها
لینک به بخش: outlier featureهاجعبه floating-point در فصل 2 با یک وعده تمام شد: اینکه این فصل وزنها را به 8 و 4 bit quantize کند و چند outlier feature پیدا کند که حاضر نیستند فشرده شوند. اینها همانها هستند، و توضیح میدهند چرا «فقط عددها را round کن» هرگز برای activationها قرار نبود جواب بدهد.
وزنهای بالا بدرفتار بودند. activationها در لیگ دیگریاند. یک prompt معمولی 84-token را بگیرید، residual stream را در هر layer capture کنید، و بزرگترین magnitudeی را که هرکدام از 896 dimension به آن میرسد اندازه بگیرید:
| layer | بزرگترین |h| | بزرگترین |h| در dimension میانه | نسبت | dimensionهای بالاتر از 6x میانه |
|---|---|---|---|---|
| 1 | 6.19 | 0.339 | 18x | 2 |
| 4 | 1543.48 | 1.550 | 996x | 34 |
| 8 | 1571.63 | 1.498 | 1049x | 36 |
| 12 | 1575.03 | 1.546 | 1019x | 34 |
| 16 | 1579.60 | 1.617 | 977x | 32 |
| 20 | 1577.98 | 2.361 | 668x | 24 |
| 24 | 204.44 | 10.760 | 19x | 12 |
Dimension 62 به 1,579.6 میرسد، درحالیکه dimension میانه هرگز از 1.6 عبور نمیکند. این تصادف یک token یا یک layer نیست: همان dimension در layer 4 هست و در layer 20 هم هنوز آنجاست، با تقریباً همان مقدار. اینها outlier featureها هستند،6 و systematic هستند — ویژگی مدل trained، نه input.
Histogram آن 896 maximum per-dimension در layer 16 شکل را بیاشتباه نشان میدهد:
0 - 1 | ######################################## 254
1 - 2 | ######################################## 283
2 - 4 | ######################################## 226
4 - 8 | ######################################## 93
8 - 16 | ################## 18
16 - 32 | ######### 9
32 - 64 | ####### 7
64 - 128 | ##### 5
128 - 256 | 0
256 - 512 | 0
512 - 1024 | 0
1024 - 4096 | # 1نهصد dimension در تودهای مرتب زیر 8، هیچچیز برای سه octave، و بعد یک dimension تنها در انتهای دور. حالا آن tensor را به INT8 quantize کنید و ببینید چه میشود:
| scheme | relative error | levelهای integer متمایز استفادهشده، کل tensor |
|---|---|---|
| یک scale برای کل tensor | 0.1083 | 14 از 256 |
| یک scale برای هر token (per row) | 0.0433 | 158 |
| کل tensor، 1 outlier dimension نگهداشتهشده در fp32 | 0.0442 | 48 |
| کل tensor، 4 outlier dimension نگهداشتهشده در fp32 | 0.0279 | 57 |
| کل tensor، 16 outlier dimension نگهداشتهشده در fp32 | 0.0085 | 102 |
چهارده level از 256. scale با 1,579.6 تعیین شد، پس هر step عرض 12.44 دارد، و activation معمولی — median magnitude برابر 0.26، نودونهمین percentile برابر 2.51 — جایی برای فرود ندارد. در سطح per dimension تندتر است:
single tensor-wide scale = 12.4378
dim 826 (max |h| = 4.77): 1 distinct level out of 256
dim 336 (max |h| = 1.62): 1 distinct level out of 256
dim 96 (max |h| = 0.69): 1 distinct level out of 256
after excluding the top 4 dimensions, scale = 0.5749 (22x smaller)
dim 826: 8 levels dim 336: 4 levels dim 96: 3 levelsیک level. کل dimension، هر token، به یک عدد یکسان quantize شده است. هشت bit اختصاص داده شد و تقریباً صفر bit استفاده شد، و مدلی که این activationها را میخواند یک constant تحویل میگیرد.
این اندازهگیری توجیه هر تکنیکی است که مردم واقعاً استفاده میکنند:
outlierها را بیرون نگه دارید. LLM.int8()6 matrix multiply را تجزیه میکند: dimensionهایی با magnitudeهای extreme در 16 bit محاسبه میشوند، بقیه در INT8، و دو نیمه جمع میشوند. جدول بالا رسید است — حذف چهار dimension خطا را تقریباً چهار برابر کم میکند. SmoothQuant7 در عوض سختی را جابهجا میکند: activationها را بر یک factor per-channel تقسیم کنید و ستون وزن متناظر را در آن ضرب کنید؛ product unchanged میماند و outlier از tensorی که نمیتواند جذبش کند به tensorی منتقل میشود که میتواند.
rounding را انتخاب کنید، صرفاً round نکنید. هیچکدام از بالا نمیپرسد ماتریس برای چه است. GPTQ8 ستون به ستون quantize میکند و بعد از هر ستون، ستونهای full-precision باقیمانده را adjust میکند تا errorی را که قبلاً committed شده جبران کند — یعنی error output layer روی inputهای واقعی را کمینه میکند، نه error وزنهایش را. AWQ9 توجه میکند که بخش کوچکی از channelهای وزن بسیار مهمتر از بقیهاند، آنها را از activation statistics پیدا میکند، و قبل از quantizing scaleشان را بالا میبرد تا روی levelهای finer فرود بیایند. هر دو به calibration set نیاز دارند؛ هیچکدام gradient نمیخواهد.
نمایش جزئیات
GGUF، و اینکه یک file format چه ربطی به همه اینها دارد.
GGUF روش quantization نیست؛ containerی است که llama.cpp استفاده میکند، و سردرگمی در مقایسههای gguf vs gptq از این میآید که این دو را از یک جنس فرض میکنند. GGUF tensorها، tokenizer، metadata معماری و chat template را در یک فایل memory-mappable نگه میدارد، و یک خانواده از block schemeها را داخل خود حمل میکند — نامهایی مثل Q4_K_M bit بهازای هر وزن، block size، و اینکه بعضی tensorها در precision بالاتر نگه داشته شدهاند یا نه را encode میکنند.
تفاوت engineering مهم این است: GPTQ و AWQ وزنهایی تولید میکنند که برای GPU kernel optimize شدهاند، درحالیکه schemeهای GGUF روی CPU با فایل mapشده، نه loadشده، بهارزانی decode میشوند. برای همین همان «مدل 4-bit 7B» اسمی در هر دو جهان با size و quality متفاوت وجود دارد، و مقایسه صادقانه هرگز format نیست — اندازهگیری پایین است، روی task خودتان.
Quantization واقعاً چه هزینهای دارد، با اندازهگیری
لینک به بخش: Quantization واقعاً چه هزینهای دارد، با اندازهگیریتقریباً هر مقالهای درباره quantization در بخش قبلی متوقف میشود: method را توضیح میدهد، compression ratio نقل میکند، و ادعا میکند quality «تا حد زیادی حفظ شده» است. فصل 4 درباره فریبندادن خودتان بود، پس بیایید بفهمیم.
همان مدل، وزنها in place با هر scheme quantize شدهاند، سپس سه measurement: perplexity روی 2,048 token از prose انگلیسی held-out — اینجا draft همین دوره، و برای همین repository یک کتاب public-domain ثابت را جایگزین میکند و جدولی با همان shape و numberهای متفاوت چاپ میکند — یک battery از 16 پرسش factual کوتاه با پاسخهای known زیر greedy decoding، و fraction از tokenهایی که مدل quantized با مدل full-precision در context یکسان روی آنها agree میکند.
| scheme | میانگین weight error | perplexity | question battery | توافق با fp32 |
|---|---|---|---|---|
| fp32 (reference) | 0.0000 | 23.08 | 13/16 | 100.0 % |
| INT8 per tensor | 0.0400 | 23.58 | 13/16 | — |
| INT8 per row | 0.0100 | 22.96 | 13/16 | 98.6 % |
| INT4 per tensor | 0.6026 | 365,416,000 | 0/16 | — |
| INT4 per row | 0.1790 | 46.18 | 6/16 | 58.3 % |
| INT4 group 128 | 0.1323 | 31.08 | 10/16 | 71.5 % |
| NF4 block 64 | 0.0952 | 24.55 | 11/16 | 84.7 % |
| INT3 group 128 | 0.3044 | 213.09 | 0/16 | 5.6 % |
| INT2 group 128 | 0.7790 | 26,325,436 | 0/16 | 0.0 % |
چهار نکته در آن جدول ارزش دارد صریح گفته شود.
INT8 اگر درست انجام شود رایگان است. Per-row INT8 در برابر 23.08 مرجع، 22.96 میگیرد — شکافی به اندازه یک بخش از دویست، که noise است و باید «یکسان» خوانده شود. جهت noise پایدار نیست: روی corpus public-domain در repository، همین دو scheme 22.24 در برابر 22.18 درمیآیند؛ نصف آن فاصله، و در جهت دیگر. روی 142 از 144 token generateشده با مدل full-precision agree میکند. یکچهارم memory در برابر reference fp32، نصف در برابر fp16ی که واقعاً deploy میکنید، و بدون هزینه detectable. INT8 اگر بیدقت انجام شود هم تقریباً رایگان است: یک scale بهازای هر ماتریس 0.5 نقطه perplexity و هیچ پاسخ battery را هزینه میکند. هشت bit آنقدر forgiving است که granularity تقریباً اهمیتی ندارد، و دقیقاً برای همین است که مردم از INT8 به INT4 تعمیم میدهند و ضربه میخورند.
INT4 با یک scale per tensor مدل را نابود میکند. Perplexity برابر 365 میلیون: نه degraded، بلکه annihilated. بعد از آن granularity کل بازی است — per-tensor برابر 365,416,000، per-row برابر 46.18، per-group-of-128 برابر 31.08، NF4 برابر 24.55. همان چهار bit بهازای هر وزن، فاصلهای پانزدهمیلیونبرابری بین بدترین و بهترین.
Perplexity ابزار زمختی است و battery از آن هم زمختتر. بین NF4 و group-128 INT4 شکاف perplexity برابر 6.5 point است و battery یک question فرق دارد — و confidence interval فصل 4 میگوید یک question از شانزده هیچچیز را اصلاً متمایز نمیکند. نمایش تیزتری از interval وجود دارد: همان battery را با repetition penalty پیشفرض مدل خاموش اجرا کنید، که greedy decoding واقعاً همین معنی را دارد، و آن دو ردیف جای خود را عوض میکنند. یک question از شانزده اثر کوچک نیست، بیاثر است. هشدار فصل 8 هم applies: perplexity فقط بین مدلهایی که tokenizer مشترک دارند قابل مقایسه است، پس عددی از نوشته دیگران را نمیتوان با عدد خودتان مقایسه کرد.
ستون agreement تیزترینِ این سه است، و تقریباً رایگان: مدل full-precision را greedily اجرا کنید، بعد از quantized بپرسید در هر position، با همان prefix، چه چیزی انتخاب میکرد. بهجای 16 observation مستقل، 144 observation دارد، ground truth نمیخواهد، و جایی که battery پلهای خراب میشود، smoothly افت میکند. همچنین دقیقاً همان quantity است که بخش بعدی لازم دارد.
این همان وعدهای است که فصل 1 درباره این فصل داده بود و بهموقع میرسد: ریاضیات میگوید یک مدل 4-bit ممکن است، و engineering تصمیم میگیرد آیا usable هست یا نه.
Speculative decoding
لینک به بخش: Speculative decodingفصل 12 این را اعلام کرد و صورتحساب را اینجا گذاشت.
ایده مستقیماً از شکاف prefill/decode میآید. verify کردن یک sequence پیشنهادی از token، یک forward pass روی position هزینه دارد — یک matrix-matrix product، فقط کمی گرانتر از pass روی یک position. پس:
Draft
لینک به بخش: Draftیک مدل کوچک و ارزان candidate token را autoregressively generate میکند.
Verify
لینک به بخش: Verifyمدل بزرگ یک forward pass روی همه candidate بهصورت همزمان اجرا میکند، و آنچه را در هر position میگفت تولید میکند.
Accept
لینک به بخش: Acceptطولانیترین prefixی را که دو مدل روی آن agree دارند نگه دارید، بهعلاوه tokenی که مدل بزرگ در اولین disagreement رایگان supply میکند. بقیه را دور بریزید و دوباره شروع کنید.
Output distribution تغییر نمیکند. با greedy decoding این واضح است — token فقط وقتی accepted میشود که target همان را تولید میکرد. با sampling به rule پذیرش modified نیاز دارد، و Leviathan و همکاران ثابت میکنند distribution حاصل دقیقاً همان target است.10 این دومین optimisation دقیق در این فصل است.
بنابراین همهچیز به acceptance rate وابسته است، که قابل اندازهگیری است — همان ستون agreement بالا است، و برای همین آنجا محاسبه شد. استفاده از هر مدل quantized بهعنوان draft برای target full-precision، روی 144 position generateشده:
| draft model | acceptance | طولانیترین run accepted | tokenهای مورد انتظار بهازای هر target pass، |
|---|---|---|---|
| fp32 (خود target) | 100.0 % | 48 | 5.00 |
| INT8 per row | 98.6 % | 48 | 4.86 |
| NF4 block 64 | 84.7 % | 20 | 3.69 |
| INT4 group 128 | 71.5 % | 13 | 2.85 |
| INT4 per row | 58.3 % | 7 | 2.24 |
| INT3 group 128 | 5.6 % | 2 | 1.06 |
| INT2 group 128 | 0.0 % | 0 | 1.00 |
tokenهای expected که در هر verification pass accepted میشوند، در draft length برابر ، برابر است با
و speedup خالص آن را بر هزینه خود draft تقسیم میکند، یعنی fraction از target بهازای هر token:
| acceptance | ، | ، | ، | ، |
|---|---|---|---|---|
| 30 % | 1.19x | 1.02x | 0.79x | 0.79x |
| 50 % | 1.61x | 1.38x | 1.08x | 1.11x |
| 70 % | 2.31x | 1.98x | 1.54x | 1.78x |
| 90 % | 3.41x | 2.93x | 2.28x | 3.40x |
ورودی bold همان چیزی است که باید به خاطر سپرد: speculative decoding میتواند generation را کندتر کند. در acceptance برابر 30 % با draftی که یکپنجم target هزینه دارد، برای پنج forward pass پول میدهید و 1.4 token نگه میدارید. ستون آخر trap دیگر است — draft طولانیتر فقط وقتی کمک میکند که acceptance بالا باشد، چون tail یک حدس -token تقریباً هرگز reached نمیشود. در acceptance 90 %، ارزش 3.40x دارد و در 30 % ارزش 0.79x: همان configuration، بسته به عددی که روی traffic خودتان اندازهگیری شده، برد یا باخت است.
Distillation، و اینکه soft label چه چیزی حمل میکند
لینک به بخش: Distillation، و اینکه soft label چه چیزی حمل میکندQuantization مدل را با ذخیره همان function در bitهای کمتر کوچک میکند. Distillation با train کردن یک مدل کوچکتر برای imitate کردن یک مدل بزرگتر آن را کوچک میکند11 — ایدهای که تقریباً یک دهه پیش از deep learning وجود داشت.12
بخش ظریف این است که student از چه چیزی یاد میگیرد. نه پاسخ درست: میتوانست مستقیماً روی همان train شود. چیزی که teacher اضافه میکند کل distribution است. از مدل بپرسید بعد از یک phrase چه میآید و از argmax عبور کنید:
"She poured the milk into the"
' jug' 0.1355 ' cup' 0.1051 ' bowl' 0.0605 ' large' 0.0380 ' milk' 0.0360hard label میگوید jug و هیچچیز دیگر. soft label میگوید jug، و همچنین اینکه cup تقریباً به همان خوبی بود، bowl plausible بود، و large — یک صفت، ادامهای از نظر grammatical کاملاً متفاوت — هنوز زنده است. استدلال original همین است: این یک 7 است، اما خیلی شبیه 1 هم هست، و این شباهت اطلاعاتی است که hard label دور میریزد.
به همین دلیل distillation از temperature استفاده میکند. تقسیم logits بر قبل از softmax، distribution را flatten میکند و وزن نسبی runner-upها را بالا میبرد: در این phrase، نسبت بین top token و سومین token از 2.24 در به 1.50 در میافتد — ریشه دوم اولی، که همان کاری است که تقسیم logits بر دو با یک ratio میکند. همان ordering، اما attention بیشتری از loss روی near missها. gradient مربوط به student، uncertainty معلم را حمل میکند و نه فقط verdict او را.
چه چیزی در 8، 16 و 24 GB جا میشود
لینک به بخش: چه چیزی در 8، 16 و 24 GB جا میشودهمهچیز در این فصل حالا یک جمع است:
که در آن کل tokenهای resident در همه requestهای concurrent است. اعمالش کنیم: ردیفهای 7B و 70B فرض میکنند 8 key-value head با dimension 128 داریم، ردیف 13B full multi-head attention با 40 head، چون آن generationهای model اینطور ساخته شده بودند — و خودش را نشان میدهد.
8 GB
| model | precision | weights | free after overhead | tokenهای context که جا میشوند |
|---|---|---|---|---|
| 7B | fp16 | 13.0 GB | جا نمیشود | — |
| 7B | int8 | 6.5 GB | جا نمیشود | — |
| 7B | int4 (g128) | 3.4 GB | 3.1 GB | 25,710 |
| 13B | int4 (g128) | 6.2 GB | 0.3 GB | 337 |
| 70B | int4 (g128) | 33.6 GB | جا نمیشود | — |
16 GB
| model | precision | weights | free after overhead | tokenهای context که جا میشوند |
|---|---|---|---|---|
| 7B | fp16 | 13.0 GB | 1.5 GB | 11,972 |
| 7B | int8 | 6.5 GB | 8.0 GB | 65,378 |
| 7B | int4 (g128) | 3.4 GB | 11.1 GB | 91,246 |
| 13B | int8 | 12.1 GB | 2.4 GB | 3,136 |
| 13B | int4 (g128) | 6.2 GB | 8.3 GB | 10,822 |
24 GB
| model | precision | weights | free after overhead | tokenهای context که جا میشوند |
|---|---|---|---|---|
| 7B | fp16 | 13.0 GB | 9.5 GB | 77,508 |
| 7B | int8 | 6.5 GB | 16.0 GB | 130,914 |
| 7B | int4 (g128) | 3.4 GB | 19.1 GB | 156,782 |
| 13B | int8 | 12.1 GB | 10.4 GB | 13,622 |
| 13B | int4 (g128) | 6.2 GB | 16.3 GB | 21,308 |
| 70B | int4 (g128) | 33.6 GB | جا نمیشود | — |
به ردیف 13B در جدول 8 GB نگاه کنید. وزنها جا میشوند — 6.2 GB از 8 — پس طبق شیوه معمول صحبتکردن، یک مدل 13B «روی یک کارت 8 GB اجرا میشود». 337 token context دارد، که conversation نیست و بهزحمت prompt است. «آیا جا میشود» سؤال غلط است. سؤال درست این است: «با چقدر context، و برای چند user بهطور همزمان».
به دو ردیف int8 در 16 GB هم نگاه کنید. 7B به 65,378 token میرسد و 13B به 3,136 — تفاوتی بیستبرابری از 5.6 GB وزن اضافه، چون 13B اینجا multi-head attention دارد و cache آن 800 KB بهازای هر token هزینه دارد در برابر 128 KB برای 7B. دو مدل با size مشابه، یکی برای long context غیرقابل استفاده، به دلیلی که در headline هیچ model cardی ظاهر نمیشود.
بعد به کجا میرود
لینک به بخش: بعد به کجا میرودسیزده فصل پیش این یک perceptron با دو وزن و یک bias بود. حالا transformerی است که طراحی، trained، aligned و یاد داده شده که روی پرسشهای سخت compute خرج کند، و با هزینه اندازهگیریشده بهازای هر token served میشود — بدون هیچ جعبهای که درونش باز نشده باشد.
اینجا تمام میشود، و عامدانه تمام میشود.
فصل 14 با مدل در جایی دیگر شروع میشود. نه در process شما، نه در memory شما، نه در variableی که بتوانید print کنید: روی ماشینی که شما administer نمیکنید، پشت یک API key، یک port و یک bill. همهچیزهایی که اینجا اندازهگیری شدند همچنان رخ میدهند — prefill هنوز قبل از token اول اجرا میشود، cache هنوز با conversation رشد میکند، batchی که شما در آن هستید هنوز مال کس دیگری است و هنوز latency شما را تعیین میکند — اما از این به بعد آن را از طریق streamی از Server-Sent Events، یک finish_reason، و یک HTTP 429 با header Retry-After مشاهده میکنید. پرسشها با نقطه دید تغییر میکنند: نه این gradient چگونه محاسبه میشود بلکه چرا invoice من سه برابر شد. زبان هم همینطور، و فصل 14 بهجای اعلامکردن این rule آن را توضیح میدهد — تا اینجا code وزنها، gradientها، logits و byteهای tokenizer را نگه میداشت؛ از آنجا به بعد connection، retry، cancellation و accumulated state را نگه میدارد. سیزده فصلی که پشت سر گذاشتید با این عبور دور ریخته نمیشوند. آنها توصیف چیزی هستند که آن سوی port در حال اجراست.
منابع و روش
لینک به بخش: منابع و روشدو omission عمدیاند. FlashAttention (Dao et al., arXiv:2205.14135) attention متفاوتی نیست — همان function را با tiling operation محاسبه میکند تا ماتریس score با اندازه هرگز در memory نوشته نشود، و به همین دلیل 67 MB در جدول دوم این فصل در practice از چیزی که arithmetic نشان میدهد کوچکتر است. و خود kernelها delegated شدهاند: lecture 10 از CS336 استنفورد inference systemها را با عمقی پوشش میدهد که این متن تلاش نمیکند، و repository مربوط به llama.cpp و specification مربوط به GGUF منابع primary برای سمت CPU هستند.
ارجاعات
لینک به بخش: ارجاعات-
Shazeer, N. Fast Transformer Decoding: One Write-Head is All You Need. arXiv:1911.02150 (2019). مقاله عمدتاً یک استدلال memory-bandwidth است، و همینطور هم خوانده میشود. ↩
-
Ainslie, J. et al. GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints. arXiv:2305.13245 (2023). شامل recipe مربوط به uptraining است که یک checkpoint چندسری موجود را تبدیل میکند، و به همین دلیل GQA اینقدر سریع پخش شد. ↩
-
Yu, G.-I., Jeong, J. S., Kim, G.-W., Kim, S. and Chun, B.-G. Orca: A Distributed Serving System for Transformer-Based Generative Models. OSDI 2022. iteration-level scheduling — continuous batching — و selective batching را معرفی میکند. ↩
-
Kwon, W. et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023), SOSP 2023. مقالهای که vLLM بر آن ساخته شده؛ §3 قیاس operating-system را کامل توضیح میدهد. ↩
-
Dettmers, T., Pagnoni, A., Holtzman, A. and Zettlemoyer, L. QLoRA: Efficient Finetuning of Quantized LLMs. arXiv:2305.14314 (2023). NF4 در §3 تعریف شده؛ شانزده مقدار level استفادهشده در measurement بالا همانهایی هستند که این مقاله derive میکند. ↩
-
Dettmers, T., Lewis, M., Belkada, Y. and Zettlemoyer, L. LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale. arXiv:2208.07339 (2022). تحلیل outlier-feature در §4 منبع پدیدهای است که بالا اندازهگیری شد، از جمله یافتهای که outlierها در scale بهصورت systematic ظاهر میشوند. ↩ ↩2
-
Xiao, G., Lin, J., Seznec, M., Wu, H., Demouth, J. and Han, S. SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models. arXiv:2211.10438 (2022). ↩
-
Frantar, E., Ashkboos, S., Hoefler, T. and Alistarh, D. GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers. arXiv:2210.17323 (2022). ↩
-
Lin, J. et al. AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration. arXiv:2306.00978 (2023). ↩
-
Leviathan, Y., Kalman, M. and Matias, Y. Fast Inference from Transformers via Speculative Decoding. arXiv:2211.17192 (2022). Theorem 1 اثبات میکند که output distribution unchanged میماند؛ Chen و همکاران (arXiv:2302.01318) همین ایده را مستقل منتشر کردند. ↩
-
Hinton, G., Vinyals, O. and Dean, J. Distilling the Knowledge in a Neural Network. arXiv:1503.02531 (2015). temperature و استدلال «dark knowledge». ↩
-
Buciluă, C., Caruana, R. and Niculescu-Mizil, A. Model Compression. KDD 2006. Distillation، نه سال زودتر، برای ensembleها نه transformerها. ↩