আপনি যদি কখনও কোনো LLM-কে একটি দীর্ঘ উত্তর তৈরি করতে দেখে থাকেন এবং প্রাথমিক প্রম্পট ফ্ল্যাশের পর কেন এটি ধীর হয়ে যায় তা নিয়ে ভাবেন, তবে আপনি বাস্তবে একটি হার্ডওয়্যার বটleneck (bottleneck) বা প্রতিবন্ধকতা প্রত্যক্ষ করছেন। বেশিরভাগ ডেভেলপার তাদের Python কোড, ফ্রেমওয়ার্ক বা মডেলের বিশাল আকারের জন্য দোষারোপ করেন। তারা ফাংশন প্রোফাইল করেন, অপ্টিমাইজার পরিবর্তন করেন এবং প্রি-প্রসেসিং থেকে মিলিসেকেন্ড কমিয়ে আনার চেষ্টা করেন। এর কোনোটিই আসল সমস্যা সমাধান করে না। গতির সীমাবদ্ধতা আপনার সফটওয়্যারে নয়; এটি সিলিকনে।
প্রতিটি লার্জ ল্যাঙ্গুয়েজ মডেল ইনফারেন্স (inference) কাজ আপনার সার্ভারে থাকা GPU-এর দুটি ভৌত বৈশিষ্ট্যের ওপর নির্ভর করে: এটি কত দ্রুত সংখ্যা গণনা করতে পারে এবং গণনা করার জন্য সেই সংখ্যাগুলোকে কত দ্রুত সঠিক অবস্থানে নিয়ে আসতে পারে।
গণিত সস্তা। ডেটা স্থানান্তর করা দামী।
GPU মার্কেটিং 'কম্পিউট' (compute) নিয়ে কথা বলতে ভালোবাসে। প্রতি সেকেন্ডে ট্রিলিয়ন ট্রিলিয়ন ফ্লোটিং-পয়েন্ট অপারেশন। সংখ্যাগুলো বিস্ময়কর। কিন্তু কম্পিউট হলো গল্পের অর্ধেক মাত্র। বাকি অর্ধেক হলো মেমরি ব্যান্ডউইথ (memory bandwidth), অর্থাৎ যে হারে ডেটা হাই-ব্যান্ডউইথ মেমরি থেকে কম্পিউট কোরগুলোতে পৌঁছায় যেখানে প্রকৃত গাণিতিক কাজ সম্পন্ন হয়।
একটি LLM এই দুটি লিঙ্কের মধ্যে যেটি দুর্বল, তার চেয়ে দ্রুত চলতে পারে না। কল্পনা করুন একটি বাণিজ্যিক রান্নাঘর যেখানে বিশজন মাস্টার শেফ আছেন। ওভেনগুলো গরম, ছুরিগুলো ধারালো এবং প্রতিটি রাঁধুনি প্রস্তুত। কিন্তু সবজি বা কাঁচামাল সাইকেল দিয়ে আসছে, প্রতিবার মাত্র একটি করে ঝুড়ি। রান্নাঘরটি থমকে যায়। আরও শেফ যোগ করলে এটি ঠিক হবে না। আরও দ্রুত ওভেন কিনলেও এটি ঠিক হবে না। এখানে মূল বাধা বা বটleneck হলো রাস্তা।
আধুনিক ডেটাসেন্টার GPU-তে অ্যারিথমেটিক ইউনিটগুলো এতই শক্তিশালী যে তারা প্রায়শই তাদের গণনা শেষ করে বসে থাকে এবং মেমরি থেকে weights এবং activations আসার অপেক্ষায় সাইকেল নষ্ট করতে থাকে। এই ভারসাম্যহীনতা আপনার কোডের কোনো বাগ (bug) নয়। এটি চিপ তৈরির ভৌত বাস্তবতা। মেমরি ব্যান্ডউইথ কাঁচা কম্পিউটের (raw compute) সাথে তাল মেলাতে পারেনি, এবং LLM-এর ক্ষেত্রে এই ভারসাম্যহীনতা বিশেষভাবে প্রকট কারণ তাদের প্রতিটি ফরওয়ার্ড পাসের (forward pass) জন্য প্রতিটি আউটপুট টোকেনের জন্য প্রতিটি প্যারামিটার স্পর্শ করতে হয়।
কেন প্রম্পট দ্রুত মনে হয় এবং জেনারেশন ধীর মনে হয়
LLM ইনফারেন্স দুটি স্বতন্ত্র পর্যায়ে বিভক্ত, এবং তারা হার্ডওয়্যারের ওপর সম্পূর্ণ ভিন্নভাবে চাপ সৃষ্টি করে।
Prefill ঘটে যখন আপনার প্রম্পটটি প্রথম মডেলের কাছে পৌঁছায়। সমস্ত টোকেন একসাথে আসে। GPU বড় আকারের matrix-matrix multiplication ব্যবহার করে সেগুলোকে সমান্তরালভাবে (parallel) প্রসেস করতে পারে। হাজার হাজার অ্যারিথমেটিক ইউনিট একসাথে কাজ করে এবং কাজের চাপ বা ওয়ার্কলোড ঘন থাকে। এই পর্যায়টি হলো compute-bound। শুরুতে আপনি যে হঠাৎ গতির বিস্ফোরণ দেখেন? সেটি হলো GPU ঠিক সেই কাজটিই করছে যার জন্য এটি তৈরি করা হয়েছে।
Decode হলো সেই পর্যায় যেখানে পরিস্থিতি কঠিন হয়ে ওঠে। যখন মডেলটি পরবর্তী টোকেন তৈরি করে, তখন এটি একটি করে টোকেন তৈরি করে। এই পর্যায়টি matrix-vector operations-এর ওপর নির্ভর করে, যা GPU-এর সমান্তরাল ক্ষমতার মাত্র একটি ক্ষুদ্র অংশ ব্যবহার করে। আরও খারাপ বিষয় হলো, প্রতিটি নতুন টোকেনের জন্য GPU-কে মেমরি থেকে সম্পূর্ণ মডেল weights পুনরায় লোড করতে হয়। অ্যারিথমেটিক ইউনিটগুলো কাজ করতে চায়, কিন্তু পরিবর্তে তারা অপেক্ষা করে। Decode হলো memory-bound। GPU কার্যত একটি দামী ট্রাফিক কন্ট্রোলার হিসেবে কাজ করে, গাণিতিক ইঞ্জিনগুলো যখন ঠান্ডা হয়ে বসে থাকে তখন মেমরি বাসের মাধ্যমে প্যারামিটারগুলোকে এদিক-ওদিক নিয়ে বেড়ায়। এই কারণেই প্রম্পট বিশ্লেষণ তাৎক্ষণিক মনে হলেও একটি ১০০ শব্দের উত্তরের জন্য দশ সেকেন্ড পর্যন্ত সময় লাগতে পারে।
KV cache বিষয়টিকে আরও আকর্ষণীয় করে তোলে। Decode করার সময়, মডেলটি প্রতিটি পূর্ববর্তী টোকেনের জন্য key এবং value tensors সংরক্ষণ করে যাতে এটিকে নতুন করে attention গণনা করতে না হয়। সেই ক্যাশ সিকোয়েন্সের দৈর্ঘ্যের সাথে সাথে বৃদ্ধি পায়। এটি মেমরিতেও থাকে। তাই এখন GPU কেবল weights রিলোড করছে না; এটি প্রতিটি ফরওয়ার্ড পাসে একটি ক্রমাগত বর্ধনশীল ক্যাশ পড়ছে এবং লিখছে। কম্পিউট কোরগুলো খুব একটা পরিশ্রম করছে না, অথচ মেমরি বাস তাদের উভয়ের জন্যই ঘামছে।
মেমরি ওয়ালের (Memory Wall) বিরুদ্ধে লড়াই
ইঞ্জিনিয়াররা ডেটা চলাচলের পরিমাণ কমাতে, বা অন্তত ডেটা স্থানান্তরের খরচ ভাগ করে নিতে কিছু কৌশলের ভাণ্ডার তৈরি করেছেন।
Batching হলো সবচেয়ে সহজ পদ্ধতি। যদি একজন ব্যবহারকারীর অনুরোধ মেমরি থেকে সম্পূর্ণ weight লোড করতে বাধ্য করে, তবে একসাথে আট বা ষোলটি অনুরোধ প্রসেস করলে GPU সেই লোডটিকে সবগুলোর মধ্যে ভাগ করে নিতে পারে। Weights একবার পড়া হয় এবং ব্যাচের প্রতিটি সিকোয়েন্সের জন্য পুনরায় ব্যবহার করা হয়। প্রোডাকশনে, উন্নত শিডিউলিং সিস্টেমগুলো ডায়নামিকভাবে অনুরোধগুলোকে গ্রুপ করে, যাকে কখনও কখনও continuous বা in-flight batching বলা হয়, যাতে GPU খুব কমই থেমে থাকে। এটি একই রুটে একটি বাস এবং ১৬টি আলাদা গাড়ির মধ্যে পার্থক্যের মতো।
Quantization সরাসরি ব্যান্ডউইডথ সমস্যার মোকাবিলা করে। মডেলের ওয়েটগুলো (weights) সাধারণত ১৬-বিট ফ্লোটিং-পয়েন্ট ফরম্যাটে সংরক্ষিত থাকে। সেগুলোকে ৮-বিট বা এমনকি ৪-বিট ইন্টিজারে সংকুচিত করার মাধ্যমে, আপনি বাসের মাধ্যমে প্রবাহিত ডেটার পরিমাণ আক্ষরিক অর্থেই অর্ধেক বা তার বেশি কমিয়ে দিতে পারেন। মডেলটির একটি সুসংগত আউটপুট তৈরির জন্য যথেষ্ট প্রিসিশন (precision) প্রয়োজন, তবে আধুনিক পোস্ট-ট্রেনিং কোয়ান্টাইজেশন পদ্ধতি গুণমান নষ্ট না করেই একটি মডেলের মেমরি ফুটপ্রিন্ট নাটকীয়ভাবে কমিয়ে আনতে পারে। ডেটা প্রবাহ কম হওয়ার অর্থ হলো মেমরি কন্ট্রোলারের জন্য অপেক্ষার সময়ও কমে যাওয়া।
FlashAttention অ্যাটেনশন মেকানিজমকে এমনভাবে পুনর্গঠিত করে যাতে মধ্যবর্তী ফলাফলগুলো GPU-এর দ্রুত অন-চিপ মেমরির ভেতরেই থাকে। স্ট্যান্ডার্ড অ্যাটেনশনে বড় অ্যাটেনশন ম্যাট্রিক্সগুলোকে ধীরগতির এক্সটার্নাল মেমরিতে লিখতে হতো এবং পরে সেগুলো আবার পড়তে হতো। FlashAttention গণনাকে ছোট ছোট টাইলে (tiles) বিভক্ত করে যা SRAM-এ জায়গা করে নেয়, অন-চিপেই softmax এবং scaling ধাপগুলো সম্পন্ন করে এবং শুধুমাত্র চূড়ান্ত আউটপুটগুলো হাই-ব্যান্ডউইডথ মেমরিতে লেখে। এটি মেইন মেমরিতে বারবার যাতায়াত কমানোর বিনিময়ে সামান্য অতিরিক্ত কম্পিউটেশন গ্রহণ করে, যা প্রায় সবসময়ই একটি লাভজনক সিদ্ধান্ত।
PagedAttention মেমরির অপচয়ের ভিন্ন একটি সমস্যার সমাধান করে। ডিকোডিংয়ের সময় KV ক্যাশ (cache) অপ্রত্যাশিতভাবে বৃদ্ধি পায়। প্রথাগত সিস্টেমগুলো প্রতিটি সিকোয়েন্সের জন্য মেমরির নির্দিষ্ট এবং অবিচ্ছিন্ন (contiguous) অংশ বরাদ্দ করে, যার ফলে কিছু সিকোয়েন্স দ্রুত শেষ হয়ে গেলে এবং অন্যগুলো বড় হয়ে গেলে মেমরিতে বড় বড় ফাঁকা জায়গা থেকে যায়। PagedAttention অপারেটিং সিস্টেম থেকে ভার্চুয়াল মেমরির ধারণাটি ধার করে। এটি KV ক্যাশ এন্ট্রিগুলোকে নির্দিষ্ট আকারের ব্লকে সংরক্ষণ করে যা নন-কন্টিনিউয়াসলি (non-contiguously) বরাদ্দ করা যায় এবং একটি ইনডাইরেকশন টেবিলের মাধ্যমে ম্যাপ করা যায়। এটি সংরক্ষিত কিন্তু অর্ধেক খালি বাফারের ভেতরে মেমরি অলস বসে থাকাকে রোধ করে এবং বড় ব্যাচ সাইজ ব্যবহারের সুযোগ দেয়, যা ফ্র্যাগমেন্টেশন ওভারহেডের পরিবর্তে মেমরি বাসকে প্রয়োজনীয় কাজে ব্যস্ত রেখে সামগ্রিক থ্রুপুট (throughput) উন্নত করে।
প্রশ্নটি পরিবর্তন করুন
যখন ল্যাটেন্সি (latency) বেড়ে যায়, তখন অনেক টিমই প্রশ্ন করে যে তাদের কি একটি ছোট মডেলে চলে যাওয়া উচিত নাকি তাদের ইনফারেন্স সার্ভারটি পুনরায় লেখা উচিত। এই প্রশ্নগুলো গুরুত্বপূর্ণ, তবে সেগুলো গৌণ। প্রথম প্রশ্নটি হওয়া উচিত হার্ডওয়্যার সম্পর্কে। আপনার GPU কি আসলেই কম্পিউটেশনে ব্যস্ত, নাকি এটি ডেটার জন্য ক্ষুধার্ত?
আপনার ইউটিলাইজেশন মেট্রিক্সগুলো দেখুন। GPU কম্পিউট অকুপেন্সি (occupancy)-র পাশাপাশি মেমরি ব্যান্ডউইডথ স্যাচুরেশন প্রোফাইল করুন। আপনি যদি ডিকোডিংয়ের সময় উচ্চ মেমরি কনটেনশন (contention) এবং নিম্ন অ্যারিথমেটিক ইনটেনসিটি (arithmetic intensity) দেখতে পান, তবে আপনার সমস্যাটি মডেল আর্কিটেকচারের নয়। এটি একটি ফিজিক্সের সমস্যা। এর সমাধান আরও পরিচ্ছন্ন পাইথন কোড থেকে আসবে না। এটি আসবে আরও আগ্রাসীভাবে ব্যাচিং করার মাধ্যমে, পাইপের মধ্য দিয়ে দ্রুত ডেটা পাঠানোর জন্য ওয়েটগুলোকে কোয়ান্টাইজ করার মাধ্যমে, অন-চিপে রাখার জন্য অ্যাটেনশন পুনর্গঠন করার মাধ্যমে এবং KV ক্যাশ এমনভাবে পরিচালনা করার মাধ্যমে যাতে জায়গা শেষ না হয়েও বড় ব্যাচ ফিট করা যায়।
একবার আপনি এই দৃষ্টিভঙ্গি দিয়ে ইনফারেন্সকে দেখতে শুরু করলে, অপ্টিমাইজেশন একটি যান্ত্রিক প্রক্রিয়া হয়ে দাঁড়াবে। আপনি মডেলের বুদ্ধিমত্তা সবকিছু ধীর করে দিচ্ছে—এমন ভ্রান্ত ধারণা পেছনে ফেলে হার্ডওয়্যার আসলে যা দিতে সক্ষম তার ওপর ভিত্তি করে ইঞ্জিনিয়ারিং সিদ্ধান্ত নিতে শুরু করবেন। এটাই সেই পরিবর্তন যা স্কেলেবল প্রোডাকশন সিস্টেমকে কেবল কাজ করে এমন সিস্টেম থেকে আলাদা করে।
