فاز 19 · درس 52

پیاده‌سازی Experiment Runner برای اجرای امن آزمایش‌ها در Subprocess

نوع
ساختنی
زمان
حدود 90 دقیقه
زبان‌ها
Python
پیش‌نیاز
فاز 19، مسیر الف، درس‌های 20 تا 29
یک محفظه ایزوله شیشه‌ای بر روی میز آزمایشگاهی که داده‌های ورودی را دریافت کرده و پس از سنجش، یک صفحه خروجی ساختاریافته تحویل می‌دهد
فهرست مطالب
  1. اهداف یادگیری
  2. چرا اجرای آزمایش در زیرفرایند؟
  3. ساختار ExperimentSpec
  4. معماری
  5. چرا سقف حافظه را غیرقطعی در نظر می‌گیریم؟
  6. دریافت خروجی‌های stdout و stderr
  7. جدول آزمون مقایسه‌ای سیستماتیک
  8. قطعیت‌پذیری
  9. اسکریپت آزمایش شبیه‌سازی‌شده (Mock)
  10. ساختار نتیجه
  11. راهنمای مطالعه‌ی کدها
  12. جایگاه این درس در مسیر کلی دوره

در سیستم‌های پژوهشی خودکار، مدل‌ها یا کدهای تولیدشده توسط فرآیند خودکار نباید مستقیماً در بستر پردازشی اصلی اجرا شوند. خطای حافظه، کرش ناگهانی یا حلقه‌های بی‌پایان در اسکریپت آزمایش به‌سادگی می‌توانند هماهنگ‌کننده را متوقف کنند و داده‌های پیشین را از بین ببرند.

راهکار پایه‌ای و کارآمد برای حل این چالش، استفاده از زیرفرایندها (Subprocesses) است. با تعریف یک مشخصات نوع‌دار (Typed Spec)، اسکریپت در یک فضای مجزا، با سقف زمانی و نظارت بر مصرف حافظه اجرا می‌شود و نتایج به شکل داده‌های متریک ساختاریافته به سیستم ارزیابی بازگردانده می‌شوند.

هر چرخه‌ی پژوهشی فقط به اندازه‌ی سنجش‌هایش قابل‌اعتماد است. در این درس، رانری (runner) می‌سازیم که یک مشخصات آزمایش (spec) را تحویل می‌گیرد، آن را در یک زیرفرایند (subprocess) ایزوله (sandboxed) اجرا می‌کند و در نهایت یک خروجی ساختاریافته‌ی JSON تحویل ارزیاب می‌دهد تا بتوان به داده‌هایش تکیه کرد.

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

  • تعریف آزمایش در قالب یک مشخصات نوع‌دار (typed spec) تا رانر بتواند آن را برای یک زیرفرایند سریال‌سازی کند.
  • اجرای زیرفرایند با سقف زمانی واقعی (hard wall clock timeout) و سقف حافظه‌ی غیرقطعی (soft memory cap)، و ثبت هر دو مورد به‌عنوان شرایط خاتمه‌ی اجرا.
  • ثبت stdout، stderr و داده‌های متریک ساختاریافته در قالب یک رکورد نتیجه‌ی یکپارچه.
  • ساخت جدول آزمون مقایسه‌ای سیستماتیک (ablation table) که هر بار فقط یک متغیر تنظیمی را روی یک مشخصات پایه‌ی ثابت تغییر می‌دهد.
  • حفظ قطعیت‌پذیری (determinism) تمام نتایج به ازای هر سید (seed)، تا ارزیاب در اجراهای مختلف اعداد کاملاً یکسانی ببیند.

چرا اجرای آزمایش در زیرفرایند؟

یک چرخه‌ی پژوهشی، کدهای غیرقابل‌اعتماد را اجرا می‌کند. فرضیه از یک نمونه‌گیر (sampler) آمده و اسکریپت آزمایش هم از دل همان مسیر بیرون آمده است؛ اگر بخواهیم هر کدام را کدی امن فرض کرده و درون همان فرایند اصلی اجرا کنیم، باید منتظر کرشی باشیم که کل هماهنگ‌کننده (orchestrator) را از کار می‌اندازد. زیرفرایندها ساده‌ترین سازوکار ایزوله‌سازی هستند که زبان در اختیارتان می‌گذارد: یک فرایند (process) مجزا، یک فضای آدرس‌دهی مستقل و یک دستگیره‌ی سیگنال در سمت فرایند والد.

رانری که در اینجا می‌سازیم، یک ساندباکس (sandbox) کامل پیاده‌سازی نمی‌کند؛ خبری از cgroup، فیلتر seccomp یا نگاشت مجدد namespace نیست. چیزی که این رانر دارد، یک سقف زمانی واقعی، یک حلقه‌ی پایش برای بررسی رشد مصرف حافظه، و مسیر توقفی است که با رسیدن به هر یک از این دو حد، فرایند را می‌کشد. این همان قرارداد زمان اجرا (runtime contract) است که ساندباکس‌های پیشرفته‌تر هم بر پایه‌ی آن توسعه می‌یابند. در این درس، این قرارداد را آن‌قدر جمع‌وجور نگه‌داشته‌ایم که بشود در یک نشست آن را خواند و فهمید.

ساختار ExperimentSpec

ExperimentSpec
  spec_id        : str            (stable id, "exp_001")
  hypothesis_id  : int            (link back to the queue from lesson 50)
  script_path    : str            (path to the python script to run)
  config         : dict           (passed to the script as one json arg)
  seed           : int            (deterministic seed for the experiment)
  wall_timeout_s : float          (hard timeout, killed on exceed)
  memory_cap_mb  : int            (soft cap, polled; killed on exceed)
  metric_keys    : list[str]      (which fields the evaluator will read)

اسکریپت روی دیسک قرار دارد؛ رانر پیکربندی را داخل یک فایل موقت می‌نویسد و اسکریپت آن مسیر را می‌خواند. انتظار می‌رود اسکریپت یک سطر JSON روی stdout چاپ کند که کلیدهای آن، ابرمجموعه‌ای از metric_keys باشند. هر چیز دیگری که روی stdout بیاید ثبت می‌شود، ولی پارسرِ متریک‌ها آن را نادیده می‌گیرد.

اجراکننده‌ی آزمایشمهلت زمانی · سقف حافظه · داده‌ی ساختاریافته
EXPERIMENT RUNNERThe runner serialises the spec to a child process and watches two meters: a hard wall-clock timeout and a polled memory cap. Run A stays under both, exits cleanly, and its stdout carries a structured metrics blob into the result record. Run B grows past the cap and takes the kill path; the record says oom instead of pretending a number exists. Same seed, same spec, same numbers on every rerun.اجرای A کامل شد · اجرای B از سقف گذشتExperimentSpecدانه 1234subprocessفضای آدرس مستقلزمان واقعیسقف حافظهSIGKILL · سقف حافظهمتریک‌ها (blob){"ppl": 31.4}exp_001 · ok · 3.1sexp_002 · oom-killنتایج
اجراکننده مشخصات آزمایش را به یک زیرفرایند ارسال می‌کند و دو سنجش را زیر نظر می‌گیرد: مهلت زمانی واقعی قطعی و سقف حافظه‌ی دوره‌ای. اجرای A زیر هر دو حد می‌ماند، با موفقیت خارج می‌شود و خروجی استاندارد آن داده‌های ساختاریافته‌ی متریک‌ها را به ثبت نتایج می‌برد. اجرای B از سقف تجاوز کرده و متوقف می‌شود؛ رکورد به‌جای ارائه‌ی یک عدد ساختگی، خطای oom را ثبت می‌کند. دانه یکسان، مشخصات یکسان و نتایج یکسان در هر اجرای مجدد.

معماری

تجاوز از حدتجاوز از حدExperimentSpecسریال‌سازی پیکربندی در فایل موقتایجاد زیرفرایندپایپ‌های stdout / stderrتایمر زمان واقعیپایشگر حافظهخاتمه دادن فرایندتجزیه آخرین خط jsonنتیجه همراه با متریک‌هاExperimentResult

رانر تنها یک کلاس با یک متد اصلی است. پایشگر (poller) یک ترد (thread) کوچک است که در هر بازه‌ی پایش یک‌بار بیدار می‌شود و در صورت دسترسی، معادل psutil را برای زیرفرایند از سیستم‌فایل proc می‌خواند؛ اگر پلتفرم چنین قابلیتی در اختیارتان نگذارد، به یک عملیات بی‌اثر (no-op) برمی‌گردد.

چرا سقف حافظه را غیرقطعی در نظر می‌گیریم؟

اعمال سقف حافظه‌ی قطعی نیازمند resource.setrlimit است و فقط روی سیستم‌های POSIX کار می‌کند. در این درس رویکردی پرتابل ارائه می‌دهیم: اندازه‌ی حافظه‌ی مقیم فیزیکی (Resident Set Size یا RSS) را از پلتفرم استعلام می‌کنیم و اگر از سقف مدنظر فراتر رفت، زیرفرایند را می‌کشیم. این سقف از آن جهت غیرقطعی است که پایشگر بازه‌ی زمانی غیرصفر دارد؛ بنابراین ممکن است مصرف حافظه‌ی یک فرایند در فاصله‌ی بین دو پایش لحظه‌ای اوج بگیرد و دوباره پایین بیاید. رانر بیشترین RSS مشاهده‌شده را ثبت می‌کند تا ارزیاب ببیند اجرا چقدر به مرز محدودیت نزدیک شده است.

روی سیستم‌هایی که از بازرسی فرایند پشتیبانی نمی‌کنند، پایشگر یک هشدار یک‌باره لاگ می‌کند و غیرفعال می‌شود. سقف زمانی واقعی همچنان اعمال خواهد شد. آزمون‌های این درس هر دو مسیر را پوشش می‌دهند.

دریافت خروجی‌های stdout و stderr

رانر پس از پایان کار، محتوای هر دو لوله (pipe) را کاملاً می‌خواند. خروجی stdout خط‌به‌خط بررسی می‌شود؛ آخرین خطی که فرمت معتبر JSON داشته باشد و همه‌ی metric_keys لازم را پوشش دهد، به‌عنوان داده‌ی نهایی متریک‌ها ثبت می‌شود. سطرهای JSON قبلی در قالب intermediate_metrics در نتیجه نگه داشته می‌شوند؛ ارزیاب می‌تواند از این داده‌ها برای رسم منحنی یادگیری (learning curve) استفاده کند.

خروجی stderr بدون تغییر در نتیجه ثبت می‌شود. رانر روی کدهای خروج غیرصفر هیچ‌وقت اکسپشن صادر نمی‌کند، بلکه کد خروج را مستقیماً در نتیجه می‌نویسد. هر کد خروج غیرصفر با برچسب "crash" ثبت می‌شود، حتی اگر اسکریپت پیش از آن متریک‌ها را چاپ کرده باشد؛ بنابراین ارزیاب به‌طور پیش‌فرض اجراهای ناقص را ناموفق در نظر می‌گیرد.

جدول آزمون مقایسه‌ای سیستماتیک

def ablate(base: ExperimentSpec, knob: str, values: list[Any]) -> list[ExperimentSpec]:
    ...

تابع کمکی با دریافت یک مشخصات پایه و نام یک متغیر تنظیمی، به ازای هر مقدار جدید، یک مشخصات تازه برمی‌گرداند که config[knob] در آن بازنویسی شده است. به هر مشخصات، یک spec_id مشتق‌شده تعلق می‌گیرد (f"{base.spec_id}_{knob}_{value}"). رانر ابزاری به نام AblationRunner ارائه می‌دهد که این موارد را به ترتیب اجرا می‌کند و یک AblationTable خروجی می‌دهد که کلیدهایش همان مقادیر متغیر هستند.

چرا در هر مرحله فقط یک متغیر؟ بررسی تمام حالت‌ها به‌صورت ترکیبی (full factorial sweeps) به‌صورت نمایی منفجر می‌شود و نتایجی تولید می‌کند که ارزیاب نمی‌تواند آن‌ها را تفسیر کند. تغییر یک متغیر در هر مرحله، محوری تمیز برای ارزیاب می‌سازد تا بتواند آن را رسم کند. این درس از بررسی چندمتغیره فقط به‌صورت اجرای مکرر آزمون‌های تک‌متغیره‌ای پشتیبانی می‌کند که توسط فراخواننده ترکیب شده باشند.

قطعیت‌پذیری

هر مشخصات یک سید (seed) با خود دارد. رانر این مقدار را از طریق دیکشنری کانفیگ به اسکریپت می‌فرستد (config["__seed"] = spec.seed). اسکریپت‌های ماک در مسیر code/experiments/ این سید را لحاظ می‌کنند و در طول اجراهای مختلف، دقیقاً متریک‌های یکسانی می‌سازند. ارزیاب درس پنجاه‌وسه به همین خصیصه متکی است؛ بدون قطعیت‌پذیری، ممکن است چیزی که به نظر «افت کارایی» می‌آید صرفاً ناشی از یک مقداردهی اولیه‌ی تصادفی متفاوت باشد.

اسکریپت آزمایش شبیه‌سازی‌شده (Mock)

در این درس یک اسکریپت آزمایش ارائه شده است: code/experiments/sparsity_experiment.py. این یک اسکریپت واقعی است که فایل پیکربندی خود را می‌خواند، اجرای یک آموزش کوچک را با محاسبات تصادفی NumPy شبیه‌سازی می‌کند، و در آخر یک سطر متریک JSON چاپ می‌کند. این اسکریپت متغیر sleep_s را برای سنجش سقف زمانی و متغیر allocate_mb را برای تست پایشگر حافظه در نظر می‌گیرد.

این شبیه‌سازی در واقع چیزی را آموزش نمی‌دهد؛ صرفاً یک محاسبه‌ی عددی است که ساختار حلقه‌ی آموزش را شبیه‌سازی می‌کند: یک منحنی تابع زیان (loss)، پرپلکسیتی (perplexity) نهایی و زمان سپری‌شده‌ی واقعی. هدف اصلی این درس خودِ رانر است، نه شبیه‌سازی. یک اسکریپت آزمایش واقعی، یک مدل هوش مصنوعی را ایمپورت می‌کند.

ساختار نتیجه

ExperimentResult
  spec_id              : str
  hypothesis_id        : int
  exit_code            : int
  terminal             : "ok" | "timeout" | "oom" | "crash"
  wall_time_s          : float
  peak_rss_mb          : float | None
  metrics              : dict
  intermediate_metrics : list[dict]
  stdout_tail          : str
  stderr_tail          : str

ارزیاب ابتدا دو فیلد metrics و terminal را می‌خواند. اگر فیلد terminal چیزی جز "ok" باشد، آزمایش به‌عنوان اجرای ناموفق در نظر گرفته می‌شود و حکم ارزیاب خودکار صادر خواهد شد. در غیر این صورت، متریک‌ها برای ارزیابی به آزمون معناداری آماری فرستاده می‌شوند.

راهنمای مطالعه‌ی کدها

فایل code/main.py شامل کلاس‌های ExperimentSpec، ExperimentResult، ExperimentRunner، AblationRunner و یک دموی قطعی است. مدیریت زیرفرایندها درون یک کلاس خلاصه شده، پایشگر حافظه یک ترد کوچک است، و تابع کمکی آزمون مقایسه‌ای هم یک تابع تکی است.

فایل code/experiments/sparsity_experiment.py همان آزمایش شبیه‌سازی‌شده‌ای است که در آزمون‌ها استفاده می‌شود؛ این اسکریپت مسیر فایل کانفیگ خود را از argv می‌خواند و پس از اتمام کار، یک خط خروجی متریک JSON می‌نویسد.

فایل code/tests/test_runner.py مسیر اجرای موفق، مسیر تایم‌اوت، مسیر کرش، جدول آزمون مقایسه‌ای و بررسی قطعیت‌پذیری نتایج در دو اجرای متوالی را پوشش می‌دهد.

جایگاه این درس در مسیر کلی دوره

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

خودآزمایی

به سؤال‌ها جواب بدهید تا ببینید چقدر از درس در ذهنتان مانده است.

  1. پیش از درسچرا اجراکننده به‌جای ایمپورت کردن اسکریپت آزمایش درون همان فرایند، یک subprocess مجزا ایجاد می‌کند؟
  2. پیش از درسچرا سقف حافظه به‌صورت «نرم» توصیف شده است؟
  3. حین درسهنگامی که stdout متریک‌ها را دارد اما کد خروج غیرصفر است، اجراکننده چه وضعیتی برمی‌گرداند؟
  4. حین درساجراکننده چگونه داده‌های ساختاریافته‌ی نهایی متریک‌ها را در stdout شناسایی می‌کند؟
  5. حین درسچرا ابزار آزمون مقایسه‌ای سیستماتیک به‌جای فاکتوریل کامل، پارامترها را تک‌به‌تک پیمایش می‌کند؟
  6. حین درسارسال spec.seed به درون config['__seed'] چه تضمینی برای ارزیاب فراهم می‌کند؟

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

چرا به جای اجرای مستقیم کد از subprocess استفاده می‌شود؟

اسکریپت آزمایش ممکن است کدهای غیرقابل‌اعتماد، ناپایدار یا سنگین داشته باشد؛ اجرای آن در فرایندی مجزا مانع از کرش کردن کل برنامه هماهنگ‌کننده می‌شود.

چرا محدودیت حافظه در این پیاده‌سازی به‌صورت غیرقطعی (soft cap) است؟

چون تعیین سقف قطعی از طریق resource.setrlimit به سیستم‌های POSIX محدود است؛ در روش ارائه‌شده میزان حافظه در بازه‌های منظم پایش می‌شود تا کد روی سیستم‌های مختلف اجراپذیر و پرتابل باشد.

خروجی اسکریپت چگونه خوانده و استخراج می‌شود؟

خروجی خط‌به‌خط بررسی می‌شود و آخرین خطی که فرمت معتبر JSON داشته و شامل کلیدهای الزامی متریک باشد استخراج می‌شود؛ خروجی‌های استاندارد دیگر نیز بدون توقف برنامه برای تحلیل و لاگ ثبت می‌شوند.

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

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