جدول تجزئة بسيط لوحدة معالجة الرسومات


لقد نشرت على Github مشروعًا جديدًا يسمى A Simple GPU Hash Table .

هذا جدول تجزئة بسيط لوحدة معالجة الرسومات ، قادر على معالجة مئات الملايين من الإدخالات في الثانية. على جهاز الكمبيوتر المحمول الخاص بي مع NVIDIA GTX 1060 ، يُدخل الرمز 64 مليون زوج من قيم المفاتيح التي يتم إنشاؤها عشوائيًا في حوالي 210 مللي ثانية ويزيل 32 مليون زوجًا في حوالي 64 مللي ثانية.

أي أن السرعة على الكمبيوتر المحمول تبلغ حوالي 300 مليون مرة في الثانية و 500 مليون مرة في الثانية.

الجدول مكتوب في CUDA ، على الرغم من أنه يمكن تطبيق نفس التقنية على HLSL أو GLSL. يحتوي التطبيق على العديد من القيود التي تضمن الأداء العالي على بطاقة الفيديو:

  • تتم معالجة مفاتيح 32 بت فقط والقيم نفسها.
  • حجم جدول التجزئة ثابت.
  • ويجب أن يساوي هذا الحجم درجتين.

بالنسبة إلى المفاتيح والقيم ، تحتاج إلى حجز علامة تحديد بسيطة (في الرمز أعلاه هو 0xffffffff).

تجزئة الجدول بدون أقفال


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

struct KeyValue
{
    uint32_t key;
    uint32_t value;
};

حجم الجدول يساوي اثنين في الطاقة ، وليس رقمًا أوليًا ، لأن استخدام pow2 / AND-mask ، يكفي تعليمات سريعة واحدة ، ويكون مشغل الوحدة أبطأ بكثير. هذا مهم في حالة الاستشعار الخطي ، لأنه في البحث الخطي في الجدول ، يجب أن يلف مؤشر الفتحة في كل فتحة. ونتيجة لذلك ، تضاف تكلفة العملية modulo في كل فتحة.

يخزن الجدول فقط المفتاح والقيمة لكل عنصر ، وليس التجزئة الرئيسية. نظرًا لأن الجدول يخزن مفاتيح 32 بت فقط ، يتم حساب التجزئة بسرعة كبيرة. يستخدم الرمز أعلاه تجزئة Murmur3 ، التي تؤدي فقط بعض التحولات و XORs والمضاعفات.

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

تتم تهيئة المفاتيح والقيم في جدول التجزئة لتفريغها.

يمكن تعديل الشفرة بحيث يمكنها معالجة مفاتيح وقيم 64 بت. تتطلب المفاتيح عمليات القراءة والكتابة والتبادل الذري (مقارنة ومبادلة). وتتطلب القيم عمليات القراءة والكتابة الذرية. لحسن الحظ ، في CUDA ، عمليات القراءة والكتابة لقيم 32 و 64 بت تكون ذرية طالما أنها محاذية بشكل طبيعي (انظر هنا) ، وبطاقات الفيديو الحديثة تدعم العمليات الذرية 64 بت بالمقارنة مع التبادل. بالطبع ، عند التبديل إلى 64 بت ، سينخفض ​​الأداء قليلاً.

حالة جدول التجزئة


يمكن أن يكون لكل زوج قيم رئيسية في جدول التجزئة واحدة من أربع حالات:

  • المفتاح والمعنى فارغان. في هذه الحالة ، تتم تهيئة جدول التجزئة.
  • تم تسجيل المفتاح ، ولكن القيمة ليست بعد. إذا كان هناك مؤشر ترابط آخر يقرأ البيانات في تلك اللحظة ، فإنه يُرجع قيمة فارغة. هذا أمر طبيعي ، سيحدث الشيء نفسه إذا نجحت سلسلة تنفيذ أخرى قبل ذلك بقليل ، ونحن نتحدث عن بنية بيانات تنافسية.
  • يتم تسجيل كل من المفتاح والقيمة.
  • القيمة متاحة لسلاسل التنفيذ الأخرى ، لكن المفتاح لم يتم بعد. يمكن أن يحدث هذا لأن نموذج البرمجة CUDA يتضمن نموذج ذاكرة ضعيف الترتيب. هذا أمر طبيعي ؛ على أي حال ، لا يزال المفتاح فارغًا ، حتى إذا لم تعد القيمة على هذا النحو.

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

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

إدراج في جدول تجزئة


تبدو دالة CUDA التي تُدرج أزواج القيمة الرئيسية في جدول التجزئة كما يلي:

void gpu_hashtable_insert(KeyValue* hashtable, uint32_t key, uint32_t value)
{
    uint32_t slot = hash(key);

    while (true)
    {
        uint32_t prev = atomicCAS(&hashtable[slot].key, kEmpty, key);
        if (prev == kEmpty || prev == key)
        {
            hashtable[slot].value = value;
            break;
        }
        slot = (slot + 1) & (kHashTableCapacity-1);
    }
}

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

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

تجزئة جدول البحث


رمز مكتشف المفاتيح:

uint32_t gpu_hashtable_lookup(KeyValue* hashtable, uint32_t key)
{
        uint32_t slot = hash(key);

        while (true)
        {
            if (hashtable[slot].key == key)
            {
                return hashtable[slot].value;
            }
            if (hashtable[slot].key == kEmpty)
            {
                return kEmpty;
            }
            slot = (slot + 1) & (kHashTableCapacity - 1);
        }
}

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

إذا لم نتمكن من العثور على المفتاح ، فإن الشفرة تُرجع قيمة فارغة.

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

حذف جدول التجزئة


رمز إزالة المفتاح:

void gpu_hashtable_delete(KeyValue* hashtable, uint32_t key, uint32_t value)
{
    uint32_t slot = hash(key);

    while (true)
    {
        if (hashtable[slot].key == key)
        {
            hashtable[slot].value = kEmpty;
            return;
        }
        if (hashtable[slot].key == kEmpty)
        {
            return;
        }
        slot = (slot + 1) & (kHashTableCapacity - 1);
    }
}

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

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

تغيير حجم جدول التجزئة


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

مقالة جدول التجزئة الخالية من القفل بدون قفل كيفية تغيير بنية البيانات المحمية بقفل.

القدرة التنافسية


في قصاصات أعلاه رمز، وظائف gpu_hashtable_insert()، _lookup()و _delete()الزوج مفتاح قيمة عملية واحدة في وقت واحد. وأدناه gpu_hashtable_insert()، _lookup()أنها _delete()معالجة مجموعة من أزواج في موازاة ذلك، كل زوج في موضوع منفصل GPU التنفيذ:

// CPU code to invoke the CUDA kernel on the GPU
uint32_t threadblocksize = 1024;
uint32_t gridsize = (numkvs + threadblocksize - 1) / threadblocksize;
gpu_hashtable_insert_kernel<<<gridsize, threadblocksize>>>(hashtable, kvs, numkvs);

// GPU code to process numkvs key/values in parallel
void gpu_hashtable_insert_kernel(KeyValue* hashtable, const KeyValue* kvs, unsigned int numkvs)
{
    unsigned int threadid = blockIdx.x*blockDim.x + threadIdx.x;
    if (threadid < numkvs)
    {
        gpu_hashtable_insert(hashtable, kvs[threadid].key, kvs[threadid].value);
    }
}

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

ومع ذلك ، إذا قمنا بمعالجة حزمة من عمليات الإدراج والحذف بالتوازي ، وإذا تم تضمين المفاتيح المكررة في مجموعة المدخلات من الأزواج ، فلن نتمكن من التنبؤ بالأزواج التي "ستفوز" - سيتم كتابتها إلى جدول التجزئة أخيرًا. لنفترض أننا أطلقنا على رمز إدراج يحتوي على مجموعة إدخال من الأزواج A/0 B/1 A/2 C/3 A/4. عند اكتمال الرمز ، B/1يتم C/3ضمان وجود الأزواج في الجدول ، ولكن في نفس الوقت سيظهر أي من الأزواج فيه A/0، A/2أوA/4. قد يكون هذا أو لا يكون مشكلة - كل هذا يتوقف على التطبيق. قد تعلم مسبقًا أنه لا توجد مفاتيح مكررة في صفيف الإدخال ، أو قد لا يهمك القيمة التي تمت كتابتها آخر مرة.

إذا كانت هذه مشكلة بالنسبة لك ، فأنت بحاجة إلى تقسيم الأزواج المكررة إلى مكالمات CUDA للنظام المختلفة. في CUDA ، تنتهي دائمًا أي عملية استدعاء kernel قبل استدعاء kernel التالي (على الأقل في نفس مؤشر الترابط. في سلاسل الرسائل المختلفة ، يتم تنفيذ kernel بالتوازي). إذا في المثال أعلاه ، اتصل بأحد النواة بـ A/0 B/1 A/2 C/3، والآخر بـ A/4، فسيحصل المفتاح Aعلى قيمة 4.

الآن دعونا نتحدث عن ما إذا كانت وظيفة lookup()و delete()استخدام بسيط (عادي) أو متغير (المتقلبة) مؤشر إلى مجموعة من أزواج في جدول تجزئة.تنص وثائق CUDA على ما يلي:

يمكن للمترجم ، حسب تقديره ، تحسين عمليات القراءة والكتابة إلى الذاكرة العامة أو المشتركة ... يمكن تعطيل هذه التحسينات باستخدام الكلمة الرئيسية volatile: ... يتم تجميع أي ارتباط إلى هذا المتغير في تعليمات قراءة أو كتابة حقيقية في الذاكرة.

اعتبارات التصحيح لا تتطلب التطبيق volatile. إذا كان مؤشر ترابط التنفيذ يستخدم القيمة المخزنة مؤقتًا من عملية قراءة سابقة ، فهذا يعني أنه سيستخدم معلومات قديمة قليلاً. ولكن مع ذلك ، هذه معلومات من الحالة الصحيحة لجدول التجزئة في نقطة معينة في نداء kernel. إذا كنت بحاجة إلى استخدام أحدث المعلومات ، يمكنك استخدام المؤشر volatile، ولكن بعد ذلك سينخفض ​​الأداء قليلاً: وفقًا لاختباراتي ، عندما تحذف 32 مليون عنصر ، تنخفض السرعة من 500 مليون حذف / ثانية إلى 450 مليون حذف / ثانية.

أداء


في اختبار إدراج 64 مليون عنصر وحذف 32 مليون عنصر ، لا std::unordered_mapتوجد منافسة بين جدول التجزئة لوحدة معالجة الرسومات:


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

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

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

يوضح جدول التجزئة لبطاقة الفيديو أداءً عاليًا نظرًا لعرض النطاق الترددي العالي والتوازي النشط.

سلبيات


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

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

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

لتوضيح المشكلات الموضحة ، أستخدم الرمز أعلاه لإنشاء جدول لـ 128 مليون عنصر ، وسأقوم بإدراج 4 ملايين عنصر بشكل دوري حتى أقوم بملء 124 مليون فتحة (الاستخدام حوالي 0.96). هنا جدول النتائج ، كل صف هو دعوة إلى CUDA الأساسية مع إدراج 4 ملايين عنصر جديد في جدول تجزئة واحد:

معدل الاستخداممدة الإدراج 4 194 304 عنصر
0.0011.608448 مللي ثانية (361.314798 مليون مفتاح / ثانية)
0،0311,751424 (356,918799 /.)
0,0611,942592 (351,205515 /.)
0,0912,081120 (347,178429 /.)
0,1212,242560 (342,600233 /.)
0,1612,396448 (338,347235 /.)
0,1912,533024 (334,660176 /.)
0,2212,703328 (330,173626 /.)
0,2512,884512 (325,530693 /.)
0,2813,033472 (321,810182 /.)
0,3113,239296 (316,807174 /.)
0,3413,392448 (313,184256 /.)
0,3713,624000 (307,861434 /.)
0,4113,875520 (302,280855 /.)
0,4414,126528 (296,909756 /.)
0,4714,399328 (291,284699 /.)
0,5014,690304 (285,515123 /.)
0,5315,039136 (278,892623 /.)
0,5615,478656 (270,973402 /.)
0,5915,985664 (262,379092 /.)
0,6216,668673 (251,627968 /.)
0,6617,587200 (238,486174 /.)
0,6918,690048 (224,413765 /.)
0,7220,278816 (206,831789 /.)
0,7522,545408 (186,038058 /.)
0,7826,053312 (160,989275 /.)
0,8131,895008 (131,503463 /.)
0,8442,103294 (99,619378 /.)
0,8761,849056 (67,815164 /.)
0,90105,695999 (39,682713 /.)
0,94240,204636 (17,461378 /.)

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

وقاس عمق فحص جدول التجزئة بعد 64 مليون إدراج (معامل الاستفادة 0.5). كان متوسط ​​العمق 0.4774 ، لذلك تم وضع معظم المفاتيح إما في أفضل فتحة ممكنة أو في فتحة واحدة من أفضل موضع. كان عمق السبر الأقصى 60.

ثم قمت بقياس عمق السبر في الجدول مع 124 مليون إدراج (معدل الاستخدام 0.97). كان متوسط ​​العمق بالفعل 10.1757 ، والحد الأقصى - 6474 (!!). ينخفض ​​أداء السبر الخطي بشكل كبير بمعدلات استخدام عالية.

من الأفضل إبقاء طاولة التجزئة منخفضة. ولكن بعد ذلك نزيد الإنتاجية باستهلاك الذاكرة. لحسن الحظ ، في حالة مفاتيح وقيم 32 بت ، يمكن تبرير ذلك. إذا في المثال أعلاه في الجدول لـ 128 مليون عنصر تم تخزين معامل الاستفادة من 0.25 ، فعندئذ لا يمكننا وضع أكثر من 32 مليون عنصر فيه ، وستفقد 96 مليون فتحة المتبقية - 8 بايت لكل زوج ، 768 ميجابايت من الذاكرة المفقودة.

يرجى ملاحظة أننا نتحدث عن فقدان ذاكرة بطاقة الفيديو ، وهو مورد أكثر قيمة من ذاكرة النظام. على الرغم من أن معظم بطاقات رسومات سطح المكتب الحديثة التي تدعم CUDA تحتوي على 4 غيغابايت على الأقل من الذاكرة (في وقت كتابة هذا التقرير ، فإن NVIDIA 2080 Ti لديها 11 غيغابايت) ، فإن فقدان هذه الكميات لن يكون القرار الأكثر حكمة.

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

قياس عمق الاستشعار


لتحديد عمق صوت المفتاح ، يمكننا استخراج التجزئة الرئيسية (فهرسها المثالي في الجدول) من فهرس الجدول الفعلي:

// get_key_index() -> index of key in hash table
uint32_t probelength = (get_key_index(key) - hash(key)) & (hashtablecapacity-1);

نظرًا لسحر رقمين ثنائيين في الرمز الإضافي وحقيقة أن سعة جدول التجزئة تساوي اثنين في الطاقة ، ستعمل هذه الطريقة حتى عند نقل المؤشر الرئيسي إلى بداية الجدول. خذ المفتاح المجزأ عند 1 ولكن تم إدراجه في الفتحة 3. ثم للحصول على جدول بسعة 4 نحصل على (3 — 1) & 3ما يعادل 2.

استنتاج


إذا كانت لديك أسئلة أو تعليقات ، فاكتبني على Twitter أو افتح موضوعًا جديدًا في المستودع .

هذا الرمز مستوحى من بعض المقالات الرائعة:


في المستقبل ، سأستمر في الكتابة عن تطبيقات جدول التجزئة لبطاقات الفيديو وسأحلل أداءها. لدي خطط للتسلسل ، وتجزئة التجزئة Robin Hood و cuckoo باستخدام العمليات الذرية في هياكل البيانات المناسبة لبطاقات الفيديو.

All Articles