اگر آپ نے کبھی کسی LLM کو طویل جواب تیار کرتے ہوئے دیکھا ہو اور سوچا ہو کہ ابتدائی پرامپٹ کی تیزی کے بعد یہ کیوں رینگنے لگتا ہے، تو آپ حقیقت میں ہارڈ ویئر کے ایک رکاوٹ (bottleneck) کا مشاہدہ کر رہے ہیں۔ زیادہ تر ڈویلپرز اپنے Python کوڈ، فریم ورک، یا ماڈل کے محض سائز کو قصوروار ٹھہراتے ہیں۔ وہ فنکشنز کی پروفائلنگ کرتے ہیں، آپٹیمائزرز تبدیل کرتے ہیں، اور پری پروسیسنگ سے ملی سیکنڈز بچانے کی کوشش کرتے ہیں۔ ان میں سے کوئی بھی چیز اصل مسئلے کو حل نہیں کرتی۔ رفتار کی حد آپ کے سافٹ ویئر میں نہیں ہے۔ یہ سلیکون (silicon) میں ہے۔

ہر لارج لینگویج ماڈل (LLM) انفرنس کا کام آپ کے سرور میں موجود GPU کی دو جسمانی خصوصیات پر منحصر ہوتا ہے: یہ کتنی تیزی سے نمبروں پر حساب کتاب کر سکتا ہے، اور یہ ان نمبروں کو حساب کے لیے صحیح جگہ پر کتنی تیزی سے منتقل کر سکتا ہے۔

ریاضی سستی ہے۔ ڈیٹا کی نقل مکانی مہنگی ہے۔

GPU مارکیٹنگ میں 'کمپیوٹ' (compute) کے بارے میں بات کرنا پسند کیا جاتا ہے۔ فی سیکنڈ ٹریلینز کی تعداد میں فلوٹنگ پوائنٹ آپریشنز۔ یہ اعداد و شمار حیران کن ہیں۔ لیکن کمپیوٹ کہانی کا صرف آدھا حصہ ہے۔ دوسرا آدھا حصہ میموری بینڈوتھ (memory bandwidth) ہے، یعنی وہ شرح جس کے تحت ڈیٹا ہائی بینڈوتھ میموری سے کمپیوٹ کورز تک پہنچتا ہے جہاں اصل ریاضیاتی عمل ہوتا ہے۔

ایک LLM ان دو کڑیوں میں سے کمزور کڑی سے زیادہ تیز نہیں چل سکتا۔ ایک کمرشل کچن کا تصور کریں جہاں بیس ماہر شیف موجود ہوں۔ اوون گرم ہیں، چاقو تیز ہیں، اور ہر باورچی تیار ہے۔ لیکن سبزیوں کی ڈیلیوری سائیکل پر آتی ہے، ایک وقت میں ایک ہی ٹوکری۔ کچن کا کام رک جاتا ہے۔ مزید شیف شامل کرنے سے یہ مسئلہ حل نہیں ہوگا۔ تیز رفتار اوون خریدنے سے بھی حل نہیں ہوگا۔ رکاوٹ (bottleneck) وہ راستہ ہے۔

جدید ڈیٹا سینٹر GPUs میں، ریاضیاتی یونٹس اتنے طاقتور ہوتے ہیں کہ وہ اکثر اپنا حساب کتاب مکمل کر لیتے ہیں اور پھر فارغ بیٹھ جاتے ہیں، اور میموری سے ویٹس (weights) اور ایکٹیویشنز (activations) کے آنے کے انتظار میں وقت ضائع کرتے ہیں۔ یہ عدم توازن آپ کے کوڈ میں کوئی بگ (bug) نہیں ہے۔ یہ چپس کی بناوٹ کی جسمانی حقیقت ہے۔ میموری بینڈوتھ، خام کمپیوٹ (raw compute) کے ساتھ ہم آہنگ نہیں ہو سکی ہے، اور LLMs اس عدم توازن کے لیے خاص طور پر مشکل پیدا کرتے ہیں کیونکہ ان کے فارورڈ پاسز (forward passes) میں ہر آؤٹ پٹ ٹوکن کے لیے ہر ایک پیرامیٹر کو استعمال کرنا ضروری ہوتا ہے۔

پرامپٹس تیز کیوں محسوس ہوتے ہیں اور جنریشن سست کیوں محسوس ہوتی ہے

LLM انفرنس دو الگ الگ مراحل میں تقسیم ہوتی ہے، اور وہ ہارڈ ویئر پر بالکل مختلف طریقوں سے دباؤ ڈالتی ہیں۔

Prefill اس وقت ہوتا ہے جب آپ کا پرامپٹ پہلی بار ماڈل تک پہنچتا ہے۔ تمام ٹوکنز ایک ساتھ آتے ہیں۔ GPU بڑے میٹرکس-میٹرکس ملٹی پلیکیشنز (matrix-matrix multiplications) کا استعمال کرتے ہوئے انہیں متوازی (parallel) طور پر پروسیس کر سکتا ہے۔ ہزاروں ریاضیاتی یونٹس ایک ساتھ کام کرتے ہیں، اور کام کا بوجھ برقرار رہتا ہے۔ یہ مرحلہ compute-bound ہے۔ شروع میں جو رفتار کا اچانک اضافہ آپ دیکھتے ہیں؟ وہ GPU کا وہی کام ہے جس کے لیے اسے بنایا گیا ہے۔

Decode وہ مرحلہ ہے جہاں چیزیں مشکل ہو جاتی ہیں۔ جب ماڈل اگلا ٹوکن تیار کرتا ہے، تو وہ ایسا ایک وقت میں ایک ہی ٹوکن کر کے کرتا ہے۔ یہ مرحلہ میٹرکس-ویکٹر آپریشنز (matrix-vector operations) پر انحصار کرتا ہے، جو GPU کی متوازی صلاحیت کا صرف ایک چھوٹا سا حصہ استعمال کرتے ہیں۔ اس سے بھی بدتر یہ ہے کہ ہر نیا ٹوکن GPU کو مجبور کرتا ہے کہ وہ میموری سے پورے ماڈل کے ویٹس (weights) دوبارہ لوڈ کرے۔ ریاضیاتی یونٹس کام کرنا چاہتے ہیں، لیکن اس کے بجائے وہ انتظار کرتے ہیں۔ Decode memory-bound ہے۔ GPU مؤثر طور پر ایک مہنگے ٹریفک کنٹرولر کے طور پر کام کر رہا ہوتا ہے، جو میتھ انجنوں کے ٹھنڈا ہونے کے دوران میموری بس کے ذریعے پیرامیٹرز کو ادھر ادھر منتقل کرتا رہتا ہے۔ یہی وجہ ہے کہ سو الفاظ کا جواب دس سیکنڈ لے سکتا ہے، حالانکہ ابتدائی پرامپٹ کا تجزیہ فوری محسوس ہوا تھا۔

KV cache اس معاملے کو مزید دلچسپ بنا دیتا ہے۔ Decode کے دوران، ماڈل ہر پچھلے ٹوکن کے لیے key اور value tensors کو محفوظ کرتا ہے تاکہ اسے دوبارہ شروع سے attention کا حساب نہ کرنا پڑے۔ وہ کیش (cache) سیکوئنس کی لمبائی کے ساتھ بڑھتا جاتا ہے۔ یہ بھی میموری میں ہوتا ہے۔ چنانچہ اب GPU صرف ویٹس کو دوبارہ لوڈ نہیں کر رہا، بلکہ ہر فارورڈ پاس پر ایک مسلسل بڑھتے ہوئے کیش کو پڑھ اور لکھ رہا ہے۔ کمپیوٹ کورز بمشکل تھکتے ہیں جبکہ میموری بس ان دونوں کے لیے پسینہ بہاتی ہے۔

میموری وال (Memory Wall) کا مقابلہ کرنا

انجینئرز نے ڈیٹا کی نقل و حرکت کو کم کرنے، یا کم از کم اس کی نقل و حرکت کی لاگت کو بانٹنے کے لیے تکنیکوں کا ایک چھوٹا سا ذخیرہ تیار کیا ہے۔

Batching سب سے سادہ طریقہ ہے۔ اگر ایک صارف کی درخواست میموری سے پورے ویٹ لوڈ کرنے پر مجبور کرتی ہے، تو ایک ساتھ آٹھ یا سولہ درخواستوں کو پروسیس کرنے سے GPU اس بوجھ کو ان تمام پر تقسیم کر سکتا ہے۔ ویٹس کو ایک بار پڑھا جاتا ہے اور بیچ (batch) میں موجود ہر سیکوئنس کے لیے دوبارہ استعمال کیا جاتا ہے۔ پروڈکشن میں، پیچیدہ شیڈولنگ سسٹمز درخواستوں کو متحرک طور پر گروپ کرتے ہیں، جسے کبھی کبھی continuous یا in-flight batching کہا جاتا ہے، تاکہ GPU شاذ و نادر ہی رکے۔ یہ ایک ہی راستے پر چلنے والی ایک بس اور سولہ الگ الگ گاڑیوں کے درمیان فرق کی طرح ہے۔

Quantization براہ راست بینڈوتھ کے مسئلے پر حملہ کرتا ہے۔ ماڈل کے ویٹس (weights) عام طور پر سولہ بٹ فلوٹنگ پوائنٹ فارمیٹس میں محفوظ کیے جاتے ہیں۔ انہیں آٹھ بٹ یا یہاں تک کہ چار بٹ انٹیجرز تک کمپریس کر کے، آپ لفظی طور پر بس (bus) کے ذریعے سفر کرنے والے ڈیٹا کی مقدار کو آدھا یا اس سے بھی زیادہ کم کر دیتے ہیں۔ ماڈل کو مربوط آؤٹ پٹ پیدا کرنے کے لیے کافی پریسیشن کی ضرورت ہوتی ہے، لیکن جدید پوسٹ ٹریننگ کوانٹائزیشن طریقے معیار کو خراب کیے بغیر ماڈل کے میموری فٹ پرنٹ کو ڈرامائی طور پر کم کر سکتے ہیں۔ ڈیٹا کی کم مقدار کا مطلب ہے میموری کنٹرولر پر انتظار میں کم وقت۔

FlashAttention اٹینشن میکانزم کو اس طرح دوبارہ ترتیب دیتا ہے کہ درمیانی نتائج GPU کی تیز رفتار آن چپ میموری کے اندر رہیں۔ اسٹینڈرڈ اٹینشن کو بڑے اٹینشن میٹرسز کو سست بیرونی میموری میں لکھنا پڑتا تھا اور پھر انہیں دوبارہ پڑھنا پڑتا تھا۔ FlashAttention حساب کتاب کو چھوٹے ٹائلز (tiles) میں تقسیم کرتا ہے جو SRAM میں سما جاتے ہیں، آن چپ پر سافٹ میکس (softmax) اور اسکیلنگ کے مراحل مکمل کرتا ہے، اور صرف حتمی آؤٹ پٹ کو ہائی بینڈوتھ میموری میں واپس لکھتا ہے۔ یہ مین میموری کے بہت کم راؤنڈ ٹرپس کے بدلے تھوڑی اضافی کمپیوٹیشن کا تبادلہ کرتا ہے، جو کہ تقریباً ہمیشہ ایک کامیاب فیصلہ ہوتا ہے۔

PagedAttention میموری کے ضیاع کی ایک مختلف قسم کا حل فراہم کرتا ہے۔ ڈیکوڈنگ کے دوران، KV کیش (KV cache) غیر متوقع طور پر بڑھتا ہے۔ روایتی نظام ہر سیکوئنس کے لیے میموری کے مقررہ، مسلسل ٹکڑے (contiguous chunks) مختص کرتے ہیں، جس سے بڑے خلا رہ جاتے ہیں کیونکہ کچھ سیکوئنس جلدی ختم ہو جاتے ہیں اور دوسرے پھیل جاتے ہیں۔ PagedAttention آپریٹنگ سسٹم سے ورچوئل میموری کا تصور ادھار لیتا ہے۔ یہ KV کیش انٹریز کو مقررہ سائز کے بلاکس میں محفوظ کرتا ہے جنہیں غیر مسلسل (non-contiguously) مختص کیا جا سکتا ہے اور ایک ان ڈائریکشن ٹیبل کے ذریعے میپ کیا جا سکتا ہے۔ یہ میموری کو ریزرو شدہ لیکن آدھے خالی بفرز کے اندر بیکار رہنے سے روکتا ہے اور بڑے بیچ سائز (batch sizes) کی اجازت دیتا ہے، جو بدلے میں فرگمنٹیشن اوور ہیڈ کے بجائے میموری بس کو مفید کام میں مصروف رکھ کر مجموعی تھرو پٹ کو بہتر بناتا ہے۔

سوال بدلیں

جب لیٹنسی (latency) میں اضافہ ہوتا ہے، تو بہت سی ٹیمیں یہ پوچھتی ہیں کہ کیا انہیں چھوٹے ماڈل پر منتقل ہو جانا چاہیے یا اپنے انفیرنس سرور کو دوبارہ لکھنا چاہیے۔ وہ سوالات اہم ہیں، لیکن وہ ثانوی ہیں۔ پہلا سوال خود ہارڈ ویئر کے بارے میں ہونا چاہیے۔ کیا آپ کا GPU واقعی کمپیوٹنگ میں مصروف ہے، یا یہ ڈیٹا کے لیے تڑپ رہا ہے؟

اپنے یوٹیلائزیشن میٹرکس (utilization metrics) دیکھیں۔ GPU کمپیوٹ آکیوپینسی کے ساتھ میموری بینڈوتھ سیچوریشن کا پروفائل بنائیں۔ اگر آپ ڈیکوڈنگ کے دوران زیادہ میموری کنٹینشن اور کم اریتھمیٹک انٹینسٹی (arithmetic intensity) دیکھتے ہیں، تو آپ کو ماڈل کے آرکیٹیکچر کا مسئلہ نہیں ہے۔ آپ کو فزکس کا مسئلہ ہے۔ اس کا حل صاف ستھرے Python کوڈ سے نہیں آئے گا۔ یہ زیادہ جارحانہ طریقے سے بیچنگ (batching) کرنے، پائپ کے ذریعے تیزی سے گزرنے کے لیے اپنے ویٹس کو کوانٹائز کرنے، آن چپ رہنے کے لیے اٹینشن کو دوبارہ ترتیب دینے، اور KV کیش کو اس طرح مینیج کرنے سے آئے گا کہ آپ جگہ ختم ہوئے بغیر بڑے بیچز کو فٹ کر سکیں۔

ایک بار جب آپ انفیرنس کو اس نظریے سے دیکھتے ہیں، تو آپٹیمائزیشن ایک میکانکی عمل بن جاتی ہے۔ آپ ماڈل کی ذہانت کے بارے میں ان افسانوں کا پیچھا کرنا چھوڑ دیتے ہیں کہ وہ چیزوں کو سست کر رہی ہے، اور ایسی انجینئرنگ کے فیصلے کرنا شروع کر دیتے ہیں جو اس بنیاد پر ہوں کہ ہارڈ ویئر حقیقت میں کیا فراہم کر سکتا ہے۔ یہی وہ تبدیلی ہے جو اسکیل ہونے والے پروڈکشن سسٹم کو ان سے الگ کرتی ہے جو محض کام کرتے ہیں۔