حجز ذاكرة الجهاز في Vulkan

من ويكي
اذهب إلى: تصفح، ابحث
هذه الصفحة لاتزال تحت الإنشاء، سيتم تحديثها مستقبلاً

في هذا المقال سنتطرق لحجز الذاكرة على الجهاز في Vulkan، أي بيانات يجب أن تكون متوفرة على ذاكرة الجهاز كي تتمكن من معالجتها، ذاكرة الجهاز هي أي ذاكرة يمكن للجهاز الوصول لها، وكما أشرنا سابقاً لايشترط أن تكون الذاكرة منفصلة عن المضيف كما هو الحال في بطاقات الشاشة المنفصلة، بل يمكن أن يكون الجهاز معالج رسومي مدمج في المعالج يتشارك الذاكرة الرئيسية مع المعالج كما هو الحال في معالجات Intel HD Graphics و AMD APUs.

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

أنواع ذواكر الجهاز

تقسّم ذاكرة الجهاز في Vulkan إلى عدة أنواع types كل نوع يحدد طريقة وصول المضيف والجهاز لذاكرة معينة، كل نوع يرتبط بكومة heap وهي الذاكرة الفعلية التي سنحجز منها الذاكرة، في بطاقة شاشة مثل GeForce GTX 980 Ti لدي[١] كومتين، الأولى بحجم 6GB وهي ذاكرة بطاقة الشاشة المنفصلة، والثانية بحجم 32GB وهي الذاكرة الرئيسية RAM، وهناك أحد عشر نوعاً من أنواع الذواكر، إثنان منها مرتبط بالكومة الأولى الخاصة بذاكرة البطاقة والتسعة الباقية خاصة بكومة الذاكرة الرئيسية.

خذ أيضاً Intel HD Graphics 530 وهي معالج رسومي مدمج في بعض معالجات Intel مثل i7-6700K، في هذا التقرير هناك كومة واحدة لكومة ذاكرة حجمها قرابة 4GB، وهناك نوعان يشيران لنفس الكومة، بمعنى أن هذه هي نفسها الذاكرة الرئيسية لكنها مشتركة بين المعالج الرئيسي والمعالج الرسومي، لو تصفحت قاعدة البيانات على موقع Vulkan Hardware Databas ستجد تشكيلات مختلفة من الأجهزة بأنواع وكوم مختلفة.

للحصول على معلومات ذاكرة الجهاز وأنواعها وكومها يمكنك استدعاء الدالة vkGetPhysicalDeviceMemoryProperties():

void vkGetPhysicalDeviceMemoryProperties(
    VkPhysicalDevice                            physicalDevice,
    VkPhysicalDeviceMemoryProperties*           pMemoryProperties);

حيث تأخذ في المعامل الأول الكائن VkPhysicalDevice الخاص بالجهاز الذي تريد استخدامه، وتأخذ في المعامل الثاني مؤشر لبنية نوعها VkPhysicalDeviceMemoryProperties والتى تخزن فيها معلومات أنواع الذاكرة والكوم، أعضاء البنية VkPhysicalDeviceMemoryProperties:

typedef struct VkPhysicalDeviceMemoryProperties {
    uint32_t        memoryTypeCount;
    VkMemoryType    memoryTypes[VK_MAX_MEMORY_TYPES];
    uint32_t        memoryHeapCount;
    VkMemoryHeap    memoryHeaps[VK_MAX_MEMORY_HEAPS];
} VkPhysicalDeviceMemoryProperties;

أعضاؤها:

  • memoryTypeCount تمثل عدد عناصر المصفوفة memoryTypes
  • memoryTypes تحتوي هذه المصفوفة على معلومات أنواع الذاكرة المتوفرة على الجهاز، سنتحدث عنها بعد قليل
  • memoryHeapCount تمثل عدد عناصر المصفوفة memoryHeaps
  • memoryHeaps تحتوي هذه المصفوفة على معلومات الكوم في الذاكرة، سنتحدث عنها بعد قليل

أنواع الذاكرة

العنصر memoryTypes كما أشرنا مصفوفة تمثل أنواع الذاكرة، العنصر الواحد فيها VkMemoryType يمثّل معلومات نوع واحد، أعضاء البنية VkMemoryType:

typedef struct VkMemoryType {
    VkMemoryPropertyFlags    propertyFlags;
    uint32_t                 heapIndex;
} VkMemoryType;
  • propertyFlags عبارة عن راية بتات، حيث أن قيمتها قد تكون أحد أو تركيب من هذه الرايات (في حال كانت صفر -بدون رايات- فهذا يعني أن هذا النوع لا تنطبق عليه أي من الخصائص التالية):
    • VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT يعني أن الذاكرة المخصصة باستخدام هذا النوع تٌخصص من أسرع ذاكرة بالنسبة للجهاز لكونها محلية عنده، إذا لم تكن هذه الراية مرفوعة، فهذا يعني أن الكومة المنتمية لهذا النوع تابعة لذاكرة محلية بالنسبة للمضيف كالذاكرة الرئيسية، لا تستطيع الكتابة مباشرةً للكومة المنتمية لهذه النوع من الذاكرة مالم يكن النوع مرئي من المضيف VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT، وإلا فأنت بحاجة للكتابة لتلك الذاكرة باستخدام طابور نقل البيانات عن طريق مثلاً الكتابة بذاكرة مرئية للمضيف ثم إرسال أمر نسخ البيانات مثلاً باستخدام vkCmdCopyBuffer() لجعل الجهاز ينسخ البيانات لذاكرته المحلية، سنرى هذا مستقبلاً عند حديثنا عن الطوابير
    • VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT يعني أن هذه الذاكرة مرئية للمضيف، بمعنى أنه يمكنه تخصيص جزء من عنوان ذاكرته وربطها بذاكرة الجهاز باستخدام الدالة vkMapMemory() لنقل البيانات من وإلى ذاكرة الجهاز، في حال لم تكن الكومة لذاكرة متماسكة تحمل الراية VK_MEMORY_PROPERTY_HOST_COHERENT_BIT فلابد من استخدام الدالتين vkFlushMappedMemoryRanges() و vkInvalidateMappedMemoryRanges() وإلا فإن البيانات في الذاكرة قد لا تكون أحدث البيانات
    • VK_MEMORY_PROPERTY_HOST_COHERENT_BIT يعني أن هذه الذاكرة متماسكة بين المضيف والجهاز، بمعنى أن أي تعديلات تجريها على الذاكرة يراها المضيف والجهاز فوراً، فلو كتبت للذاكرة من المضيف أو لو كتب الجهاز للذاكرة فسيرى كلاً منهما البيانات التي كتبها الآخر فور كتابتها، سنفصل عند الحديث عن التخزين المؤقت والدالتين vkFlushMappedMemoryRanges() و vkInvalidateMappedMemoryRanges()
    • VK_MEMORY_PROPERTY_HOST_CACHED_BIT تعني أن البيانات المخزنة في هذه الذاكرة ذاكرة تخزن مؤقتاً في المضيف لغرض الوصول السريع وقد لا تعكس أحدث البيانات مالم تكن متماسكة VK_MEMORY_PROPERTY_HOST_COHERENT_BIT، الكتابة والقراءة من هذه الذاكرة أسرع للمضيف
    • VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT تعني أن الذاكرة تستخدم آلية متكاسلة في حجز الذاكرة، بمعنى أنك لو حجزت جزءً من الذاكرة فقد لاتحجز فوراً بل يتأخر حجزك إلى أن تبدأ باستخدام الذاكرة، لا يمكن للذاكرة التي لها هذه الخاصية أن تكون مرئية من المضيف
  • العضو heapIndex يمثل مؤشر كومة الذاكرة التي تنتمي لهذا النوع في المصفوفة VkPhysicalDeviceMemoryProperties::memoryHeaps، لاحظ أن الكومة يمكن أن تنتمي لعدة أنواع

تضمن Vulkan أن هناك دوماً نوع واحد على الأقل يمثّل ذاكرة متجانسة VK_MEMORY_PROPERTY_HOST_COHERENT_BIT ومرئية للمضيف VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT في نفس النوع، وتضمن أن هناك نوع واحد على الأقل يمثل ذاكرة محلية للجهاز VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT، لا يشترط أن يكونا نوعين منفصلين، فقد يكون هناك نوع واحد فقط لكنه يحقق جميع هذه الأنواع، أيضاً تسمح Vulkan فقط بهذه التركيبات التسعة:

0
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_CACHED_BIT
VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_CACHED_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_CACHED_BIT
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_CACHED_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT | VK_MEMORY_PROPERTY_LAZILY_ALLOCATED_BIT

الكومة

بالنسبة للبنية VkMemoryHeap فهي تمثل كومة ذاكرة:

typedef struct VkMemoryHeap {
    VkDeviceSize         size;
    VkMemoryHeapFlags    flags;
} VkMemoryHeap;

ولها عضوين فقط، الأول size وهو حجم الذاكرة الكلي، flags عبارة عن راية يمكن أن تأخذ قيمة واحدة وهي VK_MEMORY_HEAP_DEVICE_LOCAL_BIT في حال كانت الذاكرة محلية على الجهاز، هذه الكومة تمثل غالباً الذاكرة الأكفأ، تفرض Vulkan أن هناك على الأقل كومة واحدة من هذا النوع، ولو كان الجهاز مثلاً معالج رسومي مدمج مع المعالج لا توجد فيه إلا الذاكرة الرئيسية فستكون كومة الذاكرة الرئيسية VK_MEMORY_HEAP_DEVICE_LOCAL_BIT.

حجز الذاكرة

لحجز الذاكرة على الجهاز يمكننا استخدام الدالة vkAllocateMemory():

VkResult vkAllocateMemory(
    VkDevice                                    device,
    const VkMemoryAllocateInfo*                 pAllocateInfo,
    const VkAllocationCallbacks*                pAllocator,
    VkDeviceMemory*                             pMemory);

حيث تأخذ الكائن VkDevice للجهاز الذي تريد حجز الذاكرة منه، وتأخذ مؤشر للبنية VkMemoryAllocateInfo التي تحتوي معلومات الذاكرة التي تريد حجزها، وتأخذ مؤشر للبنية VkAllocationCallbacks، ثم مؤشر للكائن VkDeviceMemory الذي يمثل مقبض الذاكرة المخصصة على الجهاز.

البنية VkAllocationCallbacks كما هو الحال في معظم دوال Vulkan يستخدمها المشغل لتخصيص الذاكرة داخلياً وليس لها علاقة بذاكرة الجهاز، فالمشغّل قد يحتاج لتخزين معلومات إضافية عن الكائن يحتاجها داخلياً، لذا إن وفرت مخصص ذاكرة خاص بك فسيستخدمه، ويمكنك كما كنا نعمل أن تمرر NULL كي يستخدم مخصصه الإفتراضي.

البنية VkMemoryAllocateInfo هي التي تحمل معلومات الذاكرة التي تريد تخصيصها، أعضاؤها:

typedef struct VkMemoryAllocateInfo {
    VkStructureType    sType;
    const void*        pNext;
    VkDeviceSize       allocationSize;
    uint32_t           memoryTypeIndex;
} VkMemoryAllocateInfo;

sType تأخذ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO و pNext تأخذ NULL، بينما allocationSize تمثّل حجم الذاكرة التي تريد حجزها بوحدة البايت و memoryTypeIndex تمثل مؤشر مصفوفة النوع الذي تريد حجز الذاكرة من كومته في المصفوفة VkPhysicalDeviceMemoryProperties::memoryTypes.

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

يجب الإنتباه للقيمة التي تعيدها هذه الدالة في الوثائق، حيث يمكن أن تعيد الخطأ VK_ERROR_TOO_MANY_OBJECTS وذلك عندما تتجاوز الحد الأقصى المحدد في VkPhysicalDeviceLimits::maxMemoryAllocationCount (عندي 4096) والذي يمثل الحد الأقصى لعدد طلبات التخصيص بغض النظر عن حجم البيانات التي طلبتها، برأيي أنك لو بدأت تقترب حتى من ربع هذا الرقم فلديك مشكلة كبيرة في إدارة الذاكرة، ويجيب عليك أن تفكر باستخدام أحواض الذاكرة عن طريق حجز ذاكرة كبيرة دفعة واحدة وتخصصها كيفما تريد.

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

نسخ البيانات للذاكرة من المضيف

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

VkResult vkMapMemory(
    VkDevice                                    device,
    VkDeviceMemory                              memory,
    VkDeviceSize                                offset,
    VkDeviceSize                                size,
    VkMemoryMapFlags                            flags,
    void**                                      ppData);

تأخذ الدالة في المعامل الأول الجهاز VkDevice، وفي الثاني كائن مقبض الذاكرة التي حجزتها VkDeviceMemory، وفي الثالث offset عدد صحيح يمثل الإزاحة بالبايت من بداية ذاكرة الجهاز في حال أردت ربط جزء من الذاكرة، المعامل الرابع size يمثل حجم الذاكرة المراد ربطها بالبايت، الحجم بالبايت ويجب أن يكون مجموع الإزاحة مع الحجم أقل أو يساوي الذاكرة المجوزة، في حال أردت ربط كامل الذاكرة فستضع صفر في قيمة offset وستضع حجم الذاكرة التي حجزتها في size، المعامل flags غير مستخدم حالياً، ضعه صفر، المعامل الأخير ppDate هو مؤشر لمؤشر ستخزن فيه عنوان الربط في ذاكرة المضيف، يمكنك القراءة والكتابة من وإلى من العنوان الذي مررت مؤشره في pData مثل أي عنوان عادي.

يمكنك استخدام الدالة vkUnmapMemory() عند الإنتهاء من الكتابة أو القراءة لفك الربط مع ذاكرة الجهاز:

void vkUnmapMemory(
    VkDevice                                    device,
    VkDeviceMemory                              memory);

حيث تأخذ الكائن VkDevice الخاص بالجهاز، وكذلك الكائن VkDeviceMemory الخاص بالذاكرة التي ربطت معها.

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

إنهاء صلاحية ذاكرة الوصول السريع

مالم تكن الذاكرة التي كتبت عليها أو ستقرأ منها متماسكة VK_MEMORY_PROPERTY_HOST_COHERENT_BIT، فلايوجد مايضمن أن هذه البيانات هي أحدث البيانات ولن تكون بيانات مخزنة مؤقتاً في ذاكرة تخزين سريعة، لذا بعد الكتابة للذاكرة غير المتماسكة فيلزمك أن تنهي صلاحية التخزين المؤقت كي تعتمد البيانات التي كتبتها في حال كانت مخزنة مؤقتاً باستخدام الدالة vkFlushMappedMemoryRanges():

VkResult vkFlushMappedMemoryRanges(
    VkDevice                                    device,
    uint32_t                                    memoryRangeCount,
    const VkMappedMemoryRange*                  pMemoryRanges);

حيث تأخذ كائن الجهاز VkDevice في المعامل الأول، وفي المعامل الثاني ستأخذ عدد مصفوفات VkMappedMemoryRange التي ستمررها في المعامل الثالث والتي تحتوي على معلومات الذاكرة التي تريد إعتمادها، تعريف البنية VkMappedMemoryRange:

typedef struct VkMappedMemoryRange {
    VkStructureType    sType;
    const void*        pNext;
    VkDeviceMemory     memory;
    VkDeviceSize       offset;
    VkDeviceSize       size;
} VkMappedMemoryRange;

قيمة sType يجب أن تكون VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE وقيمة pNext يجب أن تكون NULL، العضو الثالث memory يشير لمقبض الذاكرة تلك، و offset و size تمثل بداية الذاكرة والحجم بعد هذه البداية للذاكرة التي تريد إنهاء صلاحية أي تخزين مؤقت كي تعتمد عملية الكتابة، مثال لإنهاء صلاحية ذاكرة بعد الكتابة:

  void* mappedMemoryAddress = nullptr;
  result = vkMapMemory(device, deviceMemory, dataOffset, dataSize, 0, &mappedMemoryAddress);
  assert(result == VK_SUCCESS);

  std::memcpy(mappedMemoryAddress, largeWriteBuffer, dataSize);

  // نعتمد العملية فقط في حال كانت الذاكرة غير متماسكة، عدا هذا نحن لسنا بحاجة لأن تماسك الذاكرة يغنينا
  if ((typeFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0)
  {
    VkMappedMemoryRange memoryRanges{};
    memoryRanges.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE;
    memoryRanges.pNext = nullptr;
    memoryRanges.memory = deviceMemory;
    memoryRanges.offset = dataOffset;
    memoryRanges.size = dataSize;
    result = vkFlushMappedMemoryRanges(device, 1, &memoryRanges);
    assert(result == VK_SUCCESS);
  }

  vkUnmapMemory(device, deviceMemory);

انتبه أنك لاتحتاج لإنهاء صلاحية كل الذاكرة، بل فقط الجزء الذي عدلته.

أما عند القراءة من الذاكرة غير المتماسكة، فيجب إلغاء صلاحية الذاكرة باستخدام الدالة vkInvalidateMappedMemoryRanges() كي تضمن أن ذاكرة التخزين المؤقت في المضيف أنهيت صلاحيتها وحُمّلت فيها أحدث البيانات قبل القراءة منها:

VkResult vkInvalidateMappedMemoryRanges(
    VkDevice                                    device,
    uint32_t                                    memoryRangeCount,
    const VkMappedMemoryRange*                  pMemoryRanges);

الدالة تأخذ نفس المعاملات التي تأخذها vkFlushMappedMemoryRanges()، مثال عليها:

  if ((typeFlags & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0)
  {
    VkMappedMemoryRange memoryRanges{};
    memoryRanges.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE;
    memoryRanges.pNext = nullptr;
    memoryRanges.memory = deviceMemory;
    memoryRanges.offset = offset;
    memoryRanges.size = dataSize;
    result = vkInvalidateMappedMemoryRanges(device, 1, &memoryRanges);
    assert(result == VK_SUCCESS);
  }
  ...
  std::memcpy(largeReadBuffer, mappedMemoryAddress, dataSize);

التخزين المؤقت موجودة لأن عملية نقل البيانات وضمان تماسكها قد تكون مكلفة، لذا في حالة كتابة أو قراءة بيانات كبيرة، قد تفضّل الكتابة أو القراءة من ذاكرة تدعم التخزين المؤقت كي تكتب البيانات وتؤخر إعتمادها لتعتمدها لاحقاً بحيث ترسل دفعة واحدة وتقلل تكلفة النقل خصوصاً في الأجهزة التي توفر فقط إما ذاكرة متماسكة أو ذاكرة تدعم التخزين المؤقت ولاتوفّر ذاكرة متماسكة وتدعم التخزين المؤقت في نفس الوقت[٢].

طبعاً الإسراف في إستخدام تلك الدالتين واستخدامها مع البيانات الصغيرة قد يقضي على فائدتها، لذا عند استخدامها حاول بقدر ما تستطيع أن تؤجل إستخدامها إلى بعد كتابتك لكل البيانات أو كتابة أكبر قدر من البيانات في حالة vkFlushMappedMemoryRanges()، أو وصول كل البيانات أو أكبر قدر منها قبل استخدام vkInvalidateMappedMemoryRanges().

تحرير الذاكرة

عند الإنتهاء من الذاكرة والتأكد من أنها لم تعد مستخدمة فيمكن تحريرها باستخدام الدالة vkFreeMemory():

void vkFreeMemory(
    VkDevice                                    device,
    VkDeviceMemory                              memory,
    const VkAllocationCallbacks*                pAllocator);
  1. انتبه أن هذه التفاصيل قد تختلف باختلاف النظام والمشّغل
  2. تذكّر أن الذاكرة يجب أن تكون في كل الحالات مرئية من المضيف كي تتمكن من القراءة والكتابة لها.