كيف حوّلت مشكلة 100 ميلي ثانية إلى استعلام بالميكروثانية عبر هندسة أداء بمساعدة الذكاء الاصطناعي

مشكلة يانصيب مجرية حقيقية، وحلّان كاملان بكود Go الفعلي على الطاولة، والقصة الصادقة لكيف ساعد الذكاء الاصطناعي في خفض زمن الاستجابة من ميزانية 100 ميلي ثانية إلى نحو 1.5 ميكروثانية — ليس بأمر واحد، بل بتحسين منضبط خطوة بخطوة.
من أنفع ما تعلمته في العمل مع الذكاء الاصطناعي على مشاكل الأداء ألا أسأل:
«هل يمكنك تحسين هذا الكود؟»
بل أن أسأل:
«ما العمل الذي يمكن نقله خارج المسار الحرج؟»
هذا السؤال الواحد كثيرًا ما يعيد كتابة المعمارية بأكملها. هذا المقال عن مشكلة بدأت بسطر عادي في مواصفة — أقصى زمن استجابة مقبول للاستعلام: 100 ميلي ثانية — وانتهت في مكان لم أتوقعه.
كانت المهمة الإبلاغ عن أعداد الفائزين في نظام شبيه باليانصيب يصل عدد لاعبيه إلى 10 ملايين لاعب. وجاءت النتائج النهائية هكذا:
100 ms budget
↓
12 ms optimized parallel scan (ordinary systems engineering)
↓
1.5 µs (0.0015 ms) indexed O(1) query (AI-assisted redesign)
بصياغة أخرى: كانت الميزانية 100 ميلي ثانية = 100,000,000 نانوثانية. وجاء زمن الاستعلام النهائي نحو 1,500 نانوثانية — أي أدنى من الميزانية بنحو 66,000 مرة. لكن الرقم النهائي ليس الجزء المشوّق. المشوّق هو كيف يستطيع الذكاء الاصطناعي المساعدة في تفكيك مشكلة إلى لبنات معروفة تتراكب معًا في شيء أسرع بشكل درامي — وكم من التوجيه البشري تطلّب ذلك.
قبل أن نمضي، يجدر تثبيت المقياس الذي نتحدث عنه، لأن الفرق بين الميلي ثانية والميكروثانية هو الفرق بين «سريع بما يكفي» و«مجاني عمليًا».
والعلاقة بين الوحدات تستحق الحفظ — فعمل الأداء يحيا ويموت على معرفة الوحدة التي أنت فيها:
UNIT SYMBOL SECONDS vs. NEXT UNIT EVERYDAY FEEL
─────────────────────────────────────────────────────────────────────────
second s 1 — a slow page load
millisecond ms 0.001 1 s = 1,000 ms a snappy API call
microsecond µs 0.000001 1 ms = 1,000 µs a few memory reads
nanosecond ns 0.000000001 1 µs = 1,000 ns one CPU instruction
─────────────────────────────────────────────────────────────────────────
This project: 100 ms budget → 12,000 µs scan → 1.5 µs indexed query
100,000,000 ns → 12,227,000 ns → ~1,500 ns

مشكلة اليانصيب المجري
القواعد بسيطة:
- يختار كل لاعب 5 أرقام مختلفة من 1 إلى 90.
- لاحقًا، يسحب اليانصيب 5 أرقام رابحة مختلفة.
- يفوز اللاعب إذا طابق 2 أو 3 أو 4 أو 5 أرقام بالضبط.
- في الذروة قد يبلغ عدد اللاعبين نحو 10 ملايين.
- بعد السحب، يجب أن يبلّغ النظام بأعداد الفائزين فورًا.
المخرجات المطلوبة بعد كل سحب تجيب عن أربعة أسئلة: كم لاعبًا طابق رقمين بالضبط؟ وكم طابق ثلاثة؟ وأربعة؟ وخمسة؟
للوهلة الأولى يبدو الأمر تافهًا — إنها مسألة عدّ. لكن هندسة الأداء تبدأ لحظة يضيف أحدهم متطلب زمن استجابة. مع وجود أقصى زمن استجابة بعد السحب: 100 ميلي ثانية في المواصفة، يتغير السؤال بهدوء: نتوقف عن سؤال «هل نستطيع عدّ الفائزين؟» ونبدأ بالسؤال الوحيد المهم على هذا النطاق:
كم من العمل يجب أن يحدث بعد معرفة الأرقام الرابحة؟
هذا التمييز يقود إلى تنفيذين مختلفين تمامًا: حل بالمسح مُحسَّن للبساطة وقلة الذاكرة وكفاءة الـ cache؛ وحل بالفهرسة مُحسَّن لزمن استجابة شديد الانخفاض بعد السحب. كلاهما مفيد، وكلاهما يستحق الفهم — والفجوة بينهما هي حيث تعيش هذه القصة.
فهم شكل المشكلة
نادرًا ما تُحل المشاكل الثقيلة الأداء بحيلة واحدة. في معظم الأحيان، العمل الحقيقي هو فهم شكل المشكلة قبل كتابة أي كود. أحاول الإجابة عن مجموعة صغيرة من الأسئلة أولًا:
- ما المعروف مسبقًا؟
- ما الذي يجب الإجابة عنه فورًا؟
- ما المسار الحرج الحقيقي؟
- أين عنق الزجاجة — المعالج، أم نطاق الذاكرة، أم التخصيص، أم الإدخال/الإخراج، أم التزامن، أم التعقيد الخوارزمي؟
- ما المقايضة التي نقبلها بين الذاكرة ووقت المعالجة المسبقة وزمن الاستعلام؟
في اليانصيب، الإجابات كاشفة. فالتذاكر معروفة قبل السحب؛ والأرقام الرابحة لا تُعرف إلا بعده. والمسار الحرج — الجزء الذي ينتظره إنسان فعلًا — هو تقرير ما بعد السحب؛ وكل ما عداه تحضير نستطيع إنجازه على جدولنا الخاص. فالسؤال الصحيح ليس «ما سرعة البرنامج كله؟» بل «ما سرعة الجزء الذي ينتظره المستخدمون؟»
التفكير كوكيل ذكاء اصطناعي للأداء
عندما أعمل مع الذكاء الاصطناعي على مشاكل التحسين، أحاول تسليمه قيودًا بدل تفاصيل التنفيذ. شيئًا مثل هذا:
Goal: Answer winner counts after the draw.
Constraint: Maximum latency 100 ms.
Dataset: Up to 10 million tickets.
Allowed tradeoffs:
more preprocessing,
more memory,
lower query latency.
Question: What work can be moved before the draw?
هذه الصياغة تنتج طبيعيًا معماريتين: امسح كل شيء بعد السحب، أو حضّر مسبقًا بما يكفي ليصبح السحب شبه مجاني. الخيار الثاني هو حيث تصبح الأمور مشوّقة — لكنني أريد الصدق منذ البداية حول ترتيب الأحداث: لم أصل إلى الحل السريع بكتابة أمر ذكي واحد. وصلت أولًا إلى حل متين عادي، بيدي، وعندها فقط استخدمت الذكاء الاصطناعي لمهاجمة الجزء الذي عجزت الهندسة العادية عن تحسينه.
الفكرة المفتاحية: قسّم المشكلة إلى مراحل

بما أن التذاكر معروفة قبل السحب والأرقام الرابحة ليست كذلك، ينقسم النظام طبيعيًا إلى مرحلتين:
في المشاكل الثقيلة الأداء، هذا الفصل غالبًا أهم من الخوارزمية نفسها. فالنظام الذي ينجز عملًا أكثر قبل السحب وقليلًا جدًا بعده يستطيع التغلب على نظام يبدأ سريعًا لكنه يمسح كل شيء والساعة تدق. هذا من أهم الأنماط في مجالنا: انقل العمل من المسار الحرج إلى مرحلة التحضير. محركات البحث تفعلها بالفهارس المقلوبة، وقواعد البيانات بالفهارس الثانوية، والمترجمات بالتمثيلات الوسيطة، وأنظمة التوصيات بالسمات المحسوبة مسبقًا. واليانصيب ليس مختلفًا.
المعمارية العامة

يدعم التصميم النهائي وضعي تنفيذ يتشاركان الواجهة الأمامية نفسها. تُحلل المدخلات بالتوازي إلى تمثيل مضغوط مرصوص، ومنه تتغذى البيانات إما إلى مسار المسح أو مسار الفهرسة:
حقق وضع المسح هدف زمن الاستجابة بارتياح. أما وضع الفهرسة فقد ألغى زمن الاستعلام تقريبًا. سنبني كليهما — ومن حسن الحظ أن كليهما يحقق توقيع Go المكوّن من سطر واحد نفسه، فيُكتب باقي البرنامج على الواجهة وتُختار الخوارزمية عند الإقلاع بعلم (flag):
type winnerCounter func(d1, d2, d3, d4, d5 int) (w2, w3, w4, w5 int)
// ./lottery -mode scan input.txt → O(N) per draw, lower memory
// ./lottery -mode indexed input.txt → O(1) per draw, default
هذا التناظر ليس مجرد ترتيب؛ إنه ما يتيح للمسار البطيء التحقق من المسار السريع لاحقًا.
الحل 1 — المسح الودود مع الـ Cache (12 ميلي ثانية، وبلا ذكاء اصطناعي)
أريد أن أكون واضحًا: لا يوجد سحر ذكاء اصطناعي في هذا القسم. نتيجة الـ 12 ميلي ثانية جاءت من هندسة أنظمة عادية منضبطة — من النوع الذي يستطيعه أي مهندس مهتم بالأداء بمحلل أداء وقليل من الصبر. وهذا مهم، لأنه يضع الحد الذي كان على الحل المدعوم بالذكاء الاصطناعي أن يتجاوزه.
حل المسح هو أبسط نهج صحيح: حمّل كل التذاكر في الذاكرة، ثم لكل سحب امسح كل تذكرة وعدّ التطابقات:
for each ticket:
matches = count numbers that appear in the draw
if matches == 2: winners2++
if matches == 3: winners3++
if matches == 4: winners4++
if matches == 5: winners5++
التعقيد O(N) لكل سحب. لعشرة ملايين لاعب يبدو ذلك مكلفًا، لكن الثوابت ضئيلة — وتخطيط البيانات هو مصدر المكسب.
التمثيل المضغوط
بدل تخزين التذاكر ككائنات أو structs أو maps أو شرائح متداخلة، خزّنها مصفوفة بايتات واحدة مسطحة:
tickets []byte // player i occupies tickets[5*i : 5*i+5]
فيصبح تخطيط الذاكرة مجرد أرقام متلاصقة بلا أي شيء بينها:
Player 0 Player 1 Player 2
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ n n n n n │ │ n n n n n │ │ n n n n n │ ...
└─────────────┘ └─────────────┘ └─────────────┘
Flat memory: [n0 n1 n2 n3 n4][n0 n1 n2 n3 n4][n0 n1 n2 n3 n4]...
ببايت واحد لكل رقم، تنضغط 10 ملايين تذكرة في نحو 10,000,000 × 5 = 50 MB، والمعالج الحديث يتدفق عبر 50 ميغابايت من الذاكرة المتجاورة بسرعة مدهشة. (الملف النصي الخام أقرب إلى 145 ميغابايت — المزيد عن كيفية تفادي نسخه لاحقًا.) والمزايا تتراكم: لا تخصيص لكل تذكرة، ولا مطاردة مؤشرات، وبصمة مضغوطة، ووصول تسلسلي، ومسح ودود مع الـ cache، وتقسيم بديهي بين العمال.
«في الكود الثقيل الأداء، تخطيط البيانات جزء من الخوارزمية. فالخوارزمية البسيطة نظريًا بمحلية جيدة تتفوق بانتظام على الخوارزمية الأذكى المنفذة بمحلية رديئة.»
الحلقة الساخنة: جدول من 91 بايت يسكن L1

لكل سحب، ابنِ جدول عضوية صغيرًا — بايت واحد لكل رقم ممكن — وعلّم الأرقام الخمسة المسحوبة. عند 91 بايتًا يبقى الجدول مقيمًا في cache المستوى الأول L1، فتصبح كل قراءة شبه مجانية. وينهار عدّ اللاعب إلى خمس قراءات مفهرسة وزيادة عداد: لا تقاطع مجموعات، ولا بحث في map، ولا فرز، ولا تخصيص. هذه هي الحلقة الساخنة الفعلية، منقولة من المصدر مباشرة:
var isDrawn [maxNumber + 1]byte // 91 bytes — resident in L1
isDrawn[d1], isDrawn[d2], isDrawn[d3], isDrawn[d4], isDrawn[d5] = 1, 1, 1, 1, 1
// each worker walks its own slice of players into a private histogram:
for player := chunkStart; player < chunkEnd; player++ {
o := player * 5
m := isDrawn[t[o]] + isDrawn[t[o+1]] + isDrawn[t[o+2]] +
isDrawn[t[o+3]] + isDrawn[t[o+4]] // m is 0..5
histogram[m]++ // buckets 2..5 are the answer
}
هذه هي الحسبة بأكملها: جمع خالٍ من التفرعات لخمسة بايتات وزيادة خانة واحدة، عشرة ملايين مرة — من نوع العمل الذي يمضغه المعالج بلا شكوى.
المسح المتوازي

ينقسم المسح نظيفًا عبر أنوية المعالج. يعالج كل عامل نطاقًا متجاورًا من اللاعبين ويحتفظ بمدرج تكراري خاص، فلا أقفال في الحلقة الساخنة:
Packed tickets
┌───────────────────────────────────────────────┐
│ player 0 ............................. player N │
└───────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Worker 0 │ │ Worker 1 │ │ Worker 2 │
│ hist[0..5] │ │ hist[0..5] │ │ hist[0..5] │
└─────┬──────┘ └─────┬──────┘ └─────┬──────┘
└───────────────┼───────────────┘
▼
┌──────────────┐
│ Merge result │
└──────────────┘
يبقى التعقيد النظري O(N)، لكن الزمن العملي يقترب من O(N / p) حيث p عدد الأنوية. والأفضل أن كل عامل يمشي في منطقة ذاكرة متجاورة يمكن توقعها — وهذا بالضبط نمط الوصول الذي تعشقه وحدات الجلب المسبق في العتاد.
النتيجة: 12 ميلي ثانية
على جهاز مقيّد عمدًا — 4 معالجات منطقية و4 غيغابايت ذاكرة — عالج تنفيذ المسح 10 ملايين لاعب بمتوسط زمن استجابة للسحب نحو 12.2 ميلي ثانية. مقابل ميزانية 100 ميلي ثانية، هذا أسرع من المطلوب بنحو 8 مرات بالفعل. أُنجزت المهمة. هكذا بدا الأمر.
لماذا نبقي وضع المسح أصلًا؟
إنه بسيط، وكفء في الذاكرة، وسهل التنقيح، وسهل الثقة به. يصلح خط أساس مثاليًا للصحة، ويتألق حين تشح الذاكرة أو تندر السحوبات، و— كما سنرى — يستطيع التحقق من الحل السريع. قبل بناء تحسين معقد، ابنِ أبسط نسخة سريعة بما يكفي للقياس: أحيانًا تكون كافية بالفعل، ودائمًا تدلك على عنق الزجاجة الحقيقي.
سؤال أفضل للذكاء الاصطناعي
مع 12 ميلي ثانية في اليد، كانت المتابعة الكسولة أن أسأل «هل يمكن أن يصبح أسرع؟». الذكاء الاصطناعي يسعده الرد — حلقات أضيق، وSIMD، وضبط حجم القطع — فتستعيد نسبة مئوية ضئيلة. سألت بدلًا من ذلك سؤالًا مختلفًا بنيويًا:
هل يمكن أن يصبح الاستعلام مستقلًا عن عدد اللاعبين؟
هذا يغيّر كل شيء. فبضعة في المئة تحسين؛ أما الاستقلال عن N فنموذج تشغيل مختلف. واتضح أن الإجابة نعم — والمعمارية الناتجة ليست رياضيات جديدة بل تركيب لعدة أفكار كلاسيكية. وتركيبها تركيبًا صحيحًا، في الكود، عند 1.5 ميكروثانية، لم يكن أمرًا من طلقة واحدة.
التعاون مع الذكاء الاصطناعي كان تكراريًا لا أمرًا واحدًا
هذا هو الجزء الذي تتجاوزه بصمت معظم قصص «استخدمت الذكاء الاصطناعي فضاعف كودي 1000 مرة». سؤال إعادة الصياغة فتح الباب، لكن عبوره تطلّب جولات عدة من التوجيه الدقيق، والقيود المعمارية، وقبل كل شيء: القياس والتحقق في كل خطوة. إليكم سجل التكرارات الصادق.
التكرار 1 — التصميم الأول كان صحيحًا وبطيئًا
حين طُلب من النموذج جعل الاستعلام مستقلًا عن N، اقترح حساب تكرارات التوليفات مسبقًا والإجابة عبر القراءات. حدس جيد — لكن تنفيذه الأول بنى كل شيء على hash maps: map[pair]int وmap[triple]int وهكذا. صحيح لكنه ثقيل: حساب hash في المسار الساخن، وعبء دلاء، ومطاردة مؤشرات، وضغط على جامع المهملات، وزمن استجابة لا يمكن توقعه. أعطيته قيدًا صارمًا: لا hashing في مسار الاستعلام، ولا تخصيص لكل عنصر.
التكرار 2 — المصفوفات الكثيفة تفرض تقابلًا
إزالة الـ maps تعني تخزين العدادات في مصفوفات مسطحة، وهذا يفرض مسألة فرعية جديدة: كل توليفة تحتاج فهرسًا صحيحًا حتميًا. ثبّته بدقة — «حوّل كل توليفة k مفروزة إلى فهرس فريد في [0, C(90,k)) دون تخصيص» — فمدّ النموذج يده إلى نظام الأعداد التوافقي (combinadics). كان تفصيلان غير قابلين للتفاوض: حوّل الأرقام إلى أساس صفري أولًا، واحسب المعاملات الثنائية مسبقًا بعلاقة Pascal حتى لا تُحسب C(n,k) في حلقة أبدًا.
التكرار 3 — علة الأعداد المتضخمة (اصطادها خط الأساس)
أنتج أول تشغيل كامل للحل المفهرس أرقامًا أكبر من المعقول بوضوح — اللحظة الأهم في المشروع كله، ولم ألتقطها إلا لأن خط أساس المسح منحني الثابتة scan(draw) == indexed(draw)، وقد فشلت. السبب فخ كلاسيكي: مجاميع المجموعات الجزئية ليست أعداد التطابق الدقيق. فتذكرة التطابق الخماسي تُعد أيضًا داخل كل مجموع زوجي وثلاثي ورباعي. طلبت من النموذج اشتقاق التصحيح لا ترقيعه، وثبّت جدول المساهمات (تذكرة التطابق الرباعي تساهم بـ 6 أزواج و4 ثلاثيات ورباعية واحدة؛ والخماسية بـ 10/10/5/1). أنتج ذلك إعادة الإحلال العكسي بمبدأ الإدراج والاستبعاد بصيغة «بالضبط k»، وعادت الثابتة تصمد.
التكرار 4 — اجعل التحضير سريعًا أيضًا
الاستعلامات ثابتة الزمن بلا قيمة إذا استغرق بناء الفهرس دهرًا، وكان التحضير الساذج يخصص ذاكرة لكل توليفة ويسمّر أربعة أنوية على خطوط cache الساخنة نفسها. سد هذه الفجوة — زيادات بلا تخصيص، وتحليل متوازٍ فوق مدخلات ممثلة في الذاكرة (memory-mapped)، واختيار لكل جدول بين التجميع الخاص والعمليات الذرّية (موضوع قسم كامل أدناه) — هو حيث ذهب فعليًا كثير من الأخذ والرد.
التكرار 5 — الصحة والحالات الحدّية وسياسة التحقق
أخيرًا قدت جولة اختبارات: مجموعة البيانات الصغيرة القابلة للفحص اليدوي، والتذاكر المكررة، والأسطر المشوهة، والرموز الرقمية المرضية التي تفيض على محلل مكتوب يدويًا. وجعلنا سياسة التحقق صريحة (صارمة أو متساهلة) بدل ترك المدخلات الفاسدة تفسد المخرجات بصمت.
تقسيم العمل الحقيقي
قدّم الذكاء الاصطناعي اللبنات الكلاسيكية وكثيرًا من الكود. وقدّمت أنا المواصفة والثوابت والقياسات وبوابة الصحة. «اجعله أسرع» لا توصلك إلى شيء؛ أما «اجعل الاستعلام O(1)، كثيفًا، بلا تخصيص، بدلالات تطابق دقيق، وتحقق منه مقابل خط الأساس» فتوصلك إلى 1.5 ميكروثانية. الرافعة في القيود، لا في عدد الأوامر.
الحل 2 — العد المفهرس (حل الـ 1.5 ميكروثانية)

ينطلق الحل المفهرس من سؤال مختلف: هل نستطيع نقل كل العمل تقريبًا إلى ما قبل السحب؟ كل تذكرة فيها خمسة أرقام، ومن الخمسة نستطيع توليد كل توليفة أصغر:
C(5,2) = 10 pairs
C(5,3) = 10 triples
C(5,4) = 5 quadruples
C(5,5) = 1 quintuple
لكل تذكرة، أثناء التحضير، نزيد عداد تكرار لكل من تلك المجموعات الجزئية. فللتذكرة 1 2 3 4 5 تُرفع عداداتُ الأزواج العشرة (1,2) (1,3) (1,4) (1,5) (2,3) (2,4) (2,5) (3,4) (3,5) (4,5) — وكذلك ثلاثياتها ورباعياتها وخماسيتها الكاملة.
بعد التحضير، حين يصل السحب، لا نلمس قائمة اللاعبين إطلاقًا. نولّد أزواج السحب نفسه العشرة وثلاثياته العشر ورباعياته الخمس وخماسيته الواحدة، ونقرأ كلًا منها من الجداول المحسوبة مسبقًا. تلك 26 قراءة فقط، فتعقيد كل سحب O(1) — لم يعد يعتمد على عدد اللاعبين.
وضع المسح يسأل: «أي اللاعبين يطابق هذا السحب؟» ووضع الفهرسة يسأل: «كم تذكرة تحتوي أصلًا كل مجموعة جزئية من هذا السحب؟»
مصفوفات كثيفة بدل الـ Hash Maps
قد يمد التنفيذ المباشر يده إلى hash maps. لكن الكون هنا صغير وثابت — الأرقام دائمًا من 1 إلى 90 — فعدد التوليفات الممكنة منتهٍ وقابل للتوقع وصغير بما يكفي للتخزين الكثيف:
C(90,2) = 4,005
C(90,3) = 117,480
C(90,4) = 2,555,190
C(90,5) = 43,949,268
With 32-bit counters:
count2: 4,005 × 4 B ≈ 16 KB
count3: 117,480 × 4 B ≈ 470 KB
count4: 2,555,190 × 4 B ≈ 10 MB
count5: 43,949,268 × 4 B ≈ 176 MB
───────────────────────────────────
total index memory ≈ 186 MB
هذا ثمن معقول لاستعلامات ثابتة الزمن، والمصفوفات الكثيفة تتفادى الـ hashing وعبء الدلاء ومطاردة المؤشرات والتخصيص وضغط جامع المهملات. واللغز الوحيد المتبقي هو تحويل كل توليفة إلى فهرس مصفوفة فريد.
ترتيب التوليفات بنظام Combinadics
كل توليفة تحتاج رتبة حتمية. حوّل الأرقام إلى أساس صفري أولًا (1..90 → 0..89)، ثم لتوليفة مفروزة c0 < c1 < … < c(k-1) احسب:
rank = C(c0,1) + C(c1,2) + ... + C(c(k-1),k)
For a 5-number combination:
rank = C(c0,1) + C(c1,2) + C(c2,3) + C(c3,4) + C(c4,5)Example — combination 1 2 3 4 5, zero-based 0 1 2 3 4:
rank = C(0,1) + C(1,2) + C(2,3) + C(3,4) + C(4,5) → a unique index into count5[]
تصبح الرتبة فهرس المصفوفة، فتكتب count5[rank]++. لا maps، ولا مفاتيح متسلسلة، ولا تخصيص heap لكل توليفة — مجرد زيادات عدادات مباشرة. والمعاملات الثنائية نفسها محسوبة مسبقًا مرة واحدة بقاعدة Pascal، فلا يجري الترتيب أبدًا حسابًا أثقل من بضع قراءات مصفوفة وجمع:
var binomial [91][6]uint64 // binomial[n][r] = C(n,r)
func initBinomialTable() {
for n := 0; n <= 90; n++ {
binomial[n][0] = 1
for r := 1; r <= 5; r++ {
if r > n { binomial[n][r] = 0; continue }
binomial[n][r] = binomial[n-1][r-1] + binomial[n-1][r]
}
}
}// rank of a sorted, zero-based quintuple c0<c1<c2<c3<c4:
rank := binomial[c0][1] + binomial[c1][2] + binomial[c2][3] +
binomial[c3][4] + binomial[c4][5] // lands in [0, C(90,5))
لفضاء توليفات ثابت صغير، هذه بالضبط التحويلة التي تنقل حلًا عام الغرض إلى حل عالي الأداء.
الجزء الدقيق: أعداد التطابق الدقيق
في النهج المفهرس فخ واحد مهم — العلة التي لدغتني في التكرار 3. الأعداد الخام للمجموعات الجزئية ليست الإجابة النهائية. فالتذكرة المطابقة للأرقام الخمسة كلها تحتوي 10 أزواج و10 ثلاثيات و5 رباعيات وخماسية واحدة، فجمع مجاميع الأزواج والثلاثيات والرباعيات بسذاجة يعدّها مرات كثيرة. والتذكرة رباعية التطابق تساهم بالمثل بـ 6 أزواج و4 ثلاثيات ورباعية واحدة.
الجداول المحسوبة مسبقًا تجيب عن «كم تذكرة تحتوي هذا الزوج/الثلاثي/الرباعي؟». لكن العمل يريد «كم تذكرة فيها بالضبط 2 أو 3 أو 4 أو 5 تطابقات؟». إنهما سؤالان مختلفان، والخلط بينهما هو أشيع علة في الحلول المفهرسة بالتوليفات.
من مجاميع المجموعات الجزئية إلى الأعداد الدقيقة

لتكن T2, T3, T4, T5 مجاميع المجموعات الجزئية للسحب، وm2, m3, m4, m5 أعداد التطابق الدقيق المطلوبة. بعدّ مساهمة كل فئة دقيقة في كل مجموع:
Exactly 2 matches: C(2,2)=1 pair
Exactly 3 matches: C(3,2)=3 pairs, C(3,3)=1 triple
Exactly 4 matches: C(4,2)=6 pairs, C(4,3)=4 triples, C(4,4)=1 quad
Exactly 5 matches: C(5,2)=10 pairs, C(5,3)=10 triples, C(5,4)=5 quads, C(5,5)=1 quint
So the totals expand as:
T5 = m5
T4 = m4 + 5·m5
T3 = m3 + 4·m4 + 10·m5
T2 = m2 + 3·m3 + 6·m4 + 10·m5And we solve backward:
m5 = T5
m4 = T4 - 5·m5
m3 = T3 - 4·m4 - 10·m5
m2 = T2 - 3·m3 - 6·m4 - 10·m5
هذه خطوة التصحيح الجوهرية. بدونها يبلّغ الحل المفهرس أعدادًا متضخمة — إجابة خاطئة سريعة، وهي مجرد علة أغلى ثمنًا.
لماذا الاستعلام ثابت الزمن؟

اجمع الأجزاء فيصبح مسار ما بعد السحب عملًا ثابتًا: ولّد 10 أزواج و10 ثلاثيات و5 رباعيات وخماسية واحدة؛ نفّذ 26 قراءة من مصفوفات كثيفة للحصول على T2–T5؛ طبّق تصحيح الإدراج والاستبعاد؛ اطبع. سواء كان اللاعبون 10 آلاف أو 10 ملايين أو 100 مليون، ينجز السحب كمية العمل نفسها. في الكود، التصحيح كله أربعة أسطر — وتتراكم مجاميع المجموعات الجزئية في int64 مع أن كل عداد مخزن uint32، لأن مجموع المجاميع قد يتجاوز أي عداد منفرد:
// total2..total5 are int64 sums of the 26 looked-up counts
winners5 = int(total5)
winners4 = int(total4) - 5*winners5
winners3 = int(total3) - 4*winners4 - 10*winners5
winners2 = int(total2) - 3*winners3 - 6*winners4 - 10*winners5
بناء الفهرس دون إذابة الـ Cache
هذا هو الجزء الذي لا تحذرك منه الرياضيات أبدًا. على الورق، التحضير تافه: لكل لاعب ارفع 26 عدادًا — عشرة ملايين لاعب، 260 مليون زيادة، انتهينا. فتكتب الحلقة المتوازية البديهية، وتقسّم اللاعبين على أربعة أنوية، وتدع كل نواة تزيد الجداول المشتركة… وإذا باستعلامك الجميل ذي الـ O(1) يقبع خلف بناء يزحف، لأن أربعة أنوية تتزاحم على حفنة خطوط الـ cache نفسها.
الجاني هو التنازع، وهو غير متساوٍ بشدة عبر الجداول الأربعة. جدول الأزواج فيه 4,005 خانات فقط لكنه يمتص نحو 100 مليون زيادة، فتشتعل بضعة خطوط cache بياضًا — والجمع الذرّي هناك سيجعل البناء كله يقف في طابور عليها. وجدول الخماسيات على النقيض: 44 مليون خانة تتقاسم نحو 10 ملايين زيادة فقط، فالاصطدامات نادرة. استراتيجية واحدة لا يمكن أن تصح للاثنين، والكود الفعلي ينقسم تبعًا لذلك:
// SMALL + HOT (pairs: 4,005 slots, ~100M hits; triples: 117,480 slots):
// each worker counts into a PRIVATE copy, merged by index shard afterward.
local.pairs[binomial[a][1]+binomial[b][2]]++
// LARGE + SPARSE (quads: 2.5M slots; quintuples: 44M slots):
// atomic adds straight into the shared table — collisions are rare, and
// cloning 176 MB per worker would be absurd.
atomic.AddUint32(&quintupleCounts[rank], 1)
فيحصل الجدولان الصغيران على نسخ خاصة لكل عامل (صفر تنازع أثناء العد، ثم دمج سريع مقسّم بنطاق الفهارس)، بينما يأخذ الجدولان الكبيران زيادات ذرّية مباشرة في الذاكرة المشتركة (لا تكرار 10 ميغابايت و176 ميغابايت لكل نواة، ولا جولة دمج إطلاقًا). والمكسب: بناء تبقى ذروة ذاكرته مستوية تقريبًا مع إضافة الأنوية، ينجز عملًا O(N) حقيقيًا بمدى O(N/p) — دون إعادة مسح مجموعة البيانات مرة لكل عامل.
الدرس المختبئ في البناء
قالت الخوارزمية: «ارفع 26 عدادًا». وقال العتاد: «شاهد أربعة أنوية تتقاتل على خط cache واحد». اختيار «تجميع خاص» أو «ذرّي» لكل جدول، حسب حجمه ومعدل اصطداماته، هو بالضبط نوع القرارات التي تبادلت فيها الملاحظات مع الذكاء الاصطناعي — وبالضبط النوع الذي لا ينجو أبدًا إلى الملخص النهائي الأنيق. صحة التزامن رخيصة القول باهظة الكسب.
لماذا الحل المفهرس ليس رياضيات جديدة
يجدر التأكيد — من باب الصدق ولأنه الدرس الأكثر قابلية لإعادة الاستخدام هنا — أن الحل المفهرس لا يبتكر شيئًا. إنه تركيب لأربع نتائج كلاسيكية ليست أي منها أصيلة فيه — وتمتد أكثر من قرن.
1 · طبقة العنونة — نظام الأعداد التوافقي (Combinadics)
تمثيل كل توليفة k مفروزة بـ C(c₁,1) + C(c₂,2) + … + C(c_k,k) ينشئ تقابلًا على {0, …, C(n,k)−1}. لاحظ الفكرة D. H. Lehmer (1964)، وسماها لاحقًا Donald Knuth بـنظام الأعداد التوافقي في The Art of Computer Programming المجلد 4A (2011)، وهو يرجع أصل المفهوم إلى Ernesto Pascal (1887).
2 · الحساب — المعاملات الثنائية
تحت الترتيب لا يوجد أغرب من C(n,k) = C(n−1,k−1) + C(n−1,k)، المنسوبة عادة إلى Blaise Pascal (1654) وإن عُرفت أقدم من ذلك بكثير في الهند وفارس والصين. وحساب هذا المثلث مسبقًا هو ما يجعل الترتيب سريعًا وخاليًا من التخصيص.
3 · طبقة العد — عد دعم مجموعات العناصر
زيادة عداد لكل مجموعة جزئية تحتويها كل تسجيلة هي في جوهرها عد دعم مجموعات العناصر (Itemset Support Counting)، الفكرة الجوهرية خلف خوارزمية Apriori التي قدمها Rakesh Agrawal وRamakrishnan Srikant (1994). «تذاكرنا» هي المعاملات؛ و«توليفاتنا» هي مجموعات العناصر.
4 · طبقة الاسترجاع — الإدراج والاستبعاد (بالضبط k)
استرجاع أعداد الفائزين الدقيقة من مجاميع المجموعات الجزئية يستخدم صيغة «بالضبط k» لمبدأ الإدراج والاستبعاد:
m_j = Σ_{k≥j} (−1)^{k−j} · C(k,j) · T_k
تُنسب هذه إلى Charles Jordan، وعُممت لاحقًا عبر صيغة Schuette–Nesbitt، وكلها تقوم على مبدأ الإدراج والاستبعاد الكلاسيكي.
المساهمة الفعلية
الحركة الأصيلة الوحيدة في الحل هي ملاحظة أن هذه القطع الأربع تتراكب معًا بطريقة تجعل كلفة الاستعلام مستقلة عن حجم البيانات. هذه هندسة لا رياضيات جديدة — وتمييز مثل هذه التراكيب هو بالضبط حيث يستحق المتعاون الاصطناعي أجره، شرط أن تمده أنت بالقيود التي تجبر القطع على التموضع.
التحميل والتحليل بكفاءة
لمجموعة بيانات من 10 ملايين تذكرة، قد يهيمن التحليل على وقت الإقلاع. المدخلات نص ASCII، تذكرة في كل سطر (4 16 22 35 78، 1 2 3 4 5، …). المحلل الساذج الذي يستخدم تقطيع النصوص عالي المستوى وتحويل الأعداد سطرًا سطرًا جيد للملفات الصغيرة لكنه يخلق عبئًا حقيقيًا على هذا النطاق: نصوص مؤقتة، وتخصيص متكرر، وتحويلات مكلفة، وضغط على جامع المهملات.
مثّل الملف في الذاكرة (mmap). بدل نسخ الـ ~145 ميغابايت إلى شريحة يملكها الـ heap كما تفعل os.ReadFile، يُمثَّل الملف للقراءة فقط: يجلب نظام التشغيل الصفحات مباشرة من cache خاصته، بالتوازي، كلما لمسها العمال — ضغط أقل على الـ heap وتعاون مجاني مع cache الصفحات (مع مسار قراءة عادي احتياطي حين يتعذر التمثيل). ولأن التحليل هو الكلفة المهيمنة، ينخفض وقت التحميل الفعلي انخفاضًا شبه خطي مع عدد الأنوية.
قسّم العمل على حدود الأسطر. يمكن للتحليل أن يجري بالتوازي، لكن يجب ألا تشطر القطعُ سطرَ تذكرة أبدًا. لذا يُقسّم الملف إلى نطاقات بايتات ويُدفع كل حد إلى السطر الجديد التالي، فيضمن كل عامل تحليل أسطر كاملة والكتابة في مخزن مخرجاته الخاص بلا كتابات مشتركة:
File bytes
┌──────────────────────────────────────────────────────────────┐
│ line line line line line line line line line line line line │
└──────────────────────────────────────────────────────────────┘
│ │ │
▼ ▼ ▼
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Worker 0 │ │ Worker 1 │ │ Worker 2 │
│ whole lines │ │ whole lines │ │ whole lines │
└────────────┘ └────────────┘ └────────────┘
يصدر كل عامل مخزنه المرصوص الخاص؛ ثم يمنح مجموعٌ تراكمي على أحجام القطع كلَّ قطعة إزاحةً منفصلة في الشريحة النهائية، فتُخاط المخازن معًا بنسخ متوازٍ دون أن يتداخل عامل مع آخر أبدًا.
ثم اجعل الحلقة الداخلية شرسة. حيلتان صغيرتان تبقيان تحليل البايتات مشدودًا: طرح واحد بلا إشارة يقرر هل البايت رقم (إذا التفّ ch-'0' فوق 9 فليس رقمًا)، والقيمة الجارية تُحد أثناء تراكمها، فلا يستطيع رمز طويل عبثي أن يفيض على int ويلتف عائدًا إلى النطاق الصحيح — يُقرأ كاملًا ثم يُرفض كخارج نطاق:
if ch-'0' > 9 { // unsigned wrap-around: "is this a digit?"
valid = false
}
// ...
if value <= maxNumber { // clamp WHILE accumulating: no overflow, ever
value = value*10 + int(digit)
} // 99999999999999999999 is read, then rejected
ومخزن المخرجات مُحجّم إلى حد أعلى مثبت الأمان — فالتذكرة الصحيحة تستهلك تسعة بايتات مدخلات على الأقل وتصدر خمسة — فيُلحق دون أي خطر تجاوز.
تحقق أثناء التحليل. التذكرة الصحيحة فيها بالضبط خمسة أرقام مختلفة في 1..90 — فحص رخيص. لكن سياسة التحقق قرار منتج لا قرار أداء. الوضع الصارم يرفض المدخلات كلها إذا تشوه أي سطر (الصواب حين يكون كل سطر معاملة حقيقية)؛ والوضع المتساهل يتخطى الأسطر الفاسدة ويبلّغ عددها (مفيد للاستيعاب بأفضل جهد). اجعل الخيار صريحًا — فقدان البيانات الصامت ليس تحسينًا.
القياسات والتفسير
قست كلا التنفيذين على بيئة مقيّدة عمدًا:
CPU: 4 logical CPUs used by the Go runtime
RAM: 4 GB
Go: go1.23.2 linux/amd64
Setting: GOMAXPROCS=4
Draws: 1 2 3 4 5 | 10 20 30 40 50 | 5 15 25 35 45
Metrics: time-to-READY, per-draw latency, max RSS (/usr/bin/time -v)
كلمة عن طريقة تشغيل البرنامج، لأنها تشكّل معنى هذه الأرقام. يحمّل الملف، يطبع READY، ثم يقرأ السحوبات من stdin ويجيب عن كل منها على stdout مع تدفق فوري لكل سحب حتى يصل كل ناتج إلى المستهلك مباشرة. تذهب التشخيصات إلى stderr ليبقى مجرى الإجابات نظيفًا. والأهم أن توقيت كل سحب يحيط بالحساب فقط — لا تحليل السطر ولا الطباعة — فزمن السحب يعكس الحسبة ذاتها مستقلة عن الإدخال/الإخراج.
مليون لاعب:
MODE TIME→READY DRAW1 DRAW2 DRAW3 AVG DRAW MAX RSS
──────────────────────────────────────────────────────────────────────────
Scan 23.69 ms 0.884 ms 0.882 ms 0.915 ms 0.894 ms 40.5 MiB
Indexed 177.77 ms 1.472 µs 0.650 µs 0.628 µs 0.917 µs 202.9 MiB
عند مليون لاعب، وضع المسح سريع كفاية بالفعل — لكن وضع الفهرسة يجيب أسرع بنحو ثلاث مراتب عشرية لكل سحب، مقابل وقت أطول قبل READY وذاكرة أكبر.
10 ملايين لاعب:
MODE TIME→READY DRAW1 DRAW2 DRAW3 AVG DRAW MAX RSS
──────────────────────────────────────────────────────────────────────────────
Scan 210.64 ms 7.978 ms 15.017 ms 13.687 ms 12.227 ms 255.9 MiB
Indexed 1.220 s 2.649 µs 1.044 µs 0.791 µs 1.495 µs 301.5 MiB
عند 10 ملايين لاعب، ما زال وضع المسح يؤدي جيدًا لأنه يتدفق عبر ذاكرة متجاورة مضغوطة على أربعة أنوية — متوسط نحو 12.2 ميلي ثانية محترم جدًا. لكن وضع الفهرسة في طبقة أخرى: يجيب في نحو 1.5 ميكروثانية بعد بناء الفهرس. الفرق ببساطة: المسح يلمس كل تذكرة بعد السحب؛ والفهرسة تلمس 26 عدادًا.
وضع الفجوة على شريط
10 million players · average draw latency
Scan mode 12.227 ms ██████████████████████████████████████████████████
Indexed mode 0.0015 ms ▏ratio ≈ 12.227 ms / 0.001495 ms ≈ 8,178×
فكان وضع الفهرسة أسرع بنحو 8,000 مرة لكل سحب هنا. هذا لا يجعل وضع المسح رديئًا — بل يعني أن الوضعين يحسّنان جزأين مختلفين من النظام.
الإقلاع مقابل زمن الاستعلام (نقطة التعادل)
10 million players
Scan: before READY ~211 ms | per draw ~12 ms
Indexed: before READY ~1.22 s | per draw ~1.5 µs
extra startup cost of indexed = 1.220 s − 0.211 s ≈ 1.009 s
per-draw saving = 12.227 ms − 0.0015 ms ≈ 12.2255 ms
break-even = 1.009 s / 12.2255 ms ≈ 83 draws
إن كان همك الوحيد إجمالي وقت التشغيل على هذا الجهاز وهذه البيانات، يسدد وضع الفهرسة كلفة تحضيره الإضافية بعد نحو 83 سحبًا. لكن إجمالي وقت التشغيل كثيرًا ما يكون المقياس الخطأ: إذا كان المطلوب أدنى زمن استجابة بعد السحب، فوضع الفهرسة مبرر حتى لسحب واحد، لأنه ينقل العمل خارج المسار الحرج.
عن الذاكرة: بلغ أقصى RSS مقاس نحو 256 ميبيبايت (مسح) و302 ميبيبايت (فهرسة). الجداول النظرية نحو 186 ميغابايت زائد نحو 50 ميغابايت تذاكر مرصوصة؛ والـ RSS المقاس يشمل أيضًا عبء وقت التشغيل والصفحات الممثلة، فلن يتطابق تمامًا. لمسة صغيرة مقصودة: بعد بناء الجداول المفهرسة يسقط البرنامج مخزن التذاكر البالغ نحو 50 ميغابايت (packedTickets = nil) ويفرض دورة جمع مهملات، لأن سحوبات الفهرسة تقرأ الجداول وحدها — فتبقى ذاكرة الحالة المستقرة أقرب إلى الجداول نفسها. وكلا الوضعين يتسع بارتياح في 4 غيغابايت.
مقارنة النهجين
DIMENSION SCAN MODE INDEXED MODE
────────────────────────────────────────────────────────────────────────────
Preprocessing minimal heavier
Per-draw complexity O(N) O(1)
Memory usage lower higher
Implementation complexity lower higher
Scaling with players linear per draw constant per draw
Debuggability very high requires stronger validation
Best use case low memory, baseline lowest post-draw latency
اختر وضع المسح حين تكون الذاكرة شحيحة، أو البيانات متواضعة، أو السحوبات نادرة، أو حين تريد خط أساس للصحة. واختر وضع الفهرسة حين يكون زمن ما بعد السحب حرجًا، وقد تكثر الاستعلامات، وقد ينمو عدد اللاعبين، وتتوفر ذاكرة كافية. وضع الفهرسة ليس «أفضل» في كل بعد — بل في هدف واحد هو تقليل زمن المسار الحرج. تلك هي المقايضة الهندسية.
دروس أداء من هذه المشكلة
مشكلة اليانصيب المجري صغيرة، لكنها تجسّد عدة مبادئ تنطبق على كثير من الأنظمة الثقيلة الأداء.
1. حسّن المسار الحرج
المسار الحرج هو العمل الذي يجب أن يحدث بينما أحدهم ينتظر.
هنا، هو تقرير ما بعد السحب.
الفهرسة قيّمة لأنها تنقل العمل من المسار الحرج إلى مرحلة التحضير.
يظهر هذا النمط في كل مكان:
- محركات البحث تبني الفهارس المقلوبة قبل الاستعلامات
- قواعد البيانات تصون الفهارس قبل القراءات
- المترجمات تبني التمثيلات الوسيطة قبل التحسين
- أنظمة التوصيات تحسب السمات مسبقًا قبل التقديم
- أنظمة التحليلات تجمّع البيانات قبل استعلامات اللوحات
الحساب المسبق ليس تفصيل تنفيذ.
إنه استراتيجية زمن استجابة.
2. تخطيط البيانات مهم
حل المسح سريع لأن تخطيط بياناته بسيط ومضغوط.
مصفوفة البايتات المسطحة ليست فاتنة، لكنها تعمل جيدًا.
كثير من مشاكل الأداء لا يحلها مزيد من التجريد. بل تحلها إخفاقات cache أقل.
قبل تحسين الحساب، انظر إلى أنماط الوصول للذاكرة.
3. تجنّب التخصيص في المسارات الساخنة
مسار السحب يجب ألا يخصص ذاكرة لكل تذكرة.
ومسار الاستعلام المفهرس يجب ألا يخصص لكل توليفة.
والمحلل يجب أن يتجنب النصوص المؤقتة ما أمكن.
التخصيص ليس مكلفًا دائمًا، لكن تكراره في المسارات الساخنة يخلق تكاليف ثانوية:
- عبء المخصص
- جمع المهملات
- تشظي الذاكرة
- محلية أضعف
4. قِس المراحل منفصلة
رقم «إجمالي وقت التشغيل» وحده قد يضلل.
في هذه المشكلة، قد يبدأ وضع المسح أسرع، بينما يجيب وضع الفهرسة أسرع.
لا يحكي أي من الرقمين وحده القصة كاملة.
الأنظمة الثقيلة الأداء لها عادة عدة أزمنة مهمة:
- الإقلاع البارد
- الاستعلام الدافئ
- زمن p50
- زمن p99
- ذروة الذاكرة
- الإنتاجية
- سلوك الذيل تحت التزامن
قِس ما يهم.
5. أبقِ تنفيذًا بسيطًا في الجوار
تنفيذ المسح مفيد حتى حين يكون وضع الفهرسة هو الافتراضي.
يساعد على التحقق من الصحة.
ويوفر مسارًا احتياطيًا.
ويمنح مهندسي المستقبل نموذجًا ذهنيًا أبسط.
في الأنظمة عالية الأداء يميل التعقيد إلى التراكم. وخط الأساس الواضح يبقي المشروع على الأرض.
6. الدلالات الدقيقة مهمة
أكبر مخاطر الحل المفهرس ليست الأداء.
بل الدلالات.
أعداد المجموعات الجزئية ليست أعداد التطابق الدقيق.
فرق يسهل تفويته، ويغيّر الإجابة تمامًا.
عمل الأداء يجب ألا يطمس معنى النتيجة أبدًا.
الإجابة الخاطئة السريعة مجرد علة أغلى ثمنًا.
أفكار ختامية
تبدأ هذه المسألة تمرين عدّ في يانصيب، لكنها تتحول سريعًا إلى مسألة هندسة أداء.
الدرس الجوهري لا يخص اليانصيب.
الدرس الأوسع هو:
انقل العمل خارج المسار الحرج متى سمحت المشكلة بذلك.
يُري حل المسح إلى أي مدى يمكن أن يذهب تخطيط بيانات متقن وحلقة بسيطة صديقة للمعالج.
ويُري الحل المفهرس كيف يستطيع التحضير المسبق تغيير نموذج التشغيل كليًا.
على بيئة بأربعة معالجات و4 غيغابايت ذاكرة، عالج وضع المسح 10 ملايين لاعب بمتوسط زمن سحب نحو 12 ميلي ثانية. وهذا سريع جدًا بالفعل.
وعالج وضع الفهرسة الملايين العشرة نفسها بمتوسط زمن سحب نحو 1.5 ميكروثانية بعد التحضير.
خلاصة القول
في نهاية المطاف، نادرًا ما تسلّمنا هندسة البرمجيات مسارًا «صحيحًا» واحدًا — بل مقايضات فقط. كلا الحلين قيّم للغاية؛ إنهما يحسّنان بُعدين مختلفين تمامًا من أبعاد الأداء.
- أحيانًا يكون الجواب الصحيح تقشفيًا: مصفوفة مسطحة مع حلقة مشدودة صديقة للـ cache من أجل السرعة الخام.
- وأحيانًا يكون الجواب الصحيح بنيويًا: فهرس محسوب مسبقًا يقصف أزمنة البحث كلما تعاظم التعقيد.
أصمد المعماريات لا تعامل هذين النمطين كخصمين. إنها تدرك أن أفضل نظام كثيرًا ما لا يُجبر على الاختيار بينهما — بل يوظفهما معًا في انسجام.
