להוזיל Inference: KV cache, Batching ו-Quantization
אותו מודל עונה באותה תשובה תוך 8.8 שניות או 78.9. ואז INT4, נמדד בשלוש דרכים — לא נטען בעלמא.
בעמוד הזה
אותו מודל, על אותה מכונה, עונה לאותה שאלה עם אותם 48 tokens. שני הפלטים זהים 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)ארגומנט אחד השתנה: use_cache=False. שום דבר במודל, ב-prompt, בדגימה או באריתמטיקה לא שונה, והריצה השנייה אינה מדויקת יותר בתמורה למאמץ. היא איטית פי תשעה בלי סיבה.
זה המבנה של הפרק הזה. כל מה שיש בו — ה-cache, ה-batch, המשקולות המקוונטטות — הוא ניסיון להפסיק לשלם על עבודה שאינה משנה את התשובה, או לגלות כמה עולה תשובה זולה יותר. פרק 10 קבע את מחירון האימון. זה המחירון של הצד שעליו משלמים לנצח: מודל פרוס מוציא בערך FLOPs לכל token שהוא פולט, בכל בקשה, לשארית חייו.
לאן הלך הזמן של הריצה השנייה
קישור למקטע: לאן הלך הזמן של הריצה השנייהכדי ליצור token, decoder-only transformer לוקח את כל הרצף עד כה, מריץ אותו דרך כל שכבה, וקורא את התפלגות ההסתברויות מהמיקום האחרון. אחר כך הוא מוסיף את ה-token שנבחר ועושה זאת שוב. התיאור הזה נכון, וזה מה שהריצה האיטית עושה.
הוא גם בזבזני מאוד, והסיבה היא המסכה הסיבתית מ-פרק 9. וקטורי המפתח והערך של מיקום 7 מחושבים מהקלט של מיקום 7 ומהמיקומים שלפניו. כשמיקום 8 מגיע, מיקום 7 לא יכול לראות אותו — זה פירושו של סיבתי — ולכן המפתח והערך של מיקום 7 הם בדיוק אותם מספרים כמו קודם. הריצה האיטית מחשבת אותם מחדש בכל צעד.
אז שומרים אותם. המאגר הזה הוא ה-key-value cache, האופטימיזציה המשמעותית ביותר ב-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)שימו לב מה מוזן למודל בתוך הלולאה: nxt, token אחד. לא הרצף. ה-query של ה-token החדש עושה attention מול כל מפתח שמור, והמפתחות השמורים ממילא לא היו משתנים. זו לא קירוב — בדיקת הפלט הזהה למעלה היא כל העניין. ה-cache לא מחליף איכות במהירות; הוא מוחק אריתמטיקה מיותרת.
כדי לראות את הסקיילינג נקי, מסירים את ה-transformer ומודדים ראש attention יחיד עם , צעד generation אחד בשתי הדרכים:
| tokens ב-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 |
העמודה הימנית היא הסיבה. חישוב מחדש בונה בכל צעד את מטריצת ה-attention המלאה — ה- מתיבת הסימון האסימפטוטי של פרק 9, בתשלום פעם לכל token. עם cache בונים במקום זאת שורה : ב-4,096 tokens, 67 MB של scores מול 16 KB.
ספירת multiply-accumulates במקום מילישניות מסירה את המכונה מהטיעון. כדי ליצור tokens מהתחלה קרה:
| tokens שנוצרו | עם 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 |
בכל צעד הגרסה עם cache ליניארית ב-context והגרסה בלי cache ריבועית; בסכימה על פני generation, מול , והיחס גדל ללא גבול. הפער פי תשעה בפתיחה נמדד על 48 tokens — פחות מהשורה הראשונה בטבלה.
ה-cache משנה גם מה חייב להיות בזיכרון. על GPU לפטופ של 8 GB שמייצר 256 tokens ב-fp16, אם לוקחים את שיא ההקצאה ומחסרים את המשקולות resident:
| שיא זיכרון עבודה | |
|---|---|
| עם cache | 21.8 MB |
| חישוב מחדש | 181.7 MB |
פי 8.3 יותר זיכרון, כדי לייצר אותם tokens לאט יותר. זו ההבטחה מ-פרק 5, שמגיעה מכיוון לא צפוי: שם reverse-mode autodiff היה חייב לשמור כל תוצר ביניים ל-backward pass, וה-activations שלטו בזיכרון האימון. ב-inference אין backward pass ואין מה לשמור בשבילו — לכן מי ששולט בזיכרון הוא ה-cache, והוא בחירה מכוונת ולא עלות בלתי נמנעת.
Prefill ו-decode הן שתי מכונות שונות
קישור למקטע: Prefill ו-decode הן שתי מכונות שונותהביטו שוב בריצה המהירה: ה-token הראשון שלה התנהג אחרת מארבעים ושבעה האחרים.
prefill, 40 prompt tokens : 1.0224 s -> 25.6 ms per token
decode, 47 steps : 0.1665 s mean per stepה-prompt עלה 25.6 ms לכל token וכל token שנוצר עלה 166 ms. אותו מודל, אותה חומרה, אותן משקולות, פער פי שישה לכל token — ובכיוון שרוב האנשים לא מצפים לו. ה-prompt הוא החלק הזול. Generation מתחלק לשני שלבים עם פיזיקה שונה באמת:
Prefill
קישור למקטע: PrefillForward pass אחד על כל ה-prompt. כל token מעובד במקביל, ולכן כל מטריצת משקולות נטענת מהזיכרון פעם אחת ומוכפלת במטריצה של מאות וקטורי tokens — מכפלת מטריצה-מטריצה, הרבה אריתמטיקה לכל byte שמוזז, בדיוק מה ש-GPU נבנה לעשות. Prefill הוא compute-bound, ועלותו בערך ליניארית באורך ה-prompt.
Decode
קישור למקטע: DecodeForward pass אחד לכל token, batch של אחד ורצף של אחד. כל מטריצת משקולות עדיין נטענת במלואה מהזיכרון, ומוכפלת ב-וקטור יחיד — מכפלת מטריצה-וקטור, כמעט בלי אריתמטיקה לכל byte שמוזז. Decode הוא memory-bandwidth-bound, ועלותו לכל token כמעט אינה תלויה באורך ה-context.
שני החצאים ניתנים למדידה. Prefill, מעבר אחד על tokens:
| tokens ב-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 של :
| tokens ב-cache | ms ל-token אחד |
|---|---|
| 16 | 110.05 |
| 64 | 97.57 |
| 256 | 108.53 |
| 1024 | 103.86 |
קראו את הטבלה השנייה פעמיים. מעבר מ-16 tokens של context ל-1,024 — פי שישים וארבע יותר היסטוריה לעשות עליה attention — לא שינה את עלות הצעד באופן מדיד. Attention מול ה-cache היא עבודה אמיתית, אבל היא מתגמדת מול העלות הקבועה של גרירת חצי מיליארד משקולות דרך אפיק הזיכרון כדי לייצר וקטור אחד. העלות הקבועה הזו היא הסיבה לכל מה שבסעיף הבא.
שני השלבים האלה הם המקור לשני המספרים שכל מערכת serving מדווחת. Time to first token הוא למעשה prefill, והוא גדל עם ה-prompt, ולכן שיחה ארוכה מרגישה איטית בתחילתה. Tokens per second הוא , והוא בערך קבוע, ולכן התשובה אחר כך זורמת באופן אחיד. צ׳אט שמתחיל לאט ואז מוזרם חלק אינו טריק רינדור. אלו שתי הטבלאות האלה.
ה-cache הוא גם החשבון
קישור למקטע: ה-cache הוא גם החשבוןה-cache מחליף אריתמטיקה בזיכרון, והזיכרון שהוא רוצה אינו קטן. לכל token ב-context, כל שכבה מחזיקה וקטור key אחד ווקטור value אחד לכל key-value head:
ה-2 הוא עבור keys ו-values; כל השאר הוא הארכיטקטורה. עבור המודל שנמדד לאורך הפרק — 24 שכבות, 14 query heads, 2 key-value heads, ממד ראש 64 — ב-fp16 זה bytes לכל 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 נמדד | חזוי | שיא זיכרון עבודה |
|---|---|---|---|---|
| 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 |
שלוש השורות האחרונות שוות מבט נוסף. שלושים ושניים משתמשים עם 2,048 tokens כל אחד, שישים וארבעה עם 1,024, מאה עשרים ושמונה עם 512 — ה-cache הוא 768 MB בכל מקרה, כי בשלושת המקרים מוחזקים 65,536 tokens. ה-cache תלוי רק במספר הכולל של tokens resident, לא באופן שבו הם מחולקים בין משתמשים. העובדה הזו היא הבסיס לסעיף ה-batching.
מאיפה מגיעים MQA ו-GQA
קישור למקטע: מאיפה מגיעים MQA ו-GQAפרק 9 הציג multi-query ו-grouped-query attention ודחה את הסיבה לפרק הזה. הסיבה היא הנוסחה הזו, ובפרט ה- שבה.
Standard multi-head attention נותן לכל query head heads משלו ל-key ול-value. למודל כאן יש 14 query heads; עם multi-head attention מלא ה-cache שלו היה bytes לכל token — 84 KB במקום 12 KB, בדיוק פי שבעה, היחס בין query heads ל-key-value heads.
Multi-query attention1 לוקח זאת לקצה: כל query heads חולקים key-value head יחיד. Grouped-query attention2 הוא הפשרה שניצחה — קומץ key-value heads, שכל אחד משותף לקבוצת query heads — כי אובדן האיכות של MQA היה אמיתי ושל GQA לא. אף אחד מהם לא קונה אריתמטיקה. הם קיימים כדי לחלק את הנוסחה הזו במספר שלם, והתפשטו בתעשייה ברגע ש-context ארוך הפך את ה-cache למגבלה המחייבת.
וזה קורה מהר. עבור מודל בדרגת 7B עם 32 שכבות ו-8 key-value heads בממד 128, ה-cache הוא 128 KB לכל token ב-fp16:
| tokens ב-context | משתמש אחד | 8 משתמשים | 64 משתמשים |
|---|---|---|---|
| 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 |
משקולות המודל עצמו הן 13.0 GB ב-fp16, המספר בטבלה בסוף הפרק. לכן ב-context של 128,000 tokens, ה-cache של משתמש אחד גדול מהמודל. זו האריתמטיקה ש-פרק 16 הופך לכסף, ולכן שיחה ארוכה אינה רק איטית — היא תופסת פרוסה קבועה ממכונה כל עוד הבקשה חיה.
Batching: המספר שעולה והמספר שיורד
קישור למקטע: Batching: המספר שעולה והמספר שיורדDecode מוגבל בזיכרון: המשקולות נגררות דרך האפיק כדי לייצר token אחד, ויחידות האריתמטיקה בטלות. אז מכניסים יותר עבודה לאותו צעד. מריצים כמה בקשות יחד, והמשקולות, שנקראו פעם אחת, משרתות את כולן. במדידה על אותו מודל, כשכל בקשה מחזיקה cache של 64 tokens ומפענחת token אחד:
| batch | latency לכל צעד | 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 |
קראו את שתי העמודות הימניות זו מול זו, כי הן כל העניין. מעבר מבקשה אחת לשש עשרה מכפיל את ה-throughput ב-6.0 ומכפיל את ההמתנה לכל בקשה בודדת ב-2.67. ה-batch שיפר את השרת והריע לכל משתמש.
זה לא באג שאפשר לכוון החוצה; זו העסקה עצמה, ויש לה שם בכל צד. Latency הוא מה שאדם שממתין לתשובה חווה. Throughput הוא מה שמחלק את החשבונית. אין הגדרה שמשפרת את שניהם.
שימו לב גם איפה זה נעצר. מ-16 ל-32, ה-throughput עולה ב-9 % בעוד ה-latency כמעט מוכפל: הצעד הפסיק להיות memory-bound והפך compute-bound, ומעבר לברך הזו ה-batch לא קונה דבר. לכל deployment יש ברך כזו; צריך למדוד את מיקומה אצלכם, אבל קיומה אינו תלוי בכך.
Static batching מבזבז את רוב מה שהוא מרוויח
קישור למקטע: Static batching מבזבז את רוב מה שהוא מרוויחהדרך הנאיבית ל-batch היא לאסוף בקשות, להריץ אותן יחד, ולהחזיר כשכולן מסתיימות. אבל הן לא מסתיימות יחד: יש תשובות של עשרים tokens ויש של חמש מאות. Batch קבוע רץ עד שהאיבר הארוך ביותר מסתיים, וכל בקשה שכבר הסתיימה ממשיכה לתפוס את המשבצת שלה, לתרום padding, עד אז.
קחו 64 בקשות עם הטיה ריאלית באורכי פלט — חציון 18 tokens, הארוכה 231, סך הכול 1,874 — ודמו את שתי המדיניות לפי עלות הצעד הנמדדת לשמונה משבצות:
| מדיניות | זמן קיר | throughput | latency ממוצע לבקשה | slot-steps מבוזבזים |
|---|---|---|---|---|
| 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.6. Latency ממוצע משתפר יותר מפי עשרה, כי תחת static batching בקשה שהסתיימה בארבעה צעדים עדיין מחכה לשכן בן 231 tokens לפני שמישהו שומע עליה.
Continuous batching3 הוא התיקון, והוא פשוט כפי שהוא נשמע: ה-batch אינו קבוצה אלא אוסף משבצות, ומשבצת שמתפנה מכניסה את הבקשה הבאה בתור בצעד הבא ממש. המתזמן עובד ברזולוציה של token אחד במקום בקשה אחת. כל stack serving בפרודקשן עושה זאת כיום.
יש לו חצי שני, והוא ה-cache. משבצות שנכנסות ויוצאות משאירות את זיכרון ה-cache מפוצל, ושמירת ההקצאה המקסימלית לכל משבצת מבזבזת את רוב ההזמנה. PagedAttention4 שואל את התשובה ממערכות הפעלה: לאחסן את ה-cache בבלוקים בגודל קבוע עם טבלת בלוקים לכל רצף, כך שה-cache של רצף יכול להיות מפוזר פיזית ועדיין רציף לוגית — מה שגם מאפשר לשני רצפים עם prefix משותף לחלוק את הבלוקים שמחזיקים אותו. על זה vLLM בנוי, ולכן מנוע serving הוא allocator זיכרון עם transformer מחובר אליו.
Quantization, והדבר הראשון שמשתבש
קישור למקטע: Quantization, והדבר הראשון שמשתבשהחצי השני של החשבון הוא המשקולות עצמן. חצי מיליארד פרמטרים בארבעה bytes כל אחד הם 1.98 GB; בשני bytes, 0.99 GB; ב-byte אחד, 0.49 GB. פחות bits למשקולת מכווץ את המודל בדיסק, מכווץ אותו בזיכרון, ו—מכיוון ש-decode הוא bandwidth-bound—מזרז כל צעד, כי יש פחות bytes להזיז.
השיטה הפשוטה ביותר היא 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 כך שהמשקולת הגדולה ביותר תמופה למספר השלם הגדול ביותר, מחלקים, מעגלים, שומרים את השלמים ואת ה-scale. משחזרים על ידי הכפלה חזרה. אין בזה שום תחכום, וזה עובד — עד שזה לא.
נמדד על המשקולות האמיתיות של המודל: כל 168 מטריצות ההטלה, 357.8 מיליון פרמטרים, שגיאה יחסית :
| שיטה | שגיאה יחסית ממוצעת | המטריצה הגרועה ביותר |
|---|---|---|
| INT8, scale אחד לכל המטריצה | 0.0400 | 0.1487 |
| INT8, scale אחד לכל שורת פלט | 0.0100 | 0.0149 |
| INT4, scale אחד לכל המטריצה | 0.6026 | 0.9931 |
| INT4, scale אחד לכל שורת פלט | 0.1790 | 0.2589 |
| INT4, scale אחד לכל קבוצה של 128 | 0.1323 | 0.1992 |
| NF4, scale אחד לכל בלוק של 64 | 0.0952 | 0.1205 |
| INT3, scale אחד לכל קבוצה של 128 | 0.3044 | 0.4123 |
| INT2, scale אחד לכל קבוצה של 128 | 0.7790 | 0.8076 |
השורה הרביעית היא הקריסה. שגיאה יחסית של 0.99 במטריצה הגרועה פירושה שהשחזור כמעט לא משמר דבר מהמקור — המטריצה הוחלפה ברעש בערך בגודל הנכון. הסיבה נראית באותו ניסוי על מטריצה אחת:
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 %)משקולת אחת מתוך ששת אלפים נמצאת מעבר לשש סטיות תקן, והגדולה ביותר רחוקה 24. עם scale יחיד לכל המטריצה, אותה משקולת אחת קובעת את גודל הצעד לכל 4.3 מיליון המשקולות. ב-8 bits יש 256 צעדים והמשקולת הטיפוסית עדיין נוחתת על צעד משמעותי. ב-4 bits יש 16, החיצוני שמור לערך שכמעט לא קיים, והמשקולות הרגילות — כלומר כולן — מתעגלות לשתיים או שלוש רמות מובחנות.
כל מה שאחרי השורה הזו הוא אותו תיקון בגרנולריות אחרת: לתת ל-scale טריטוריה קטנה יותר. לכל שורת פלט מחלק את השגיאה ב-3.4; לכל קבוצה של 128 משקולות רצופות מחלק שוב. העלות היא bookkeeping — scale של 16 bits לכל קבוצה של 128 הוא bits למשקולת במקום 4 — והוא מחזיר את רוב הפער.
NF4 תוקף מהצד השני.5 הרמות אינן חייבות להיות במרווחים שווים. משקולות בתוך בלוק מתפלגות בקירוב נורמלית, לכן בוחרים את שש-עשרה הרמות כקוונטילים של התפלגות נורמלית: צפוף ליד אפס, שם המשקולות באמת נמצאות, ודל בזנבות, שם הן לא. אותם ארבעה bits, אותו block scaling, בבלוק קטן יותר — 4.25 bits למשקולת מול 4.125 של group-128 — והשגיאה הנמדדת יורדת מ-0.1323 ל-0.0952, נמוך ב-28 %. חלק מזה הוא הבלוק העדין יותר והשאר הוא הצבת הרמות במקום שבו המסה נמצאת; להפריד ביניהם היה דורש שורה שלישית.
ה-outlier features
קישור למקטע: ה-outlier featuresתיבת ה-floating-point של פרק 2 הסתיימה בהבטחה: שבפרק הזה נכווץ משקולות ל-8 ול-4 bits ונמצא קומץ outlier features שמסרבים להידחס. הנה הם, והם מסבירים למה "פשוט לעגל את המספרים" אף פעם לא היה עובד על activations.
המשקולות למעלה התנהגו רע. ה-activations הן ליגה אחרת. קחו prompt רגיל בן 84 tokens, לכדו את ה-residual stream בכל שכבה, ומדדו את הגודל המקסימלי שכל אחד מ-896 הממדים מגיע אליו:
| שכבה | |h| הגדול ביותר | |h| הגדול ביותר בממד החציוני | יחס | ממדים מעל 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 |
ממד 62 מגיע ל-1,579.6 בזמן שהממד החציוני לעולם לא עובר 1.6. זו לא תקלה של token אחד או שכבה אחת: אותו ממד נמצא כבר בשכבה 4 ועדיין בשכבה 20, כמעט באותו ערך. אלו ה-outlier features,6 והם שיטתיים — תכונה של המודל המאומן, לא של הקלט.
ההיסטוגרמה של 896 המקסימות לכל ממד בשכבה 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תשע מאות ממדים בערימה מסודרת מתחת ל-8, כלום במשך שלוש אוקטבות, ואז ממד אחד לבדו בקצה הרחוק. עכשיו כוונטטו את הטנזור הזה ל-INT8 וספרו מה קורה:
| שיטה | שגיאה יחסית | רמות שלמים מובחנות בשימוש, כל הטנזור |
|---|---|---|
| scale אחד לכל הטנזור | 0.1083 | 14 מתוך 256 |
| scale אחד לכל token (לכל שורה) | 0.0433 | 158 |
| כל הטנזור, ממד outlier אחד נשמר ב-fp32 | 0.0442 | 48 |
| כל הטנזור, 4 ממדי outlier נשמרים ב-fp32 | 0.0279 | 57 |
| כל הטנזור, 16 ממדי outlier נשמרים ב-fp32 | 0.0085 | 102 |
ארבע-עשרה רמות מתוך 256. ה-scale נקבע על ידי 1,579.6, לכן כל צעד רחב 12.44, ול-activation טיפוסי — גודל חציוני 0.26, אחוזון 99 של 2.51 — אין איפה לנחות. לפי ממד זה חריף יותר:
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רמה אחת. כל הממד, כל token, כוונטט לאותו מספר. שמונה bits הוקצו וכמעט אפס נוצלו, והמודל שקורא את ה-activations מקבל קבוע.
המדידה הזו היא ההצדקה לכל טכניקה שאנשים באמת משתמשים בה:
להשאיר את ה-outliers בחוץ. LLM.int8()6 מפרק את מכפלת המטריצות: הממדים עם גדלים קיצוניים מחושבים ב-16 bits, כל השאר ב-INT8, והחצאים נסכמים. הטבלה למעלה היא הקבלה — הסרת ארבעה ממדים חותכת את השגיאה כמעט פי ארבעה. SmoothQuant7 במקום זאת מזיז את הקושי: מחלקים את ה-activations בפקטור לכל channel ומכפילים בו את עמודת המשקולות התואמת, מה שמשאיר את המכפלה ללא שינוי ומעביר את ה-outlier מהטנזור שלא יכול לספוג אותו לזה שכן.
לבחור את העיגול, לא פשוט לעגל. שום דבר למעלה לא שואל למה המטריצה משמשת. GPTQ8 מכוונטט עמודה אחר עמודה ואחרי כל אחת מתאים את העמודות שנותרו ב-full precision כדי לפצות על השגיאה שכבר נעשתה — ממזער את שגיאת פלט השכבה על קלטים אמיתיים, לא את שגיאת המשקולות. AWQ9 מבחין שחלק קטן מערוצי המשקולות חשוב בהרבה מהשאר, מוצא אותם מסטטיסטיקות activation, ומגדיל אותם לפני quantization כדי שינחתו על רמות עדינות יותר. שניהם צריכים סט כיול; אף אחד מהם לא צריך gradients.
הצגת פרטים
GGUF, ומה לקובץ פורמט יש לעשות עם כל זה.
GGUF אינו שיטת quantization; הוא הקונטיינר ש-llama.cpp משתמש בו, והבלבול בהשוואות gguf vs gptq נובע מהתייחסות לשניים כאותו סוג של דבר. GGUF מחזיק טנזורים, tokenizer, מטא-דאטה של ארכיטקטורה ו-chat template בקובץ אחד שניתן למיפוי לזיכרון, ונושא בתוכו משפחה של סכמות בלוקים — שמות כמו Q4_K_M מקודדים bits למשקולת, גודל בלוק, והאם טנזורים מסוימים נשמרים בדיוק גבוה יותר.
ההבדל ההנדסי החשוב: GPTQ ו-AWQ מייצרים משקולות שמותאמות ל-GPU kernel, בעוד סכמות GGUF מפוענחות בזול על CPU כשהקובץ ממופה ולא נטען. לכן אותו "מודל 7B ב-4-bit" קיים בשני עולמות בגדלים שונים ובאיכות שונה, ולכן ההשוואה הכנה לעולם אינה הפורמט — אלא המדידה למטה, על המשימה שלכם.
מה quantization באמת עולה, במדידה
קישור למקטע: מה quantization באמת עולה, במדידהכמעט כל מאמר על quantization נעצר בסעיף הקודם: מסביר את השיטה, מצטט יחס דחיסה, וטוען שהאיכות "נשמרת ברובה". פרק 4 עסק בלא לרמות את עצמכם, אז נבדוק.
אותו מודל, משקולות מכוונטטות במקום בכל שיטה, ואז שלוש מדידות: perplexity על 2,048 tokens של פרוזה אנגלית שלא שימשה באימון — כאן, טיוטת הקורס הזה, ולכן המאגר מחליף בספר public-domain קבוע ומדפיס טבלה באותה צורה עם מספרים אחרים — סוללה של 16 שאלות עובדתיות קצרות עם תשובות ידועות תחת greedy decoding, ושיעור ה-tokens שבהם המודל המכוונטט מסכים עם מודל full-precision בהינתן context זהה.
| שיטה | שגיאת משקולות ממוצעת | perplexity | סוללת שאלות | מסכים עם fp32 |
|---|---|---|---|---|
| fp32 (ייחוס) | 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 שנעשה נכון הוא חינם. INT8 לכל שורה מקבל 22.96 מול 23.08 של הייחוס — פער של אחד מתוך מאתיים, כלומר רעש, ויש לקרוא אותו כ"זהה". כיוון הרעש אינו יציב: על קורפוס public-domain במאגר, שתי הסכמות יוצאות 22.24 מול 22.18: חצי מהמרחק ובכיוון ההפוך. הוא מסכים עם מודל full-precision ב-142 מתוך 144 tokens שנוצרו. רבע מהזיכרון מול fp32, חצי מול fp16 שהייתם פורסים בפועל, וללא עלות ניתנת לזיהוי. INT8 שנעשה ברשלנות כמעט חינם גם הוא: scale אחד לכל מטריצה עולה 0.5 נקודות perplexity ואינו משנה תשובות בסוללה. שמונה bits סלחניים מספיק כך שגרנולריות כמעט לא חשובה, וזה בדיוק למה אנשים מכלילים מ-INT8 ל-INT4 ונפגעים.
INT4 עם scale אחד לכל tensor הורס את המודל. Perplexity של 365 מיליון: לא הידרדרות, השמדה. מכאן גרנולריות היא כל המשחק — per-tensor 365,416,000, per-row 46.18, per-group-of-128 31.08, NF4 24.55. אותם ארבעה bits למשקולת, פער של חמישה-עשר מיליון בין הגרוע לטוב.
Perplexity הוא מכשיר גס והסוללה גסה עוד יותר. בין NF4 ל-group-128 INT4 יש פער perplexity של 6.5 נקודות והסוללה שונה בשאלה אחת — ורווח הסמך של פרק 4 אומר ששאלה אחת מתוך שש-עשרה לא מבחינה בשום דבר. יש הדגמה חדה יותר מהרווח: מריצים את אותה סוללה כשה-repetition penalty המובנה של המודל כבוי, כלומר מה ש-greedy decoding באמת אומר, ושתי השורות מתחלפות. שאלה אחת מתוך שש-עשרה אינה אפקט קטן; היא אפס אפקט. גם האזהרה של פרק 8 חלה: perplexity בר-השוואה רק בין מודלים שחולקים tokenizer, ולכן מספר מפוסט של מישהו אחר לא ניתן להשוואה לשלכם.
עמודת ההסכמה היא החדה מבין השלוש, וכמעט חינמית: מריצים את מודל full-precision ב-greedy, ואז שואלים את המודל המכוונטט, בכל מיקום, מה היה בוחר בהינתן אותו prefix. יש לה 144 תצפיות בלתי תלויות במקום 16, היא לא צריכה אמת קרקעית, והיא מתדרדרת בצורה חלקה במקום בקפיצות. היא גם בדיוק הכמות שהסעיף הבא צריך.
זו ההבטחה ש-פרק 1 נתן לגבי הפרק הזה, בזמן: המתמטיקה אומרת שמודל 4-bit אפשרי, וההנדסה מחליטה אם הוא שימושי.
Speculative decoding
קישור למקטע: Speculative decodingפרק 12 הכריז על זה והשאיר את החשבון כאן.
הרעיון מגיע ישירות מפיצול prefill/decode. אימות רצף מוצע של tokens עולה forward pass אחד על מיקומים — מכפלת מטריצה-מטריצה, כמעט לא יקרה יותר ממעבר על אחד. לכן:
Draft
קישור למקטע: Draftמודל קטן וזול יוצר tokens מועמדים באופן אוטורגרסיבי.
Verify
קישור למקטע: Verifyהמודל הגדול מריץ forward pass אחד על כל המועמדים יחד, ומייצר מה שהיה אומר בכל מיקום.
Accept
קישור למקטע: Acceptשומרים את ה-prefix הארוך ביותר שבו השניים מסכימים, ועוד ה-token שהמודל הגדול מספק בחינם באי-ההסכמה הראשונה. זורקים את השאר ומתחילים שוב.
התפלגות הפלט אינה משתנה. עם greedy decoding זה ברור — token מתקבל רק אם היעד היה מפיק אותו. עם sampling צריך כלל קבלה מתוקן, ו-Leviathan et al. מוכיחים שההתפלגות המתקבלת היא בדיוק של היעד.10 זו האופטימיזציה המדויקת השנייה בפרק.
לכן הכול תלוי ב-acceptance rate , שניתן למדוד — זו עמודת ההסכמה למעלה, ולכן היא חושבה שם. כשמשתמשים בכל מודל מכוונטט כ-draft ליעד full-precision, על פני 144 מיקומים שנוצרו:
| draft model | acceptance | הריצה הארוכה ביותר שהתקבלה | tokens צפויים לכל target pass, |
|---|---|---|---|
| fp32 (היעד עצמו) | 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 |
מספר ה-tokens הצפוי שמתקבל לכל verification pass, באורך draft , הוא
וההאצה נטו מחלקת זאת בעלות ה-draft עצמו, חלק מעלות היעד לכל 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 |
הערך המודגש הוא מה שצריך לזכור: speculative decoding יכול להאט generation. ב-30 % acceptance עם draft שעולה חמישית מהיעד, משלמים על חמישה forward passes ושומרים 1.4 tokens. העמודה האחרונה היא המלכודת השנייה — draft ארוך יותר עוזר רק כשה-acceptance גבוה, כי הזנב של ניחוש בן tokens כמעט לעולם אינו מגיע. ב-90 % acceptance שווה 3.40x וב-30 % הוא שווה 0.79x: אותה קונפיגורציה, רווח או הפסד לפי מספר שנמדד על התעבורה שלכם.
Distillation, ומה soft label נושא
קישור למקטע: Distillation, ומה soft label נושאQuantization מכווץ מודל על ידי שמירת אותה פונקציה בפחות bits. Distillation מכווץ אותו על ידי אימון מודל קטן יותר לחקות גדול יותר11 — רעיון שקדם ל-deep learning בכמעט עשור.12
החלק העדין הוא ממה התלמיד לומד. לא מהתשובה הנכונה: עליו היה אפשר לאמן ישירות. מה שהמורה מוסיף הוא כל ההתפלגות. שאלו את המודל מה בא אחרי ביטוי והביטו מעבר ל-argmax:
"She poured the milk into the"
' jug' 0.1355 ' cup' 0.1051 ' bowl' 0.0605 ' large' 0.0380 ' milk' 0.0360ה-hard label אומר jug ולא יותר. ה-soft label אומר jug, וגם ש-cup היה כמעט טוב באותה מידה, bowl סביר, ו-large — שם תואר, המשך דקדוקי שונה לגמרי — עדיין חי. זה הטיעון המקורי: זה 7, אבל הוא נראה די דומה ל-1, והדמיון הוא מידע שה-hard label זורק.
זו גם הסיבה ש-distillation משתמש בטמפרטורה. חלוקת ה-logits ב- לפני ה-softmax משטחת את ההתפלגות ומעלה את המשקל היחסי של המקומות הבאים: בביטוי הזה, היחס בין ה-token העליון לשלישי יורד מ-2.24 ב- ל-1.50 ב- — השורש הריבועי של הראשון, שזה מה שחלוקת logits בשתיים עושה ליחס. אותו סדר, יותר attention של פונקציית ההפסד לפספוסים הקרובים. ה-gradient של התלמיד נושא את אי-הוודאות של המורה ולא רק את פסק הדין שלו.
מה נכנס ב-8, 16 ו-24 GB
קישור למקטע: מה נכנס ב-8, 16 ו-24 GBכל מה שבפרק הזה הוא עכשיו סכום אחד:
כאשר הוא סך ה-tokens resident בכל הבקשות המקבילות. ביישום: שורות 7B ו-70B מניחות 8 key-value heads בממד 128, שורת 13B מניחה multi-head attention מלא עם 40 heads, כך נבנו הדורות האלה של מודלים — וזה ניכר.
8 GB
| model | precision | weights | פנוי אחרי overhead | tokens ב-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 | פנוי אחרי overhead | tokens ב-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 | פנוי אחרי overhead | tokens ב-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 tokens של context, וזה לא שיחה אלא בקושי prompt. "האם זה נכנס" היא השאלה הלא נכונה. הנכונה היא "עם כמה context, ולכמה משתמשים בו-זמנית".
הביטו גם בשתי שורות int8 של 16 GB. ה-7B מקבל 65,378 tokens וה-13B מקבל 3,136 — פער פי עשרים בגלל 5.6 GB של משקולות נוספות, כי ל-13B כאן יש multi-head attention וה-cache שלו עולה 800 KB לכל token מול 128 KB של ה-7B. שני מודלים בגודל דומה, אחד בלתי שימושי ל-context ארוך, מסיבה שאינה מופיעה בכותרת של שום model card.
לאן זה ממשיך
קישור למקטע: לאן זה ממשיךלפני שלושה-עשר פרקים זה היה perceptron עם שתי משקולות ו-bias. עכשיו זה transformer שתוכנן, אומן, יושר, למד להוציא compute על שאלות קשות, ומוגש בעלות מדודה לכל token — בלי קופסה אחת שנותרה סגורה.
זה נגמר כאן, וזה נגמר בכוונה.
פרק 14 מתחיל כשהמודל נמצא במקום אחר. לא בתהליך שלכם, לא בזיכרון שלכם, לא במשתנה שאפשר להדפיס: על מכונה שאינכם מנהלים, מאחורי API key, פורט וחשבון. כל מה שנמדד כאן עדיין קורה — ה-prefill עדיין רץ לפני ה-token הראשון, ה-cache עדיין גדל עם השיחה, ה-batch שאתם נמצאים בו עדיין שייך למישהו אחר ועדיין קובע את ה-latency שלכם — אבל מעכשיו אתם צופים בו דרך זרם Server-Sent Events, finish_reason, ו-HTTP 429 עם header Retry-After. השאלות משתנות עם נקודת המבט: לא איך ה-gradient הזה מחושב אלא למה החשבונית שלי שולשה. גם השפה משתנה, ופרק 14 מסביר את הכלל הזה במקום רק להכריז עליו — עד כאן הקוד החזיק משקולות, gradients, logits ו-bytes של tokenizer; מכאן והלאה הוא מחזיק חיבור, ניסיון חוזר, ביטול ומצב מצטבר. שלושה-עשר הפרקים שמאחוריכם לא נזרקים במעבר. הם התיאור של מה שרץ בצד השני של הפורט.
מקורות ושיטה
קישור למקטע: מקורות ושיטהשתי השמטות מכוונות. FlashAttention (Dao et al., arXiv:2205.14135) אינו attention אחר — הוא מחשב את אותה פונקציה באמצעות חלוקת הפעולה לאריחים כך שמטריצת ה-score לעולם לא נכתבת לזיכרון, ולכן ה-67 MB בטבלה השנייה של הפרק קטנים בפועל ממה שהאריתמטיקה מרמזת. וה-kernels עצמם מואצלים: הרצאה 10 של CS336 ב-Stanford מכסה מערכות inference בעומק שהפרק הזה לא מנסה להגיע אליו, ומאגר llama.cpp ומפרט GGUF הם המקורות העיקריים לצד ה-CPU.
הפניות
קישור למקטע: הפניות-
Shazeer, N. Fast Transformer Decoding: One Write-Head is All You Need. arXiv:1911.02150 (2019). המאמר הוא בעיקר טיעון על רוחב פס זיכרון, ונקרא כך. ↩
-
Ainslie, J. et al. GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints. arXiv:2305.13245 (2023). כולל את מתכון ה-uptraining שממיר checkpoint קיים של multi-head, ולכן 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. מציג תזמון ברמת איטרציה — 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 הוא אנלוגיית מערכות ההפעלה במלואה. ↩
-
Dettmers, T., Pagnoni, A., Holtzman, A. and Zettlemoyer, L. QLoRA: Efficient Finetuning of Quantized LLMs. arXiv:2305.14314 (2023). NF4 מוגדר ב-§3; שישה-עשר ערכי הרמות ששימשו במדידה למעלה הם אלה שהמאמר גוזר. ↩
-
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 הוא מקור התופעה שנמדדה למעלה, כולל הממצא ש-outliers מופיעים שיטתית בסקייל. ↩ ↩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). משפט 1 הוא ההוכחה שהתפלגות הפלט אינה משתנה; Chen et al. (arXiv:2302.01318) פרסמו את אותו רעיון באופן עצמאי. ↩
-
Hinton, G., Vinyals, O. and Dean, J. Distilling the Knowledge in a Neural Network. arXiv:1503.02531 (2015). הטמפרטורה וטיעון ה-"dark knowledge". ↩
-
Buciluă, C., Caruana, R. and Niculescu-Mizil, A. Model Compression. KDD 2006. Distillation, תשע שנים קודם, ל-ensembles ולא ל-transformers. ↩