سؤال

$$f(x) = x anh(\log(1 + e^x))$$

يمكن تنفيذ الوظيفة (تنشيط Mish) بسهولة باستخدام log1pexp المستقر دون أي خسارة كبيرة في الدقة.لسوء الحظ، هذا ثقيل حسابيا.

هل من الممكن كتابة تنفيذ أكثر استقرارًا عدديًا وأسرع؟

دقة جيدة مثل x * std::tanh(std::log1p(std::exp(x))) سيكون جميلا.لا توجد قيود صارمة ولكن يجب أن تكون دقيقة بشكل معقول للاستخدام في الشبكات العصبية.

توزيع المدخلات من $[-\infty, \infty]$.يجب أن تعمل في كل مكان.

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

المحلول

يشير OP إلى معين تطبيق التابع mish وظيفة التنشيط لمواصفات الدقة، لذلك كان علي أن أصف ذلك أولاً.يستخدم هذا التنفيذ دقة واحدة (float) ، وهو مستقر ودقيق في نصف المستوى الموجب.في نصف الطائرة السالبة، لأنه يستخدم logf بدلاً من log1pf, ، الخطأ النسبي ينمو بسرعة أ $x o-\infty$.يبدأ فقدان الدقة $-1$ وبالفعل في $-16.6355324$ يعود التنفيذ بشكل خاطئ $0$, ، لأن $\exp(-16.6355324) = 2^{-24}$.

يمكن تحقيق نفس الدقة والسلوك باستخدام تحويل رياضي بسيط يزيل $\mathrm{طح}$, ، مع الأخذ في الاعتبار أن وحدات معالجة الرسومات تقدم عادةً إضافة مضاعفة مدمجة (FMA) بالإضافة إلى متبادل سريع، وهو ما قد يرغب المرء في الاستفادة منه.يبدو رمز CUDA المثالي كما يلي:

__device__ float my_mishf (float x)
{
    float r;
    float e = expf (x);
    r = 1.0f / fmaf (fmaf (-0.5f, e, -1.0f), e, -1.0f);
    r = fmaf (r, x, x);
    return r;
}

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

إذا كانت هناك رغبة في معالجة مشكلات الدقة هذه، فيمكننا تطبيق الملاحظات التالية.لصغيرة بما فيه الكفاية $x$, $f(x) = x \exp(x)$ ضمن دقة الفاصلة العائمة.ل float حساب هذا يحمل ل $س <-15$.للفاصل الزمني $[-15,-1]$, يمكننا استخدام التقريب العقلاني $R(x)$ لحساب $f(x) := R(x)x\exp(x)$.يبدو رمز CUDA المثالي كما يلي:

__device__ float my_mishf (float x)
{
    float r;
    if (x >= -1.0f) {
        float e = expf (x);
        r = 1.0f / fmaf (fmaf (-0.5f, e, -1.0f), e, -1.0f);
        r = fmaf (r, x, x);
    } else {
        float eh = expf (0.5f * x);
        float p =        1.03628484e-3f;  //  0x1.0fa7e6p-10
        p = fmaf (p, x, -7.28869531e-3f); // -0x1.ddac04p-8
        p = fmaf (p, x,  3.47027816e-2f); //  0x1.1c4902p-5
        p = fmaf (p, x, -3.54762226e-1f); // -0x1.6b46cap-2
        p = fmaf (p, x,  8.58785570e-1f); //  0x1.b7b2bep-1
        p = fmaf (p, x, -1.38065982e+0f); // -0x1.6172ecp+0
        p = fmaf (p, x,  5.97694337e-1f); //  0x1.3204fep-1
        float q =        1.03527203e-3f;  //  0x1.0f63eep-10
        q = fmaf (q, x, -7.35638570e-3f); // -0x1.e21bacp-8
        q = fmaf (q, x,  3.28683928e-2f); //  0x1.0d4204p-5
        q = fmaf (q, x, -3.79927397e-1f); // -0x1.850bb0p-2 
        q = fmaf (q, x,  6.86127126e-1f); //  0x1.5f4c0ep-1
        q = fmaf (q, x, -1.81509292e+0f); // -0x1.d0a9eep+0
        q = fmaf (q, x,  1.00000000e+0f); //  0x1.000000p+0
        r = (1.0f / q) * p;
        if (x < -15.0f) r = 1.0f;
        r = r * x * eh * eh;
    }
    return r;
}

ولسوء الحظ، يتم تحقيق هذا الحل الدقيق على حساب انخفاض كبير في الأداء.إذا كان المرء على استعداد لقبول دقة منخفضة مع الاستمرار في تحقيق ذيل أيسر متحلل بسلاسة، فإن مخطط الاستيفاء التالي، يعتمد مرة أخرى على $f(x) \تقريبا x\exp(x)$, ، يستعيد الكثير من الأداء:

__device__ float my_mishf (float x)
{
    float r;
    float e = expf (x);
    if (x >= -6.0625f) {
        r = 1.0f / fmaf (fmaf (-0.5f, e, -1.0f), e, -1.0f);
        r = fmaf (r, x, x);
    } else {
        r = fmaf (-0.5f, e, 1.0f);
        r = r * x * e;
    }
    return r;
}

كتعزيز أداء خاص بالجهاز، expf() يمكن استبداله بالجهاز الجوهري __expf().

نصائح أخرى

مع بعض التلاعب الجبري (كما هو موضح في إجابة @ orlp)، يمكننا استنتاج ما يلي:

$$f(x) = x anh(\log(1+e^x)) ag{1}$$ $$ = x\frac{(1+e^x)^2 - 1}{(1+e^x)^2 + 1} = x\frac{e^{2x} + 2e^x}{e^ {2x} + 2e^x + 2} ag{2}$$ $$ = x - \frac{2x}{(1 + e^x)^2 + 1} ag{3}$$

تعبير $(3)$ يعمل بشكل رائع عندما $x$ هو سلبي مع فقدان القليل جدا من الدقة.تعبير $(2)$ غير مناسب للقيم الكبيرة $x$ لأن المصطلحات سوف تنفجر في البسط والمقام.

الوظيفة $(1)$ بشكل مقارب يصل إلى الصفر $x o-\infty$.لم يكن $x$ يصبح أكبر في الحجم، والتعبير $(3)$ سيعاني من الإلغاء الكارثي:حدان كبيران يلغي كل منهما الآخر ليعطينا عددًا صغيرًا حقًا.التعبير $(2)$ هو أكثر ملاءمة في هذا النطاق.

هذا يعمل بشكل جيد إلى حد ما حتى $-18$ وبعد ذلك تفقد العديد من الشخصيات المهمة.

دعونا نلقي نظرة فاحصة على الوظيفة ونحاول التقريب $f(x)$ مثل $x o-\infty$.

$$f(x) = x \frac{e^{2x} + 2e^x}{e^{2x} + 2e^x + 2}$$

ال $ه^{2x}$ سوف تكون أوامر من حيث الحجم أصغر من $ه^س$. $ه^س$ سوف تكون أوامر من حيث الحجم أصغر من $1$.باستخدام هاتين الحقيقتين، يمكننا التقريب $f(x)$ ل:

$f(x) \approx x\frac{e^x}{e^x+1}\approx xe^x$

نتيجة:

$ f (x) apprx start {cases} xe^x ، & text {if $ x le -18 $} x frac {e^{2x} + 2e^x} {e^{2x } + 2e^x + 2} & text {if $ -18 lt x le -0.6 $} x - frac {2x} {(1 + e^x)^2 + 1} ، & نص {خلاف ذلك} end {cases} $

تنفيذ CUDA السريع:

__device__ float mish(float x)
{
    auto e = __expf(x);
    if (x <= -18.0f)
        return x * e;    

    auto n = e * e + 2 * e;
    if (x <= -0.6f)
        return x * __fdividef(n, n + 2);

    return x - 2 * __fdividef(x, n + 2);
}

يحرر:

إصدار أكثر سرعة ودقة:

$ f (x) apprx start {cases} x frac {e^{2x} + 2e^x} {e^{2x} + 2e^x + 2} & text {$ x le -0.6 $ } x - frac {2x} {(1 + e^x)^2 + 1} ، & text {خلاف

__device__ float mish(float x)
{
    auto e = __expf(value);
    auto n = e * e + 2 * e;
    if (value <= -0.6f)
        return value * __fdividef(n, n + 2);

    return value - 2 * __fdividef(value, n + 2);
}

شفرة: https://Gist.github.com/YashasSamaga/8ad0cd3b30dbd0eb588c1f4c035db28c

$$ start {array} {c | c | c | c | نص {mish} & 1.49ms & 1.39ms & 2.4583e-05 hline text {relu} & 1.47ms & 1.39ms & text {n/a} hline end {array} $$

ليست هناك حاجة لتنفيذ اللوغاريتم.إذا سمحت $p = 1+\exp(x)$ إذن لدينا$f(x) = x\cdot\dfrac{p^2-1}{p^2+1}$ أو بدلا من ذلك $f(x) = x - \dfrac{2x}{p^2+1}$.

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

بعد اختيار المعلمة ر، دعونا $p_t(x) = 1/2 + (3 / 4t)x - x^3 / (4t^3)$, ، ثم $p_t(0) = 1/2$, $p_t(t) = 1$, $p_t(-t) = 0$, ، و $p_t'(t) = p_t'(-t) = 0$.دع g(x) = 0 إذا x < -t، 1 إذا x > +1، و $p_t(x)$ إذا -t ≥ x ≥ +t.هذه دالة تتغير بسلاسة من 0 إلى 1.اختر معلمة أخرى s، وبدلاً من f(x) احسب x * g (x - s).

t = 3.0 و s = -0.3 يتطابقان مع الوظيفة المحددة بشكل معقول ويتم حسابهما بشكل أسرع كثيرًا (وهو ما يبدو مهمًا).الأمر مختلف بالطبع.بما أن هذه الوظيفة تستخدم كأداة في بعض المسائل، فأنا أريد أن أرى سببًا رياضيًا وراء استخدام الوظيفة الأصلية أحسن.

السياق هنا هي رؤية الكمبيوتر ووظيفة التنشيط لتدريب الشبكات العصبية.

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

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

تطبيقان خاليان من الفروع

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

في هذه الحالة، أود أن أقترح الكود البسيط التالي:

__device__ float mish(float x)
{
    float expx = __expf(x);
    return x / (1.0f + 2.0f / (expx * (2.0f + expx)));
}

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

__device__ float mish(float x)
{
    float expx = __expf(x);
    float psi = expx * (2.0f + expx);
    return x * (psi / (2.0f + psi));
}

وهذا ليس له فروع، أسي واحد، ضربان، وقسمة واحدة.

خطأ نسبي

لقد قمت بحساب الدقة النسبية log10 لهذين التطبيقين بالإضافة إلى إجابة OP.لقد قمت بالحساب على مدى الفاصل الزمني (-100,100) بزيادة قدرها 1/1024، ثم حسبت الحد الأقصى للتشغيل على 51 قيمة (لتقليل الفوضى المرئية مع الاستمرار في إعطاء الانطباع الصحيح).ويكفي حساب التنفيذ الأول بدقة مضاعفة كمرجع.الأسي دقيق ضمن ULP واحد، ولا يوجد سوى عدد قليل من العمليات الحسابية؛بقية البتات أكثر من كافية لجعل معضلة صانع الطاولة غير محتملة على الإطلاق.وبالتالي، من المحتمل جدًا أن نكون قادرين على حساب القيم المرجعية ذات الدقة الفردية المقربة بشكل صحيح.

Log10 relative error

أخضر:التنفيذ الأول.أحمر:التنفيذ الثاني.أزرق:تنفيذ OP.يتداخل اللونان الأزرق والأحمر في معظم نطاقهما (يسار حوالي -20).

ملاحظة إلى OP:ستحتاج إلى تغيير القطع إلى أكبر من -5 إذا كنت تريد الحفاظ على الدقة الكاملة.

أداء

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

سؤال مهم:

ما هو توزيع قيم المدخلات النموذجية التي تتوقع رؤيتها؟هل سيتم توزيع القيم بشكل موحد على النطاق بأكمله الذي تكون فيه الوظيفة قابلة للحساب بشكل فعال؟أم أنهم سوف يتجمعون حول 0 طوال الوقت تقريبًا؟إذا كان الأمر كذلك، مع ما التباين / الانتشار؟

يمكن تحسين المقاربات.

على اليسار، يستخدم OP x * expx مع قطع -18.يمكن زيادة هذا القطع إلى حوالي -15.5625 دون فقدان الدقة.مع تكلفة الضرب الإضافي، يمكنك استخدامها x * expx * (1.0f - 0.5f * expx) وقطع حوالي -4.875.ملحوظة:يمكن تحسين الضرب في 0.5 لطرح 1 من الأس لذلك أنا لا أحسب ذلك هنا.

على اليمين، يمكنك تقديم مقارب آخر.لو x > 8.75, ، ببساطة return x.مع تكلفة أكثر قليلا، يمكنك أن تفعل ذلك x * (1.0f - 2.0f * __expf(-2.0f * x)) متى x > 6.0.

إقحام

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

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