خطای کمبود حافظهای که پشت یک برچسب پیشرفت پنهان بود
یک شب در اواخر سپتامبر، عامل هوش مصنوعی در فضای کاری یک کاربر وسط یک نوبت با خطای کمبود حافظه کرش کرد. مظنونهای بدیهی — زیادی زیرعاملهای همزمان، یا زمینهای که خیلی بزرگ شده بود — هر دو اشتباه بودند. علت واقعی یک برچسب وضعیت بود: وقتی مدل یک دستور شل بزرگ را چند کاراکتر در میان میفرستاد، ما کل دستور تا آن لحظه را بهعنوان برچسب پیشرفت، با هر تکه، دوباره میفرستادیم. یک دستور ۱۲۴ کیلوبایتی تبدیل شد به حدود ۴۱٬۰۰۰ رویداد و تقریباً ۶۹۰ مگابایت رشتهی در صف.
عامل چطور کارش را استریم میکند
وقتی روی Mythex میسازید، عامل داخل ساندباکس ابری خصوصی پروژهی شما اجرا میشود. وقتی مدل مینویسد، خروجیاش به مرورگر شما استریم میشود: متن کلمهبهکلمه ظاهر میشود، و وقتی ابزاری را اجرا میکند — مثلاً یک دستور شل که فایلی مینویسد — برچسب آن زنده بهروز میشود، تا بدانید چه کاری میکند.
مدل در تکههای کوچک استریم میکند. با مدلی که ما اجرا میکردیم، این تکهها اغلب فقط سه کاراکتر بودند.
فرضیههای اول اشتباه بودند
کرش شبیه یک مشکل مقیاسپذیری بود، پس از همانجا شروع کردیم.
- زیرعاملهای موازی؟ نوبت فقط دو تا استفاده کرده بود، که هرکدام حدود ۱٫۵ مگابایت وضعیت نگه میداشتند. کافی نبود.
- ردیابیهای نگهداشتهشده؟ ردیابی ما یک درخت اجرا را در حافظه نگه میداشت، اما نزدیک اندازهی heap در لحظهی کرش هم نبود.
هر دو داستانی باورپذیر بودند و باور کردنشان ارزان بود. هیچکدام کرش را بازتولید نکردند. درسی که مدام دوباره یاد میگیریم: یک فرضیه تا وقتی شکست را بازتولید نکند، علت نیست.
بازتولیدش واقعاً
یک ساندباکس موقتی ساختیم با همان محدودیتهای حافظهی production و یک مدل ساختگی که یک فایل بزرگ را در تکههای واقعگرایانهی سهکاراکتری استریم میکرد. خوانندهی آن سر دیگر سوکت را هم کند کردیم، مثل یک شبکهی واقعی.
همین کار کرد. heap بالا رفت و پروسه با همان خطا مرد.
مکانیسم، وقتی توانستیم تماشایش کنیم، ساده بود. وقتی مدل یک دستور بزرگ را مینوشت — در این مورد یک heredoc که یک فایل ۱۲۴ کیلوبایتی مینوشت — کد استریم ما برچسب ابزار را با هر تکه بهروز میکرد. برچسب آن سه کاراکتر جدید نبود؛ کل دستور تا آن لحظه بود. پس رویدادها رشد میکردند: ۳ کاراکتر، بعد ۶، بعد ۹، … تا ۱۲۴ کیلوبایت، حدود ۴۱٬۰۰۰ بار. سوکت نمیتوانست به سرعت تولیدشان بفرستد، پس در بافر نوشتنش بهصورت رشته انباشته میشدند — حدود ۶۹۰ مگابایتشان.
رفع
- برچسب را محدود کنید. برچسب وضعیت فقط باید بگوید چه اتفاقی میافتد. حالا به ۲۰۰ کاراکتر محدود است و تعداد دفعات اسکن مجدد دستور ناقص را کنترل میکنیم.
- الحاق درجهدوم را متوقف کنید. پیوستن تکههای استریمشده با الحاق مکرر رشته برای خروجیهای بزرگ O(n²) است. به یک روش مبتنی بر تکه منتقل شدیم که بهصورت خطی الحاق میکند.
سپس دنبال هر چیز دیگری گشتیم که در سیل خروجی بیحد رشد کند، و آنها را محدود کردیم:
- خروجی زندهی دستورات که در چت نشان داده میشود، دستهبندی شده و فقط ۸٬۰۰۰ کاراکتر آخر نگه داشته میشود.
- هر خط رویداد حداکثر ۴ مگابایت است.
- بافر بازپخش که مرورگر برای گرفتن عقبافتادهها پس از اتصال مجدد استفاده میکند، به ۸ مگابایت برای هر پروژه محدود است.
- checkpointهای قدیمی با پیشرفت نوبت حذف میشوند، و تازهترین وضعیت و نقاط لنگری که نوبتهای قبلی به آنها نیاز دارند نگه داشته میشوند.
آزمودن سیلها، صادقانه
دو چیز آزمودن این را سختتر از حد لازم کرد:
- مدلهای واقعی همکاری نمیکنند. از یک مدل بخواهید فایل غولپیکری چاپ کند، معمولاً امتناع میکند، خلاصه میکند، یا معقولتر مینویسدش. برای آزمودن سیلها به یک مدل اسکریپتشده نیاز دارید که دقیقاً کاری را بکند که گفتهاید، و از کد واقعی عامل عبور کند.
- اسکریپتها باید مثل مدل رفتار کنند. مدل اسکریپتشدهی اول ما در هر پاسخ همان شناسهی پاسخ را دوباره استفاده میکرد، پس چارچوب عامل هر پاسخ را جایگزین پاسخ قبلی میدید و نوبتها را زودتر تمام میکرد. شبیه باگ تازهسازی رابط به نظر میرسید. نبود — خود هارنس آزمایش به ما دروغ میگفت. همچنین مجبور شدیم حد واقعی زمینهی مدل را در اسکریپت اعمال کنیم، وگرنه عدد حافظه غیرواقعی بود.
آنچه از آن برداشتیم
- رابطهای استریمی اشتباهات کوچک را ضرب میکنند. هر چیزی که «به ازای هر تکه» فرستاده شود دهها هزار بار فرستاده میشود. تغییرات را بفرستید، یا بار (payload) را محدود کنید.
- هر بافری را که از یک مرز کند عبور میکند محدود کنید. سوکتها، لاگهای بازپخش و checkpointها همه به سقف نیاز دارند.
- پیش از رفع، بازتولید کنید. دو توضیح اول ما درست به نظر میرسیدند و اشتباه بودند.
اگر اخیراً یک فایل خیلی بلند را در چت Mythex دیدهاید که عامل بدون مکث ظاهر شده، این بخشی از دلیلش است.