انتقل إلى المحتوى الرئيسي

نماذج البرمجة

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

في هذا الدرس، سنراجع نماذج البرمجة الكمومية والكلاسيكية ونرى كيف يمكننا دمجها لتشغيل الخوارزميات في بيئات متباينة. يقدم لنا Iskandar Sitdikov نظرة عامة في الفيديو الآتي.

نموذج البرمجة لوحدات المعالجة الكمومية

سنبدأ بنموذج البرمجة للحواسيب الكمومية. نموذج البرمجة الأساسي المألوف لدى تقريباً جميع مطوري الكم هو الدائرة الكمومية. لن ندخل في تفاصيل نموذج الدائرة الكمومية هنا، إذ لدينا بالفعل محاضرة رائعة بقلم John Watrous تشرح ذلك بالتفصيل. سنكتفي بالإشارة إلى أن الدائرة مبنية من مجموعة خطوط (تُسمى الأسلاك) تمثل الكيوبتات، وبوابات تمثل عمليات على الحالات الكمومية، ومجموعة من القياسات.

رسم تخطيطي لدائرة كمومية يُظهر الكيوبتات كخطوط أفقية والبوابات الكمومية كمربعات أو وصلات بين الكيوبتات.

مفهوم نموذج برمجة مهم آخر للحوسبة الكمومية هو ما نسميه العمليات الحسابية الأولية. هذه العمليات الأولية تمثل بعض أكثر المهام شيوعاً التي يسعى المستخدمون إلى تحقيقها بالحاسوب الكمومي. هناك عدة عمليات أولية متاحة الآن، بما فيها المنفذ (Executor). في هذه الدورة سنركز أساساً على العمليتَين الأوليتَين Sampler وEstimator. يمنحك Sampler القدرة على أخذ عينات من حالة يُحضِّرها دائرتك الكمومية. يُخبرك بالحالات الأساسية الحسابية التي تشكّل الحالة الكمومية المُحضَّرة على دائرتك. يتيح لك Estimator تقدير القيمة التوقعية لقياس لنظام في الحالة التي يُحضِّرها دائرتك الكمومية. سياق شائع هو تقدير طاقة نظام في حالة معينة.

رسم توضيحي لنتائج Sampler على شكل رسم بياني. بعض الحالات شديدة الاحتمالية للقياس، وأخرى نادرة الاحتمالية.

آخر ما سنتناوله في هذا القسم هو التصريف (transpilation). التصريف هو عملية إعادة كتابة دائرة مدخلة لتتوافق مع القيود المادية وبنية مجموعة التعليمات (ISA) لجهاز كمومي محدد. مشابهاً لمُصرِّفات البرمجيات الكلاسيكية، يعني ذلك ترجمة العمليات الأحادية المجردة إلى مجموعة البوابات الأصلية التي يستطيع الجهاز المستهدف تنفيذها. كما يُحسِّن تعليمات الدائرة للتنفيذ الفعّال على الحواسيب الكمومية الضوضائية، وتغيّر الروتين تدريجياً بنية الدائرة من خلال تطبيق عدة مراحل تحسين.

رسم تخطيطي للتصريف يوضح كيف تُحوَّل دائرة مجردة إلى دائرة بنية مجموعة التعليمات. أي أن الدائرة تُعاد كتابتها باستخدام البوابات الأصلية والترابطية للعتاد المستهدف.

اختبر فهمك

كم عدد الكيوبتات في الدائرة أدناه؟ رسم تخطيطي لدائرة بأربعة خطوط أفقية وبوابات كثيرة.

الإجابة:

أربعة.

اختبر فهمك

افترض أنك تنمذج الإلكترونات في جزيء. تريد تقريب (أ) طاقة الحالة الأرضية للجزيء، و(ب) أي الحالات الأساسية الحسابية أكثر هيمنةً في الحالة الأرضية للجزيء. في كل حالة، هل ستستخدم Estimator أم Sampler؟

الإجابة:

(أ) Estimator (ب) Sampler

نماذج البرمجة الكلاسيكية

ثمة نماذج برمجة كثيرة للحواسيب الكلاسيكية، لكن في هذا القسم سنركز على اثنتَين من أشهرها: البرمجة المتوازية وسير عمل المهام. باستخدام هذين النموذجَين جانباً نماذج البرمجة الكمومية، يمكن للمرء التعبير عن تقريباً أي سير عمل هجين كمومي-كلاسيكي بأي تعقيد.

البرمجة المتوازية

البرمجة المتوازية هي نموذج يقسّم البرنامج إلى مسائل فرعية يمكن تنفيذها في آنٍ معاً. ثمة نموذجان رئيسيان للبرمجة المتوازية:

  • متوازية الذاكرة المشتركة (Open Multiprocessing, أو OpenMP): تُستخدم لاستغلال نوى متعددة داخل عقدة حوسبة واحدة. تشترك خيوط التنفيذ في فضاء ذاكرة واحد.

  • متوازية الذاكرة الموزعة (Message Passing Interface, أو MPI): تُستخدم للتوسع عبر عقد حوسبة منفصلة متعددة. كل عملية لها فضاء ذاكرتها المعزولة.

هنا، سنركز على نموذج الذاكرة الموزعة لأنه ضروري للحوسبة الفائقة متعددة العقد وتنسيق مهام كمومية-كلاسيكية متباينة على نطاق واسع.

هناك بعض المفاهيم التي نحتاج إلى فهمها للعمل في نماذج برمجة الذاكرة الموزعة المتوازية:

  • العملية (Process) - نسخة مستقلة من البرنامج مع فضاء ذاكرتها الخاص.
  • الرتبة (Rank) - معرّف صحيح فريد مُخصَّص لكل عملية، يُستخدم تحديداً لتحديد المُرسِل والمُستقبِل أثناء الاتصال (ليست بالضرورة "رتبة" بمعنى الأولوية).
  • المزامنة (Synchronization) - آلية للتنسيق بين رتب وعمليات مختلفة.
  • برنامج واحد، بيانات متعددة (SPMD) - نموذج حسابي مجرد حيث نسخة كود مصدر واحدة تعمل في آنٍ معاً على عمليات متعددة، كل منها تعمل على مجموعة فرعية مختلفة من البيانات الكلية.
  • تمرير الرسائل (Message passing) - نموذج الاتصال المستخدم في بنى الذاكرة الموزعة الذي يتيح للعمليات المستقلة تبادل البيانات والنتائج الوسيطة. يعتمد على عمليات 'إرسال' و'استقبال' صريحة لتنسيق التنفيذ بين عقد الحوسبة المختلفة.

هناك معيار يُسمى MPI يُنفِّذ هذا النموذج لتمرير الرسائل في البنى المتوازية. يعمل MPI كتجسيد وظيفي لجميع المفاهيم المدرجة أعلاه، إذ يوفر استدعاءات المكتبة المحددة اللازمة لإدارة العمليات وتعيين الرتب وتيسير المزامنة وتفعيل تمرير الرسائل تحت نموذج SPMD. بجمع كل هذه المفاهيم، يمكننا القول إن تنفيذ البرنامج المتوازي يسير بالطريقة الآتية:

  • يُنسَخ برنامج مُصرَّف واحد (نفس الملف الثنائي) وينفّذه مُشغِّل المهام لإنشاء عمليات متوازية متعددة عبر عقد متعددة.
  • تيار التحكم الرئيسي للبرنامج يُمليه رتبة العملية. هذا هو مبدأ SPMD في العمل: يستخدم البرنامج منطق شرط (مثلاً، if (rank == 0)) لضمان تنفيذ أقسام معينة ومتوازية فقط من الكود بواسطة عمليات العامل، بينما تتولى العملية الرئيسية (كثيراً ما تكون Rank 0) التهيئة والتجميع النهائي.
  • التواصل بين العمليات يجري عبر تمرير الرسائل (باستخدام MPI)، ويُستدعى كلما احتاجت عملية إلى تبادل بيانات أو نتائج وسيطة مع رتبة أخرى.

بصرياً، سيبدو الأمر كالآتي:

رسم تخطيطي لمهمة مُقسَّمة بين عقد.

لنجرب تطبيق بعض المفاهيم التي تعلمناها للتو في الكود.

أولاً، سنحاول تشغيل برنامج "مرحباً بالعالم" المتوازي البسيط باستخدام OpenMPI، وهو تنفيذ لبروتوكول MPI، معيار لتمرير الرسائل في البرمجة المتوازية. هنا، سنستخدم حزمة Python mpi4py، وهي ربط Python لمعيار واجهة تمرير الرسائل (MPI).

$ vim mpi-hello-world.py
from mpi4py import MPI
import sys

comm = MPI.COMM_WORLD
rank = comm.Get_rank()
size = comm.Get_size()

sys.stdout.write(f"[Rank {rank}] Hello from process {rank} of {size}!\n")

if rank == 0:
data = {'answer': 42, 'pi': 3.14}
sys.stdout.write(f"[Rank {rank}] Sending: {data}\n")
comm.send(data, dest=1, tag=42)
elif rank == 1:
data = comm.recv(source=0, tag=42)
sys.stdout.write(f"[Rank {rank}] Received: {data}\n")

~
~

سنستخدم عقدتَين لتشغيل هذا البرنامج، وهو ما سنحدده في نص التقديم.

$ vim mpi-hello-world.sh

#!/bin/bash
#
#SBATCH --job-name=mpi-hello-world
#SBATCH --output=mpi-hello-world.out
#SBATCH --nodes=2
#SBATCH --ntasks-per-node=1
#SBATCH --cpus-per-task=1
#SBATCH --partition=normal

/usr/lib64/openmpi/bin/mpirun python /data/ch3/parallel/mpi-hello-world.py

ثم نشغّل نص الشل.

$ sbatch mpi-hello-world.sh

يمكننا التحقق من سجلات نتائج المهمة.

$ cat mpi-hello-world.out | grep Rank

[Rank 1] Hello from process 1 of 2!
[Rank 0] Hello from process 0 of 2!
[Rank 0] Sending: {'answer': 42, 'pi': 3.14}
[Rank 1] Received: {'answer': 42, 'pi': 3.14}

هنا استخدمنا عقدتَين والعملية على كل عقدة تُعرَّف الآن برتبة - Rank 0 وRank 1 - والتي تُستخدم لتحديد تيار التحكم في البرنامج.

سير عمل المهام

الآن لنتحدث عن نموذج برمجة سير عمل المهام. يُجرِّد سير عمل المهام الحساب إلى رسم بياني موجّه لاحلقي (DAG). في هذا الرسم البياني، كل عقدة تمثل مهمة أو وظيفة معينة، والحواف (الأسهم التي تربط العقد) تمثل التبعيات (البيانات والترتيب) بينها. المجدوِل هو المكوّن الذي يعيّن المهام إلى الموارد وينسق التنفيذ.

مثال ملموس على نموذج سير عمل المهام المطبَّق على الحوسبة الكمومية هو إطار أنماط Qiskit. نمط Qiskit هو إطار عمل عام مصمم لتجزئة المسائل الخاصة بمجال معين إلى سلسلة من المراحل، خاصةً للمهام الكمومية. هذا يتيح قابلية التأليف السلسة للقدرات الجديدة التي طورها باحثو IBM Quantum® (وغيرهم) ويُهيئ مستقبلاً تُنجز فيه مهام الحوسبة الكمومية بواسطة بنية تحتية حاسوبية متباينة قوية (CPU/GPU/QPU). الخطوات الأربع لنمط Qiskit هي: التعيين، والتحسين، والتنفيذ، والمعالجة اللاحقة، حيث تُنفَّذ جميع المهام واحدة تلو الأخرى في خط أنابيب. لكن مع سير عمل المهام لسنا مقيدين بترتيب تنفيذ خطي ويمكننا تنفيذ المهام بالتوازي. كل مهمة في سير العمل يمكن أن تكون وظيفة متوازية كاملة بذاتها. لذا يمكنك مزج هذه النماذج ومطابقتها لوصف خوارزميات معقدة بشكل تعسفي، وسيتولى مدير الأحمال مثل Slurm التعامل معها.

رسم تخطيطي لمهام حوسبة منظمة في سير عمل حيث تُنفَّذ بعض العمليات بالتوازي وأخرى بالتسلسل.

الصورة أعلاه توضح نمط Qiskit في العمل. لسير العمل بنية رسم بياني بأربع مراحل. هذه البنية الفرعية الشجرية يُنسِّقها المجدوِل وينفّذها. تُعيَّن المسألة إلى صورة قابلة للتنفيذ كمومياً (دائرة كمومية) في المرحلة الأولى. في المرحلة التالية، تُحسَّن هذه الدائرة الكمومية للعتاد الكمومي المحدد. تُظهر الصورة هذا كعملية متوازية، مما يُوضّح كيف يمكن تطبيق إستراتيجيات تحسين متعددة في نفس الوقت. تُنفَّذ الدائرة الكمومية المُحسَّنة بعد ذلك على العتاد الكمومي الفعلي. هذه هي المرحلة الثالثة في الصورة حيث يعمل المجدوِل مع وحدة معالجة كمومية أرجوانية واحدة. أخيراً، تُعالَج النتائج بواسطة موارد كلاسيكية.

لماذا كلاهما؟

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

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

مخطط لسير عمل خاص بالتقطير الكمومي المستند إلى العينات. تشمل الخطوات دائرة كمومية تغايرية، واستخدام القياسات لإسقاط الهاميلتوني في فضاء فرعي، ثم استخدام مُحسِّن كلاسيكي لتحديث المعاملات التغايرية في الدائرة والتكرار.

سيُراجَع هذا سير العمل بتفصيل أكبر وسيُنفَّذ في القسم التالي. الشيء الوحيد الذي تحتاج إلى استيعابه من هذا القسم هو أن سير عمل المهام ضروري.

ممارسة البرمجة

جمال نماذج البرمجة أنه يمكنك مزجها ومطابقتها معاً. بمعرفة نماذج البرمجة الكمومية والكلاسيكية، يمكنك وصف حساب متباين بأي تعقيد وتنفيذه على العتاد. لنمارس ذلك مع مثال صغير لسير عمل مدمج، يُنفِّذ نمط Qiskit (تعيين، تحسين، تنفيذ، ومعالجة لاحقة) داخل Slurm الذي تعلمناه في الفصل الأخير. كل مهمة من المهام الأربع ستكون وظيفة Slurm منفصلة، لكل منها مواردها الخاصة. ستستخدم مهمة التحسين MPI لتحسين الدوائر بالتوازي (للتوضيح فحسب، كالصورة أعلاه). ستستخدم مهمة التنفيذ موارد كمومية ونماذج برمجة كمومية (دائرة وsampler). المهمة الأخيرة - المعالجة اللاحقة - ستستخدم MPI مجدداً بالتوازي مع موارد كلاسيكية.

التعيين

برنامج mapping.py مصمم لبناء دائرة PauliTwoDesign، المستخدمة كثيراً في أدبيات تعلم الآلة الكمومي وأدبيات المعايرة الكمومية، مع قياس بسيط يقيس الكيوبت رقم (n1)(n-1) في اتجاه ZZ لنظام من nn كيوبت مع معاملات ابتدائية عشوائية. كل من هذه العناصر (الدائرة الكمومية المحوَّلة إلى ملف qasm، والقياس، والمعاملات) ستُحفظ في ملف منفصل تحت مجلد البيانات وستُستخدم كمدخل في مرحلة التحسين.

نص الشل لهذه المرحلة (mapping.sh) هو

#!/bin/bash
#
#SBATCH --job-name=mapping
#SBATCH --output=mapping.out
#SBATCH --nodes=1
#SBATCH --ntasks-per-node=1
#SBATCH --cpus-per-task=1
#SBATCH --partition=normal

srun python /data/ch3/workflows/mapping.py

الذي يحدد اسم المهمة وصيغة المخرجات وعدد العقد/المهام/وحدات المعالجة المركزية.

التحسين

برنامج optimization.py يبدأ باستيراد الملفات من مرحلة التعيين. هنا ستستخدم QRMI لإدخال موارد كمومية في هذا البرنامج.

qrmi = QRMI()
resources = qrmi.resources()
quantum_resource = resources[0]
...

ثم يُجري تحسيناً خفيفاً بتعيين optimization_level=1 لتصريف الدائرة الكمومية وتطبيق تخطيط الدائرة على القياس، ثم حفظ هذه في مجلد البيانات.

نص الشل لهذه المرحلة (optimization.sh) هو

#!/bin/bash
#SBATCH --job-name=optimization
#SBATCH --output=output/optimization.out
#SBATCH --ntasks=4
#SBATCH --partition=classical

srun python3 /tmp/optimization.py

هنا --ntasks=4 تطلب أربع مهام كلاسيكية من Slurm لعملية متوازية.

التنفيذ

هذه هي المرحلة الكمومية الأساسية حيث تُشغَّل الدائرة الكمومية المُحسَّنة من الخطوة السابقة على وحدة المعالجة الكمومية بواسطة Estimator. لتحقيق ذلك، أولاً سنستيرد ثلاثة ملفات - الدائرة الكمومية المُصرَّفة، والقياس، والمعاملات الابتدائية - ثم نمررها إلى Estimator. يُنتج القيمة المُقدَّرة للقياس ويطبعها.

نص execution.sh يستعين بإضافة Slurm لاستخدام مورد كمومي.

#!/bin/bash
#
#SBATCH --job-name=execution
#SBATCH --output=execution.out
#SBATCH --nodes=1
#SBATCH --ntasks-per-node=1
#SBATCH --cpus-per-task=1
#SBATCH --partition=quantum
#SBATCH --gres=qpu:1

srun python /data/ch3/workflows/execution.py

المعالجة اللاحقة

غالباً ما تتضمن خطوة المعالجة اللاحقة التقطير الكلاسيكي وفحوصات الاتساق الذاتي. وقد تكون تكرارية أيضاً. من المفيد أكثر النظر في خطوة المعالجة اللاحقة في الدرس التالي، حيث يكون السياق الفيزيائي والغرض من الخطوات التكرارية واضحَين.

تجميع كل شيء معاً

يمكننا ربط جميع هذه المهام في سير عمل باستخدام وسيط التبعية لأمر sbatch:

$ MAPPING_JOB=$(sbatch --parsable mapping.sh)
$ OPTIMIZE_JOB=$(sbatch --parsable --dependency=afterok:$MAPPING_JOB optimization.sh)
$ EXECUTE_JOB=$(sbatch --parsable --dependency=afterok:$OPTIMIZE_JOB execute.sh)

ويمكننا التحقق من قائمة انتظار تنفيذ Slurm.

$ squeue
# JOBID PARTITION NAME USER ST TIME NODES NODELIST(REASON)
# 3 classical mapping admin PD 0:00 1 (None)
# 4 classical optimiza admin PD 0:00 1 (Dependency)
# 5 quantum execute admin PD 0:00 1 (Dependency)

كان هذا مثالاً توضيحياً لعرض مزيج نماذج البرمجة. في الفصل التالي سنتناول خوارزميات من العالم الحقيقي ونوضح نماذج البرمجة وإدارة الموارد على سير عمل مفيدة.

ملخص

في هذا الدرس، وضّحنا كيفية دمج نماذج برمجة كلاسيكية وكمومية متعددة لبناء وإدارة وتنفيذ سير عمل كامل من أربع مراحل. بدأنا بالمفاهيم الأساسية للدوائر الكمومية والعمليات الأولية، ثم استكشفنا النماذج الكلاسيكية كالبرمجة المتوازية وسير عمل المهام. بدمج جميع المفاهيم، بنينا نمط Qiskit — تعيين، تحسين، تنفيذ، ومعالجة لاحقة — يُنسِّقه مدير الأحمال Slurm مع دائرة كمومية بسيطة وقياس.

في الدرس التالي، سنستخدم هذا الإطار لتشغيل خوارزميات كمومية مستندة إلى العينات، مُوضِّحين كيف يمكن تطبيق سير العمل هذا لحل مسائل ذات معنى.

جميع الكود والنصوص البرمجية المستخدمة في هذا الفصل متاحة لك في هذا مستودع Github.