سؤال

نسخة مختصرة: من الشائع إرجاع الأشياء الكبيرة - مثل المتجهات/المصفوفات - في العديد من لغات البرمجة. هل هذا النمط مقبول الآن في C ++ 0x إذا كان لدى الفصل منشئ نقل ، أم هل يعتبر مبرمج C ++ غريبة/قبيحة/رجس؟

نسخة طويلة: في C ++ 0x ، لا يزال هذا يعتبر شكلًا سيئًا؟

std::vector<std::string> BuildLargeVector();
...
std::vector<std::string> v = BuildLargeVector();

النسخة التقليدية ستبدو هكذا:

void BuildLargeVector(std::vector<std::string>& result);
...
std::vector<std::string> v;
BuildLargeVector(v);

في الإصدار الأحدث ، تم إرجاع القيمة من BuildLargeVector هو rvalue ، لذلك سيتم إنشاء V باستخدام مُنشئ الحركة من std::vector, ، على افتراض (ن) RVO لا يحدث.

حتى قبل C ++ 0x ، غالبًا ما يكون النموذج الأول "فعالًا" بسبب (N) RVO. ومع ذلك ، (N) RVO هو وفقًا لتقدير المترجم. الآن بعد أن أصبح لدينا مراجع RVALUE مضمون لن تحدث نسخة عميقة.

يحرر: السؤال لا يتعلق حقًا بالتحسين. كلا النموذجين المعروضة لهما أداء شبه متطابق في برامج العالم الحقيقي. بينما ، في الماضي ، كان من الممكن أن يكون للنموذج الأول أداء أسوأ ترتيب الأسوأ. نتيجة لذلك ، كان النموذج الأول رائحة رمز رئيسية في برمجة C ++ لفترة طويلة. ليس بعد الآن ، أتمنى؟

هل كانت مفيدة؟

المحلول

ديف أبراهامز لديه تحليل شامل جدا ل سرعة تمرير/عودة القيم.

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

نصائح أخرى

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

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

جوهره هو:

نسخ Elision و RVO يستطيع تجنب "النسخ المخيفة" (المترجم غير مطلوب لتنفيذ هذه التحسينات ، وفي بعض الحالات لا يمكن تطبيقه)

C ++ 0x RVALUE المراجع السماح تطبيقات سلسلة/متجه ضمانات الذي - التي.

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

لسوء الحظ ، يكون لهذا تأثير كبير على واجهاتك. إذا لم يكن C ++ 0x خيارًا ، وكنت بحاجة إلى ضمانات ، فيمكنك استخدام الكائنات المرجعية أو النسخ على النسخ في بعض السيناريوهات. لديهم سلبيات مع Multithreading ، رغم ذلك.

(أتمنى أن تكون إجابة واحدة فقط في C ++ بسيطة ومباشرة وبدون شروط).

في الواقع ، منذ C ++ 11 ، تكلفة نسخ ال std::vector ذهب في معظم الحالات.

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

فلنقارن:

std::vector<int> BuildLargeVector1(size_t vecSize) {
    return std::vector<int>(vecSize, 1);
}

مع:

void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
    v.assign(vecSize, 1);
}

الآن ، لنفترض أننا بحاجة إلى استدعاء هذه الطرق numIter مرات في حلقة ضيقة ، وأداء بعض الإجراءات. على سبيل المثال ، دعنا نحسب مجموع جميع العناصر.

استخدام BuildLargeVector1, ، كنت ستفعل:

size_t sum1 = 0;
for (int i = 0; i < numIter; ++i) {
    std::vector<int> v = BuildLargeVector1(vecSize);
    sum1 = std::accumulate(v.begin(), v.end(), sum1);
}

استخدام BuildLargeVector2, ، كنت ستفعل:

size_t sum2 = 0;
std::vector<int> v;
for (int i = 0; i < numIter; ++i) {
    BuildLargeVector2(/*out*/ v, vecSize);
    sum2 = std::accumulate(v.begin(), v.end(), sum2);
}

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

معيار

لنلعب بقيم vecSize و numIter. سنبقي مستثنى على الثابت*numiter بحيث "من الناحية النظرية" ، يجب أن يستغرق نفس الوقت (= هناك نفس عدد المهام والإضافات ، مع نفس القيم بالضبط) ، ويمكن أن يأتي الفرق الزمني فقط من تكلفة التخصيصات ، التخصيصات ، واستخدام أفضل لذاكرة التخزين المؤقت.

وبشكل أكثر تحديدًا ، دعنا نستخدم VecSize*numiter = 2^31 = 2147483648 ، لأن لدي 16 جيجابايت من ذاكرة الوصول العشوائي وهذا الرقم يضمن عدم تخصيص أكثر من 8 جيجابايت (حجم (int) = 4) ، مما يضمن عدم تبديل القرص ( تم إغلاق جميع البرامج الأخرى ، وكان لدي حوالي 15 جيجابايت عند إجراء الاختبار).

هنا هو الرمز:

#include <chrono>
#include <iomanip>
#include <iostream>
#include <numeric>
#include <vector>

class Timer {
    using clock = std::chrono::steady_clock;
    using seconds = std::chrono::duration<double>;
    clock::time_point t_;

public:
    void tic() { t_ = clock::now(); }
    double toc() const { return seconds(clock::now() - t_).count(); }
};

std::vector<int> BuildLargeVector1(size_t vecSize) {
    return std::vector<int>(vecSize, 1);
}

void BuildLargeVector2(/*out*/ std::vector<int>& v, size_t vecSize) {
    v.assign(vecSize, 1);
}

int main() {
    Timer t;

    size_t vecSize = size_t(1) << 31;
    size_t numIter = 1;

    std::cout << std::setw(10) << "vecSize" << ", "
              << std::setw(10) << "numIter" << ", "
              << std::setw(10) << "time1" << ", "
              << std::setw(10) << "time2" << ", "
              << std::setw(10) << "sum1" << ", "
              << std::setw(10) << "sum2" << "\n";

    while (vecSize > 0) {

        t.tic();
        size_t sum1 = 0;
        {
            for (int i = 0; i < numIter; ++i) {
                std::vector<int> v = BuildLargeVector1(vecSize);
                sum1 = std::accumulate(v.begin(), v.end(), sum1);
            }
        }
        double time1 = t.toc();

        t.tic();
        size_t sum2 = 0;
        {
            std::vector<int> v;
            for (int i = 0; i < numIter; ++i) {
                BuildLargeVector2(/*out*/ v, vecSize);
                sum2 = std::accumulate(v.begin(), v.end(), sum2);
            }
        } // deallocate v
        double time2 = t.toc();

        std::cout << std::setw(10) << vecSize << ", "
                  << std::setw(10) << numIter << ", "
                  << std::setw(10) << std::fixed << time1 << ", "
                  << std::setw(10) << std::fixed << time2 << ", "
                  << std::setw(10) << sum1 << ", "
                  << std::setw(10) << sum2 << "\n";

        vecSize /= 2;
        numIter *= 2;
    }

    return 0;
}

وهنا النتيجة:

$ g++ -std=c++11 -O3 main.cpp && ./a.out
   vecSize,    numIter,      time1,      time2,       sum1,       sum2
2147483648,          1,   2.360384,   2.356355, 2147483648, 2147483648
1073741824,          2,   2.365807,   1.732609, 2147483648, 2147483648
 536870912,          4,   2.373231,   1.420104, 2147483648, 2147483648
 268435456,          8,   2.383480,   1.261789, 2147483648, 2147483648
 134217728,         16,   2.395904,   1.179340, 2147483648, 2147483648
  67108864,         32,   2.408513,   1.131662, 2147483648, 2147483648
  33554432,         64,   2.416114,   1.097719, 2147483648, 2147483648
  16777216,        128,   2.431061,   1.060238, 2147483648, 2147483648
   8388608,        256,   2.448200,   0.998743, 2147483648, 2147483648
   4194304,        512,   0.884540,   0.875196, 2147483648, 2147483648
   2097152,       1024,   0.712911,   0.716124, 2147483648, 2147483648
   1048576,       2048,   0.552157,   0.603028, 2147483648, 2147483648
    524288,       4096,   0.549749,   0.602881, 2147483648, 2147483648
    262144,       8192,   0.547767,   0.604248, 2147483648, 2147483648
    131072,      16384,   0.537548,   0.603802, 2147483648, 2147483648
     65536,      32768,   0.524037,   0.600768, 2147483648, 2147483648
     32768,      65536,   0.526727,   0.598521, 2147483648, 2147483648
     16384,     131072,   0.515227,   0.599254, 2147483648, 2147483648
      8192,     262144,   0.540541,   0.600642, 2147483648, 2147483648
      4096,     524288,   0.495638,   0.603396, 2147483648, 2147483648
      2048,    1048576,   0.512905,   0.609594, 2147483648, 2147483648
      1024,    2097152,   0.548257,   0.622393, 2147483648, 2147483648
       512,    4194304,   0.616906,   0.647442, 2147483648, 2147483648
       256,    8388608,   0.571628,   0.629563, 2147483648, 2147483648
       128,   16777216,   0.846666,   0.657051, 2147483648, 2147483648
        64,   33554432,   0.853286,   0.724897, 2147483648, 2147483648
        32,   67108864,   1.232520,   0.851337, 2147483648, 2147483648
        16,  134217728,   1.982755,   1.079628, 2147483648, 2147483648
         8,  268435456,   3.483588,   1.673199, 2147483648, 2147483648
         4,  536870912,   5.724022,   2.150334, 2147483648, 2147483648
         2, 1073741824,  10.285453,   3.583777, 2147483648, 2147483648
         1, 2147483648,  20.552860,   6.214054, 2147483648, 2147483648

Benchmark results

(Intel I7-7700K @ 4.20GHz ؛ 16GB DDR4 2400MHz ؛ Kubuntu 18.04)

التدوين: mem (v) = v.size () * sizeof (int) = v.size () * 4 على النظام الأساسي الخاص بي.

ليس من المستغرب متى numIter = 1 (أي ، MEM (V) = 8GB) ، الأوقات متطابقة تمامًا. في الواقع ، في كلتا الحالتين ، نحن نخصص إلا مرة واحدة متجهًا كبيرًا من 8 جيجابايت في الذاكرة. هذا يثبت أيضًا أنه لم يحدث أي نسخة عند استخدام BuildLargeVector1 (): لن يكون لدي ما يكفي من ذاكرة الوصول العشوائي للقيام بالنسخة!

متي numIter = 2, ، يعد إعادة استخدام سعة المتجه بدلاً من إعادة تخصيص المتجه الثاني أسرع 1.37x.

متي numIter = 256, ، إعادة استخدام سعة المتجه (بدلاً من تخصيص/تعامل المتجه مرارًا وتكرارًا 256 مرة ...) أسرع 2.45x :)

يمكننا أن نلاحظ أن الوقت 1 ثابت إلى حد كبير من numIter = 1 إلى numIter = 256, ، مما يعني أن تخصيص ناقل ضخم يبلغ 8 جيجابايت مكلف إلى حد كبير مثل تخصيص 256 متجهًا من 32 ميجابايت. ومع ذلك ، فإن تخصيص ناقل ضخم واحد من 8 جيجابايت هو بالتأكيد أغلى من تخصيص ناقل واحد من 32 ميجابايت ، لذلك فإن إعادة استخدام سعة المتجه توفر مكاسب الأداء.

من numIter = 512 (MEM (V) = 16 ميجابايت) إلى numIter = 8M (MEM (V) = 1KB) هي البقعة الحلوة: كلتا الطريقتين أسرع ، وأسرع من جميع المجموعات الأخرى من numiter و vecsize. ربما يكون هذا يتعلق بحقيقة أن حجم ذاكرة التخزين المؤقت L3 لمعالجتي هو 8 ميغابايت ، بحيث يناسب المتجه بالكامل في ذاكرة التخزين المؤقت. أنا لا أشرح حقًا لماذا القفزة المفاجئة time1 هو لـ MEM (V) = 16 ميغابايت ، يبدو أن الحدوث أكثر منطقية بعد ، عندما يكون MEM (V) = 8MB. لاحظ أنه من المثير للدهشة ، في هذه البقعة الحلوة ، لا تعتبر عملية إعادة الاستخدام في الواقع أسرع قليلاً! أنا لا أشرح هذا حقًا.

متي numIter > 8M الأمور تبدأ في أن تصبح قبيحة. تصبح كلتا الطريقتين أبطأ ولكن إرجاع المتجه حسب القيمة يصبح أبطأ. في أسوأ الحالات ، مع ناقل يحتوي على واحد فقط int, ، إعادة استخدام السعة بدلاً من العودة بالقيمة هو 3.3x أسرع. من المفترض أن هذا يرجع إلى التكاليف الثابتة لـ Malloc () التي تبدأ في السيطرة.

لاحظ كيف يكون منحنى Time2 أكثر سلاسة من منحنى الوقت 1: ليس فقط إعادة استخدام سعة المتجه أسرع بشكل عام ، ولكن الأهم من ذلك ، أنه أكثر من ذلك قابل للتنبؤ.

لاحظ أيضًا أنه في المكان الحلو ، تمكنا من أداء 2 مليار من الأعداد الصحيحة 64 بت في ~ 0.5s ، وهو الأمثل تمامًا على معالج 64 جيجا هرتز. يمكننا أن نفعل بشكل أفضل عن طريق موازاة الحساب من أجل استخدام جميع النوى الثمانية (يستخدم الاختبار أعلاه فقط نواة واحدة في وقت واحد ، والتي قمت بالتحقق منها من خلال إعادة اختبار الاختبار أثناء مراقبة استخدام وحدة المعالجة المركزية). يتم تحقيق أفضل أداء عند MEM (V) = 16 كيلو بايت ، وهو ترتيب حجم ذاكرة التخزين المؤقت L1 (ذاكرة التخزين المؤقت لبيانات L1 لـ I7-7700K هي 4 × 32 كيلو بايت).

بطبيعة الحال ، تصبح الاختلافات أقل وأقل ملاءمة كلما زاد الحساب الذي يتعين عليك القيام به بالفعل في البيانات. فيما يلي النتائج إذا استبدلنا sum = std::accumulate(v.begin(), v.end(), sum); بواسطة for (int k : v) sum += std::sqrt(2.0*k);:

Benchmark 2

الاستنتاجات

  1. باستخدام معلمات الإخراج بدلاً من العودة حسب القيمة مايو توفير مكاسب الأداء عن طريق إعادة استخدام السعة.
  2. على كمبيوتر سطح المكتب الحديث ، يبدو أن هذا ينطبق فقط على متجهات كبيرة (> 16 ميجابايت) وناقلات صغيرة (<1 كيلو بايت).
  3. تجنب تخصيص الملايين/المليارات من المتجهات الصغيرة (<1 كيلو بايت). إن أمكن ، إعادة استخدام السعة ، أو الأفضل من ذلك ، تصميم بنيةك بشكل مختلف.

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

ما زلت أعتقد أنها ممارسة سيئة ، لكن تجدر الإشارة إلى أن فريقي يستخدم MSVC 2008 و GCC 4.1 ، لذلك نحن لا نستخدم أحدث المجمعين.

في السابق ، ظهر الكثير من النقاط الساخنة الموضحة في Vtune مع MSVC 2008 لنسخ السلسلة. كان لدينا رمز مثل هذا:

String Something::id() const
{
    return valid() ? m_id: "";
}

... لاحظ أننا استخدمنا نوع السلسلة الخاص بنا (كان هذا مطلوبًا لأننا نوفر مجموعة تطوير برامج حيث يمكن لكتاب المكوّن الإضافي استخدام مجمعين مختلفين وبالتالي تطبيقات مختلفة وغير متوافقة مع STD :: String/Std :: WSTRING).

لقد قمت بتغيير بسيط استجابةً لجلسة التوصيف لأخذ عينات من الرسوم البيانية التي تعرض السلسلة :: String (Const String &) لتتناول قدرًا كبيرًا من الوقت. كانت الأساليب كما في المثال أعلاه هي أعظم المساهمين (في الواقع أظهرت جلسة التوصيف تخصيص الذاكرة وتخصيصها لتكون واحدة من أكبر النقاط الساخنة ، حيث يكون مُنشئ نسخ السلسلة هو المساهم الرئيسي في المخصصات).

كان التغيير الذي أجريته بسيطًا:

static String null_string;
const String& Something::id() const
{
    return valid() ? m_id: null_string;
}

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

الخلاصة: نحن لا نستخدم أحدث المجمعين المطلقين ، لكن ما زلنا لا يمكننا أن نعتمد على المترجم الذي يحسن النسخ للعودة بالقيمة بشكل موثوق (على الأقل ليس في جميع الحالات). قد لا يكون هذا هو الحال بالنسبة لأولئك الذين يستخدمون مجمعين الأحدث مثل MSVC 2010. أتطلع إلى متى يمكننا استخدام C ++ 0x ونستخدم ببساطة مراجع RVALUE ولا داعي للقلق أبدًا من أننا نتعامل مع كودنا من خلال إرجاع المجمع فصول حسب القيمة.

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

فقط إلى NITPICK قليلاً: ليس من الشائع في العديد من لغات البرمجة العودة من المصفوفات من الوظائف. في معظمهم ، يتم إرجاع إشارة إلى الصفيف. في C ++ ، فإن أقرب تشبيه هو العودة boost::shared_array

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

مرخصة بموجب: CC-BY-SA مع الإسناد
لا تنتمي إلى StackOverflow
scroll top