ضريبة IPC: ضع محرك الاختبار الرجعي خلف مقبس وتخسر 13% — لا يكاد أي منها بسبب المقبس
مقال من سلسلة "اختبارات رجعية بلا أوهام".
📄 تطورت هذه المقالة إلى ورقة بحثية. نواة اختبار رجعي واحدة تعتمد على المسار (path-dependent) تم تطبيقها سطراً بسطر من numba إلى Rust واستُدعيت عبر حدود العملية/اللغة بأربع طرق مختلفة، مع بوابة تكافؤ تُؤكد تطابق PnL لكل توليفة — بالإضافة إلى قياسات معزولة لمنحنى زمن استجابة IPC الخالص، وضريبة التسلسل (serialization)، وتكلفة إطلاق العملية (spawn). اقرأ الورقة على الإنترنت (نسخة تفاعلية + PDF) على ipc-tax.marketmaker.cc، والكود والبيانات على github.com/suenot/ipc-tax.
كل محرك اختبار رجعي يصبح سريعاً يُثير في النهاية نفس النقاش. محركنا وصل إلى هذه النقطة في موعده. سلم السرعة كان قد أخذ للتو مسحاً بـ80 توليفة معلمات من 69.9 ثانية بـpandas إلى حوالي 2 ثانية بـnumba أحادية الخيط، وكانت الرغبة الطبيعية التالية: لماذا التوقف عند JIT بايثون؟ أعد كتابة النواة بلغة Rust. اجعلها خدمة محرك حقيقية — ملف تنفيذي واحد مُترجَم خلف مقبس، قابل للاستدعاء من كل سكريبت بحث، وكل لغة، ومن مُتداول الإنتاج الحي أيضاً. نواة واحدة، حقيقة واحدة، بدون منطق مُكرَّر.
ثم تصل الحجة المضادة، أيضاً في موعدها: في اللحظة التي تغادر فيها العملية، يلتهمك IPC. يجب تسلسل البيانات (serialize)، وشحنها عبر حد فاصل، وفك تسلسلها؛ كل استدعاء يدفع ثمن نداءات نظام (syscalls) وتبديلات سياق (context switches)؛ نواة Rust الجميلة الخاصة بك ستقضي حياتها في انتظار أنبوب (pipe). ابقَ داخل العملية. الجميع يعرف هذا.
تقيس هذه المقالة الشيء الذي يعرفه الجميع، والقياس أكثر إثارة للاهتمام من أي جانب من جانبي الجدال. المعتقد الشائع — "المحرك الأسرع عبر اللغات يخسر أمام numba داخل العملية لأن IPC يقتلك" — يتبين أنه خاطئ بشكل عام وصحيح فقط تحت شروط محددة. عبور الحد مرة واحدة، ببايتات خام، يكلف حوالي 2 مللي ثانية على مهمة مدتها ثانيتان: خطأ تقريب لا أكثر. الضريبة ليست في الحد الفاصل. إنها في كيفية عبوره — والطرق الثلاث التي تُنشر بها خدمات المحركات عادةً في الواقع (واجهة JSON API، استدعاء لكل وحدة عمل، إطلاق عملية لكل استدعاء) كل واحدة منها، بشكل قابل للقياس، جزء من الكارثة التي تتنبأ بها الحكمة الشعبية.
إليك التجربة كاملة منذ البداية. كل ما يلي هو تشريح لكل سطر منها.
| البنية | ما يعبر الحد لكل مسح | الوقت الفعلي | مقابل داخل العملية |
|---|---|---|---|
| numba داخل العملية | لا شيء — استدعاء مباشر | 2.010 ث | 1.00x |
| خادم Rust، كتلي (batched) (مقبس Unix) | رحلة ذهاب وإياب واحدة: السلسلة كاملة + جميع مجموعات المعلمات الـ80 | 2.276 ث | 1.13x |
خادم Rust، كتلي، نواة get_unchecked |
نفس الرحلة الواحدة — نسخة من النواة بدون تحقق من الحدود (انظر الحكم النهائي) | 2.337 ث | 1.16x |
| خادم Rust، ثرثار (chatty) (مقبس Unix) | 80 رحلة ذهاب وإياب: السلسلة تُعاد شحنها لكل توليفة | 2.383 ث | 1.19x |
| إطلاق (spawn) Rust (stdin/stdout) | إطلاق عملية + طلب واحد عبر أنبوب | 2.300 ث | 1.14x |
Apple M2 Max، Python 3.14.6، numpy 2.4.3، numba 0.64.0، rustc 1.94.0 (بناء إصدار (release)، بدون أي صناديق (crates) خارجية). 150,000 شمعة × 80 توليفة، رسم 0.09% لكل رحلة ذهاب وإياب، البذرة (seed) 42؛ سلسلة الإغلاق هي 1,200,000 بايت (1.2 ميجابايت) على السلك. الوسيط لـ10 تشغيلات لكل بنية؛ فروق الحد الأدنى-الحد الأقصى تبقى ضمن ~2%. الخمسة جميعها تُشغّل نفس مسح HMA/HMA3 من نوع التوقف والعكس (stop-and-reverse)، وبوابة تكافؤ تؤكد أن نتائج (PnL، عدد الصفقات) لكل توليفة لكلا نسختي نواة Rust تطابق numba تماماً — بصمة PnL تبلغ −5165.58 عبر 57,029 صفقة، مطابقة بايتاً لبايت لنواة numba في دراسة سلم السرعة على نفس البذرة. نحن نقارن الحدود، وليس التطبيقات.
اقرأ صف البنية الكتلية بعناية، لأنه يحمل الأطروحة كاملة. بنية Rust عبر مقبس أبطأ بـ1.13x من numba داخل العملية — متأخرة بـ266 مللي ثانية على المسح الكامل (مُشتق: 2.276 − 2.010). الرواية الشعبية تقول إن تلك المللي ثواني هي IPC. ليست كذلك. حوالي 2 مللي ثانية من تلك الفجوة هي الحد الفاصل — سلسلة الإغلاق الكاملة (1.2 ميجابايت) شُحنت للداخل، والنتائج شُحنت للخارج، مقيسة مباشرة. الـ~264 مللي ثانية المتبقية هي أن نواة Rust الساذجة لدينا تحسب المسح ببساطة أبطأ بحوالي 13% من نواة numba (مُشتق: 2.276 ث ناقص ~2 مللي ثانية من الحد ≈ 2.274 ث لحساب Rust، مقابل 2.010 ث لـnumba). لغة Rust لم تخسر أمام لغة Python؛ حلقة عددية (scalar) واحدة مُترجَمة بـLLVM خسرت سباق توليد الكود أمام أخرى — ولم نستطع حتى أن نُحمّل الخسارة على المشتبه به الواضح: بناء get_unchecked بدون تحقق من الحدود لنفس النواة جاء غير أسرع (2.337 ث؛ قسم الحكم النهائي يُشرّح هذا). لم يكن للمقبس أي علاقة تقريباً بأي من ذلك.
احتفظ بشطري تلك الجملة معاً. الحد الفاصل شبه مجاني عندما يُعبر بشكل صحيح — و"أعد كتابته بلغة Rust" تشتري لك حد نشر (deployment boundary)، وليس فوزاً حسابياً تلقائياً. كلا الحقيقتين تتعارضان مع الحدس الشائع، وكلاهما موجود في الجدول.
نواة واحدة، لغتان، أربعة حدود
عبء العمل هو عمداً نفس العبء الذي حدده سلم السرعة، بحيث تترسخ الدراستان إحداهما على الأخرى. النواة هي تقاطع HMA/HMA3 — نظام توقف وعكس (stop-and-reverse) على متوسطين متحركين من نوع Hull، سبعة تمريرات متوسط متحرك مرجح لكل توليفة معلمات بالإضافة إلى حلقة أحداث ذات حالة شمعة-تلو-شمعة تحمل مركزاً، وتُسجّل PnL ناقص رسم 0.09% لكل رحلة ذهاب وإياب عند كل تقاطع، ثم تعكس. البيانات هي 150,000 شمعة من حركة براونية هندسية اصطناعية مُبذَرة (seed=42)؛ الشبكة هي 80 طولاً لـHMA موزعة على . المرجع داخل العملية هو درجة numba أحادية الخيط من السلم، أُعيد قياسها لهذه الدراسة: 1.98 ث هناك، 2.010 ث هنا — نفس النواة، نفس الجهاز، مملة بشكل مطمئن.
المحرك عبر اللغات هو نقل سطر-بسطر لنواة numba تلك إلى Rust — نفس الحلقات، نفس معالجة NaN، نفس حساب الرسوم — مُترجَم في وضع الإصدار (release) بدون أي صناديق خارجية، بحيث تبقى التجربة بأكملها خالية من التبعيات وقابلة لإعادة الإنتاج. يتحدث بروتوكولاً ثنائياً بسيطاً عمداً: إطار واحد مسبوق بالطول (length-prefixed) في كل اتجاه، كل شيء بترتيب البايت الصغير (little-endian).
request: [u32 body_len][body]
body: [u8 opcode][u32 n_bars][u32 n_combos]
[n_bars × f64 close][n_combos × 6 × i64 params]
opcode 0 = sweep : reply = [n_combos × f64 pnl][n_combos × i64 trades]
opcode 1 = echo : reply = the close array, verbatim
رمز العملية echo هو مبضع الدراسة: رحلة ذهاب وإياب بحجم يمكن التحكم فيه ولا تحسب أي شيء، بحيث يمكن قياس تكلفة الحد الفاصل الخالصة بمعزل عن غيرها — التسلسل (serialization)، نداءات النظام، العبور عبر المقبس، فك التسلسل، ولا شيء آخر.
خمس بنيات مقيسة — أربعة أنماط للحد الفاصل بالإضافة إلى نسخة واحدة من النواة:
- in_process — استدعاء نواة numba مباشرة. بدون حد فاصل. المرجع.
- rust_batch_unix — خادم Rust دائم على مقبس نطاق Unix. رحلة ذهاب وإياب واحدة تشحن سلسلة الإغلاق كاملة بالإضافة إلى مجموعات المعلمات الـ80؛ Rust يحسب كل توليفة؛ رد واحد يعود. الاستدعاء الكتلي.
- rust_batch_unchecked — نفس الحد الفاصل الكتلي، لكن النواة تفهرس باستخدام
get_unchecked(بدون تحقق من الحدود في المسار الساخن). موجودة لاختبار فرضية محددة حول فجوة الحساب؛ قسم الحكم النهائي ينفقها. - rust_chatty_unix — نفس الخادم، لكن رحلة ذهاب وإياب واحدة لكل توليفة، وسلسلة الـ1.2 ميجابايت تُعاد شحنها في كل مرة. بنية RPC-لكل-وحدة-عمل الساذجة.
- rust_spawn_stdin — إطلاق الملف التنفيذي لكل مسح وتمرير الطلب عبر stdin. نمط "استدعاء محرك CLI من الصدفة (shell out)"؛ يدفع ثمن إنشاء العملية.
وبوابة التكافؤ، التي بدونها لن يعني أي من هذا شيئاً: بعد القياس، يُقارن متجه (PnL، عدد الصفقات) لكل توليفة لكل نسخة من Rust مع نظيره في numba — عدد الصفقات مطابق تماماً، وPnL بدقة مطلقة . التشغيل المُثبَّت يُبلغ all_ok: true لكل من بناء الفهرسة الآمنة وبناء get_unchecked. بصمة التوليفة الأولى — PnL تبلغ −5165.58 نقطة مئوية عبر 57,029 صفقة — تطابق نواة numba في دراسة سلم السرعة رقماً برقم، ما يُرسّخ كلا الورقتين على نفس النواة وبنفس البذرة. النقل عبر اللغات هو بالضبط حيث يحب الانحراف الصامت أن يعيش (رسم يُطبَّق قبل تحويل النسبة المئوية بدلاً من بعده، مقارنة NaN تتفرع بشكل مختلف، خطأ بفارق واحد (off-by-one) في نافذة — نفس نوع الخطأ الذي أظهر تصنيف النظر المستقبلي لدينا أنه يمكن أن يُلفّق نسبة Sharpe تبلغ 15 من الضوضاء). مقياس أداء لمحركين يحسبان أشياء مختلفة ليس مقياس أداء؛ إنه برنامجان غير مرتبطين يتسابقان.
مع إثبات التكافؤ، كل فرق في الجدول أعلاه هو حد فاصل وحساب — لا شيء آخر.
ما الذي يكلفه العبور فعلياً: منحنى echo

لنبدأ بالمبضع. عملية echo تُرسل حمولة من رقماً عشرياً ذهاباً وإياباً عبر خادم Rust — بايثون يبني الإطار، والخادم يُحلّل جميع الأرقام العشرية الـ، يُعيد ترميزها، ويشحنها للخلف. كلا الاتجاهين يدفعان ثمن التسلسل، ونداءات النظام، والعبور عبر المقبس. إليك المنحنى المقيس (وسطاء لـ10 تشغيلات):
| الحمولة (أرقام عشرية) | البايتات في كل اتجاه | ذهاب وإياب |
|---|---|---|
| 1 | 8 | 14.1 ميكروثانية |
| 100 | 800 | 16.4 ميكروثانية |
| 1,000 | 8,000 | 18.1 ميكروثانية |
| 10,000 | 80,000 | 192.5 ميكروثانية |
| 100,000 | 800,000 | 1,367.3 ميكروثانية |
| 150,000 | 1,200,000 | 2,043.4 ميكروثانية |
حقيقتان بنيويتان تعيشان في هذا الجدول.
أولاً، الأرضية. رحلة ذهاب وإياب تحمل لا شيء تقريباً — 8 بايتات — تكلف 14 ميكروثانية. هذا هو الثمن غير القابل للاختزال لـإجراء استدعاء على الإطلاق عبر هذا النقل: نداءا نظام write، ونداءا نظام read، وآلية مقبس النواة، وتنبيهات جدولة الخيوط. لاحظ كم هو مسطح المنحنى على اليسار: من 1 رقم عشري إلى 1,000 رقم عشري، بالكاد تتحرك التكلفة (14.1 ← 18.1 ميكروثانية). تحت حوالي 8 كيلوبايت أنت تدفع ثمن الاستدعاء، وليس البايتات. هذا الرقم — أرضية زمن الاستجابة — هو الثابت الأهم على الإطلاق في الدراسة بأكملها، وسنبني حساب نقطة التعادل عليه أدناه.
ثانياً، الميل. بعد ~10,000 رقم عشري يصبح المنحنى محدوداً بعرض النطاق الترددي (bandwidth-bound) وخطياً تقريباً. سلسلة الـ1.2 ميجابايت الكاملة — 2.4 ميجابايت منقولة إجمالاً، ذهاباً وإياباً، بما في ذلك تحليل كامل وإعادة ترميز لـ150,000 رقم عشري على جانب Rust — تكلف 2,043.4 ميكروثانية. هذا يُحسب كسرعة فعلية ~1.2 جيجابايت/ثانية عبر المكدس الساذج بأكمله (مُشتق: 2.4 ميجابايت / 2.04 مللي ثانية) — مقبس نطاق Unix بإطارات مسبوقة بالطول ومُحلِّل أرقام عشرية بايتاً-بايتاً، بدون أي حيل نسخ صفري (zero-copy)، بدون ذاكرة مشتركة، لا شيء ذكي.
نموذج معقول لعبور واحد، بكلا الثابتين مقيسين:
الآن ضع الرقم الرئيسي في سياقه. المسح الكامل يستغرق 2.010 ثانية داخل العملية. شحن مجموعة بياناته كاملة عبر الحد الفاصل وعودتها يكلف ~2.0 مللي ثانية — حوالي 0.1% من المهمة (مُشتق: 2.0434 مللي ثانية / 2.010 ثانية). إذا عبرت مرة واحدة، ببايتات خام، فالحد الفاصل خطأ تقريب لا أكثر. هذا هو النصف من المعتقد الشائع الذي يموت أولاً: الخوف لم يكن أبداً بشأن شيء رخيص إلى هذا الحد.
جانب Rust من ذلك العبور غير براق بقدر ما يكون كود الأنظمة عادة — مُقتبَس من engine/src/main.rs:
fn read_frame<R: Read>(r: &mut R) -> Option<Vec<u8>> {
let mut len_buf = [0u8; 4];
r.read_exact(&mut len_buf).ok()?;
let len = u32::from_le_bytes(len_buf) as usize;
let mut body = vec![0u8; len];
r.read_exact(&mut body).ok()?;
Some(body)
}
fn write_frame<W: Write>(w: &mut W, body: &[u8]) {
w.write_all(&(body.len() as u32).to_le_bytes()).unwrap();
w.write_all(body).unwrap();
w.flush().unwrap();
}
// the server is a loop: read frame -> compute -> write frame
for stream in listener.incoming() {
serve_stream(stream.unwrap());
}
ملاحظة نطاق صادقة قبل المتابعة: كل أرقام الحد الفاصل في هذه الدراسة هي عبر مقبس نطاق Unix على مضيف واحد. المحرك يتحدث أيضاً TCP (بـTCP_NODELAY)، لكننا لم نقسه؛ TCP عبر loopback يقع أعلى قليلاً من هذه الأرضيات، والقفزة الشبكية الفعلية هي نظام مختلف تماماً — أرضية بالمللي ثانية، وليس الميكروثانية. كل شيء هنا إذاً هو أفضل حالة تقريباً لعبور حد فاصل بهذه الطريقة. ما يجعل الضرائب المقيسة تالياً أكثر إدانة: إنها ما تدفعه فوق ذلك، باختيارك.
ضريبة التسلسل: 1348x لاختيار JSON

هنا يتبين أن المعتقد الشائع حول "عبء IPC" هو تسمية خاطئة. قسنا تكلفة ترميز نفس سلسلة الإغلاق البالغة 150,000 رقماً عشرياً بثلاث طرق — الحمولة نفسها التي تشحنها كل بنية أعلاه:
| الترميز | وقت ترميز 1.2 ميجابايت من الأرقام العشرية | مقابل الخام |
|---|---|---|
بايتات خام (.tobytes()) |
49.1 ميكروثانية | 1.0x |
| pickle | 29.8 ميكروثانية | 0.6x |
JSON (json.dumps(close.tolist())) |
66,243 ميكروثانية | 1348x |
المسار الخام هو نسخ ذاكرة (memcpy) يرتدي زي استدعاء دالة:
def build_request(opcode, close, params):
body = bytes([opcode]) + struct.pack("<II", len(close), len(params))
body += close.astype("<f8").tobytes() # 150,000 floats -> 1.2 MB in 49 µs
body += np.asarray(params, dtype="<i8").reshape(-1).tobytes()
return struct.pack("<I", len(body)) + body # length-prefixed frame
(يأتي pickle أرخص قليلاً حتى من مسارنا الخام لأن astype يدفع ثمن نسخة تحويل نوع البيانات (dtype) حتى عندما يتطابق النوع بالفعل؛ كلاهما من فئة memcpy وكلاهما خطأ تقريب. عائلة الترميز الثنائي ككل تعيش بثلاث مراتب من الحجم أدنى من عائلة النص.)
أما مسار النص فهو ما يشحنه فعلياً كل نشر تقريباً من نوع "لنجعل المحرك خدمة مصغرة (microservice)":
body = json.dumps({"op": "sweep", "close": close.tolist(), "params": params})
ستٌّ وستون مللي ثانية. من أجل الترميز فقط. json.dumps(close.tolist()) يُغلّف كل رقم عشري في كائن بايثون، ثم يُصيّر كل واحد كنص عشري — 150,000 عملية تخصيص كومة (heap) و150,000 تحويل من رقم عشري إلى نص، حيث فعل المسار الخام نسخة كتلة واحدة. وحمولة السلك تتضخم أيضاً (float64 يكلف 8 بايتات بالترميز الثنائي وحوالي ضعفين إلى ثلاثة أضعاف ذلك كنص عشري — ولم نحتسب حتى تكلفة النقل الإضافي).
الآن قسها بالطريقة التي يفعلها نشر حقيقي. تلك الـ66 مللي ثانية هي ترميز واحد، جانب واحد، استدعاء واحد. خدمة JSON تدفع ثمن الترميز وفك الترميز، على كلا جانبي الحد، في كل استدعاء. استدعاء كتلي واحد عبر JSON سيحرق ~3.3% من ميزانية الحساب الكاملة للمسح على ترميز جانب العميل وحده (مُشتق: 66 مللي ثانية / 2.010 ثانية). ضع JSON تحت بنية الثرثرة — استدعاء واحد لكل توليفة، النمط أدناه — وترميز جانب العميل وحده يكلف 80 × 66 مللي ثانية = 5.3 ثانية: أكثر من ضعفين ونصف من المهمة المفيدة بأكملها (مُشتق)، قبل أن يتحرك بايت واحد وقبل أن يُحلّل الخادم أي شيء.
هذه هي "ضريبة IPC" الفعلية التي قاستها معظم الفرق في الإنتاج دون أن تعرف ذلك. لم تكن أبداً اتصالاً بين العمليات (inter-process communication). كانت تسلسلاً نصياً للمصفوفات العددية — 1348x مفروضاً ذاتياً على أرخص مكون في الحد الفاصل. عالم البيانات العمودية (columnar) تعلّم هذا الدرس منذ سنوات، وهو نفس الدرس الذي واجهته دراستنا بين Polars وpandas من جانب خط أنابيب البيانات: صيغ مثل Arrow موجودة بالضبط لكي تعبر بيانات المصفوفات حدود العملية واللغة كبايتات عمودية خام، وليس كنص. إذا كانت خدمة محركك تتحدث JSON لمصفوفات الأسعار، فلن ينقذك أي ضبط للمقبس — البروتوكول هو عنق الزجاجة.
الثرثرة مقابل الكتلية: قانون Fowler، مقيساً

القانون الأول لتصميم الكائنات الموزعة لمارتن فاولر — "لا تُوزّع كائناتك" — يأتي مع نتيجة منطقية صاغها في نفس النفَس: إذا كان لا بد لك من عبور حد فاصل، فيجب أن تكون الواجهة خشنة الحبيبات (coarse-grained)، لأن الاستدعاء عن بُعد يكلف مراتب من الحجم أكثر من استدعاء محلي. كل خبير في الأنظمة الموزعة يُومئ برأسه موافقاً. لا أحد تقريباً لديه رقم لعبء عمله الخاص. إليك رقمنا.
البنيتان الكتلية والثرثارة تُشغّلان نفس الخادم، نفس البروتوكول، نفس البيانات — يختلف فقط تحبب الاستدعاء (call granularity):
srv.call(0, close, params)
[srv.call(0, close, [params[k]]) for k in range(n)]
الكتلية: 2.276 ثانية (1.13x). الثرثارة: 2.383 ثانية (1.19x) — أبطأ بـ107 مللي ثانية (مُشتق: 2.383 − 2.276). لنكون دقيقين حول ما هو هذا الفارق وما ليس هو: منحنى echo يعطي تنبؤاً ساذجاً له — 79 شحنة إضافية للسلسلة الكاملة بحوالي نصف رحلة الذهاب والإياب الكاملة الحمولة البالغة 2,043 ميكروثانية لكل واحدة، حوالي 81 مللي ثانية — وهو ما يقع أقل بحوالي 25% من الـ107 مللي ثانية المقيسة؛ والباقي هو بناء الطلب وتأطيره لكل استدعاء على جانب بايثون، وهو ما لا يشمله تنبؤ echo. على أي حال يصل إلى ~1.4 مللي ثانية لكل عبور إضافي (مُشتق: 107 / 79)؛ الردود ضئيلة الأهمية — 16 بايت لكل توليفة.
قراءتان لتلك الـ107 مللي ثانية، وكلتاهما مهمة.
القراءة المتساهلة: إنها فقط ~4.5% من الوقت الفعلي، وليست كارثة. صحيح — ويستحق فهم لماذا فشلت كارثة الحكمة الشعبية في التجسد هنا. كل استدعاء ثرثار لا يزال يحمل 25,130 ميكروثانية من حساب حقيقي (قيمة توليفة واحدة — تكلفة داخل العملية المقيسة لكل توليفة)، لذا يبقى عبء الحد الفاصل لكل استدعاء البالغ ~1.4 مللي ثانية أقل بمرتبة من الحجم من العمل لكل استدعاء. البنى الثرثارة ليست قاتلة عندما يكون كل استدعاء ثقيلاً حقاً. تصبح قاتلة مع تقلص الدقة الحبيبية — وهذا هو موضوع قسم نقطة التعادل بأكمله.
القراءة المُدينة: هذه الضريبة كانت طوعية بالكامل، وتتناسب مع عدد الاستدعاءات × الحمولة. النمط الثرثار يُعيد شحن مجموعة البيانات في كل استدعاء لسبب واحد فقط: الخدمة عديمة الحالة (stateless)، لذا يجب أن يحمل كل طلب كل السياق. هذا هو الشكل الافتراضي لـ"نقطة نهاية مسح" ساذجة — وهو أساساً شكل كل خدمة REST مصغرة رُسمت يوماً على سبورة بيضاء. خادم ذو حالة (stateful) — يُحمّل السلسلة مرة واحدة، ثم يرسل إطارات معلمات بـ48 بايتاً — من شأنه أن يضع كل استدعاء لكل توليفة قرب طرف الحمولة الصغيرة جداً من منحنى echo: حوالي 16 ميكروثانية لكل استدعاء، وحوالي 1.3 مللي ثانية للـ80 كلها (مُشتق من أرضية echo؛ تحليلي، غير مقيس بشكل منفصل). عقوبة الثرثرة لن تتقلص؛ بل ستختفي. الدرس دقيق: المشكلة ليست في إجراء استدعاءات كثيرة — إنها إعادة شحن الحالة لأن البروتوكول يتظاهر بأن كل استدعاء هو الأول.
حمّل البيانات مسبقاً. اشحن المعلمات. اعبر الحد الفاصل بقصد، لا بالعالم كله في حقيبتك كل مرة.
تكلفة الإطلاق: استئجار المحرك بالاستدعاء

نمط النشر الثالث هو الأقدم: لا خادم على الإطلاق. أطلق الملف التنفيذي للمحرك، مرّر طلباً واحداً عبر stdin، اقرأ الرد من stdout، ودعه يموت. غريزة كل كاتب سكريبتات صدفة، وكل تكامل من نوع "فقط استدعِ CLI من بايثون"، وكل إطار عمل لضبط المعلمات الفائقة مُهيّأ لإطلاق ملف تنفيذي لكل محاولة.
المقيس: 2.300 ثانية (1.14x) — حوالي 24 مللي ثانية فوق الدفعة الكتلية للخادم الدائم (مُشتق: 2.300 − 2.276). تلك الـ24 مللي ثانية تشتري fork/exec، والمُحمِّل الديناميكي، وإعداد الأنبوب، وتفكيك العملية. ولاحظ أن ما يُقاس هنا قريب من الأرضية لهذا النمط: ملف تنفيذي أصلي صغير خالٍ من التبعيات، دافئ في مخبأ الصفحات (page cache). إطلاق أي شيء له بيئة تشغيل (runtime) — JVM، أو مفسّر بايثون مع استيرادات — يكلف أكثر بكثير؛ لم نقس تلك الحالات هنا، لكن الاتجاه ليس موضع شك.
بنية هذه الضريبة هي ما يهم: إنها ثابتة لكل استدعاء، غير مبالية بكمية العمل الذي يحمله الاستدعاء. مُستهلَكة (amortized) عبر مسح كامل بـ80 توليفة، 24 مللي ثانية هي حوالي 1% — ضوضاء. أعد الإطلاق لكل توليفة وسيصبح نفس الثابت 80 × ~24 مللي ثانية ≈ 1.9 ثانية — أساساً المهمة المفيدة بأكملها محروقة على إنشاء العمليات (مُشتق؛ تحليلي). أعد الإطلاق لكل شمعة والحساب لا يستحق حتى الكتابة.
تكلفة ثابتة، دقة حبيبية عالية: اختر واحدة. النمط الذي يدفع ثمن إطلاق يكون عاقلاً فقط عندما يكون الإطلاق نادراً والحمولة خلفه ضخمة — تماماً مثل قياسنا لإطلاق واحد لكل مسح، وعلى النقيض تماماً من الطريقة التي تنتهي بها بنى العمليات الفرعية لكل رمز (symbol) إلى الاستخدام بمجرد أن ينمو عدد الرموز.
حساب نقطة التعادل: الأرضية هي معدل عتبة

كل ما قسناه حتى الآن ينضغط في قاعدة تصميم واحدة، والقاعدة حساب، وليست رأياً.
كل عبور للحد الفاصل يكلف على الأقل أرضية زمن الاستجابة — 14 ميكروثانية هنا، رحلة echo ذهاباً وإياباً بحمولة ضئيلة جداً، وقريبة من أفضل ما يقدمه هذا النقل. تلك الأرضية هي معدل عتبة (hurdle rate): الاستدعاء عبر الحد الفاصل يستحق القيام به فقط إذا تجاوز الحساب الذي يشحنه العتبة بمضاعف مريح. عرّف نسبة الدقة الحبيبية
وحصة الحد الفاصل من وقتك الفعلي هي تقريباً — بالإضافة إلى نقل الحمولة فوق ذلك إذا كان الاستدعاء يحمل بيانات أيضاً.
الآن مرر أرقام المسح عبرها. التكلفة المقيسة داخل العملية لتوليفة واحدة هي 25,130 ميكروثانية. عند دقة حبيبية لكل توليفة:
استدعاءات كل توليفة تقع ~1,795x فوق الأرضية — الحد الفاصل يُطالب بأقل بكثير من عُشر بالمئة لكل استدعاء. هذا هو سبب أن حتى البنية الثرثارة خسرت 107 مللي ثانية فقط: عند دقة حبيبية عبء العمل هذا، كل نمط عبور لا يُعيد شحن البيانات أو يتحدث نصاً يكون مُستهلكاً (amortized) بأمان. استدعاءات على مستوى التوليفة، ومستوى الطية (fold)، ومستوى المسح كلها عميقاً في المنطقة الرخيصة.
الآن انقلب إلى النقيض الآخر. هذا استقراء توضيحي عبر أعباء عمل مختلفة — ليس نسخة من مسحنا، بل شكل عبء عمل موجود فعلاً في الواقع: يُستشار المحرك لكل شمعة. خدمة محرك بأسلوب حي لكل نبضة سعر (tick)؛ تدفق إشارات gRPC لكل شمعة؛ "خادم استراتيجية" يُستطلع مرة لكل شمعة من الـ150,000 شمعة. الحساب المفيد لكل شمعة في هذه النواة هو 25,130 ميكروثانية / 150,000 ≈ 0.17 ميكروثانية (مُشتق) — كل استدعاء سيحمل حوالي 1/84 من تكلفة الحد الفاصل الخاصة به كعمل مفيد (مُشتق: أرضية 14.05 ميكروثانية مقابل 0.168 ميكروثانية من الحساب). المجموع أسوأ مما توحي به النسبة:
— أكثر من مهمة الـ2.010 ثانية داخل العملية بأكملها، تُنفق قبل أن يحسب المحرك البعيد رقماً واحداً، وستبقى 2.1 ثانية حتى لو كان المحرك على الجانب الآخر سريعاً بلا حدود (مُشتق: 150,000 × 14 ميكروثانية). لا ميزة حسابية تنجو من دقة حبيبية بهذه الدقة. وتذكّر أن هذه الأرضية هي مقبس Unix على مضيف واحد؛ اجعل ذلك الاستدعاء لكل شمعة إلى خدمة عبر شبكة وستنمو الأرضية بمرتبتين إلى ثلاث مراتب من الحجم، على 150,000 استدعاء.

معايرة صادقة أخرى، لأن الـ14 ميكروثانية ليست قانوناً فيزيائياً أيضاً — إنها ثمن نقلنا نحن: عميل بايثون، مقبس نواة النظام، نداءات نظام في كلا الاتجاهين. نقل مُصمَّم خصيصاً لنفس الجهاز ينزل أدنى بكثير. ZigBolt — ناقل الرسائل مفتوح المصدر بلغة Zig الخاص بنا لأعباء عمل HFT، مقيس أصلياً على نفس هذا الجهاز — يُنجز رحلة ذهاب وإياب حلقية بالذاكرة المشتركة في حوالي 39 نانوثانية كمتوسط (p50 باتجاه واحد يبلغ 10/20/30 نانوثانية عند رسائل بـ64/256/1024 بايت). هذا أقل بحوالي 360x من أرضية مقبسنا (مُشتق: 14.05 ميكروثانية / 39 نانوثانية). المقارنة هي عمداً بين تفاحة وبرتقالة، ونحن نُشير إلى ذلك: 14 ميكروثانية لدينا هي رحلة ذهاب وإياب لمقبس عميل بايثون، و39 نانوثانية لـZigBolt هي Zig أصلية عبر ذاكرة مشتركة، لذا تخلط الفجوة بين النقل وبيئة التشغيل معاً. اقرأها ليس كسباق بين الاثنين بل باعتبارها المدى الذي يمكن أن تشغله أرضية نفس الجهاز: حوالي ثلاث مراتب من الحجم، يُختار بحسب التنفيذ. هذا هو درس RPC الخفيف القديم (Bershad وآخرون، 1990) بحلة عصرية — عبورات نفس الجهاز يسيطر عليها آلية البروتوكول، وتنهار عندما يُبنى النقل لحالة نفس الجهاز. حساب نقطة التعادل أعلاه لا يتغير شكله؛ العتبة فقط تتحرك. عند أرضية 39 نانوثانية، حتى الدقة الحبيبية لكل شمعة ستتجاوزها (150,000 × 39 نانوثانية ≈ 5.9 مللي ثانية، مُشتق) — وهذا بالضبط كيف يمكن لأنظمة HFT أن تتحمل حدوداً لا تستطيعها خدمة REST.
هذه هي قصة نقطة التعادل كاملة في جملة واحدة: الحد الفاصل لا يهتم بمدى سرعة محركك؛ إنه يُحصّل رسوماً لكل عبور، لذا فإن المتغيرات التي تتحكم بها هي كمية العمل الذي يحمله كل عبور — ومما يتكون هذا العبور. اجمع دفعة لكل مسح و يتجاوز مئة ألف. اجمع دفعة لكل توليفة، — لا يزال جيداً. استدعِ لكل شمعة عبر مقبس، — البنية ميتة قبل أول تحسين، ولا يمكن لأي إعادة كتابة للمحرك، بلغة Rust أو أي شيء آخر، أن تُحييها.
أين يعيش الـ1.13x فعلياً — والحكم النهائي

حان وقت تشريح الفجوة الرئيسية بصدق، لأنها تحمل أكثر نتائج الدراسة مخالفة للحدس.
بنية Rust الكتلية تتأخر عن numba داخل العملية بـ266 مللي ثانية (مُشتق: 2.276 − 2.010). مكونات الحد الفاصل المقيسة: رحلة ذهاب وإياب واحدة بحمولة كاملة عند ~2.0 مللي ثانية، تسلسل خام عند 49 ميكروثانية، ترويسات إطار بحفنة من البايتات — لنُسمِّ فاتورة الحد الفاصل بأكملها ~2 مللي ثانية. أكثر من 99% من الفجوة إذاً ليست الحد الفاصل على الإطلاق. إنها حساب: مُجرَّداً من IPC، يقضي خادم Rust ~2.274 ثانية في أداء المسح الذي يؤديه numba في 2.010 ثانية — نواة Rust الساذجة أبطأ بحوالي 13% في الحساب الخام (مُشتق).
هذا يستحق فقرة لا تتردد، لأن "أعد كتابته بلغة Rust وسيكون أسرع" هو معتقد شائع بقدر "IPC سيقتلك". كلتا النواتين تنتهيان في LLVM — numba يُخفّض شيفرة بايثون البايتية عبره، ورستك (rustc) يُخفّض MIR عبره — وكلاهما على الأرجح يعملان كحلقات عددية (scalar): المجموع الداخلي لـWMA هو اختزال (reduction) لأرقام الفاصلة العائمة، الذي لن يُتّجهه LLVM تلقائياً بدون رخصة إعادة الترابط (reassociation) الخاصة بـfast-math، والتي لا تمنحها إعدادات @njit الافتراضية في numba ولا يطلبها منفذنا (port). لذا فإن الـ13% هي فجوة توليد كود مقيسة بين حلقتين عدديتين مُترجَمتين بـLLVM — وبدلاً من تأكيد سبب، اختبرنا السبب الواضح. المشتبه به الطبيعي هو الفهرسة الآمنة في Rust: حلقة WMA الساخنة تتحقق من الحدود عند كل وصول للمصفوفة، بينما يُترجم 13% حقيقية وقابلة لإعادة الإنتاج (وسطاء عبر 10 تشغيلات، فروق ضمن ~2%)، وحالياً غير معزوة لسبب — فرق ما في سلوك التخصيص، أو بنية الحلقة، أو جدولة التعليمات لن يحسمه سوى التنميط على مستوى التجميع (assembly). الدرس يبقى سليماً: Rust الساذج ليس أسرع تلقائياً من numba جيد، وحد لغوي مُشترى على افتراض فوز حسابي مجاني يمكن أن يصل مع خسارة حسابية مرفقة به. نواة Rust مضبوطة — بمخازن مؤقتة مُخصَّصة مسبقاً، وSIMD صريح، وخيوط عبر التوليفات — قد تقلب الإشارة رغم ذلك. لكن هذا سؤال حسابي، يُحسم بالتنميط وعمل النواة، وسؤال هذه الدراسة هو الحد الفاصل. إجابة الحد الفاصل: عند عبوره مرة واحدة، بالبايتات، يكلف ~0.1%.@njit في numba بدون تحقق من الحدود. لذا بنينا نسخة مُتحقَّقاً من تكافئها من نفس النواة على get_unchecked — بدون أي تحقق من الحدود في أي مكان في المسار الساخن — وقسناها كبنية خامسة. لم تُغلق الفجوة: 2.337 ثانية (1.16x)، أبطأ قليلاً من بناء التحقق من الحدود البالغ 2.276 ثانية. فرضية اختُبرت، فرضية رُفضت. حالة المعرفة الصادقة: الـ
لنجمع الحكم النهائي الكامل، كل بند منه مقيس أعلاه.
خدمة محرك عبر اللغات تفوز عندما تتحقق كل هذه الشروط:
- الميزة الحسابية حقيقية — مقيسة على نواتك أنت، وليست مُفترَضة من سمعة اللغة. (نواتنا كانت −13% إلى أن يثبت العكس — والتفسير "الواضح" الأول لذلك العجز مات في الاختبار.)
- تعبر بشكل خشن الحبيبات — استدعاء واحد لكل مسح أو لكل طية، آلاف المضاعفات فوق أرضية الـ14 ميكروثانية، بالطريقة التي يُظهرها إجمالي الـ1.13x لبنية الدفعة (~0.1% حد فاصل).
- تتحدث ثنائياً — مصفوفات خام مسبوقة بالطول، Arrow، أي شيء من فئة memcpy عند 49 ميكروثانية لكل 1.2 ميجابايت؛ وليس نصاً أبداً عند 66,243 ميكروثانية.
- البيانات مُحمَّلة مسبقاً — خادم ذو حالة يأخذ استدعاءات المعلمات فقط عند الطرف البالغ ~16 ميكروثانية من منحنى echo بدلاً من إعادة شحن ميجابايتات.
تخسر عندما تُنشر بالطريقة التي تُنشر بها خدمات المحركات عادةً:
- خدمة JSON/REST مصغرة — تدفع ضريبة التسلسل بمقدار 1348x في كل استدعاء، بكلا الاتجاهين؛ تحت دقة حبيبية ثرثارة يعني ذلك 5.3 ثانية من الترميز على مهمة مدتها 2 ثانية.
- RPC لكل وحدة عمل — لكل توليفة تكلف 107 مللي ثانية هنا وتنجو فقط لأن كل استدعاء يحمل 25,130 ميكروثانية من الحساب؛ لكل شمعة إنها ~2.1 ثانية من IPC خالص قبل حدوث أي عمل، على مهمة مدتها 2.0 ثانية.
- إطلاق لكل استدعاء — ~24 مللي ثانية من التكلفة الثابتة في كل مرة، غير ضارة مرة واحدة لكل مسح، وقرابة ثانيتين عند الدفع لكل توليفة.
أي بمعنى: البنى التي تفشل ليست غريبة. محرك JSON REST، عملية فرعية لكل رمز (symbol)، gRPC لكل نبضة سعر — هذا إحصاء عادل لكيفية بناء "لنستخرج محرك الاختبار الرجعي كوحدة منفصلة" فعلياً. المعتقد الشائع مُثبَت تجريبياً كوصف للممارسة الشائعة وخاطئ تجريبياً كقانون طبيعي. الحد الفاصل لم يكن أبداً المشكلة. الطرق الافتراضية لعبوره هي المشكلة.
حجة واحدة لصالح الحد الفاصل تستحق جملتها الخاصة، لأنها السبب الذي دفعنا لإجراء هذه الدراسة أصلاً. نواة واحدة مُترجَمة خلف حد فاصل مُصمَّم جيداً يمكنها أن تخدم مسح البحث وحلقة التداول الحي — نفس الملف التنفيذي، نفس الحساب، بت ببت. دراسة تطابق الاختبار الخلفي والتداول الحي لدينا صنّفت كيف تنجرف محركات البحث والإنتاج عن بعضها عندما تكون قاعدتي شيفرة منفصلتين؛ خدمة المحرك هي أقوى علاج بنيوي لذلك الانجراف، وهذه الدراسة تُسعّر العلاج بصدق: عند تنفيذه بشكل صحيح، حوالي 0.1% من الوقت الفعلي وبوابة تكافؤ لإثبات أن لا شيء تغير في النقل. تلك الصفقة — حد عملية مخصص مقابل تطابق نواة واحدة — هي، وفقاً لهذه الأرقام، صفقة رابحة. عند تنفيذها بشكل خاطئ، نفس الفكرة تشحن ضريبة تسلسل بمقدار 1348x إلى الإنتاج مع ركوب PnL الخاص بك فوقها.
الخلاصات
- الحد الفاصل شبه مجاني؛ المعتقد الشائع يفشل أمام القياس. إرسال سلسلة الإغلاق الكاملة (1.2 ميجابايت) ذهاباً وإياباً عبر مقبس Unix — بما في ذلك التحليل الكامل وإعادة الترميز — يكلف 2,043.4 ميكروثانية، حوالي 0.1% من مهمة الـ2.010 ثانية (مُشتق). بنية Rust الكتلية عبر المقبس تصل إلى إجمالي 1.13x، و~99% حتى من تلك الفجوة ليست IPC.
- "أعد كتابته بلغة Rust" ادعاء حسابي — تحقق منه قبل شراء الحد الفاصل. منفذنا (port) بلغة Rust سطراً بسطر يحسب أبطأ بحوالي 13% من نواة numba (مُشتق: 2.274 ثانية مقابل 2.010 ثانية) — فجوة توليد كود قابلة لإعادة الإنتاج بين حلقتين عدديتين مُترجَمتين بـLLVM تبقى غير معزوة لسبب: اختبرنا المشتبه به الواضح ورفضناه، إذ جاء بناء
get_uncheckedالمُتحقَّق من تكافئه بدون تحقق من الحدود غير أسرع (2.337 ثانية مقابل 2.276 ثانية). Rust الساذج ليس أسرع تلقائياً؛ قد تكون نواة مضبوطة كذلك فعلاً — قِس، ثم قرِّر. - الضريبة الحقيقية هي النص. ترميز 150,000 رقم عشري كـJSON يكلف 66,243 ميكروثانية مقابل 49.1 ميكروثانية للترميز الخام — 1348x، تُدفع لكل اتجاه، لكل استدعاء، على كلا الجانبين. نشر JSON ثرثار يحرق 5.3 ثانية من الترميز على مهمة مدتها 2 ثانية (مُشتق). تحدث ثنائياً عبر الحدود: إطارات خام، Arrow — ولا تستخدم أبداً
json.dumpsعلى مصفوفة أسعار. - الثرثرة مقابل الكتلية قابلة للقياس، وعديمية الحالة هي الجاني. استدعاءات كل توليفة التي تُعيد شحن البيانات: 1.19x مقابل 1.13x للدفعة الكتلية (+107 مللي ثانية، مُشتق؛ تنبؤ منحنى echo باتجاه واحد البالغ ~81 مللي ثانية يقع ~25% أقل منها، والباقي هو تأطير لكل استدعاء). خادم ذو حالة مُحمَّل مسبقاً كان سيأخذ نفس الـ80 استدعاء بحوالي 16 ميكروثانية لكل واحد — حوالي 1.3 مللي ثانية إجمالاً (مُشتق من أرضية echo). اشحن المعلمات، لا مجموعة البيانات.
- احترم الأرضية — واعلم أن الأرضية خيار. عبورنا بايثون-عبر-مقبس-Unix يستقر عند أرضية 14 ميكروثانية؛ دقة حبيبية لكل توليفة تتجاوزها بـ~1,795x (25,130 ميكروثانية من الحساب لكل استدعاء) — آمنة. نمط لكل شمعة (نقيض توضيحي عبر أعباء عمل مختلفة: محرك حي لكل نبضة سعر، وليس هذا المسح) كان سيدفع 150,000 × 14 ميكروثانية ≈ 2.1 ثانية من IPC خالص على مهمة مدتها 2.0 ثانية (مُشتق) — ميت عند الوصول حتى مع محرك سريع بلا حدود. الإطلاق لكل استدعاء يضيف تكلفة ثابتة ~24 مللي ثانية (مُشتق). ونقل مُصمَّم خصيصاً بالذاكرة المشتركة مثل ZigBolt يقوم برحلة ذهاب وإياب في ~39 نانوثانية أصلياً على هذا الجهاز — ~360x أقل من أرضية مقبسنا (مُشتق؛ Zig أصلي مقابل عميل بايثون، لذا اقرأها كمدى يمكن أن تشغله الأرضية، وليس سباقاً).
- اعبر مرة واحدة، بالبايتات، والبيانات موجودة بالفعل — والحد الفاصل يشتري لك التطابق مقابل ~0.1%. نواة واحدة تخدم البحث والتداول الحي، محروسة بفحص تكافؤ (PnL −5165.58، 57,029 صفقة، متطابقة عبر اللغات وعبر كلا بنائي Rust)، هي الحالة الصادقة لخدمة محرك. الحالات غير الصادقة — JSON، الثرثرة، الإطلاق لكل استدعاء — هي التي أعطت IPC سمعتها.
التجربة الكاملة — محرك Rust، وبروتوكول السلك، وأدوات القياس (harnesses) الخاصة بـecho والتسلسل، وبوابة التكافؤ، وكل رقم في هذه المقالة قابل لإعادة التوليد من نص واحد حتمي — موجودة في الورقة المرافقة على ipc-tax.marketmaker.cc، مع الكود والبيانات على github.com/suenot/ipc-tax.
المقبس لم يكن أبداً المشكلة. مللي ثانيتان لمجموعة البيانات كاملة، ذهاباً وإياباً — كانت الحكمة الشعبية مخطئة بثلاث مراتب من الحجم، وفي كلا الاتجاهين في آن واحد: متشائمة جداً حول البايتات، ومتسامحة جداً مع النص. اعبر الحد الفاصل وكأنه يكلف شيئاً، ولن يكلف.
Authors
Trading-systems engineer
Trading-systems engineer building bots since 2017: cross-exchange arbitrage (connected up to 30 venues), cointegration-based pairs arbitrage across spot and futures, scalping, news and sentiment-driven strategies, trend algorithms, and portfolio management and balancing algorithms. Also builds sub-millisecond order execution, big-data warehouses, backtesting engines, AI agents, and trading interfaces (incl. open-source profitmaker.cc). Stack: JS/TS, Python, Rust/Zig/Go, DevOps, backend, frontend, architecture.