فاز 10 · درس 11

کوانتیزه‌سازی مدل‌های زبانی: کاهش حجم و اجرای مدل روی سخت‌افزار محدود

نوع
ساختنی
زمان
حدود 120 دقیقه
زبان‌ها
Python (with numpy)
پیش‌نیاز
آشنایی با الگوریتم‌های توکنایزر: BPE، WordPiece و SentencePiece، ساخت توکن‌ساز از صفر: پیاده‌سازی گام‌به‌گام BPE در پایتون، پایپ‌لاین داده برای پیش‌آموزش مدل‌های زبانی بزرگ، آموزش پیش‌آموزش یک مینی جی‌پی‌تی (124M پارامتر)، آموزش توزیع‌شده مدل‌های زبانی: FSDP، DeepSpeed و موازی‌سازی سه‌بعدی، آموزش گام‌به‌گام فاین‌تیونینگ نظارت‌شده (SFT) و ساخت مدل دستورپذیر، آموزش گام‌به‌گام RLHF با ساخت مدل پاداش و PPO، آموزش DPO: بهینه‌سازی مستقیم ترجیحات بدون نیاز به مدل پاداش، هوش مصنوعی قانون‌محور (CAI) و الگوریتم GRPO: خودبهبودبخشی در مدل‌های زبانی، ارزیابی مدل‌های زبانی: از بنچمارک تا بستر تست اختصاصی
مقایسه‌ی بصری یک قطعه‌سنگ بزرگ متراکم و سنگین در برابر یک ساختار فشرده و متخلخل مکعبی سبک روی ترازوی تعادل
فهرست مطالب
  1. اهداف یادگیری
  2. صورت مسئله
  3. مفاهیم پایه
  4. فرمت‌های عددی: نقش هر بیت چیست؟
  5. سازوکار کوانتیزه‌سازی
  6. سلسله‌مراتب حساسیت
  7. مقایسه‌ی PTQ و QAT
  8. روش‌های GPTQ، AWQ و GGUF
  9. سنجش کیفیت
  10. اعداد و ارقام واقعی
  11. پیاده‌سازی گام‌به‌گام
  12. گام 1: نمایش فرمت‌های عددی
  13. گام 2: کوانتیزه‌سازی متقارن (بر حسب تنسور و بر حسب کانال)
  14. گام 3: سنجش کیفیت
  15. گام 4: ارزیابی بازه‌ی پهنای بیت‌ها
  16. گام 5: آزمایش حساسیت
  17. گام 6: شبیه‌سازی GPTQ
  18. گام 7: شبیه‌سازی AWQ
  19. گام 8: پایپ‌لاین کامل
  20. استفاده از ابزارهای واقعی
  21. کوانتیزه‌سازی با GPTQModel
  22. کوانتیزه‌سازی به AWQ با LLM Compressor
  23. تبدیل به GGUF
  24. سرویس‌دهی مدل‌های کوانتیزه‌شده
  25. خروجی این درس
  26. تمرین‌ها
  27. اصطلاحات کلیدی
  28. برای مطالعه‌ی بیشتر

مدل‌های زبانی پیشرفته مانند Llama 3 70B در دقت استاندارد FP16 به بیش از 140 گیگابایت حافظه‌ی گرافیکی نیاز دارند؛ یعنی برای اجرای آن‌ها حتی به دو پردازنده‌ی A100 نیاز است. این گلوگاه سخت‌افزاری، استقرار و کار با مدل‌های زبانی را برای بسیاری از توسعه‌دهندگان به چالشی پرهزینه یا ناممکن تبدیل می‌کند.

تهیه‌ی کارت‌های گرافیک قدرتمند یا اجاره‌ی نمونه‌های ابری خارجی به دلیل موانع پرداخت ارزی و هزینه‌های بالا همواره دشوار است. در چنین شرایطی، کوانتیزه‌سازی (Quantization) دقیقاً همان ابزاری است که وزن‌های مدل را از 16 بیت به 8 یا 4 بیت فشرده می‌کند تا بتوان یک مدل 70 میلیارد پارامتری را روی یک کارت گرافیک معمولی یا حتی لپ‌تاپ شخصی و پردازنده‌های Apple Silicon اجرا کرد.

در این درس یاد می‌گیرید که چطور بدون افت محسوس کیفیت، از روش‌های پیشرفته‌ای مثل GPTQ ،AWQ و فرمت GGUF برای فشرده‌سازی مدل‌ها استفاده کنید، تفاوت میان PTQ و QAT را بسنجید و مدل‌ها را به شکل بهینه با ابزارهایی مانند vLLM به مرحله‌ی استقرار برسانید.

یک مدل 70B در قالب FP16 به 140 گیگابایت حافظه نیاز دارد؛ یعنی دو کارت A100 فقط برای نگهداری وزن‌ها. کوانتیزه‌سازی به FP8: یک GPU با حافظه‌ی 80 گیگابایتی. کوانتیزه‌سازی به INT4: یک MacBook.

اهداف یادگیری

  • پیاده‌سازی کوانتیزه‌سازی متقارن و نامتقارن از FP16 به INT8 و INT4، همراه با مقیاس‌بندی بر حسب تنسور (per-tensor) و بر حسب کانال (per-channel)
  • محاسبه‌ی میزان صرفه‌جویی حافظه با کوانتیزه‌سازی و تشخیص این‌که کدام دقت در حافظه‌ی گرافیکی (VRAM) یک GPU جا می‌شود
  • توضیح تفاوت بین کوانتیزه‌سازی پس از آموزش (PTQ) و آموزش آگاه از کوانتیزه‌سازی (QAT)
  • به‌کارگیری GPTQ یا AWQ برای کوانتیزه‌سازی یک مدل واقعی و ارزیابی مصالحه‌ی بین دقت و حافظه روی یک بنچمارک

صورت مسئله

مدل Llama 3 70B حدود 70 میلیارد پارامتر دارد. هر پارامتر یک عدد ممیز شناور 16 بیتی است؛ یعنی 140 میلیارد بایت یا همان 140 گیگابایت. یک کارت گرافیک A100 به تنهایی 80 گیگابایت VRAM دارد. روی یک کارت گرافیک واحد، حتی نمی‌توانید وزن‌ها را بارگذاری کنید، چه رسد به اجرای استنتاج (inference). برای سرویس‌دهی این مدل به دو کارت A100 نیاز دارید که هر کدام ساعتی 2 دلار هزینه برمی‌دارند.

اما اختصاص 16 بیت برای هر پارامتر اتلاف منابع است. بیشتر وزن‌ها در یک شبکه عصبی حول صفر تجمع می‌کنند و دامنه‌ی پویای کامل FP16 (از 0.000000059 تا 65,504) تقریباً دست‌نخورده می‌ماند. اگر توزیع واقعی وزن‌ها را در Llama 3 70B اندازه بگیرید، 95 درصد آن‌ها بین 0.1- و 0.1+ قرار دارند. در واقع دارید 16 بیت را برای مقادیری هدر می‌دهید که در 4 بیت هم جا می‌شوند.

کوانتیزه‌سازی (Quantization) اعداد با دقت بالا را با اعدادی با دقت پایین‌تر جایگزین می‌کند. تبدیل FP16 به FP8 مصرف حافظه را نصف و تبدیل FP16 به INT4 آن را یک‌چهارم می‌کند. با این کار، مدل 140 گیگابایتی به 35 گیگابایت کاهش می‌یابد و روی یک کارت گرافیک معمولیِ مصرف‌کننده جا می‌شود. اگر به سراغ کوانتیزه‌سازی 2 بیتی بروید (روشی تهاجمی و همراه با افت کیفیت، اما قابل استفاده برای برخی وظایف خاص)، همان مدل روی یک لپ‌تاپ با 16 گیگابایت رم بالا می‌آید.

هزینه‌ی این کار، افت دقت است؛ چون هر بیتی که حذف می‌کنید بخشی از اطلاعات را از بین می‌برد. مسئله این است که چقدر افت کیفیت پیدا می‌کنید و این افت در کجاست. یک مدل INT4 با کوانتیزه‌سازی اصولی، 95 تا 99 درصد کیفیت نسخه‌ی اصلی را روی بیشتر بنچمارک‌ها حفظ می‌کند. در مقابل، یک کوانتیزه‌سازی خام و ساده‌لوحانه به INT4 کل مدل را ناکارآمد می‌کند. تفاوت، در تکنیک کار است.

نسخه‌های کوانتیزه‌شده‌ی Llama 3 به INT4 با روش GPTQ که کاربران منتشر کرده‌اند، حدود 1 تا 2 واحد افزایش پرپلکسیتی (افت کیفیت) را روی WikiText نشان می‌دهند. شرکت Mistral چک‌پوینت‌های FP8 مدل Mixtral 8x22B را منتشر کرد که هیچ افت کیفیت قابل‌اندازه‌گیری روی MMLU نداشتند. فرمت GGUF قلب تپنده‌ی llama.cpp است که اجرای مدل‌های 70B را روی لپ‌تاپ‌های MacBook با تراشه‌های سری M ممکن می‌کند. کوانتیزه‌سازی یک ترفند پیش‌پاافتاده نیست؛ مسیر استاندارد استقرار برای هر مدلی است که از 7B بزرگ‌تر باشد.

مفاهیم پایه

فرمت‌های عددی: نقش هر بیت چیست؟

هر عدد ممیز شناور از سه بخش تشکیل شده است: علامت (sign)، توان (exponent) و مانتیس (mantissa یا significand). علامت 1 بیت است. توان، بازه را تعیین می‌کند (این‌که عدد چقدر می‌تواند بزرگ یا کوچک باشد). مانتیس دقت را مشخص می‌کند (چه تعداد ارقام اعشار در اختیارتان می‌گذارد).

FP32:  [1 sign] [8 exponent] [23 mantissa]  = 32 bits
FP16:  [1 sign] [5 exponent] [10 mantissa]  = 16 bits
BF16:  [1 sign] [8 exponent] [7  mantissa]  = 16 bits
FP8:   [1 sign] [4 exponent] [3  mantissa]  = 8  bits (E4M3)
FP8:   [1 sign] [5 exponent] [2  mantissa]  = 8  bits (E5M2)
INT8:  [1 sign] [7 value]                   = 8  bits (uniform steps)
INT4:  [1 sign] [3 value]                   = 4  bits (16 levels total)

فرمت FP32 دقت کامل محسوب می‌شود. 23 بیت مانتیس حدود 7 رقم اعشار دقیق به شما می‌دهد. بازه‌ی عددی: تقریباً 1.2×10−381.2 \times 10^{-38} تا 3.4×10383.4 \times 10^{38}. در گذشته آموزش مدل‌ها منحصراً با FP32 انجام می‌شد؛ هنوز هم برای تجمیع مقادیر (جمع‌های در حال محاسبه هنگام ضرب ماتریسی) از آن استفاده می‌شود.

فرمت FP16 تعداد بیت‌ها را نصف می‌کند. 10 بیت مانتیس حدود 3.3 رقم اعشار فراهم می‌کند. توان به 5 بیت کاهش می‌یابد که دامنه‌ی بازه را شدیداً محدود می‌کند (حداکثر مقدار در حدود 65,504). این بازه برای وزن‌ها (که حول صفر متمرکز هستند) مشکلی ایجاد نمی‌کند، اما برای اکتیویشن‌ها (activations) و گرادیان‌هایی که ممکن است در طول آموزش جهش‌های ناگهانی داشته باشند خطرناک است. آموزش با FP16 برای جلوگیری از کم‌ریز عددی به مقیاس‌بندی تابع زیان نیاز دارد.

فرمت BF16 (مخفف Brain Float 16) همان توان 8 بیتیِ FP32 را نگه می‌دارد اما مانتیس را به 7 بیت کاهش می‌دهد. بازه‌ی عددی آن مشابه FP32 است، اما دقت کمتری نسبت به FP16 دارد. شرکت Google آن را مشخصاً برای یادگیری عمیق طراحی کرد. منطق آن ساده است: در شبکه‌های عصبی، دامنه‌ی بازه مهم‌تر از دقت است. گرادیانی با مقدار 10−2010^{-20} که در FP16 به صفر تقلیل می‌یابد (کم‌ریز می‌شود)، در BF16 زنده می‌ماند. وزنی با مقدار 0.07342 هم اگر در BF16 به 0.0734 گرد شود، به اندازه‌ی کافی نزدیک و قابل قبول است. تمام روندهای امروزیِ آموزش از BF16 یا ترکیبی از BF16 و FP32 استفاده می‌کنند.

فرمت FP8 در دو نوع ارائه می‌شود: E4M3 (دارای 4 بیت توان و 3 بیت مانتیس) که برای وزن‌ها و اکتیویشن‌ها در طول استنتاج به کار می‌رود؛ و E5M2 (دارای 5 بیت توان و 2 بیت مانتیس) که برای گرادیان‌ها در زمان آموزش استفاده می‌شود، یعنی جایی که بازه بر دقت ارجحیت دارد. استنتاج با FP8 روی پردازنده‌های گرافیکی H100 بدون افت کیفیت محسوس، 30 تا 50 درصد شتاب بیشتر نسبت به FP16 ثبت می‌کند.

فرمت INT8 یک فرمت عدد صحیح است؛ نه توانی در کار است و نه مانتیسی. فقط 256 مقدار با فواصل یکسان از 128- تا 127. برای نگاشت وزن‌های ممیز شناور به این بازه، به یک ضریب مقیاس (scale factor) نیاز دارید. مزیت کار در این است: محاسبات اعداد صحیح سریع‌تر است و برق کمتری نسبت به ممیز شناور مصرف می‌کند. ضرب ماتریسی با INT8 روی تراشه‌ی A100 به سرعت 624 TOPS می‌رسد، در حالی که برای FP16 این عدد 312 TFLOPS است.

فرمت INT4 فشرده‌سازی را باز هم فراتر می‌برد: فقط 16 مقدار ممکن. در اینجا ضریب مقیاس بار سنگینی را به دوش می‌کشد. کیفیت نهایی کاملاً به نحوه‌ی انتخاب این ضریب و این‌که کدام وزن‌ها را کوانتیزه می‌کنید بستگی دارد. روش‌های پیشرفته‌ی INT4 (مثل GPTQ و AWQ) بیش از 95 درصد کیفیت مدل اصلی را حفظ می‌کنند.

چشم‌انداز فرمت‌های عددیآموزشاستنتاجنیتیو H100استقرار سرورلبه / لپ‌تاپFP3232 بیت4 بایت/پارامتراستاندارد طلایی آموزشBF1616 بیت2 بایت/پارامترپیش‌فرض آموزشFP1616 بیت2 بایت/پارامترخط مبنا استنتاجFP88 بیت1 بایت/پارامتر30-50% سریع‌ترINT88 بیت1 بایت/پارامتر2x نرخ پردازشINT44 بیت0.5 بایت/پارامتر4x فشرده‌سازی

سازوکار کوانتیزه‌سازی

عملیات اصلی بسیار ساده است: تنسوری از مقادیر ممیز شناور را بردارید، ضریب مقیاس را پیدا کنید، ضرب را انجام دهید، عدد را به نزدیک‌ترین عدد صحیح گرد کنید و در نهایت اعداد صحیح را به همراه ضریب مقیاس ذخیره کنید.

کوانتیزه‌سازی:

scale = max(abs(tensor)) / max_int_value
quantized = round(tensor / scale)

واکوانتیزه‌سازی (Dequantize):

reconstructed = quantized * scale

برای INT8 با دامنه‌ی متقارن (127- تا 127):

scale = max(abs(tensor)) / 127
quantized = clamp(round(tensor / scale), -128, 127)

خطا همان خطای گرد کردن است. هر مقدار می‌تواند حداکثر به اندازه‌ی scale / 2 انحراف داشته باشد. خطای کل در سرتاسر یک لایه به تعداد وزن‌ها و میزان حساسیت مدل به تغییرات آن وزن‌ها بستگی دارد.

کوانتیزه‌سازی بر حسب تنسور در برابر بر حسب کانال. در روش بر حسب تنسور، از یک ضریب مقیاس برای کل ماتریس وزن استفاده می‌شود. این رویکرد ساده اما پرخطاست؛ اگر یک ستون مقادیر بزرگی داشته باشد و ستون دیگر مقادیر کوچک، مقادیر کوچک بخش عمده‌ی دقت خود را از دست می‌دهند. در روش بر حسب کانال، برای هر کانال خروجی (به ازای هر سطر یا ستون از ماتریس وزن) یک ضریب مقیاس مجزا در نظر گرفته می‌شود. این حالت سربار بیشتری دارد (به جای 1 ضریب، N ضریب ذخیره می‌کنید) اما کیفیت نهایی را به شکل چشمگیری بهتر می‌کند. تمام روش‌های کوانتیزه‌سازی در محیط عملیاتی از روش بر حسب کانال یا دانه‌بندی‌های ریزتر استفاده می‌کنند.

کوانتیزه‌سازی نامتقارن یک نقطه‌ی صفر (zero-point) به عنوان آفست اضافه می‌کند: quantized = round(tensor / scale) + zero_point. این روش برای توزیع‌هایی کاربرد دارد که حول صفر متمرکز نیستند. برای نمونه، اکتیویشن‌های تابع ReLU همیشه نامنفی هستند. کوانتیزه‌سازی متقارن نیمی از بازه‌ی اعداد صحیح را برای مقادیر منفی که اصلاً وجود ندارند هدر می‌دهد؛ اما کوانتیزه‌سازی نامتقارن، بازه‌ی واقعی [min, max] را روی کل بازه‌ی اعداد صحیح می‌نگارد.

سلسله‌مراتب حساسیت

اجزای مختلف یک مدل تحمل یکسانی در برابر کوانتیزه‌سازی ندارند و سلسله‌مراتب روشنی میان آن‌ها برقرار است.

وزن‌ها (بیشترین مقاومت). وزن‌های مدل در طول آموزش به‌آرامی تغییر می‌کنند و از توزیعی شبیه به گوسیِ متمرکز در نزدیکی صفر پیروی می‌کنند. وزن‌ها خیلی خوب کوانتیزه می‌شوند. پیاده‌سازی وزن‌های INT8 با مقیاس‌های بر حسب کانال نتایجی تقریباً بدون افت به بار می‌آورد. تبدیل به INT4 نیازمند روش‌های پیچیده‌تری است، اما کاملاً شدنی است.

اکتیویشن‌ها (حساسیت متوسط). اکتیویشن‌ها مقادیر میانی هستند که در طول استنتاج در سراسر شبکه جاری می‌شوند. آن‌ها دامنه‌ی پویای عریض‌تری نسبت به وزن‌ها دارند و داده‌های پرت (outliers) را در خود جای داده‌اند. ممکن است یک سرِ اتنشن، مقادیر اکتیویشنی تولید کند که 100 برابر بزرگ‌تر از میانگین باشند. این داده‌های پرت برای کیفیت مدل نقشی حیاتی دارند و کوانتیزه‌سازی نسنجیده‌ی آن‌ها اطلاعات را نابود می‌کند. راهکارها: حفظ کانال‌های پرت در دقت بالاتر (رویکرد LLM.int8()) یا استفاده از مقیاس‌های اکتیویشن بر حسب توکن یا بر حسب کانال.

کش کلید-مقدار (حساسیت زیاد). کش کلید-مقدار (KV cache) حالت‌های اتنشن را برای همه‌ی توکن‌های قبلی نگه می‌دارد. در طول کانتکست‌های طولانی، کش کلید-مقدار بیشترین سهم از حافظه را می‌بلعد. برای مدلی با حجم 70B در کانتکست 32K، کش کلید-مقدار به تنهایی در FP16 حدود 40 گیگابایت فضا می‌گیرد. کوانتیزه‌سازی این کش به FP8 یا INT8 صرفه‌جویی عظیمی در حافظه رقم می‌زند، اما هر خطایی در این بخش در تمام محاسبات بعدی اتنشن ترکیب شده و اثرش تشدید می‌شود. اثر منفی بر کیفیت با افزایش طول توالی بزرگ‌تر می‌شود.

لاجیت‌های اتنشن (بیشترین حساسیت). سافت‌مکس در مکانیزم اتنشن حساسیت فوق‌العاده‌ای به تغییرات کوچک ورودی‌هایش دارد. خطای کوانتیزه‌سازی به اندازه‌ی 0.01 در لاجیتِ پیش از سافت‌مکس می‌تواند توزیع اتنشن را به شکلی معنادار دستخوش تغییر کند. بیشتر طرح‌های کوانتیزه‌سازی، محاسبات اتنشن را در دقت بالاتر (FP16 یا BF16) حفظ می‌کنند، حتی اگر سایر بخش‌ها به طور کامل کوانتیزه شده باشند.

حساسیت کوانتیزاسیون (کم به زیاد)امنبااحتیاطخطرناکوزن‌هاگاوسی، نزدیک صفرINT4 خوب عمل می‌کندفعال‌سازی‌هابازه وسیع‌تر، داده‌های پرتINT8 با احتیاطکش کلید-مقدارخطاها انباشته می‌شوندFP8 یا INT8لاجیت‌های اتنشنسافت‌مکس خطا را تشدید می‌کنددر FP16 نگه دارید

مقایسه‌ی PTQ و QAT

کوانتیزه‌سازی پس از آموزش (PTQ) روی مدلی اعمال می‌شود که فرآیند آموزش آن به پایان رسیده است؛ بدون نیاز به بازآموزی. وزن‌های FP16 را می‌گیرید، ضرایب مقیاس را محاسبه می‌کنید، مقادیر را گرد می‌کنید و مدل را مستقر می‌سازید. این روش سریع (از چند دقیقه تا چند ساعت) و کم‌هزینه است و برای INT8 و FP8 خیلی خوب جواب می‌دهد. اما برای INT4، روش PTQ معمولی به دلیل انباشت خطاهای گرد کردن اغلب شکست می‌خورد. روش‌های پیشرفته‌ی PTQ (مانند GPTQ و AWQ) از داده‌های کالیبراسیون برای به حداقل رساندن خطای کوانتیزه‌سازی بهره می‌برند.

آموزش آگاه از کوانتیزه‌سازی (QAT) عملیاتِ کوانتیزه‌سازی ساختگی (fake quantization) را وارد گذر رو به جلو در طول آموزش می‌کند. بدین ترتیب مدل یاد می‌گیرد وزن‌های خود را در نقاطی متمرکز کند که خطاهای گرد کردن در آن‌ها کمترین مقدار را دارند. گرادیان‌ها با استفاده از «تخمین‌گر عبور مستقیم» (straight-through estimator یا STE) از میان این کوانتیزه‌سازی ساختگی عبور می‌کنند؛ با این فرض که عملیات گرد کردن گرادیانی برابر با 1 دارد. روش QAT مدل‌های INT4 و INT2 بسیار باکیفیت‌تری نسبت به PTQ تولید می‌کند، اما به یک فرآیند کامل آموزش نیاز دارد. شرکت Google برای سرویس‌دهی بهینه‌ی Gemini از QAT استفاده کرد؛ Meta نیز برای برخی نسخه‌های استقرار Llama از همین روش بهره گرفت.

جنبه PTQ QAT
هزینه چند دقیقه تا چند ساعت اجرای یک دور آموزش کامل
کیفیت در INT8 عالی (کمتر از 0.1% افت) عالی
کیفیت در INT4 خوب با GPTQ/AWQ (1 تا 3% افت) بهتر (کمتر از 1% افت)
کیفیت در INT2 ضعیف قابل استفاده برای برخی وظایف
داده‌های کالیبراسیون 128 تا 1024 نمونه کل مجموعه‌داده‌ی آموزش
زمان استفاده استقرار، تکرار سریع بیشترین کیفیت ممکن در بیت‌های پایین

روش‌های GPTQ، AWQ و GGUF

روش GPTQ (کوتاه‌شده‌ی GPT Quantization) یک متد PTQ تک‌مرحله‌ای است. این روش وزن‌ها را لایه به لایه کوانتیزه می‌کند و با تکیه بر یک مجموعه‌داده‌ی کالیبراسیون کوچک (معمولاً 128 نمونه)، ماتریس هسیان (Hessian؛ اطلاعات مرتبه دوم درباره‌ی حساسیت خروجی به هر یک از وزن‌ها) را اندازه می‌گیرد. وزن‌هایی که هسیان اهمیت آن‌ها را بالا تشخیص دهد، با دقت و مراقبت بیشتری کوانتیزه می‌شوند. روش GPTQ اولین متدی بود که کوانتیزه‌سازی INT4 را برای مدل‌های زبانی بزرگ عملی کرد. TheBloke در Hugging Face با عرضه‌ی نسخه‌های کوانتیزه‌شده‌ی صدها مدل مختلف، این فرمت را فراگیر ساخت.

روش AWQ (کوتاه‌شده‌ی Activation-Aware Weight Quantization) بر این مشاهده استوار است که بخش بسیار کوچکی از وزن‌ها (حدود 1 درصد) اهمیت نامتناسب و فوق‌العاده بالایی دارند؛ چون این وزن‌ها در مقادیر اکتیویشنِ بزرگ ضرب می‌شوند. روش AWQ این وزن‌های برجسته را با داده‌های کالیبراسیون شناسایی می‌کند و پیش از کوانتیزه‌سازی، مقیاس آن‌ها را بالا می‌برد (و متقابلاً مقیاس اکتیویشن‌های متناظر را پایین می‌آورد). این کار وزن‌های مهم را در محدوده‌ای حفظ می‌کند که کوانتیزه‌سازی INT4 آسیبی به دقت آن‌ها نزند. روش AWQ کیفیتی مشابه یا کمی بهتر از GPTQ ارائه می‌دهد و در عین حال فرآیند اِعمال آن 1.5 تا 2 برابر سریع‌تر است.

فرمت GGUF (کوتاه‌شده‌ی GPT-Generated Unified Format) فرمت فایلی است که پروژه‌ی llama.cpp و اکوسیستم آن استفاده می‌کنند. این فرمت از کوانتیزه‌سازی ترکیبی (mixed quantization) پشتیبانی می‌کند؛ به طوری که لایه‌های مختلف می‌توانند تعداد بیت‌های متفاوتی داشته باشند. لایه‌های اول و آخر (امبدینگ و سر خروجی) معمولاً با دقت بالاتری حفظ می‌شوند و لایه‌های میانی با INT4 یا INT3 ذخیره می‌گردند. فایل‌های GGUF خودکفا هستند؛ یعنی وزن‌ها، توکنایزر و متادیتا همگی داخل یک فایل واحد قرار دارند. این فرمت برای استنتاج روی CPU و تراشه‌های Apple Silicon طراحی شده است، جایی که بارگذاری کامل مدل در حافظه و انجام ضرب‌های ماتریسی روی CPU یا پردازنده‌ی گرافیکی Metal راهکار معمول به شمار می‌رود. نوع Q4_K_M محبوب‌ترین گونه‌ی کوانتیزه‌سازی GGUF است که تعادلی بهینه میان کیفیت و اندازه ایجاد می‌کند.

مناسب برایروش‌های کوانتیزاسیونGPTQمبتنی بر هسیانبهینه‌سازی لایه‌به‌لایهمحبوب در HuggingFaceAWQآگاه از فعال‌سازیمقیاس‌بندی وزن‌های مهم1.5-2x سریع‌تر از GPTQGGUFدقت ترکیبیبهینه‌شده برای CPU + Metalاکوسیستم llama.cppاستنتاج GPU(CUDA, ROCm)لبه / لپ‌تاپ(CPU, Metal)

سنجش کیفیت

چگونه متوجه شویم مدل کوانتیزه‌شده همچنان کیفیت مطلوبی دارد؟

پرپلکسیتی. رایج‌ترین معیار سنجش است و مقدار کمتر آن نشان‌دهنده‌ی عملکرد بهتر خواهد بود. پرپلکسیتی را روی یک مجموعه‌داده‌ی جداگانه (معمولاً WikiText-2) هم برای مدل اصلی و هم مدل کوانتیزه‌شده محاسبه کنید. میزان تفاوت (دلتا) نشان می‌دهد کوانتیزه‌سازی چه مقدار اطلاعات را نابود کرده است. راهنمای سرانگشتی: دلتای کمتر از 0.5 عالی است، 0.5 تا 1.0 خوب محسوب می‌شود، 1.0 تا 2.0 برای بیشتر کاربردها قابل قبول است، و دلتای بیشتر از 2.0 یعنی در روند کار مشکلی پیش آمده است.

بنچمارک‌های متناسب با وظیفه. مدل کوانتیزه‌شده را روی بنچمارک‌های MMLU ،HumanEval ،GSM8K یا مجموعه‌ی ارزیابی اختصاصی خودتان اجرا کنید و نتایج را با نسخه‌ی اصلی بسنجید. کوانتیزه‌سازی روی قابلیت‌های مختلف مدل اثر یکنواختی ندارد؛ مهارت‌های ریاضی و کدنویسی به افت دقت بسیار حساس‌تر از دانش عمومی هستند.

مقایسه‌ی خروجی‌ها. به هر دو مدل پرامپت‌های یکسانی بدهید و پاسخ‌ها را مقایسه کنید. ارزیابی به کمک مدل زبانی در نقش داور (که در درس 10 بررسی می‌شود) در اینجا بسیار کارآمد است. نرخ برد (win rate) را به دست آورید: مدل کوانتیزه‌شده در چند درصد از پرامپت‌ها همپای مدل اصلی یا بهتر از آن ظاهر شده است؟

تاخیر و نرخ پردازش. هدف نهایی کوانتیزه‌سازی، سریع‌تر و ارزان‌تر کردن مدل‌هاست. توکن بر ثانیه، زمان تا اولین توکن و میزان مصرف حافظه را اندازه بگیرید. مدل کوانتیزه‌شده‌ای که کندتر از نسخه‌ی اصلی عمل کند، عملاً از بی‌مصرف هم بدتر است.

مدل فرمت اندازه پرپلکسیتی (WikiText-2) MMLU توکن/ثانیه (A100)
Llama 3 70B FP16 140GB 3.12 79.5% 38
Llama 3 70B FP8 70GB 3.14 79.3% 55
Llama 3 70B GPTQ INT4 35GB 4.32 77.8% 72
Llama 3 70B AWQ INT4 35GB 4.18 78.1% 75
Llama 3 70B GGUF Q4_K_M 40GB 4.25 77.9% 28 (CPU)

الگوی کلی روشن است: FP8 تقریباً هیچ هزینه‌ای تحمیل نمی‌کند. فرمت INT4 حدود 1 تا 2 نمره از MMLU کم می‌کند، اما در مقابل، نرخ پردازش را دو برابر کرده و حافظه را به یک‌چهارم می‌رساند. این مصالحه تقریباً برای تمام استقرارها ارزشش را دارد.

اعداد و ارقام واقعی

تبدیل FP16 به FP8 روی H100: افزایش 30 تا 50 درصدی سرعت استنتاج، کمتر از 0.1 درصد افت کیفیت. انتخابی کاملاً بی‌درنگ و بدیهی؛ هر سرویسی روی H100 باید از آن استفاده کند.

تبدیل FP16 به INT8 (رویکرد LLM.int8()): کاهش 2 برابری حافظه، کمتر از 0.5 درصد افت کیفیت. رویکرد دقت ترکیبی، ویژگی‌های پرت را در قالب FP16 نگه می‌دارد و مابقی را به INT8 کوانتیزه می‌کند.

تبدیل FP16 به INT4 (روش GPTQ/AWQ): کاهش 4 برابری حافظه، 1 تا 3 درصد افت کیفیت بسته به مدل و روش پیاده‌سازی. امکان اجرای مدل‌های 70B را روی یک کارت گرافیک منفردِ 48 گیگابایتی فراهم می‌کند.

تبدیل FP16 به INT4 (فرمت GGUF Q4_K_M): کاهش 3.5 برابری حافظه، 1 تا 2 درصد افت کیفیت. بهینه‌سازی‌شده برای استنتاج روی CPU. یک مدل 70B با فرمت Q4_K_M حدود 40 گیگابایت حجم دارد و روی یک پردازنده‌ی M3 Max با 64 گیگابایت رم، با سرعت 10 تا 15 توکن بر ثانیه اجرا می‌شود.

تبدیل FP16 به INT2: کاهش 8 برابری حافظه، 5 تا 15 درصد افت کیفیت. صرفاً برای وظایف محدود و مشخصی که افت کارایی در آن‌ها قابل تحمل است مناسب خواهد بود. در مرزهای پژوهش قرار دارد و برای استفاده‌ی عمومی در محیط عملیاتی آماده نیست.

کوانتایزیشن (کوانتش)انتخاب میزان دقت
هر وزن به اندازه عرض بیتی خود فضا اشغال می‌کند. نصف کردن بیت‌ها، مصرف حافظه را نصف کرده و نرخ پردازش را تقریباً دو برابر می‌کند، در حالی که افت دقت افزایش می‌یابد. کوانتش 8 بیتی تقریباً بدون افت کیفیت است؛ 4 بیتی نیازمند روش‌های دقیق و هوشمندانه است؛ و پایین‌تر از آن، دقت به شدت افت می‌کند.

پیاده‌سازی گام‌به‌گام

گام 1: نمایش فرمت‌های عددی

نمایش در سطح بیت هر فرمت را پیاده‌سازی کنید تا ببینید علامت، توان و مانتیس دقیقاً چه می‌کنند.

import numpy as np


def float_to_fp32_bits(value):
    bits = np.float32(value).view(np.uint32)
    sign = (bits >> 31) & 1
    exponent = (bits >> 23) & 0xFF
    mantissa = bits & 0x7FFFFF
    return {"sign": int(sign), "exponent": int(exponent), "mantissa": int(mantissa),
            "exponent_bits": format(int(exponent), '08b'),
            "mantissa_bits": format(int(mantissa), '023b'),
            "value": float(value),
            "actual_exponent": int(exponent) - 127}


def float_to_fp16_bits(value):
    fp16 = np.float16(value)
    bits = fp16.view(np.uint16)
    sign = (bits >> 15) & 1
    exponent = (bits >> 10) & 0x1F
    mantissa = bits & 0x3FF
    return {"sign": int(sign), "exponent": int(exponent), "mantissa": int(mantissa),
            "exponent_bits": format(int(exponent), '05b'),
            "mantissa_bits": format(int(mantissa), '010b'),
            "value": float(fp16),
            "actual_exponent": int(exponent) - 15}


def float_to_bf16_bits(value):
    fp32_bits = np.float32(value).view(np.uint32)
    bf16_bits = (fp32_bits >> 16).astype(np.uint16)
    sign = (bf16_bits >> 15) & 1
    exponent = (bf16_bits >> 7) & 0xFF
    mantissa = bf16_bits & 0x7F
    reconstructed = np.uint32(bf16_bits.astype(np.uint32) << 16).view(np.float32)
    return {"sign": int(sign), "exponent": int(exponent), "mantissa": int(mantissa),
            "exponent_bits": format(int(exponent), '08b'),
            "mantissa_bits": format(int(mantissa), '07b'),
            "value": float(reconstructed),
            "actual_exponent": int(exponent) - 127}


def simulate_fp8_e4m3(value):
    sign = 1 if value < 0 else 0
    abs_val = abs(value)
    max_val = 448.0
    abs_val = min(abs_val, max_val)
    if abs_val == 0:
        return {"sign": sign, "exponent": 0, "mantissa": 0, "value": 0.0,
                "exponent_bits": "0000", "mantissa_bits": "000"}
    exp = int(np.floor(np.log2(abs_val)))
    exp = max(-6, min(8, exp))
    mantissa_val = abs_val / (2.0 ** exp) - 1.0
    mantissa_quant = round(mantissa_val * 8) / 8
    mantissa_quant = max(0, min(0.875, mantissa_quant))
    reconstructed = (1.0 + mantissa_quant) * (2.0 ** exp)
    if sign:
        reconstructed = -reconstructed
    mantissa_int = int(round(mantissa_quant * 8))
    return {"sign": sign, "exponent": exp + 7, "mantissa": mantissa_int,
            "exponent_bits": format(exp + 7, '04b'),
            "mantissa_bits": format(mantissa_int, '03b'),
            "value": float(reconstructed),
            "actual_exponent": exp}


def display_format_comparison(value):
    fp32 = float_to_fp32_bits(value)
    fp16 = float_to_fp16_bits(value)
    bf16 = float_to_bf16_bits(value)
    fp8 = simulate_fp8_e4m3(value)

    print(f"\n  Value: {value}")
    print(f"  {'Format':<8} {'Stored Value':>14} {'Error':>12} {'Sign':>5} {'Exp Bits':>10} {'Man Bits':>25}")
    print(f"  {'-'*76}")
    print(f"  {'FP32':<8} {fp32['value']:>14.6f} {abs(fp32['value'] - value):>12.8f} {fp32['sign']:>5} {fp32['exponent_bits']:>10} {fp32['mantissa_bits']:>25}")
    print(f"  {'FP16':<8} {fp16['value']:>14.6f} {abs(fp16['value'] - value):>12.8f} {fp16['sign']:>5} {fp16['exponent_bits']:>10} {fp16['mantissa_bits']:>25}")
    print(f"  {'BF16':<8} {bf16['value']:>14.6f} {abs(bf16['value'] - value):>12.8f} {bf16['sign']:>5} {bf16['exponent_bits']:>10} {bf16['mantissa_bits']:>25}")
    print(f"  {'FP8e4m3':<8} {fp8['value']:>14.6f} {abs(fp8['value'] - value):>12.8f} {fp8['sign']:>5} {fp8['exponent_bits']:>10} {fp8['mantissa_bits']:>25}")

گام 2: کوانتیزه‌سازی متقارن (بر حسب تنسور و بر حسب کانال)

عملیات پایه‌ای کوانتیزه‌سازی. روش بر حسب تنسور یک مقیاس برای کل ماتریس اختصاص می‌دهد و روش بر حسب کانال یک مقیاس برای هر سطر یا ستون در نظر می‌گیرد.

def quantize_symmetric(tensor, num_bits=8):
    qmin = -(2 ** (num_bits - 1))
    qmax = 2 ** (num_bits - 1) - 1
    abs_max = np.max(np.abs(tensor))
    if abs_max == 0:
        return np.zeros_like(tensor, dtype=np.int32), 1.0
    scale = abs_max / qmax
    quantized = np.clip(np.round(tensor / scale), qmin, qmax).astype(np.int32)
    return quantized, float(scale)


def dequantize_symmetric(quantized, scale):
    return quantized.astype(np.float64) * scale


def quantize_per_channel(tensor, num_bits=8, axis=0):
    qmin = -(2 ** (num_bits - 1))
    qmax = 2 ** (num_bits - 1) - 1

    if axis == 0:
        abs_max = np.max(np.abs(tensor), axis=1, keepdims=True)
    else:
        abs_max = np.max(np.abs(tensor), axis=0, keepdims=True)

    abs_max = np.where(abs_max == 0, 1.0, abs_max)
    scales = abs_max / qmax
    quantized = np.clip(np.round(tensor / scales), qmin, qmax).astype(np.int32)
    return quantized, scales.squeeze()


def dequantize_per_channel(quantized, scales, axis=0):
    if axis == 0:
        return quantized.astype(np.float64) * scales.reshape(-1, 1)
    else:
        return quantized.astype(np.float64) * scales.reshape(1, -1)


def quantize_asymmetric(tensor, num_bits=8):
    qmin = 0
    qmax = 2 ** num_bits - 1
    t_min = np.min(tensor)
    t_max = np.max(tensor)
    if t_max == t_min:
        return np.zeros_like(tensor, dtype=np.int32), 1.0, 0
    scale = (t_max - t_min) / (qmax - qmin)
    zero_point = int(np.round(qmin - t_min / scale))
    zero_point = max(qmin, min(qmax, zero_point))
    quantized = np.clip(np.round(tensor / scale + zero_point), qmin, qmax).astype(np.int32)
    return quantized, float(scale), int(zero_point)


def dequantize_asymmetric(quantized, scale, zero_point):
    return (quantized.astype(np.float64) - zero_point) * scale

گام 3: سنجش کیفیت

میزان نابودی اطلاعات ناشی از کوانتیزه‌سازی را بسنجید: میانگین مربعات خطا، نسبت سیگنال به نویز، و شباهت کسینوسی میان تنسورهای اصلی و بازسازی‌شده.

def quantization_error(original, reconstructed):
    diff = original - reconstructed
    mse = float(np.mean(diff ** 2))
    rmse = float(np.sqrt(mse))
    max_error = float(np.max(np.abs(diff)))
    signal_power = float(np.mean(original ** 2))
    snr_db = 10 * np.log10(signal_power / max(mse, 1e-20))

    orig_flat = original.flatten()
    recon_flat = reconstructed.flatten()
    norm_orig = np.linalg.norm(orig_flat)
    norm_recon = np.linalg.norm(recon_flat)
    if norm_orig == 0 or norm_recon == 0:
        cosine_sim = 0.0
    else:
        cosine_sim = float(np.dot(orig_flat, recon_flat) / (norm_orig * norm_recon))

    return {"mse": mse, "rmse": rmse, "max_error": max_error,
            "snr_db": float(snr_db), "cosine_similarity": cosine_sim}


def compare_quantization_methods(tensor, num_bits=8):
    q_pt, s_pt = quantize_symmetric(tensor, num_bits)
    recon_pt = dequantize_symmetric(q_pt, s_pt)
    err_pt = quantization_error(tensor, recon_pt)

    q_pc, s_pc = quantize_per_channel(tensor, num_bits, axis=0)
    recon_pc = dequantize_per_channel(q_pc, s_pc, axis=0)
    err_pc = quantization_error(tensor, recon_pc)

    q_asym, s_asym, zp = quantize_asymmetric(tensor, num_bits)
    recon_asym = dequantize_asymmetric(q_asym, s_asym, zp)
    err_asym = quantization_error(tensor, recon_asym)

    print(f"\n  Quantization Comparison ({num_bits}-bit, tensor shape {tensor.shape}):")
    print(f"  {'Method':<20} {'MSE':>12} {'SNR (dB)':>10} {'Cosine Sim':>12} {'Max Error':>12}")
    print(f"  {'-'*68}")
    print(f"  {'Per-tensor sym':<20} {err_pt['mse']:>12.8f} {err_pt['snr_db']:>10.2f} {err_pt['cosine_similarity']:>12.8f} {err_pt['max_error']:>12.8f}")
    print(f"  {'Per-channel sym':<20} {err_pc['mse']:>12.8f} {err_pc['snr_db']:>10.2f} {err_pc['cosine_similarity']:>12.8f} {err_pc['max_error']:>12.8f}")
    print(f"  {'Asymmetric':<20} {err_asym['mse']:>12.8f} {err_asym['snr_db']:>10.2f} {err_asym['cosine_similarity']:>12.8f} {err_asym['max_error']:>12.8f}")

    return {"per_tensor": err_pt, "per_channel": err_pc, "asymmetric": err_asym}

گام 4: ارزیابی بازه‌ی پهنای بیت‌ها

همان تنسور را در تعداد بیت‌های مختلف (2، 3، 4، 8 و 16) کوانتیزه کنید و کیفیت را در هر سطح بسنجید. این آزمایش دقیقاً نشان می‌دهد نقطه‌ی پرتگاه و افت ناگهانی کیفیت کجاست.

def bit_width_sweep(tensor):
    print(f"\n  Bit-Width Sweep (tensor shape {tensor.shape}):")
    print(f"  {'Bits':>6} {'Levels':>8} {'MSE':>14} {'SNR (dB)':>10} {'Cosine Sim':>12} {'Compression':>12}")
    print(f"  {'-'*64}")

    results = []
    for bits in [2, 3, 4, 8, 16]:
        q, s = quantize_per_channel(tensor, bits, axis=0)
        recon = dequantize_per_channel(q, s, axis=0)
        err = quantization_error(tensor, recon)
        levels = 2 ** bits
        compression = 32.0 / bits

        print(f"  {bits:>6} {levels:>8} {err['mse']:>14.8f} {err['snr_db']:>10.2f} {err['cosine_similarity']:>12.8f} {compression:>11.1f}x")
        results.append({"bits": bits, "levels": levels, "error": err, "compression": compression})

    return results

گام 5: آزمایش حساسیت

کوانتیزه‌سازی بخش‌های مختلف یک ترنسفورمر را شبیه‌سازی کرده و اندازه‌گیری کنید کدام قطعات بیشترین حساسیت را از خود نشان می‌دهند. این کار سلسله‌مراتب حساسیت را مشخص می‌کند: وزن‌ها < اکتیویشن‌ها < کش کلید-مقدار < اتنشن.

def simulate_transformer_layer(input_data, weights, kv_scale=1.0):
    hidden = input_data @ weights["qkv"]
    seq_len = hidden.shape[1]
    d_model = weights["qkv"].shape[1] // 3
    q, k, v = hidden[:, :, :d_model], hidden[:, :, d_model:2*d_model], hidden[:, :, 2*d_model:]

    attn_scores = (q @ k.transpose(0, 2, 1)) / np.sqrt(d_model) * kv_scale
    attn_max = np.max(attn_scores, axis=-1, keepdims=True)
    attn_exp = np.exp(attn_scores - attn_max)
    attn_weights = attn_exp / np.sum(attn_exp, axis=-1, keepdims=True)

    attn_output = attn_weights @ v
    output = attn_output @ weights["out"]
    return output, {"q": q, "k": k, "v": v, "attn_scores": attn_scores,
                    "attn_weights": attn_weights, "attn_output": attn_output}


def sensitivity_experiment(batch_size=2, seq_len=16, d_model=64, num_bits=8):
    np.random.seed(42)
    input_data = np.random.randn(batch_size, seq_len, d_model) * 0.1

    weights = {
        "qkv": np.random.randn(d_model, 3 * d_model) * (2.0 / d_model) ** 0.5,
        "out": np.random.randn(d_model, d_model) * (2.0 / d_model) ** 0.5,
    }

    baseline_output, baseline_internals = simulate_transformer_layer(input_data, weights)

    experiments = {}

    q_qkv, s_qkv = quantize_per_channel(weights["qkv"], num_bits, axis=0)
    q_out, s_out = quantize_per_channel(weights["out"], num_bits, axis=0)
    quantized_weights = {
        "qkv": dequantize_per_channel(q_qkv, s_qkv, axis=0),
        "out": dequantize_per_channel(q_out, s_out, axis=0),
    }
    weight_quant_output, _ = simulate_transformer_layer(input_data, quantized_weights)
    experiments["Weights only"] = quantization_error(baseline_output, weight_quant_output)

    _, fresh_internals = simulate_transformer_layer(input_data, weights)
    q_act, s_act = quantize_per_channel(
        fresh_internals["attn_output"].reshape(-1, d_model), num_bits, axis=0
    )
    quant_attn_out = dequantize_per_channel(q_act, s_act, axis=0).reshape(batch_size, seq_len, d_model)
    act_quant_output = quant_attn_out @ weights["out"]
    experiments["Activations only"] = quantization_error(baseline_output, act_quant_output)

    q_k, s_k = quantize_per_channel(fresh_internals["k"].reshape(-1, d_model), num_bits, axis=0)
    q_v, s_v = quantize_per_channel(fresh_internals["v"].reshape(-1, d_model), num_bits, axis=0)
    quant_k = dequantize_per_channel(q_k, s_k, axis=0).reshape(batch_size, seq_len, d_model)
    quant_v = dequantize_per_channel(q_v, s_v, axis=0).reshape(batch_size, seq_len, d_model)
    attn_scores_kv = (fresh_internals["q"] @ quant_k.transpose(0, 2, 1)) / np.sqrt(d_model)
    attn_max_kv = np.max(attn_scores_kv, axis=-1, keepdims=True)
    attn_exp_kv = np.exp(attn_scores_kv - attn_max_kv)
    attn_weights_kv = attn_exp_kv / np.sum(attn_exp_kv, axis=-1, keepdims=True)
    kv_quant_output = (attn_weights_kv @ quant_v) @ weights["out"]
    experiments["KV cache only"] = quantization_error(baseline_output, kv_quant_output)

    noise_scale = np.std(fresh_internals["attn_scores"]) * 0.05
    noisy_scores = fresh_internals["attn_scores"] + np.random.randn(*fresh_internals["attn_scores"].shape) * noise_scale
    noisy_max = np.max(noisy_scores, axis=-1, keepdims=True)
    noisy_exp = np.exp(noisy_scores - noisy_max)
    noisy_weights = noisy_exp / np.sum(noisy_exp, axis=-1, keepdims=True)
    attn_quant_output = (noisy_weights @ fresh_internals["v"]) @ weights["out"]
    experiments["Attention logits (5% noise)"] = quantization_error(baseline_output, attn_quant_output)

    print(f"\n  Sensitivity Experiment ({num_bits}-bit quantization):")
    print(f"  {'Component':<30} {'MSE':>14} {'SNR (dB)':>10} {'Cosine Sim':>12}")
    print(f"  {'-'*68}")
    for name, err in sorted(experiments.items(), key=lambda x: x[1]["mse"]):
        print(f"  {name:<30} {err['mse']:>14.8f} {err['snr_db']:>10.2f} {err['cosine_similarity']:>12.8f}")

    return experiments

گام 6: شبیه‌سازی GPTQ

روش GPTQ کار را ستون به ستون پیش می‌برد و با تکیه بر ماتریس هسیان تصمیم می‌گیرد خطای گرد کردن چطور توزیع شود. این نسخه، پیاده‌سازی ساده‌شده‌ای است که ایده‌ی اصلی را پیاده می‌کند: استفاده از داده‌های کالیبراسیون برای سنجش اهمیت وزن‌ها و سپس کوانتیزه‌سازی تهاجمی‌ترِ وزن‌هایی با اهمیت کمتر.

def simulated_gptq(weight_matrix, calibration_inputs, num_bits=4):
    n_in, n_out = weight_matrix.shape
    qmin = -(2 ** (num_bits - 1))
    qmax = 2 ** (num_bits - 1) - 1

    H = np.zeros((n_in, n_in))
    for x in calibration_inputs:
        x = x.reshape(-1, 1) if x.ndim == 1 else x
        for row in range(x.shape[0]):
            xi = x[row].reshape(-1, 1)
            H += xi @ xi.T
    H /= len(calibration_inputs)
    H += np.eye(n_in) * 1e-4

    weight_importance = np.diag(H)

    quantized = np.zeros_like(weight_matrix, dtype=np.int32)
    scales = np.zeros(n_out)
    errors = np.zeros(n_out)

    W = weight_matrix.copy()

    for col in range(n_out):
        w_col = W[:, col]
        abs_max = np.max(np.abs(w_col))
        if abs_max == 0:
            scales[col] = 1.0
            continue
        scale = abs_max / qmax
        scales[col] = scale

        q_col = np.clip(np.round(w_col / scale), qmin, qmax).astype(np.int32)
        quantized[:, col] = q_col

        quant_error = w_col - q_col * scale
        errors[col] = np.sqrt(np.mean(quant_error ** 2))

        if col < n_out - 1:
            importance_weights = weight_importance / (np.max(weight_importance) + 1e-10)
            for next_col in range(col + 1, min(col + 4, n_out)):
                compensation = quant_error * importance_weights * 0.1
                W[:, next_col] += compensation

    return quantized, scales, {"column_errors": errors,
                               "mean_error": float(np.mean(errors)),
                               "max_error": float(np.max(errors))}


def dequantize_gptq(quantized, scales):
    result = np.zeros_like(quantized, dtype=np.float64)
    for col in range(quantized.shape[1]):
        result[:, col] = quantized[:, col] * scales[col]
    return result

گام 7: شبیه‌سازی AWQ

روش AWQ وزن‌های برجسته (آن‌هایی که در اکتیویشن‌های بزرگ ضرب می‌شوند) را شناسایی می‌کند و با تغییر مقیاس قبل از کوانتیزه‌سازی، جلوی تخریب آن‌ها را می‌گیرد.

def simulated_awq(weight_matrix, calibration_inputs, num_bits=4, salient_fraction=0.01):
    n_in, n_out = weight_matrix.shape
    qmin = -(2 ** (num_bits - 1))
    qmax = 2 ** (num_bits - 1) - 1

    activation_magnitudes = np.zeros(n_in)
    for x in calibration_inputs:
        if x.ndim == 1:
            activation_magnitudes += np.abs(x)
        else:
            activation_magnitudes += np.mean(np.abs(x), axis=0)
    activation_magnitudes /= len(calibration_inputs)

    n_salient = max(1, int(n_in * salient_fraction))
    salient_indices = np.argsort(activation_magnitudes)[-n_salient:]

    scale_factors = np.ones(n_in)
    for idx in salient_indices:
        col_max = np.max(np.abs(weight_matrix[idx, :]))
        if col_max > 0:
            scale_factors[idx] = min(4.0, 1.0 / (col_max + 1e-8) * np.mean(np.abs(weight_matrix)))

    scaled_weights = weight_matrix * scale_factors.reshape(-1, 1)

    quantized, scales = quantize_per_channel(scaled_weights, num_bits, axis=0)
    dequantized = dequantize_per_channel(quantized, scales, axis=0)

    result = dequantized / scale_factors.reshape(-1, 1)

    err = quantization_error(weight_matrix, result)

    return result, {"salient_indices": salient_indices,
                    "scale_factors": scale_factors[salient_indices],
                    "error": err,
                    "n_salient": n_salient}

گام 8: پایپ‌لاین کامل

همه‌ی بخش‌ها را به هم متصل کنید. کوانتیزه‌سازی ساده‌لوحانه، بر حسب کانال، GPTQ و AWQ را روی همان ماتریس وزن با یکدیگر مقایسه کنید.

def full_quantization_comparison(d_in=256, d_out=512, num_bits=4, n_calibration=32):
    np.random.seed(42)

    weight = np.random.randn(d_in, d_out) * 0.02
    outlier_rows = np.random.choice(d_in, size=5, replace=False)
    weight[outlier_rows] *= 10

    calibration = [np.random.randn(8, d_in) * 0.1 for _ in range(n_calibration)]

    q_naive, s_naive = quantize_symmetric(weight, num_bits)
    recon_naive = dequantize_symmetric(q_naive, s_naive)
    err_naive = quantization_error(weight, recon_naive)

    q_pc, s_pc = quantize_per_channel(weight, num_bits, axis=0)
    recon_pc = dequantize_per_channel(q_pc, s_pc, axis=0)
    err_pc = quantization_error(weight, recon_pc)

    q_gptq, s_gptq, gptq_info = simulated_gptq(weight, calibration, num_bits)
    recon_gptq = dequantize_gptq(q_gptq, s_gptq)
    err_gptq = quantization_error(weight, recon_gptq)

    recon_awq, awq_info = simulated_awq(weight, calibration, num_bits)
    err_awq = awq_info["error"]

    print(f"\n  Full Quantization Comparison ({num_bits}-bit, {d_in}x{d_out} matrix)")
    print(f"  Matrix has {len(outlier_rows)} outlier rows (10x scale)")
    print()
    print(f"  {'Method':<20} {'MSE':>14} {'SNR (dB)':>10} {'Cosine Sim':>12}")
    print(f"  {'-'*58}")
    print(f"  {'Naive per-tensor':<20} {err_naive['mse']:>14.8f} {err_naive['snr_db']:>10.2f} {err_naive['cosine_similarity']:>12.8f}")
    print(f"  {'Per-channel':<20} {err_pc['mse']:>14.8f} {err_pc['snr_db']:>10.2f} {err_pc['cosine_similarity']:>12.8f}")
    print(f"  {'Simulated GPTQ':<20} {err_gptq['mse']:>14.8f} {err_gptq['snr_db']:>10.2f} {err_gptq['cosine_similarity']:>12.8f}")
    print(f"  {'Simulated AWQ':<20} {err_awq['mse']:>14.8f} {err_awq['snr_db']:>10.2f} {err_awq['cosine_similarity']:>12.8f}")

    test_input = np.random.randn(4, d_in) * 0.1
    baseline = test_input @ weight
    output_naive = test_input @ recon_naive
    output_pc = test_input @ recon_pc
    output_gptq = test_input @ recon_gptq
    output_awq = test_input @ recon_awq

    print(f"\n  End-to-End Output Error (matmul with test input):")
    print(f"  {'Method':<20} {'Output MSE':>14} {'Output Cosine':>14}")
    print(f"  {'-'*50}")
    for name, output in [("Naive", output_naive), ("Per-channel", output_pc),
                          ("GPTQ", output_gptq), ("AWQ", output_awq)]:
        out_err = quantization_error(baseline, output)
        print(f"  {name:<20} {out_err['mse']:>14.8f} {out_err['cosine_similarity']:>14.8f}")

    return {"naive": err_naive, "per_channel": err_pc, "gptq": err_gptq, "awq": err_awq}


def memory_calculator(num_params_billions, bits_per_param):
    bytes_per_param = bits_per_param / 8
    total_bytes = num_params_billions * 1e9 * bytes_per_param
    total_gb = total_bytes / (1024 ** 3)
    return total_gb


def print_memory_table():
    print("\n  Memory Requirements by Model and Precision:")
    print(f"  {'Model':<15} {'FP32':>8} {'FP16':>8} {'FP8':>8} {'INT8':>8} {'INT4':>8} {'INT2':>8}")
    print(f"  {'-'*64}")
    for name, params in [("7B", 7), ("13B", 13), ("34B", 34), ("70B", 70), ("405B", 405)]:
        fp32 = memory_calculator(params, 32)
        fp16 = memory_calculator(params, 16)
        fp8 = memory_calculator(params, 8)
        int8 = memory_calculator(params, 8)
        int4 = memory_calculator(params, 4)
        int2 = memory_calculator(params, 2)
        print(f"  {name:<15} {fp32:>7.1f}G {fp16:>7.1f}G {fp8:>7.1f}G {int8:>7.1f}G {int4:>7.1f}G {int2:>7.1f}G")


if __name__ == "__main__":
    np.random.seed(42)

    print("=" * 70)
    print("QUANTIZATION: MAKING MODELS FIT")
    print("=" * 70)

    print("\nSTEP 1: Number Format Comparison")
    print("-" * 50)
    for val in [0.1, 3.14159, -0.00073, 42.5, 0.0000012]:
        display_format_comparison(val)

    print("\n\nSTEP 2: Memory Requirements")
    print("-" * 50)
    print_memory_table()

    print("\n\nSTEP 3: Quantization Methods Comparison")
    print("-" * 50)
    weight_matrix = np.random.randn(128, 256) * 0.02
    weight_matrix[0] *= 15
    weight_matrix[42] *= 8
    compare_quantization_methods(weight_matrix, num_bits=8)
    compare_quantization_methods(weight_matrix, num_bits=4)

    print("\n\nSTEP 4: Bit-Width Sweep")
    print("-" * 50)
    sweep_tensor = np.random.randn(64, 128) * 0.05
    bit_width_sweep(sweep_tensor)

    print("\n\nSTEP 5: Sensitivity Experiment")
    print("-" * 50)
    print("\n  INT8:")
    sensitivity_experiment(num_bits=8)
    print("\n  INT4:")
    sensitivity_experiment(num_bits=4)

    print("\n\nSTEP 6: GPTQ vs AWQ vs Naive (INT4)")
    print("-" * 50)
    full_quantization_comparison(d_in=256, d_out=512, num_bits=4)

    print("\n\nSTEP 7: Distribution Analysis")
    print("-" * 50)
    np.random.seed(0)
    simulated_weights = np.random.randn(1000) * 0.02
    abs_vals = np.abs(simulated_weights)
    pct_in_range = np.mean(abs_vals < 0.1) * 100
    print(f"\n  Simulated weight distribution (1000 params, std=0.02):")
    print(f"  Weights in [-0.1, 0.1]: {pct_in_range:.1f}%")
    print(f"  Weights in [-0.05, 0.05]: {np.mean(abs_vals < 0.05) * 100:.1f}%")
    print(f"  Weights in [-0.01, 0.01]: {np.mean(abs_vals < 0.01) * 100:.1f}%")
    print(f"  Max absolute value: {np.max(abs_vals):.6f}")
    print(f"  Mean absolute value: {np.mean(abs_vals):.6f}")

    histogram = np.histogram(simulated_weights, bins=20)
    print(f"\n  Weight histogram:")
    max_count = max(histogram[0])
    for i in range(len(histogram[0])):
        bar_len = int(histogram[0][i] / max_count * 40)
        lo = histogram[1][i]
        hi = histogram[1][i + 1]
        print(f"  [{lo:>7.4f}, {hi:>7.4f}] {'#' * bar_len} ({histogram[0][i]})")

    print("\n\n" + "=" * 70)
    print("DONE")
    print("=" * 70)

استفاده از ابزارهای واقعی

کوانتیزه‌سازی با GPTQModel

# pip install gptqmodel
# from gptqmodel import GPTQConfig, GPTQModel
#
# model_id = "meta-llama/Llama-3.1-8B"
# quant_config = GPTQConfig(bits=4, group_size=128)
#
# model = GPTQModel.load(model_id, quant_config)
# model.quantize(calibration_texts[:128], batch_size=1)
# model.save("llama-8b-gptq-int4")

کوانتیزه‌سازی به AWQ با LLM Compressor

# pip install llmcompressor
# from transformers import AutoModelForCausalLM, AutoTokenizer
# from llmcompressor import oneshot
# from llmcompressor.modifiers.quantization import QuantizationModifier
# from llmcompressor.modifiers.transform.awq import AWQModifier
#
# model_id = "meta-llama/Llama-3.1-8B"
# model = AutoModelForCausalLM.from_pretrained(model_id)
# tokenizer = AutoTokenizer.from_pretrained(model_id)
#
# recipe = [
#     AWQModifier(duo_scaling="both"),
#     QuantizationModifier(ignore=["lm_head"], scheme="W4A16_ASYM", targets=["Linear"]),
# ]
# oneshot(
#     model=model,
#     dataset="perfectblend",
#     splits="train[:512]",
#     recipe=recipe,
#     max_seq_length=512,
#     num_calibration_samples=256,
# )
# model.save_pretrained("llama-8b-awq-int4", save_compressed=True)
# tokenizer.save_pretrained("llama-8b-awq-int4")

ابزارهای AutoGPTQ و AutoAWQ که نسخه‌های اصلی این دو روش بودند، دیگر نگهداری نمی‌شوند و آرشیو شده‌اند. کتابخانه‌های GPTQModel و LLM Compressor جانشین‌های فعال و تحت پشتیبانی آن‌ها هستند.

تبدیل به GGUF

# git clone https://github.com/ggml-org/llama.cpp
# cmake -S llama.cpp -B llama.cpp/build && cmake --build llama.cpp/build --config Release
# pip install -r llama.cpp/requirements.txt
# hf download meta-llama/Llama-3.1-8B --local-dir Llama-3.1-8B
# python llama.cpp/convert_hf_to_gguf.py Llama-3.1-8B --outtype f16 --outfile llama-8b-f16.gguf
# llama.cpp/build/bin/llama-quantize llama-8b-f16.gguf llama-8b-q4km.gguf Q4_K_M
# llama.cpp/build/bin/llama-server -m llama-8b-q4km.gguf -c 4096 -ngl 99

مبدل خروجیِ K-quant ندارد (پارامتر --outtype مقادیر f32 ،f16 ،bf16 ،q8_0 ،tq1_0 ،tq2_0 یا auto را می‌پذیرد)، بنابراین دستور llama-quantize فایل نهایی Q4_K_M را تولید می‌کند.

سرویس‌دهی مدل‌های کوانتیزه‌شده

# pip install vllm
# vllm serve llama-8b-awq-int4 --max-model-len 8192

موتور vLLM به صورت ذاتی از مدل‌های AWQ و GPTQ پشتیبانی می‌کند و روش کوانتیزه‌سازی را مستقیماً از فایل کانفیگ چک‌پوینت می‌خواند؛ بنابراین نیازی به فلگ --quantization نیست. این ابزار واکوانتیزه‌سازی را حین ضرب ماتریسی مدیریت می‌کند و برای کش کلید-مقدار از مکانیزم paged attention بهره می‌گیرد. برای FP8 روی تراشه‌های H100، فلگ --quantization fp8_per_tensor را اضافه کنید تا وزن‌های یک چک‌پوینت 16 بیتی موقع بارگذاری مدل کوانتیزه شوند.

خروجی این درس

این درس فایل outputs/skill-quantization.md را تولید می‌کند؛ چارچوبی برای تصمیم‌گیری در انتخاب بهترین استراتژی کوانتیزه‌سازی. با گرفتن ابعاد مدل، سخت‌افزار مقصد و الزامات کیفی، به شما می‌گوید کدام فرمت، روش و مراحل اعتبارسنجی را انتخاب کنید. این سند شامل محاسبات بودجه‌ی حافظه، دقت پیشنهادی به ازای هر جزء مدل و دستورالعمل‌های استقرار روی vLLM ،llama.cpp و TensorRT-LLM است.

تمرین‌ها

  1. کوانتیزه‌سازی گروهی (group quantization) را پیاده‌سازی کنید. به جای اختصاص یک مقیاس برای هر کانال، یک مقیاس را برای هر گروه 128 تایی از وزن‌های داخل یک کانال در نظر بگیرید. این همان شیوه‌ای است که GPTQ و AWQ در عمل به کار می‌برند. گروه‌هایی با اندازه‌ی 32، 64، 128 و 256 را روی همان ماتریس وزن با هم مقایسه کنید. گروه‌های کوچک‌تر کیفیت بالاتری می‌دهند اما سربار ذخیره‌سازی ضرایب مقیاس در آن‌ها بیشتر است.

  2. یک کوانتیزه‌ساز با دقت ترکیبی بسازید. لایه‌های اول و آخرِ یک شبکه‌ی چندلایه را با INT8 کوانتیزه کنید، در حالی که لایه‌های میانی با INT4 فشرده شوند. کیفیت خروجی سرتاسری را با حالت‌های کاملاً INT4 و کاملاً INT8 مقایسه کنید. میزان صرفه‌جویی در حافظه را نسبت به حالت کاملاً INT8 اندازه بگیرید.

  3. تخمین‌گر عبور مستقیم (STE) را برای آموزش آگاه از کوانتیزه‌سازی پیاده‌سازی کنید. عملیات کوانتیزه/واکوانتیزه‌ی ساختگی را در گذر رو به جلوی یک شبکه‌ی ساده‌ی دولایه که روی یک مسئله‌ی رگرسیون آموزش دیده بگنجانید. تابع زیان (loss) نهایی را میان مدلی که به شکل عادی آموزش دیده (و بعد با PTQ به INT4 تبدیل شده) و مدلی که از ابتدا با QAT آموزش دیده، مقایسه کنید.

  4. با الهام از LLM.int8() یک کوانتیزه‌ساز بسازید که داده‌های پرت را تشخیص دهد. کانال‌هایی را که دامنه‌ی اکتیویشن آن‌ها از 6 برابر میانگین بیشتر می‌شود شناسایی کنید. این کانال‌ها را در قالب FP16 نگه دارید و بقیه را به INT8 تبدیل کنید. کیفیت سرتاسری را روی لایه‌ی ترنسفورمرِ گام 5 با آستانه‌های مختلف برای داده‌های پرت (3، 6 و 10 برابر) اندازه بگیرید.

  5. یک داشبورد سنجش کیفیت کوانتیزه‌سازی طراحی کنید. به ازای یک ماتریس وزن ورودی، این موارد را محاسبه کرده و نمایش دهید: هیستوگرام توزیع وزن‌ها، توزیع خطای کوانتیزه‌سازی، ضرایب مقیاس بر حسب کانال، کانال‌هایی با بدترین وضعیت کوانتیزه‌سازی (بیشترین خطای بازسازی)، و شباهت کسینوسی میان خروجی‌های اصلی و کوانتیزه‌شده در ازای 100 ورودی تصادفی. مشخص کنید کدام کانال‌ها باید با دقت بالاتری حفظ شوند.

اصطلاحات کلیدی

اصطلاح آنچه افراد می‌گویند معنای واقعی آن
FP16 «دقت نیمه» عدد ممیز شناور 16 بیتی با 5 بیت توان و 10 بیت مانتیس، حداکثر مقدار 65,504، فرمت استاندارد استنتاج
BF16 «برین فلوت» ممیز شناور 16 بیتی با 8 بیت توان (بازه‌ی مشابه FP32) و 7 بیت مانتیس، طراحی‌شده توسط Google برای آموزش
FP8 «ممیز شناور هشت بیتی» دارای دو نسخه: E4M3 (استنتاج، دقت بالاتر) و E5M2 (آموزش، بازه‌ی گسترده‌تر)، پشتیبانی بومی در H100
INT8 «عدد صحیح هشت بیتی» 256 مقدار با فواصل یکنواخت از 128- تا 127، برای نگاشت از ممیز شناور نیازمند ضریب مقیاس است
INT4 «عدد صحیح چهار بیتی» در مجموع 16 سطح مقداری، حفظ کیفیت در آن نیازمند روش‌های پیشرفته (GPTQ و AWQ) است
کوانتیزه‌سازی بر حسب کانال «یک مقیاس برای هر سطر» استفاده از ضریب مقیاس جداگانه برای هر کانال خروجی به جای یک ضریب برای کل تنسور، که خطا را شدیداً کم می‌کند
GPTQ «روش هسیان» کوانتیزه‌سازی پس از آموزش با استفاده از اطلاعات مرتبه دوم برای کمینه‌سازی خطای خروجی، به صورت لایه به لایه
AWQ «آگاه از اکتیویشن» مقیاس‌بندی وزن‌های برجسته (ضرب‌شده در اکتیویشن‌های بزرگ) پیش از کوانتیزه‌سازی به منظور محافظت از آن‌ها
GGUF «فرمت ابزار llama.cpp» فایل مدل خودکفا با لایه‌های دارای دقت ترکیبی، بهینه‌سازی‌شده برای استنتاج روی CPU و تراشه‌های Apple Silicon
PTQ «کوانتیزه‌سازی پس از آموزش» تبدیل وزن‌های مدل آموزش‌دیده به دقت پایین‌تر بدون بازآموزی؛ سریع ولی با محدودیت در فشرده‌سازی‌های شدید
QAT «کوانتیزه‌سازی حین آموزش» وارد کردن کوانتیزه‌سازی ساختگی در گذر رو به جلو تا مدل تحمل خطای گرد کردن را بیاموزد؛ نتایج بهتر در INT4 و INT2
داده‌های کالیبراسیون «همان 128 نمونه» مجموعه‌داده‌ای کوچک که وارد مدل می‌شود تا آمار اکتیویشن‌ها برای تنظیم ضرایب مقیاس محاسبه شود
ضریب مقیاس «ضریب تبدیل» رابط تبدیل میان بازه‌ی ممیز شناور و عدد صحیح: float_val = int_val * scale
تغییر پرپلکسیتی (Perplexity delta) «چقدر افت کرده» اختلاف پرپلکسیتی میان مدل اصلی و مدل کوانتیزه‌شده؛ کمتر از 0.5 عالی و بالاتر از 2.0 مشکل‌ساز است

برای مطالعه‌ی بیشتر

سؤالات متداول

تفاوت اصلی میان کوانتیزه‌سازی GPTQ و AWQ چیست؟

روش GPTQ با استفاده از اطلاعات مرتبه دوم (ماتریس هسیان) خطای لایه‌ها را جبران می‌کند؛ در حالی که AWQ با شناسایی وزن‌های کلیدیِ مرتبط با اکتیویشن‌های بزرگ و تغییر مقیاس آن‌ها پیش از کوانتیزه‌سازی، کیفیت را حفظ می‌کند و اجرای آن سریع‌تر است.

فرمت GGUF چه مزیتی دارد و برای چه سیستمی مناسب است؟

فرمت GGUF یک قالب خودکفا است که وزن‌ها، توکنایزر و متادیتا را در یک فایل نگه می‌دارد و با پشتیبانی از کوانتیزه‌سازی ترکیبی، برای استنتاج سریع روی پردازنده‌ی مرکزی (CPU) و تراشه‌های Apple Silicon با رم یکپارچه بهینه‌سازی شده است.

کوانتیزه‌سازی پس از آموزش (PTQ) چه فرقی با آموزش آگاه از کوانتیزه‌سازی (QAT) دارد؟

روش PTQ بدون نیاز به بازآموزی و در چند دقیقه تا چند ساعت روی مدل آماده اعمال می‌شود، اما QAT عملیات کوانتیزه‌سازی را در حین یک دور آموزش کامل شبیه‌سازی می‌کند و برای بیت‌های بسیار پایین مثل INT4 و INT2 کیفیت بهتری می‌دهد.

ما رو توی گوگل بیشتر ببین

با افزودن نکست ادمین به «منابع ترجیحی» گوگل، درس‌ها و مطالب ما را در بخش اخبار برتر جستجو بیشتر می‌بینید.