דלג לתוכן
13/30פרק 13 מתוך 30

להוזיל Inference: ‏KV cache, ‏Batching ו-Quantization

אותו מודל עונה באותה תשובה תוך 8.8 שניות או 78.9. ואז INT4, נמדד בשלוש דרכים — לא נטען בעלמא.

בעמוד הזה

אותו מודל, על אותה מכונה, עונה לאותה שאלה עם אותם 48 tokens. שני הפלטים זהים token אחר token — נבדק, לא הונח.

TEXT
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 קבע את מחירון האימון. זה המחירון של הצד שעליו משלמים לנצח: מודל פרוס מוציא בערך 2N2N FLOPs לכל token שהוא פולט, בכל בקשה, לשארית חייו.

לאן הלך הזמן של הריצה השנייה

קישור למקטע: לאן הלך הזמן של הריצה השנייה

כדי ליצור token, ‏decoder-only transformer לוקח את כל הרצף עד כה, מריץ אותו דרך כל שכבה, וקורא את התפלגות ההסתברויות מהמיקום האחרון. אחר כך הוא מוסיף את ה-token שנבחר ועושה זאת שוב. התיאור הזה נכון, וזה מה שהריצה האיטית עושה.

הוא גם בזבזני מאוד, והסיבה היא המסכה הסיבתית מ-פרק 9. וקטורי המפתח והערך של מיקום 7 מחושבים מהקלט של מיקום 7 ומהמיקומים שלפניו. כשמיקום 8 מגיע, מיקום 7 לא יכול לראות אותו — זה פירושו של סיבתי — ולכן המפתח והערך של מיקום 7 הם בדיוק אותם מספרים כמו קודם. הריצה האיטית מחשבת אותם מחדש בכל צעד.

אז שומרים אותם. המאגר הזה הוא ה-key-value cache, האופטימיזציה המשמעותית ביותר ב-serving של מודלי שפה:

generate.pyPYTHON
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 יחיד עם d=64d = 64, צעד generation אחד בשתי הדרכים:

tokens ב-contextחישוב מחדש של הכולעם cacheיחסמטריצת score
1280.59 ms0.062 ms10x65,536 B vs 512 B
2561.20 ms0.163 ms7x262,144 B vs 1,024 B
5127.03 ms0.078 ms90x1,048,576 B vs 2,048 B
102417.31 ms0.114 ms152x4,194,304 B vs 4,096 B
204859.83 ms0.214 ms279x16,777,216 B vs 8,192 B
4096236.18 ms0.284 ms832x67,108,864 B vs 16,384 B

העמודה הימנית היא הסיבה. חישוב מחדש בונה בכל צעד את מטריצת ה-attention המלאה n×nn \times n — ה-O(n2)O(n^2) מתיבת הסימון האסימפטוטי של פרק 9, בתשלום פעם לכל token. עם cache בונים במקום זאת שורה 1×n1 \times n: ב-4,096 tokens, ‏67 MB של scores מול 16 KB.

ספירת multiply-accumulates במקום מילישניות מסירה את המכונה מהטיעון. כדי ליצור TT tokens מהתחלה קרה:

tokens שנוצרועם cacheחישוב מחדשיחס
1282.6 M192.0 M73x
51223.1 M7.36 G318x
2048293.7 M392.6 G1,336x

בכל צעד הגרסה עם cache ליניארית ב-context והגרסה בלי cache ריבועית; בסכימה על פני generation, ‏O(T2)O(T^2) מול O(T3)O(T^3), והיחס גדל ללא גבול. הפער פי תשעה בפתיחה נמדד על 48 tokens — פחות מהשורה הראשונה בטבלה.

ה-cache משנה גם מה חייב להיות בזיכרון. על GPU לפטופ של 8 GB שמייצר 256 tokens ב-fp16, אם לוקחים את שיא ההקצאה ומחסרים את המשקולות resident:

שיא זיכרון עבודה
עם cache21.8 MB
חישוב מחדש181.7 MB

פי 8.3 יותר זיכרון, כדי לייצר אותם tokens לאט יותר. זו ההבטחה מ-פרק 5, שמגיעה מכיוון לא צפוי: שם reverse-mode autodiff היה חייב לשמור כל תוצר ביניים ל-backward pass, וה-activations שלטו בזיכרון האימון. ב-inference אין backward pass ואין מה לשמור בשבילו — לכן מי ששולט בזיכרון הוא ה-cache, והוא בחירה מכוונת ולא עלות בלתי נמנעת.

Prefill ו-decode הן שתי מכונות שונות

קישור למקטע: Prefill ו-decode הן שתי מכונות שונות

הביטו שוב בריצה המהירה: ה-token הראשון שלה התנהג אחרת מארבעים ושבעה האחרים.

TEXT
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 מתחלק לשני שלבים עם פיזיקה שונה באמת:

Forward pass אחד על כל ה-prompt. כל token מעובד במקביל, ולכן כל מטריצת משקולות נטענת מהזיכרון פעם אחת ומוכפלת במטריצה של מאות וקטורי tokens — מכפלת מטריצה-מטריצה, הרבה אריתמטיקה לכל byte שמוזז, בדיוק מה ש-GPU נבנה לעשות. Prefill הוא compute-bound, ועלותו בערך ליניארית באורך ה-prompt.

Forward pass אחד לכל token, ‏batch של אחד ורצף של אחד. כל מטריצת משקולות עדיין נטענת במלואה מהזיכרון, ומוכפלת ב-וקטור יחיד — מכפלת מטריצה-וקטור, כמעט בלי אריתמטיקה לכל byte שמוזז. Decode הוא memory-bandwidth-bound, ועלותו לכל token כמעט אינה תלויה באורך ה-context.

שני החצאים ניתנים למדידה. Prefill, מעבר אחד על PP tokens:

tokens ב-promptשניותms לכל token
160.351521.97
320.525416.42
641.049116.39
1281.655212.93
2563.096512.10

Decode, ‏token אחד מול cache של CC:

tokens ב-cachems ל-token אחד
16110.05
6497.57
256108.53
1024103.86

קראו את הטבלה השנייה פעמיים. מעבר מ-16 tokens של context ל-1,024 — פי שישים וארבע יותר היסטוריה לעשות עליה attention — לא שינה את עלות הצעד באופן מדיד. Attention מול ה-cache היא עבודה אמיתית, אבל היא מתגמדת מול העלות הקבועה של גרירת חצי מיליארד משקולות דרך אפיק הזיכרון כדי לייצר וקטור אחד. העלות הקבועה הזו היא הסיבה לכל מה שבסעיף הבא.

שני השלבים האלה הם המקור לשני המספרים שכל מערכת serving מדווחת. Time to first token הוא למעשה prefill, והוא גדל עם ה-prompt, ולכן שיחה ארוכה מרגישה איטית בתחילתה. Tokens per second הוא 1/decode step1/\text{decode step}, והוא בערך קבוע, ולכן התשובה אחר כך זורמת באופן אחיד. צ׳אט שמתחיל לאט ואז מוזרם חלק אינו טריק רינדור. אלו שתי הטבלאות האלה.

ה-cache מחליף אריתמטיקה בזיכרון, והזיכרון שהוא רוצה אינו קטן. לכל token ב-context, כל שכבה מחזיקה וקטור key אחד ווקטור value אחד לכל key-value head:

bytes per token=2×L×Hkv×dhead×bytes per element\text{bytes per token} = 2 \times L \times H_{kv} \times d_{\text{head}} \times \text{bytes per element}

ה-2 הוא עבור keys ו-values; כל השאר הוא הארכיטקטורה. עבור המודל שנמדד לאורך הפרק — 24 שכבות, 14 query heads, ‏2 key-value heads, ממד ראש 64 — ב-fp16 זה 2×24×2×64×2=12,2882 \times 24 \times 2 \times 64 \times 2 = 12{,}288 bytes לכל token.

לנוסחאות בתחום הזה יש נטייה לטעות בפקטור של שניים, לכן בודקים מול ה-allocator במקום להאמין להן:

TEXT
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 שנוסה:

batchcontextcache נמדדחזוישיא זיכרון עבודה
15126.0 MB6.0 MB15.4 MB
116,384192.0 MB192.0 MB207.3 MB
165,536768.0 MB768.0 MB793.7 MB
84,096384.0 MB384.0 MB401.5 MB
322,048768.0 MB768.0 MB794.2 MB
641,024768.0 MB768.0 MB797.0 MB
128512768.0 MB768.0 MB816.4 MB

שלוש השורות האחרונות שוות מבט נוסף. שלושים ושניים משתמשים עם 2,048 tokens כל אחד, שישים וארבעה עם 1,024, מאה עשרים ושמונה עם 512 — ה-cache הוא 768 MB בכל מקרה, כי בשלושת המקרים מוחזקים 65,536 tokens. ה-cache תלוי רק במספר הכולל של tokens resident, לא באופן שבו הם מחולקים בין משתמשים. העובדה הזו היא הבסיס לסעיף ה-batching.

פרק 9 הציג multi-query ו-grouped-query attention ודחה את הסיבה לפרק הזה. הסיבה היא הנוסחה הזו, ובפרט ה-HkvH_{kv} שבה.

Standard multi-head attention נותן לכל query head heads משלו ל-key ול-value. למודל כאן יש 14 query heads; עם multi-head attention מלא ה-cache שלו היה 2×24×14×64×2=86,0162 \times 24 \times 14 \times 64 \times 2 = 86{,}016 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,0000.49 GB3.91 GB31.2 GB
32,0003.91 GB31.25 GB250.0 GB
128,00015.62 GB125.00 GB1,000.0 GB
1,000,000122.07 GB976.56 GB7,812.5 GB

משקולות המודל עצמו הן 13.0 GB ב-fp16, המספר בטבלה בסוף הפרק. לכן ב-context של 128,000 tokens, ה-cache של משתמש אחד גדול מהמודל. זו האריתמטיקה ש-פרק 16 הופך לכסף, ולכן שיחה ארוכה אינה רק איטית — היא תופסת פרוסה קבועה ממכונה כל עוד הבקשה חיה.

Batching: המספר שעולה והמספר שיורד

קישור למקטע: Batching: המספר שעולה והמספר שיורד

Decode מוגבל בזיכרון: המשקולות נגררות דרך האפיק כדי לייצר token אחד, ויחידות האריתמטיקה בטלות. אז מכניסים יותר עבודה לאותו צעד. מריצים כמה בקשות יחד, והמשקולות, שנקראו פעם אחת, משרתות את כולן. במדידה על אותו מודל, כשכל בקשה מחזיקה cache של 64 tokens ומפענחת token אחד:

batchlatency לכל צעדthroughputlatency לעומת B=1
10.1286 s7.78 tok/s1.00x
20.1839 s10.88 tok/s1.43x
40.1909 s20.95 tok/s1.49x
80.2781 s28.76 tok/s2.16x
160.3430 s46.64 tok/s2.67x
320.6302 s50.78 tok/s4.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 היא לאסוף BB בקשות, להריץ אותן יחד, ולהחזיר כשכולן מסתיימות. אבל הן לא מסתיימות יחד: יש תשובות של עשרים tokens ויש של חמש מאות. Batch קבוע רץ עד שהאיבר הארוך ביותר מסתיים, וכל בקשה שכבר הסתיימה ממשיכה לתפוס את המשבצת שלה, לתרום padding, עד אז.

קחו 64 בקשות עם הטיה ריאלית באורכי פלט — חציון 18 tokens, הארוכה 231, סך הכול 1,874 — ודמו את שתי המדיניות לפי עלות הצעד הנמדדת לשמונה משבצות:

מדיניותזמן קירthroughputlatency ממוצע לבקשהslot-steps מבוזבזים
static batches of 8176.9 s10.6 tok/s83.2 s3,214
continuous, 8 slots109.0 s17.2 tok/s8.1 s0

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, והיא נכנסת לשלוש שורות:

quantize.pyPYTHON
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 מיליון פרמטרים, שגיאה יחסית WW^/W\lVert W - \hat{W}\rVert / \lVert W \rVert:

שיטהשגיאה יחסית ממוצעתהמטריצה הגרועה ביותר
INT8, scale אחד לכל המטריצה0.04000.1487
INT8, scale אחד לכל שורת פלט0.01000.0149
INT4, scale אחד לכל המטריצה0.60260.9931
INT4, scale אחד לכל שורת פלט0.17900.2589
INT4, scale אחד לכל קבוצה של 1280.13230.1992
NF4, scale אחד לכל בלוק של 640.09520.1205
INT3, scale אחד לכל קבוצה של 1280.30440.4123
INT2, scale אחד לכל קבוצה של 1280.77900.8076

השורה הרביעית היא הקריסה. שגיאה יחסית של 0.99 במטריצה הגרועה פירושה שהשחזור כמעט לא משמר דבר מהמקור — המטריצה הוחלפה ברעש בערך בגודל הנכון. הסיבה נראית באותו ניסוי על מטריצה אחת:

TEXT
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 הוא 4+16/128=4.1254 + 16/128 = 4.125 bits למשקולת במקום 4 — והוא מחזיר את רוב הפער.

NF4 תוקף מהצד השני.5 הרמות אינן חייבות להיות במרווחים שווים. משקולות בתוך בלוק מתפלגות בקירוב נורמלית, לכן בוחרים את שש-עשרה הרמות כקוונטילים של התפלגות נורמלית: צפוף ליד אפס, שם המשקולות באמת נמצאות, ודל בזנבות, שם הן לא. אותם ארבעה bits, אותו block scaling, בבלוק קטן יותר — 4.25 bits למשקולת מול 4.125 של group-128 — והשגיאה הנמדדת יורדת מ-0.1323 ל-0.0952, נמוך ב-28 %. חלק מזה הוא הבלוק העדין יותר והשאר הוא הצבת הרמות במקום שבו המסה נמצאת; להפריד ביניהם היה דורש שורה שלישית.

תיבת ה-floating-point של פרק 2 הסתיימה בהבטחה: שבפרק הזה נכווץ משקולות ל-8 ול-4 bits ונמצא קומץ outlier features שמסרבים להידחס. הנה הם, והם מסבירים למה "פשוט לעגל את המספרים" אף פעם לא היה עובד על activations.

המשקולות למעלה התנהגו רע. ה-activations הן ליגה אחרת. קחו prompt רגיל בן 84 tokens, לכדו את ה-residual stream בכל שכבה, ומדדו את הגודל המקסימלי שכל אחד מ-896 הממדים מגיע אליו:

שכבה|h| הגדול ביותר|h| הגדול ביותר בממד החציונייחסממדים מעל 6x החציון
16.190.33918x2
41543.481.550996x34
81571.631.4981049x36
121575.031.5461019x34
161579.601.617977x32
201577.982.361668x24
24204.4410.76019x12

ממד 62 מגיע ל-1,579.6 בזמן שהממד החציוני לעולם לא עובר 1.6. זו לא תקלה של token אחד או שכבה אחת: אותו ממד נמצא כבר בשכבה 4 ועדיין בשכבה 20, כמעט באותו ערך. אלו ה-outlier features,6 והם שיטתיים — תכונה של המודל המאומן, לא של הקלט.

ההיסטוגרמה של 896 המקסימות לכל ממד בשכבה 16 מבהירה את הצורה:

TEXT
     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.108314 מתוך 256
scale אחד לכל token (לכל שורה)0.0433158
כל הטנזור, ממד outlier אחד נשמר ב-fp320.044248
כל הטנזור, 4 ממדי outlier נשמרים ב-fp320.027957
כל הטנזור, 16 ממדי outlier נשמרים ב-fp320.0085102

ארבע-עשרה רמות מתוך 256. ה-scale נקבע על ידי 1,579.6, לכן כל צעד רחב 12.44, ול-activation טיפוסי — גודל חציוני 0.26, אחוזון 99 של 2.51 — אין איפה לנחות. לפי ממד זה חריף יותר:

TEXT
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.000023.0813/16100.0 %
INT8 per tensor0.040023.5813/16
INT8 per row0.010022.9613/1698.6 %
INT4 per tensor0.6026365,416,0000/16
INT4 per row0.179046.186/1658.3 %
INT4 group 1280.132331.0810/1671.5 %
NF4 block 640.095224.5511/1684.7 %
INT3 group 1280.3044213.090/165.6 %
INT2 group 1280.779026,325,4360/160.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 אפשרי, וההנדסה מחליטה אם הוא שימושי.

פרק 12 הכריז על זה והשאיר את החשבון כאן.

הרעיון מגיע ישירות מפיצול prefill/decode. אימות רצף מוצע של γ\gamma tokens עולה forward pass אחד על γ\gamma מיקומים — מכפלת מטריצה-מטריצה, כמעט לא יקרה יותר ממעבר על אחד. לכן:

מודל קטן וזול יוצר γ\gamma tokens מועמדים באופן אוטורגרסיבי.

המודל הגדול מריץ forward pass אחד על כל γ\gamma המועמדים יחד, ומייצר מה שהיה אומר בכל מיקום.

שומרים את ה-prefix הארוך ביותר שבו השניים מסכימים, ועוד ה-token שהמודל הגדול מספק בחינם באי-ההסכמה הראשונה. זורקים את השאר ומתחילים שוב.

התפלגות הפלט אינה משתנה. עם greedy decoding זה ברור — token מתקבל רק אם היעד היה מפיק אותו. עם sampling צריך כלל קבלה מתוקן, ו-Leviathan et al. מוכיחים שההתפלגות המתקבלת היא בדיוק של היעד.10 זו האופטימיזציה המדויקת השנייה בפרק.

לכן הכול תלוי ב-acceptance rateα\alpha, שניתן למדוד — זו עמודת ההסכמה למעלה, ולכן היא חושבה שם. כשמשתמשים בכל מודל מכוונטט כ-draft ליעד full-precision, על פני 144 מיקומים שנוצרו:

draft modelacceptanceהריצה הארוכה ביותר שהתקבלהtokens צפויים לכל target pass, ‏γ=4\gamma = 4
fp32 (היעד עצמו)100.0 %485.00
INT8 per row98.6 %484.86
NF4 block 6484.7 %203.69
INT4 group 12871.5 %132.85
INT4 per row58.3 %72.24
INT3 group 1285.6 %21.06
INT2 group 1280.0 %01.00

מספר ה-tokens הצפוי שמתקבל לכל verification pass, באורך draft ‏γ\gamma, הוא

E[tokens]=1αγ+11α\mathbb{E}[\text{tokens}] = \frac{1 - \alpha^{\gamma+1}}{1 - \alpha}

וההאצה נטו מחלקת זאת בעלות ה-draft עצמו, חלק cc מעלות היעד לכל token:

acceptancec=0.05c=0.05, γ=4\gamma=4c=0.1c=0.1, γ=4\gamma=4c=0.2c=0.2, γ=4\gamma=4c=0.1c=0.1, γ=8\gamma=8
30 %1.19x1.02x0.79x0.79x
50 %1.61x1.38x1.08x1.11x
70 %2.31x1.98x1.54x1.78x
90 %3.41x2.93x2.28x3.40x

הערך המודגש הוא מה שצריך לזכור: speculative decoding יכול להאט generation. ב-30 % acceptance עם draft שעולה חמישית מהיעד, משלמים על חמישה forward passes ושומרים 1.4 tokens. העמודה האחרונה היא המלכודת השנייה — draft ארוך יותר עוזר רק כשה-acceptance גבוה, כי הזנב של ניחוש בן γ\gamma tokens כמעט לעולם אינו מגיע. ב-90 % acceptance ‏γ=8\gamma = 8 שווה 3.40x וב-30 % הוא שווה 0.79x: אותה קונפיגורציה, רווח או הפסד לפי מספר שנמדד על התעבורה שלכם.

Quantization מכווץ מודל על ידי שמירת אותה פונקציה בפחות bits. Distillation מכווץ אותו על ידי אימון מודל קטן יותר לחקות גדול יותר11 — רעיון שקדם ל-deep learning בכמעט עשור.12

החלק העדין הוא ממה התלמיד לומד. לא מהתשובה הנכונה: עליו היה אפשר לאמן ישירות. מה שהמורה מוסיף הוא כל ההתפלגות. שאלו את המודל מה בא אחרי ביטוי והביטו מעבר ל-argmax:

TEXT
"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 ב-TT לפני ה-softmax משטחת את ההתפלגות ומעלה את המשקל היחסי של המקומות הבאים: בביטוי הזה, היחס בין ה-token העליון לשלישי יורד מ-2.24 ב-T=1T = 1 ל-1.50 ב-T=2T = 2 — השורש הריבועי של הראשון, שזה מה שחלוקת logits בשתיים עושה ליחס. אותו סדר, יותר attention של פונקציית ההפסד לפספוסים הקרובים. ה-gradient של התלמיד נושא את אי-הוודאות של המורה ולא רק את פסק הדין שלו.

כל מה שבפרק הזה הוא עכשיו סכום אחד:

memory=N×bytes per weightfixed+T×2LHkvdhead×bytesgrows with every token+runtime overheadcall it 1.5 GB\text{memory} = \underbrace{N \times \text{bytes per weight}}_{\text{fixed}} + \underbrace{T \times 2 L H_{kv} d_{\text{head}} \times \text{bytes}}_{\text{grows with every token}} + \underbrace{\text{runtime overhead}}_{\text{call it 1.5 GB}}

כאשר TT הוא סך ה-tokens resident בכל הבקשות המקבילות. ביישום: שורות 7B ו-70B מניחות 8 key-value heads בממד 128, שורת 13B מניחה multi-head attention מלא עם 40 heads, כך נבנו הדורות האלה של מודלים — וזה ניכר.

8 GB

modelprecisionweightsפנוי אחרי overheadtokens ב-context שנכנסים
7Bfp1613.0 GBלא נכנס
7Bint86.5 GBלא נכנס
7Bint4 (g128)3.4 GB3.1 GB25,710
13Bint4 (g128)6.2 GB0.3 GB337
70Bint4 (g128)33.6 GBלא נכנס

16 GB

modelprecisionweightsפנוי אחרי overheadtokens ב-context שנכנסים
7Bfp1613.0 GB1.5 GB11,972
7Bint86.5 GB8.0 GB65,378
7Bint4 (g128)3.4 GB11.1 GB91,246
13Bint812.1 GB2.4 GB3,136
13Bint4 (g128)6.2 GB8.3 GB10,822

24 GB

modelprecisionweightsפנוי אחרי overheadtokens ב-context שנכנסים
7Bfp1613.0 GB9.5 GB77,508
7Bint86.5 GB16.0 GB130,914
7Bint4 (g128)3.4 GB19.1 GB156,782
13Bint812.1 GB10.4 GB13,622
13Bint4 (g128)6.2 GB16.3 GB21,308
70Bint4 (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 ‏n×nn \times n לעולם לא נכתבת לזיכרון, ולכן ה-67 MB בטבלה השנייה של הפרק קטנים בפועל ממה שהאריתמטיקה מרמזת. וה-kernels עצמם מואצלים: הרצאה 10 של CS336 ב-Stanford מכסה מערכות inference בעומק שהפרק הזה לא מנסה להגיע אליו, ומאגר llama.cpp ומפרט GGUF הם המקורות העיקריים לצד ה-CPU.

  1. Shazeer, N. Fast Transformer Decoding: One Write-Head is All You Need. arXiv:1911.02150 (2019). המאמר הוא בעיקר טיעון על רוחב פס זיכרון, ונקרא כך.

  2. Ainslie, J. et al. GQA: Training Generalized Multi-Query Transformer Models from Multi-Head Checkpoints. arXiv:2305.13245 (2023). כולל את מתכון ה-uptraining שממיר checkpoint קיים של multi-head, ולכן GQA התפשט מהר כל כך.

  3. 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.

  4. Kwon, W. et al. Efficient Memory Management for Large Language Model Serving with PagedAttention. arXiv:2309.06180 (2023), SOSP 2023. המאמר שעליו vLLM בנוי; §3 הוא אנלוגיית מערכות ההפעלה במלואה.

  5. Dettmers, T., Pagnoni, A., Holtzman, A. and Zettlemoyer, L. QLoRA: Efficient Finetuning of Quantized LLMs. arXiv:2305.14314 (2023). NF4 מוגדר ב-§3; שישה-עשר ערכי הרמות ששימשו במדידה למעלה הם אלה שהמאמר גוזר.

  6. 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

  7. 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).

  8. Frantar, E., Ashkboos, S., Hoefler, T. and Alistarh, D. GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers. arXiv:2210.17323 (2022).

  9. Lin, J. et al. AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration. arXiv:2306.00978 (2023).

  10. 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) פרסמו את אותו רעיון באופן עצמאי.

  11. Hinton, G., Vinyals, O. and Dean, J. Distilling the Knowledge in a Neural Network. arXiv:1503.02531 (2015). הטמפרטורה וטיעון ה-"dark knowledge".

  12. Buciluă, C., Caruana, R. and Niculescu-Mizil, A. Model Compression. KDD 2006. Distillation, תשע שנים קודם, ל-ensembles ולא ל-transformers.


נוצר על ידי

David Vicente Campos

מייסד NeuraLIA Labs ושותף-מייסד MyRealFood

אני מהנדס מחשבים, בוגר אוניברסיטת לאון. הייתי שותף בהקמת MyRealFood, שם, כסמנכ״ל טכנולוגיות, בניתי את האפליקציה שמיליוני אנשים השתמשו בה כדי לאכול בריא יותר, והקמתי את NeuraLIA Labs, שם אני בונה מוצרי בינה מלאכותית. כאן אני כותב על מה שהייתי צריך להבין לאורך הדרך, כפי שהייתי רוצה שמישהו היה מסביר לי בזמנו.

עוד על המחבר

פורסם על ידי NeuraLIA Labs.

פוסטים חדשים ישירות לתיבת הדואר

חדשות AI, מדריכים ועדכוני מוצר — מייל קצר כשאנחנו מפרסמים משהו ששווה את הזמן שלך.

תוכן הקורס

Abstract software decision engine with branching paths, probability nodes, and glowing gates.
jev10 דקות קריאה

מודל ה-AI Jev נבנה להחלטות, לא לפרוזה

Jev של TypeSafe AI מושך תשומת לב כי הוא מתייחס לאינטליגנציית תוכנה כאל בעיית הסתברות: לבחור את ההסתעפות הנכונה, להצמיד ביטחון, ולהימנע מתשלום ל-LLM כדי שיכתוב טקסט כשהקוד צריך החלטה.

Abstract agent runtime sorting documents, memory blocks and pointer nodes inside a bounded context frame.
context-engineering10 דקות קריאה

הנדסת הקשר לסוכני AI ארוכי־טווח

סוכנים שרצים לאורך זמן לא נכשלים רק כי החלון קטן. הם נכשלים כשקבצים, פלטי כלים והיסטוריה מיושנת דוחקים החוצה את המשימה שהסוכן היה אמור להשלים.

מוכנים לתת ל-LIA לבחור?

בנו עם כל מודלי ה-AI במקום אחד — התחילו בחינם עוד היום.