بدء استخدام GPU Compute على الويب

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

François Beaufort
François Beaufort

الخلفية

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

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

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

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

الوصول إلى وحدة معالجة الرسومات

يمكن الوصول بسهولة إلى وحدة معالجة الرسومات في WebGPU. يؤدي استدعاء navigator.gpu.requestAdapter() إلى عرض وعد JavaScript سيتم حلّه بشكل غير متزامن باستخدام محوِّل GPU. يمكنك اعتبار هذا المحوِّل بمثابة بطاقة الرسومات. يمكن أن يكون مدمجًا (على الشريحة نفسها التي تتضمّن وحدة المعالجة المركزية) أو منفصلاً (عادةً ما يكون بطاقة PCIe ذات أداء أفضل ولكنّها تستهلك طاقة أكبر).

بعد حصولك على محوِّل وحدة معالجة الرسومات، اتّصل برقم adapter.requestDevice() للحصول على وعد سيؤدي إلى حلّ المشكلة باستخدام جهاز وحدة معالجة الرسومات الذي ستستخدمه لإجراء بعض العمليات الحسابية باستخدام وحدة معالجة الرسومات.

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) { return; }
const device = await adapter.requestDevice();

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

ذاكرة التخزين المؤقت للكتابة

لنتعرّف على كيفية استخدام JavaScript لكتابة البيانات إلى الذاكرة لوحدة GPU. وهذه العملية ليست مباشرة بسبب نموذج وضع الحماية المستخدم في متصفحات الويب الحديثة.

يوضّح لك المثال أدناه كيفية كتابة أربعة بايت في الذاكرة المؤقتة التي يمكن للوحدة المعالجة الرسومية الوصول إليها. وتستدعي device.createBuffer() حجم المخزن المؤقت واستخدامه. على الرغم من أنّ علامة الاستخدام GPUBufferUsage.MAP_WRITE ليست مطلوبة لهذه المكالمة المحدّدة، لنوضّح أنّنا نريد الكتابة إلى هذا المخزن المؤقت. وينتج عن ذلك ربط عنصر المخزن المؤقت لوحدة معالجة الرسومات عند الإنشاء بفضل ضبط السمة mappedAtCreation على "صحيح". بعد ذلك، يمكن استرجاع ملف تخزين البيانات الثنائية الأولية المرتبط من خلال استدعاء طريقة ملف تخزين وحدة معالجة الرسومات getMappedRange().

إذا سبق لك استخدام ArrayBuffer، ستكون الكتابة بوحدات البايت مألوفة لك. استخدِم ملف TypedArray وانسخ القيم فيه.

// Get a GPU buffer in a mapped state and an arrayBuffer for writing.
const gpuBuffer = device.createBuffer({
  mappedAtCreation: true,
  size: 4,
  usage: GPUBufferUsage.MAP_WRITE
});
const arrayBuffer = gpuBuffer.getMappedRange();

// Write bytes to buffer.
new Uint8Array(arrayBuffer).set([0, 1, 2, 3]);

في هذه المرحلة، يتمّ ربط وحدة تخزين مؤقت لوحدة معالجة الرسومات، ما يعني أنّها مملوكة لوحدة المعالجة المركزية، ويمكن الوصول إليها للقراءة/الكتابة من JavaScript. لكي تتمكّن وحدة معالجة الرسومات من الوصول إلى المساحة، يجب أولاً إلغاء ربطها، وذلك من خلال استدعاء gpuBuffer.unmap().

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

ذاكرة التخزين المؤقت للقراءة

لنطّلِع الآن على كيفية نسخ وحدة تخزين مؤقت لوحدة معالجة الرسومات إلى وحدة تخزين مؤقت أخرى لوحدة معالجة الرسومات وقراءتها مرة أخرى.

بما أنّنا نكتب في أول وحدة تخزين مؤقت لوحدة معالجة الرسومات ونريد نسخها إلى ملف تخزين مؤقت ثاني لوحدة معالجة الرسومات، يجب استخدام علامة استخدام جديدة GPUBufferUsage.COPY_SRC. تم إنشاء ملف التخزين المؤقت الثاني لوحدة معالجة الرسومات في حالة غير مرتبطة هذه المرة باستخدام device.createBuffer(). علامة الاستخدام الخاصة بهذه السياسة هي GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ لأنّها ستُستخدم كوجهة أول مخزن مؤقت لوحدة معالجة الرسومات وستتم قراءتها في JavaScript بعد تنفيذ أوامر نسخ وحدة معالجة الرسومات.

// Get a GPU buffer in a mapped state and an arrayBuffer for writing.
const gpuWriteBuffer = device.createBuffer({
  mappedAtCreation: true,
  size: 4,
  usage: GPUBufferUsage.MAP_WRITE | GPUBufferUsage.COPY_SRC
});
const arrayBuffer = gpuWriteBuffer.getMappedRange();

// Write bytes to buffer.
new Uint8Array(arrayBuffer).set([0, 1, 2, 3]);

// Unmap buffer so that it can be used later for copy.
gpuWriteBuffer.unmap();

// Get a GPU buffer for reading in an unmapped state.
const gpuReadBuffer = device.createBuffer({
  size: 4,
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
});

ولأنّ وحدة معالجة الرسومات هي معالج مساعد مستقل، يتم تنفيذ جميع أوامر وحدة معالجة الرسومات بشكل غير متزامن. ولهذا السبب هناك قائمة بأوامر وحدة معالجة الرسومات مدمجة ويتم إرسالها على دفعات عند الحاجة. في WebGPU، يكون برنامج ترميز أوامر وحدة معالجة الرسومات الذي يعرضه device.createCommandEncoder() هو كائن JavaScript الذي ينشئ مجموعة من الطلبات "المخزّنة مؤقتًا" التي سيتم إرسالها إلى وحدة معالجة الرسومات في مرحلة ما. من ناحية أخرى، تكون الطرق على GPUBuffer "غير مخزنة مؤقتًا"، ما يعني أنه يتم تنفيذها بشكل جزئي في وقت استدعائها.

بعد الحصول على برنامج ترميز أوامر وحدة معالجة الرسومات، يمكنك استدعاء copyEncoder.copyBufferToBuffer() كما هو موضّح أدناه لإضافة هذا الأمر إلى قائمة انتظار الأوامر لتنفيذه لاحقًا. أخيرًا، يمكنك إنهاء أوامر الترميز من خلال استدعاء copyEncoder.finish() وإرسالها إلى قائمة انتظار أوامر جهاز وحدة معالجة الرسومات. تتحمّل "القائمة الانتظار" مسؤولية معالجة عمليات الإرسال التي تتم من خلال device.queue.submit() باستخدام أوامر وحدة معالجة الرسومات كوسائط. سيؤدي ذلك إلى تنفيذ جميع الأوامر المخزّنة في الصفيف بالترتيب.

// Encode commands for copying buffer to buffer.
const copyEncoder = device.createCommandEncoder();
copyEncoder.copyBufferToBuffer(
  gpuWriteBuffer /* source buffer */,
  0 /* source offset */,
  gpuReadBuffer /* destination buffer */,
  0 /* destination offset */,
  4 /* size */
);

// Submit copy commands.
const copyCommands = copyEncoder.finish();
device.queue.submit([copyCommands]);

في هذه المرحلة، تم إرسال أوامر "قائمة انتظار وحدة معالجة الرسومات"، ولكن ليس بالضرورة أن يتم تنفيذها. لقراءة وحدة تخزين مؤقت ثانوية لوحدة معالجة الرسومات، يمكنك استدعاء gpuReadBuffer.mapAsync() مع GPUMapMode.READ. ويعرض وعدًا سيتم حلّه عند ربط ذاكرة التخزين المؤقت لوحدة معالجة الرسومات. بعد ذلك، احصل على النطاق المرتبط باستخدام gpuReadBuffer.getMappedRange() الذي يحتوي على القيم نفسها التي يحتوي عليها أول وحدة تخزين مؤقت لوحدة معالجة الرسومات بعد تنفيذ جميع أوامر وحدة معالجة الرسومات التي تم وضعها في "قائمة الانتظار".

// Read buffer.
await gpuReadBuffer.mapAsync(GPUMapMode.READ);
const copyArrayBuffer = gpuReadBuffer.getMappedRange();
console.log(new Uint8Array(copyArrayBuffer));

يمكنك تجربة هذا النموذج.

باختصار، إليك ما يجب تذكره في ما يتعلق بعمليات ذاكرة التخزين المؤقت:

  • يجب إلغاء ربط ذاكرة التخزين المؤقت لوحدة معالجة الرسومات لاستخدامها في إرسال بيانات الجهاز إلى "قائمة الانتظار".
  • عند ربطها، يمكن قراءة وكتابة مخازن مؤقتة لوحدة معالجة الرسومات باستخدام JavaScript.
  • يتم ربط مخازن GPU عند استدعاء mapAsync() وcreateBuffer() مع تحديد mappedAtCreation على true.

برمجة Shader

تسمى البرامج التي تعمل على وحدة معالجة الرسومات التي تجري عمليات حسابية فقط (ولا ترسم مثلات مثلثات) بأدوات تظليل الحوسبة. ويتم تنفيذها بشكل موازٍ من خلال مئات نوى وحدة معالجة الرسومات (التي تكون أصغر من نوى وحدة المعالجة المركزية) التي تعمل معًا لمعالجة البيانات. إدخالاتها ومخرجاتها هي مخازن مؤقتة في WebGPU.

لتوضيح استخدام برامج shaders الحسابية في WebGPU، سنستخدم عملية ضرب المصفوفات، وهي خوارزمية شائعة في تعلُّم الآلة موضّحة أدناه.

مخطط ضرب المصفوفة
مخطّط ضرب المصفوفات

باختصار، في ما يلي الإجراءات التي سننفذها:

  1. أنشئ ثلاثة مخازن لوحدة معالجة الرسومات (اثنان للمصفوفات التي سيتم ضربها وواحد ل مصفوفة النتائج).
  2. وصف الإدخال والإخراج لشريحة البرامج الحسابية
  3. تجميع رمز برنامج التظليل الحسابي
  4. إعداد مسار بيانات حسابية
  5. إرسال الأوامر المشفَّرة إلى وحدة معالجة الرسومات بشكل مجمّع
  6. قراءة المخزن المؤقت لوحدة معالجة الرسومات في مصفوفة النتائج

إنشاء وحدات تخزين مؤقتة لوحدة معالجة الرسومات

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

تمثيل بسيط لمصفوفة في JavaScript وما يعادلها في الترميز الرياضي
تمثيل بسيط لمصفّفة في JavaScript وما يعادلها في الرموز الرياضية

وتكون مصفوفات التخزين الثلاث لوحدة معالجة الرسومات هي مصفوفات التخزين لأنّنا نحتاج إلى تخزين البيانات واستردادها في shader الحسابي. وهذا ما يفسّر سبب تضمين علامات استخدام وحدة تخزين مؤقت لوحدة معالجة الرسومات GPUBufferUsage.STORAGE لكلّ منها. تحتوي علامة استخدام مصفوفة النتائج أيضًا علىGPUBufferUsage.COPY_SRC لأنّه سيتم نسخها إلى ذاكرة تخزين مؤقت أخرى لقراءة بعد تنفيذ جميع أوامر "قائمة انتظار وحدة معالجة الرسومات".

const adapter = await navigator.gpu.requestAdapter();
if (!adapter) { return; }
const device = await adapter.requestDevice();


// First Matrix

const firstMatrix = new Float32Array([
  2 /* rows */, 4 /* columns */,
  1, 2, 3, 4,
  5, 6, 7, 8
]);

const gpuBufferFirstMatrix = device.createBuffer({
  mappedAtCreation: true,
  size: firstMatrix.byteLength,
  usage: GPUBufferUsage.STORAGE,
});
const arrayBufferFirstMatrix = gpuBufferFirstMatrix.getMappedRange();
new Float32Array(arrayBufferFirstMatrix).set(firstMatrix);
gpuBufferFirstMatrix.unmap();


// Second Matrix

const secondMatrix = new Float32Array([
  4 /* rows */, 2 /* columns */,
  1, 2,
  3, 4,
  5, 6,
  7, 8
]);

const gpuBufferSecondMatrix = device.createBuffer({
  mappedAtCreation: true,
  size: secondMatrix.byteLength,
  usage: GPUBufferUsage.STORAGE,
});
const arrayBufferSecondMatrix = gpuBufferSecondMatrix.getMappedRange();
new Float32Array(arrayBufferSecondMatrix).set(secondMatrix);
gpuBufferSecondMatrix.unmap();


// Result Matrix

const resultMatrixBufferSize = Float32Array.BYTES_PER_ELEMENT * (2 + firstMatrix[0] * secondMatrix[1]);
const resultMatrixBuffer = device.createBuffer({
  size: resultMatrixBufferSize,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC
});

ربط تنسيق المجموعة وربط المجموعة

إنّ مفاهيم تنسيق مجموعة الربط ومجموعة الربط خاصة بـ WebGPU. يحدِّد تنسيق مجموعة الربط واجهة الإدخال/الإخراج المتوقّعة من برنامج تشويش، في حين تمثِّل مجموعة الربط بيانات الإدخال/الإخراج الفعلية لبرنامج تشويش.

في المثال أدناه، يتوقّع تنسيق مجموعة الربط اثنين من مخازن التخزين للقراءة فقط عند عمليات ربط الإدخال المرقّمة 0 و1، ومستودع تخزين في 2 لبرنامج التظليل الحسابي. من ناحية أخرى، تربط مجموعة الربط هذه، المحددة لتنسيق مجموعة الربط هذا، المخزن المؤقت لوحدة معالجة الرسومات بالإدخالات: gpuBufferFirstMatrix بالربط 0 وgpuBufferSecondMatrix بالربط 1 وresultMatrixBuffer بالربط 2.

const bindGroupLayout = device.createBindGroupLayout({
  entries: [
    {
      binding: 0,
      visibility: GPUShaderStage.COMPUTE,
      buffer: {
        type: "read-only-storage"
      }
    },
    {
      binding: 1,
      visibility: GPUShaderStage.COMPUTE,
      buffer: {
        type: "read-only-storage"
      }
    },
    {
      binding: 2,
      visibility: GPUShaderStage.COMPUTE,
      buffer: {
        type: "storage"
      }
    }
  ]
});

const bindGroup = device.createBindGroup({
  layout: bindGroupLayout,
  entries: [
    {
      binding: 0,
      resource: {
        buffer: gpuBufferFirstMatrix
      }
    },
    {
      binding: 1,
      resource: {
        buffer: gpuBufferSecondMatrix
      }
    },
    {
      binding: 2,
      resource: {
        buffer: resultMatrixBuffer
      }
    }
  ]
});

رمز برنامج تظليل الحساب

سيكون رمز أداة تظليل الحوسبة لضرب المصفوفات مكتوبًا بلغة WGSL، وهي لغة WebGPU Shader Language التي يمكن ترجمتها بشكل بسيط إلى SPIR-V. وبدون الخوض في التفاصيل، من المفترض أن تجد أدناه مخازن التخزين الثلاثة المحددة بـ var<storage>. سيستخدم البرنامج firstMatrix وsecondMatrix كأحد المدخلات وresultMatrix كأحد المخرجات.

يُرجى العِلم أنّ كل وحدة تخزين مؤقتة تحتوي على زخرفة binding يتم استخدامها بما يتوافق مع الفهرس نفسه المحدّد في تصاميم مجموعات الربط ومجموعات الربط الموضّحة أعلاه.

const shaderModule = device.createShaderModule({
  code: `
    struct Matrix {
      size : vec2f,
      numbers: array<f32>,
    }

    @group(0) @binding(0) var<storage, read> firstMatrix : Matrix;
    @group(0) @binding(1) var<storage, read> secondMatrix : Matrix;
    @group(0) @binding(2) var<storage, read_write> resultMatrix : Matrix;

    @compute @workgroup_size(8, 8)
    fn main(@builtin(global_invocation_id) global_id : vec3u) {
      // Guard against out-of-bounds work group sizes
      if (global_id.x >= u32(firstMatrix.size.x) || global_id.y >= u32(secondMatrix.size.y)) {
        return;
      }

      resultMatrix.size = vec2(firstMatrix.size.x, secondMatrix.size.y);

      let resultCell = vec2(global_id.x, global_id.y);
      var result = 0.0;
      for (var i = 0u; i < u32(firstMatrix.size.y); i = i + 1u) {
        let a = i + resultCell.x * u32(firstMatrix.size.y);
        let b = resultCell.y + i * u32(secondMatrix.size.y);
        result = result + firstMatrix.numbers[a] * secondMatrix.numbers[b];
      }

      let index = resultCell.y + resultCell.x * u32(secondMatrix.size.y);
      resultMatrix.numbers[index] = result;
    }
  `
});

إعداد مسار الإجراءات

مسار حساب البيانات هو العنصر الذي يصف فعليًا عملية الحساب التي سننفّذها. يمكنك إنشاؤه من خلال الاتصال على device.createComputePipeline(). تأخذ هذه الدالة مَعلمتَين: تنسيق مجموعة الربط الذي أنشأناه سابقًا، وخطوة حساب تحدد نقطة دخول برنامجنا الحسابي (دالة main WGSL) ومرّكب برنامج الحسابي الفعلي الذي تم إنشاؤه باستخدام device.createShaderModule().

const computePipeline = device.createComputePipeline({
  layout: device.createPipelineLayout({
    bindGroupLayouts: [bindGroupLayout]
  }),
  compute: {
    module: shaderModule,
    entryPoint: "main"
  }
});

إرسال الطلبات

بعد إنشاء مجموعة ربط باستخدام ثلاث وحدات تخزين مؤقت لوحدة معالجة الرسومات ومسار محاسبة مع تنسيق مجموعة ربط، حان وقت استخدامها.

لنبدأ برنامج ترميز مبرمَج لمرور البيانات الحسابية باستخدام commandEncoder.beginComputePass(). سنستخدم ذلك لتشفير أوامر وحدة معالجة الرسومات التي ستُجري عملية ضرب المصفوفة. اضبط مسار الإحالة الناجحة باستخدام passEncoder.setPipeline(computePipeline) ومجموعة الربط في المؤشر 0 باستخدام passEncoder.setBindGroup(0, bindGroup). يتطابق الفهرس 0 مع الزخرفة group(0) في رمز WGSL.

لنتحدث الآن عن كيفية تنفيذ برنامج تظليل الحساب على وحدة معالجة الرسومات. هدفنا هو تنفيذ هذا البرنامج بالتوازي مع كل خلية في مصفوفة النتيجة خطوة بخطوة. بالنسبة إلى مصفوفة النتائج التي يبلغ حجمها 16 x ‏32 مثلاً، لتشفير أمر التنفيذ، في @workgroup_size(8, 8)، سنستدعي passEncoder.dispatchWorkgroups(2, 4) أو passEncoder.dispatchWorkgroups(16 / 8, 32 / 8). الوسيطة الأولى "x" هي السمة الأولى، والوسيطة الثانية "y" هي السمة الثانية، والوسيطة الأخيرة "z" هي السمة الثالثة التي تكون قيمتها التلقائية 1 لأنّنا لا نحتاج إليها هنا. في عالم الحوسبة في وحدة معالجة الرسومات، يُعرف ترميز أمر لتنفيذ إحدى وظائف النواة على مجموعة من البيانات بالإرسال.

التنفيذ بشكل موازٍ لكل خلية في مصفوفة النتائج
التنفيذ بشكل موازٍ لكل خلية في مصفوفة النتائج

حجم شبكة مجموعة العمل لبرنامجنا المشتمِل على تظليل حسابي هو (8, 8) في رمز WGSL. ولهذا السبب، سيتم تقسيم "س" و "ص"، وهما عدد صفوف المصفوفة الأولى وعدد أعمدة المصفوفة الثانية على التوالي، بـ 8. بعد ذلك، يمكننا الآن إرسال طلب حساب باستخدام passEncoder.dispatchWorkgroups(firstMatrix[0] / 8, secondMatrix[1] / 8). ويمثّل عدد شبكات مجموعات العمل المطلوب تشغيلها وسيطات dispatchWorkgroups().

كما هو موضّح في الرسم أعلاه، سيتمكّن كلّ مخطّط ألوان من الوصول إلى عنصر builtin(global_invocation_id) فريد سيتم استخدامه لمعرفة خلية ملف تعريف الارتباط النتيجة التي سيتم احتسابها.

const commandEncoder = device.createCommandEncoder();

const passEncoder = commandEncoder.beginComputePass();
passEncoder.setPipeline(computePipeline);
passEncoder.setBindGroup(0, bindGroup);
const workgroupCountX = Math.ceil(firstMatrix[0] / 8);
const workgroupCountY = Math.ceil(secondMatrix[1] / 8);
passEncoder.dispatchWorkgroups(workgroupCountX, workgroupCountY);
passEncoder.end();

لإنهاء برنامج ترميز "تمرير البيانات الحسابية"، اتصل بالرقم passEncoder.end(). بعد ذلك، أنشئ ملف تدوير ذاكرة GPU لاستخدامه كوجهة لنسخ ملف تدوير ذاكرة مصفوفة النتائج باستخدام copyBufferToBuffer. أخيرًا، أكمِل أوامر الترميز باستخدام copyEncoder.finish() وأرسِلها إلى قائمة انتظار أجهزة وحدة معالجة الرسومات من خلال استدعاء device.queue.submit() مع أوامر وحدة معالجة الرسومات.

// Get a GPU buffer for reading in an unmapped state.
const gpuReadBuffer = device.createBuffer({
  size: resultMatrixBufferSize,
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ
});

// Encode commands for copying buffer to buffer.
commandEncoder.copyBufferToBuffer(
  resultMatrixBuffer /* source buffer */,
  0 /* source offset */,
  gpuReadBuffer /* destination buffer */,
  0 /* destination offset */,
  resultMatrixBufferSize /* size */
);

// Submit GPU commands.
const gpuCommands = commandEncoder.finish();
device.queue.submit([gpuCommands]);

قراءة مصفوفة النتائج

إنّ قراءة مصفوفة النتائج هي عملية سهلة مثل استدعاء gpuReadBuffer.mapAsync() باستخدام GPUMapMode.READ والانتظار إلى أن يتم حلّ الوعد الذي يشير إلى تمّ الآن ربط وحدة تخزين مؤقت لوحدة معالجة الرسومات. في هذه المرحلة، يمكن الحصول على النطاق الذي تم ربطه باستخدام gpuReadBuffer.getMappedRange().

نتيجة ضرب المصفوفة
نتيجة ضرب المصفوفة

في الرمز البرمجي، النتيجة المسجّلة في وحدة تحكّم JavaScript في DevTools هي "2، 2، 50، 60، 114، 140".

// Read buffer.
await gpuReadBuffer.mapAsync(GPUMapMode.READ);
const arrayBuffer = gpuReadBuffer.getMappedRange();
console.log(new Float32Array(arrayBuffer));

تهانينا! فهذا يعني أن العملية تمت بنجاح. يمكنك اللعب بالعيّنة.

حيلة أخيرة

من بين الطرق التي تسهِّل قراءة التعليمات البرمجية استخدام getBindGroupLayout السهلة في مسار تسلسل العمليات الحسابية لاستنتاج تنسيق مجموعة الربط من وحدة برنامج التظليل. تلغي هذه الحيلة الحاجة إلى إنشاء تخطيط مجموعة ربط مخصصة وتحديد تخطيط المسار في مسار الحوسبة كما ترون أدناه.

تتوفّر صورة توضيحية لـ getBindGroupLayout للنموذج السابق.

 const computePipeline = device.createComputePipeline({
-  layout: device.createPipelineLayout({
-    bindGroupLayouts: [bindGroupLayout]
-  }),
   compute: {
-// Bind group layout and bind group
- const bindGroupLayout = device.createBindGroupLayout({
-   entries: [
-     {
-       binding: 0,
-       visibility: GPUShaderStage.COMPUTE,
-       buffer: {
-         type: "read-only-storage"
-       }
-     },
-     {
-       binding: 1,
-       visibility: GPUShaderStage.COMPUTE,
-       buffer: {
-         type: "read-only-storage"
-       }
-     },
-     {
-       binding: 2,
-       visibility: GPUShaderStage.COMPUTE,
-       buffer: {
-         type: "storage"
-       }
-     }
-   ]
- });
+// Bind group
  const bindGroup = device.createBindGroup({
-  layout: bindGroupLayout,
+  layout: computePipeline.getBindGroupLayout(0 /* index */),
   entries: [

نتائج الأداء

إذًا، كيف يقارن تشغيل عملية ضرب المصفوفة على وحدة معالجة رسومات بتشغيلها على وحدة المعالجة المركزية؟ لمعرفة ذلك، كتبت البرنامج الموصوف للتوّ لوحدة المعالجة المركزية. وكما يمكنك الاطّلاع عليه في الرسم البياني أدناه، يبدو أنّ استخدام طاقة وحدة معالجة الرسومات الكاملة هو خيار واضح عندما يكون حجم المصفوفات أكبر من 256 x ‏256.

مقارنة بين أداء وحدة معالجة الرسومات ووحدة المعالجة المركزية (CPU)
مقاييس أداء وحدة معالجة الرسومات مقارنةً بوحدة المعالجة المركزية

كانت هذه المقالة مجرد بداية رحلتي في استكشاف WebGPU. ترقب المزيد من المقالات قريبًا التي تعرض المزيد من التفصيلات حول حوسبة GPU وحول كيفية عمل العرض (اللوحة والهيئة وعينة) في WebGPU.