مدلهای زبانی پیشرفته مانند 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 رقم اعشار دقیق به شما میدهد. بازهی عددی: تقریباً تا . در گذشته آموزش مدلها منحصراً با FP32 انجام میشد؛ هنوز هم برای تجمیع مقادیر (جمعهای در حال محاسبه هنگام ضرب ماتریسی) از آن استفاده میشود.
فرمت FP16 تعداد بیتها را نصف میکند. 10 بیت مانتیس حدود 3.3 رقم اعشار فراهم میکند. توان به 5 بیت کاهش مییابد که دامنهی بازه را شدیداً محدود میکند (حداکثر مقدار در حدود 65,504). این بازه برای وزنها (که حول صفر متمرکز هستند) مشکلی ایجاد نمیکند، اما برای اکتیویشنها (activations) و گرادیانهایی که ممکن است در طول آموزش جهشهای ناگهانی داشته باشند خطرناک است. آموزش با FP16 برای جلوگیری از کمریز عددی به مقیاسبندی تابع زیان نیاز دارد.
فرمت BF16 (مخفف Brain Float 16) همان توان 8 بیتیِ FP32 را نگه میدارد اما مانتیس را به 7 بیت کاهش میدهد. بازهی عددی آن مشابه FP32 است، اما دقت کمتری نسبت به FP16 دارد. شرکت Google آن را مشخصاً برای یادگیری عمیق طراحی کرد. منطق آن ساده است: در شبکههای عصبی، دامنهی بازه مهمتر از دقت است. گرادیانی با مقدار که در 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 درصد کیفیت مدل اصلی را حفظ میکنند.
سازوکار کوانتیزهسازی
عملیات اصلی بسیار ساده است: تنسوری از مقادیر ممیز شناور را بردارید، ضریب مقیاس را پیدا کنید، ضرب را انجام دهید، عدد را به نزدیکترین عدد صحیح گرد کنید و در نهایت اعداد صحیح را به همراه ضریب مقیاس ذخیره کنید.
کوانتیزهسازی:
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) حفظ میکنند، حتی اگر سایر بخشها به طور کامل کوانتیزه شده باشند.
مقایسهی 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 است که تعادلی بهینه میان کیفیت و اندازه ایجاد میکند.
سنجش کیفیت
چگونه متوجه شویم مدل کوانتیزهشده همچنان کیفیت مطلوبی دارد؟
پرپلکسیتی. رایجترین معیار سنجش است و مقدار کمتر آن نشاندهندهی عملکرد بهتر خواهد بود. پرپلکسیتی را روی یک مجموعهدادهی جداگانه (معمولاً 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 درصد افت کیفیت. صرفاً برای وظایف محدود و مشخصی که افت کارایی در آنها قابل تحمل است مناسب خواهد بود. در مرزهای پژوهش قرار دارد و برای استفادهی عمومی در محیط عملیاتی آماده نیست.
پیادهسازی گامبهگام
گام 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 است.
تمرینها
-
کوانتیزهسازی گروهی (group quantization) را پیادهسازی کنید. به جای اختصاص یک مقیاس برای هر کانال، یک مقیاس را برای هر گروه 128 تایی از وزنهای داخل یک کانال در نظر بگیرید. این همان شیوهای است که GPTQ و AWQ در عمل به کار میبرند. گروههایی با اندازهی 32، 64، 128 و 256 را روی همان ماتریس وزن با هم مقایسه کنید. گروههای کوچکتر کیفیت بالاتری میدهند اما سربار ذخیرهسازی ضرایب مقیاس در آنها بیشتر است.
-
یک کوانتیزهساز با دقت ترکیبی بسازید. لایههای اول و آخرِ یک شبکهی چندلایه را با INT8 کوانتیزه کنید، در حالی که لایههای میانی با INT4 فشرده شوند. کیفیت خروجی سرتاسری را با حالتهای کاملاً INT4 و کاملاً INT8 مقایسه کنید. میزان صرفهجویی در حافظه را نسبت به حالت کاملاً INT8 اندازه بگیرید.
-
تخمینگر عبور مستقیم (STE) را برای آموزش آگاه از کوانتیزهسازی پیادهسازی کنید. عملیات کوانتیزه/واکوانتیزهی ساختگی را در گذر رو به جلوی یک شبکهی سادهی دولایه که روی یک مسئلهی رگرسیون آموزش دیده بگنجانید. تابع زیان (loss) نهایی را میان مدلی که به شکل عادی آموزش دیده (و بعد با PTQ به INT4 تبدیل شده) و مدلی که از ابتدا با QAT آموزش دیده، مقایسه کنید.
-
با الهام از LLM.int8() یک کوانتیزهساز بسازید که دادههای پرت را تشخیص دهد. کانالهایی را که دامنهی اکتیویشن آنها از 6 برابر میانگین بیشتر میشود شناسایی کنید. این کانالها را در قالب FP16 نگه دارید و بقیه را به INT8 تبدیل کنید. کیفیت سرتاسری را روی لایهی ترنسفورمرِ گام 5 با آستانههای مختلف برای دادههای پرت (3، 6 و 10 برابر) اندازه بگیرید.
-
یک داشبورد سنجش کیفیت کوانتیزهسازی طراحی کنید. به ازای یک ماتریس وزن ورودی، این موارد را محاسبه کرده و نمایش دهید: هیستوگرام توزیع وزنها، توزیع خطای کوانتیزهسازی، ضرایب مقیاس بر حسب کانال، کانالهایی با بدترین وضعیت کوانتیزهسازی (بیشترین خطای بازسازی)، و شباهت کسینوسی میان خروجیهای اصلی و کوانتیزهشده در ازای 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 مشکلساز است |
برای مطالعهی بیشتر
- Frantar et al., 2022 -- "GPTQ: Accurate Post-Training Quantization for Generative Pre-trained Transformers" -- مقالهای که کوانتیزهسازی INT4 را با گرد کردن وزنها به کمک ماتریس هسیان برای مدلهای زبانی بزرگ عملی کرد.
- Lin et al., 2023 -- "AWQ: Activation-aware Weight Quantization for LLM Compression and Acceleration" -- محافظت از وزنهای برجسته با تغییر مقیاس پیش از کوانتیزهسازی، با کیفیتی همپای GPTQ یا بهتر از آن.
- Dettmers et al., 2022 -- "LLM.int8(): 8-bit Matrix Multiplication for Transformers at Scale" -- ضرب ماتریسی INT8 با دقت ترکیبی که ویژگیهای پرت را در FP16 نگه میدارد و استنتاج INT8 را بدون افت کیفیت میسر میسازد.
- Xiao et al., 2023 -- "SmoothQuant: Accurate and Efficient Post-Training Quantization for Large Language Models" -- انتقال سختی کوانتیزهسازی از اکتیویشنها به وزنها برای استقرار به شیوهی W8A8.
- Micikevicius et al., 2022 -- "FP8 Formats for Deep Learning" -- مقالهی مشترک NVIDIA، ARM و Intel که فرمتهای E4M3 و E5M2 را تعریف کرد؛ فرمتهایی که اکنون در H100 به صورت بومی پشتیبانی میشوند.
