Performance Tips

في الأقسام التالية، نستعرض بإيجاز بعض التقنيات التي يمكن أن تساعد في جعل كود جوليا الخاص بك يعمل بأسرع ما يمكن.

Performance critical code should be inside a function

أي كود يعتبر حرجًا من حيث الأداء يجب أن يكون داخل دالة. الكود داخل الدوال يميل إلى العمل بشكل أسرع بكثير من الكود في المستوى الأعلى، بسبب كيفية عمل مترجم جوليا.

استخدام الدوال ليس مهمًا فقط من أجل الأداء: الدوال أكثر قابلية لإعادة الاستخدام والاختبار، وتوضح ما هي الخطوات التي يتم تنفيذها وما هي مدخلاتها ومخرجاتها، Write functions, not just scripts هو أيضًا توصية من دليل أسلوب جوليا.

يجب أن تأخذ الدوال الوسائط، بدلاً من العمل مباشرة على المتغيرات العالمية، انظر النقطة التالية.

Avoid untyped global variables

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

نجد أن الأسماء العالمية غالبًا ما تكون ثوابت، وأن إعلانها على هذا النحو يحسن الأداء بشكل كبير:

const DEFAULT_VAL = 0

إذا كان من المعروف أن المتغير العالمي دائمًا من نفس النوع، the type should be annotated.

يمكن تحسين استخدام المتغيرات العالمية غير المعلّمة عن طريق توضيح أنواعها في نقطة الاستخدام:

global x = rand(1000)

function loop_over_global()
    s = 0.0
    for i in x::Vector{Float64}
        s += i
    end
    return s
end

تمرير المعاملات إلى الدوال هو أسلوب أفضل. إنه يؤدي إلى كود أكثر قابلية لإعادة الاستخدام ويُوضح ما هي المدخلات والمخرجات.

Note

يتم تقييم كل الشيفرة في REPL في النطاق العالمي، لذا فإن المتغير الذي يتم تعريفه وتعيينه في المستوى الأعلى سيكون متغيرًا عالميًا. المتغيرات المعرفة في نطاق المستوى الأعلى داخل الوحدات أيضًا عالمية.

في جلسة REPL التالية:

julia> x = 1.0

يعادل:

julia> global x = 1.0

لذا فإن جميع مشكلات الأداء التي تم مناقشتها سابقًا تنطبق.

Measure performance with @time and pay attention to memory allocation

أداة مفيدة لقياس الأداء هي الماكرو @time. هنا نكرر المثال مع المتغير العالمي أعلاه، ولكن هذه المرة مع إزالة تعليق النوع:

julia> x = rand(1000);

julia> function sum_global()
           s = 0.0
           for i in x
               s += i
           end
           return s
       end;

julia> @time sum_global()
  0.011539 seconds (9.08 k allocations: 373.386 KiB, 98.69% compilation time)
523.0007221951678

julia> @time sum_global()
  0.000091 seconds (3.49 k allocations: 70.156 KiB)
523.0007221951678

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

يجب أن نوضح أن ما تقارير @time هو بالتحديد تخصيصات الذاكرة، والتي تكون عادةً مطلوبة إما للكائنات القابلة للتغيير أو لإنشاء/زيادة الحاويات ذات الحجم المتغير (مثل Array أو Dict، السلاسل، أو الكائنات "غير المستقرة" من حيث النوع والتي يكون نوعها معروفًا فقط في وقت التشغيل). قد يتطلب تخصيص (أو إلغاء تخصيص) مثل هذه الكتل من الذاكرة استدعاء دالة مكلفة إلى libc (على سبيل المثال عبر malloc في C)، ويجب تتبعها لجمع القمامة. بالمقابل، يمكن تخزين القيم غير القابلة للتغيير مثل الأرقام (باستثناء الأرقام الكبيرة)، والصفوف، وstructs غير القابلة للتغيير بشكل أرخص بكثير، على سبيل المثال في الذاكرة المؤقتة أو ذاكرة سجلات المعالج، لذا لا يقلق المرء عادةً بشأن تكلفة الأداء لـ "تخصيصها".

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

في هذه الحالة الخاصة، فإن تخصيص الذاكرة ناتج عن استخدام متغير عالمي غير مستقر من حيث النوع x، لذا إذا قمنا بتمرير x كوسيلة إلى الدالة، فلن يتم تخصيص الذاكرة بعد الآن (التخصيص المتبقي المبلغ عنه أدناه ناتج عن تشغيل الماكرو @time في النطاق العالمي) وهو أسرع بشكل ملحوظ بعد الاستدعاء الأول:

julia> x = rand(1000);

julia> function sum_arg(x)
           s = 0.0
           for i in x
               s += i
           end
           return s
       end;

julia> @time sum_arg(x)
  0.007551 seconds (3.98 k allocations: 200.548 KiB, 99.77% compilation time)
523.0007221951678

julia> @time sum_arg(x)
  0.000006 seconds (1 allocation: 16 bytes)
523.0007221951678

تخصيص 1 الذي تم رؤيته هو نتيجة تشغيل الماكرو @time نفسه في النطاق العالمي. إذا قمنا بدلاً من ذلك بتشغيل التوقيت في دالة، يمكننا أن نرى أنه بالفعل لا يتم إجراء أي تخصيصات:

julia> time_sum(x) = @time sum_arg(x);

julia> time_sum(x)
  0.000002 seconds
523.0007221951678

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

Note

لأغراض القياس الأكثر جدية، ضع في اعتبارك حزمة BenchmarkTools.jl التي تقوم، من بين أشياء أخرى، بتقييم الدالة عدة مرات من أجل تقليل الضوضاء.

Tools

تتضمن جوليا ونظام حزمها أدوات قد تساعدك في تشخيص المشكلات وتحسين أداء كودك:

  • Profiling يتيح لك قياس أداء الكود الذي تقوم بتشغيله وتحديد الأسطر التي تعمل كعقبات. بالنسبة للمشاريع المعقدة، يمكن أن تساعدك حزمة ProfileView في تصور نتائج التوصيف الخاصة بك.
  • يمكن أن تساعدك حزمة JET في العثور على مشاكل الأداء الشائعة في كودك.
  • تخصيصات الذاكرة الكبيرة بشكل غير متوقع - كما هو موضح بواسطة @time، @allocated، أو المحلل (من خلال استدعاءات روتينات جمع القمامة) - تشير إلى أنه قد تكون هناك مشاكل في الكود الخاص بك. إذا لم تر سببًا آخر للتخصيصات، فاشك في وجود مشكلة في النوع. يمكنك أيضًا بدء جوليا مع خيار --track-allocation=user وفحص ملفات *.mem الناتجة لرؤية معلومات حول مكان حدوث تلك التخصيصات. انظر Memory allocation analysis.
  • @code_warntype يولد تمثيلًا لشفرتك يمكن أن يكون مفيدًا في العثور على التعبيرات التي تؤدي إلى عدم اليقين في النوع. انظر @code_warntype أدناه.

Avoid containers with abstract type parameters

عند العمل مع الأنواع المعلمة، بما في ذلك المصفوفات، من الأفضل تجنب المعلمة بأنواع مجردة حيثما كان ذلك ممكنًا.

يرجى تقديم المحتوى الذي ترغب في ترجمته.

julia> a = Real[]
Real[]

julia> push!(a, 1); push!(a, 2.0); push!(a, π)
3-element Vector{Real}:
 1
 2.0
 π = 3.1415926535897...

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

julia> a = Float64[]
Float64[]

julia> push!(a, 1); push!(a, 2.0); push!(a,  π)
3-element Vector{Float64}:
 1.0
 2.0
 3.141592653589793

تعيين الأرقام في a سيحولها الآن إلى Float64 وسيتم تخزين a ككتلة متجاورة من قيم النقطة العائمة بدقة 64 بت يمكن معالجتها بكفاءة.

إذا لم تتمكن من تجنب الحاويات التي تحتوي على أنواع قيمة مجردة، فمن الأفضل أحيانًا استخدام Any لتجنب التحقق من نوع التشغيل. على سبيل المثال، IdDict{Any, Any} يعمل بشكل أفضل من IdDict{Type, Vector}

انظر أيضًا المناقشة تحت Parametric Types.

Type declarations

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

Avoid fields with abstract type

يمكن إعلان الأنواع دون تحديد أنواع حقولها:

julia> struct MyAmbiguousType
           a
       end

هذا يسمح لـ a بأن يكون من أي نوع. يمكن أن يكون هذا مفيدًا في كثير من الأحيان، لكنه له عيب: بالنسبة للكائنات من نوع MyAmbiguousType، لن يتمكن المترجم من توليد كود عالي الأداء. السبب هو أن المترجم يستخدم أنواع الكائنات، وليس قيمها، لتحديد كيفية بناء الكود. للأسف، يمكن استنتاج القليل جدًا عن كائن من نوع MyAmbiguousType:

julia> b = MyAmbiguousType("Hello")
MyAmbiguousType("Hello")

julia> c = MyAmbiguousType(17)
MyAmbiguousType(17)

julia> typeof(b)
MyAmbiguousType

julia> typeof(c)
MyAmbiguousType

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

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

julia> mutable struct MyType{T<:AbstractFloat}
           a::T
       end

هذا خيار أفضل من

julia> mutable struct MyStillAmbiguousType
           a::AbstractFloat
       end

لأن النسخة الأولى تحدد نوع a من نوع كائن التغليف. على سبيل المثال:

julia> m = MyType(3.2)
MyType{Float64}(3.2)

julia> t = MyStillAmbiguousType(3.2)
MyStillAmbiguousType(3.2)

julia> typeof(m)
MyType{Float64}

julia> typeof(t)
MyStillAmbiguousType

يمكن تحديد نوع الحقل a بسهولة من نوع m، ولكن ليس من نوع t. في الواقع، في t من الممكن تغيير نوع الحقل a:

julia> typeof(t.a)
Float64

julia> t.a = 4.5f0
4.5f0

julia> typeof(t.a)
Float32

على العكس، بمجرد إنشاء m، لا يمكن أن يتغير نوع m.a:

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float64

حقيقة أن نوع m.a معروف من نوع m—بالإضافة إلى حقيقة أن نوعه لا يمكن أن يتغير أثناء الدالة—يسمح للمترجم بإنشاء كود مُحسّن للغاية لكائنات مثل m ولكن ليس لكائنات مثل t.

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

julia> m = MyType{AbstractFloat}(3.2)
MyType{AbstractFloat}(3.2)

julia> typeof(m.a)
Float64

julia> m.a = 4.5f0
4.5f0

julia> typeof(m.a)
Float32

لجميع الأغراض العملية، تتصرف هذه الكائنات بشكل متطابق مع تلك الخاصة بـ MyStillAmbiguousType.

من المفيد جدًا مقارنة الكمية الهائلة من الشيفرة التي تم إنشاؤها لوظيفة بسيطة

func(m::MyType) = m.a+1

استخدام

code_llvm(func, Tuple{MyType{Float64}})
code_llvm(func, Tuple{MyType{AbstractFloat}})

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

يجب أيضًا أن نأخذ في الاعتبار أن الأنواع غير المهيأة بالكامل تتصرف مثل الأنواع المجردة. على سبيل المثال، على الرغم من أن Array{T,n} المحددة بالكامل هي نوع ملموس، إلا أن Array نفسها بدون أي معلمات ليست ملموسة:

julia> !isconcretetype(Array), !isabstracttype(Array), isstructtype(Array), !isconcretetype(Array{Int}), isconcretetype(Array{Int,1})
(true, true, true, true, true)

في هذه الحالة، سيكون من الأفضل تجنب إعلان MyType مع حقل a::Array وبدلاً من ذلك إعلان الحقل كـ a::Array{T,N} أو كـ a::A، حيث أن {T,N} أو A هي معلمات لـ MyType.

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

struct MyCallableWrapper
    f::Function
end

لكن نظرًا لأن Function هو نوع مجرد، فإن كل استدعاء لـ wrapper.f سيتطلب تنفيذ ديناميكي، بسبب عدم استقرار النوع عند الوصول إلى الحقل f. بدلاً من ذلك، يجب عليك كتابة شيء مثل:

struct MyCallableWrapper{F}
    f::F
end

الذي له سلوك متطابق تقريبًا ولكنه سيكون أسرع بكثير (لأن عدم استقرار النوع قد تم القضاء عليه). لاحظ أننا لا نفرض F<:Function: هذا يعني أن الكائنات القابلة للاستدعاء التي لا تتفرع من Function مسموح بها أيضًا للحقل f.

Avoid fields with abstract containers

تعمل نفس أفضل الممارسات أيضًا مع أنواع الحاويات:

julia> struct MySimpleContainer{A<:AbstractVector}
           a::A
       end

julia> struct MyAmbiguousContainer{T}
           a::AbstractVector{T}
       end

julia> struct MyAlsoAmbiguousContainer
           a::Array
       end

على سبيل المثال:

julia> c = MySimpleContainer(1:3);

julia> typeof(c)
MySimpleContainer{UnitRange{Int64}}

julia> c = MySimpleContainer([1:3;]);

julia> typeof(c)
MySimpleContainer{Vector{Int64}}

julia> b = MyAmbiguousContainer(1:3);

julia> typeof(b)
MyAmbiguousContainer{Int64}

julia> b = MyAmbiguousContainer([1:3;]);

julia> typeof(b)
MyAmbiguousContainer{Int64}

julia> d = MyAlsoAmbiguousContainer(1:3);

julia> typeof(d), typeof(d.a)
(MyAlsoAmbiguousContainer, Vector{Int64})

julia> d = MyAlsoAmbiguousContainer(1:1.0:3);

julia> typeof(d), typeof(d.a)
(MyAlsoAmbiguousContainer, Vector{Float64})

بالنسبة لـ MySimpleContainer، يتم تحديد الكائن بالكامل بواسطة نوعه ومعاييره، لذا يمكن للمترجم توليد دوال محسّنة. في معظم الحالات، من المحتمل أن يكون هذا كافياً.

بينما يمكن للمترجم الآن القيام بعمله بشكل جيد للغاية، هناك حالات قد ترغب فيها أن يقوم كودك بأشياء مختلفة اعتمادًا على نوع العنصر لـ a. عادةً ما تكون أفضل طريقة لتحقيق ذلك هي تغليف عمليتك المحددة (هنا، foo) في دالة منفصلة:

julia> function sumfoo(c::MySimpleContainer)
           s = 0
           for x in c.a
               s += foo(x)
           end
           s
       end
sumfoo (generic function with 1 method)

julia> foo(x::Integer) = x
foo (generic function with 1 method)

julia> foo(x::AbstractFloat) = round(x)
foo (generic function with 2 methods)

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

ومع ذلك، هناك حالات قد تحتاج فيها إلى إعلان إصدارات مختلفة من الدالة الخارجية لأنواع عناصر مختلفة أو أنواع AbstractVector للحقل a في MySimpleContainer. يمكنك القيام بذلك على النحو التالي:

julia> function myfunc(c::MySimpleContainer{<:AbstractArray{<:Integer}})
           return c.a[1]+1
       end
myfunc (generic function with 1 method)

julia> function myfunc(c::MySimpleContainer{<:AbstractArray{<:AbstractFloat}})
           return c.a[1]+2
       end
myfunc (generic function with 2 methods)

julia> function myfunc(c::MySimpleContainer{Vector{T}}) where T <: Integer
           return c.a[1]+3
       end
myfunc (generic function with 3 methods)
julia> myfunc(MySimpleContainer(1:3))
2

julia> myfunc(MySimpleContainer(1.0:3))
3.0

julia> myfunc(MySimpleContainer([1:3;]))
4

Annotate values taken from untyped locations

من الملائم غالبًا العمل مع هياكل البيانات التي قد تحتوي على قيم من أي نوع (مصفوفات من النوع Array{Any}). ولكن، إذا كنت تستخدم واحدة من هذه الهياكل وتعرف نوع عنصر ما، فإن من المفيد مشاركة هذه المعرفة مع المترجم:

function foo(a::Array{Any,1})
    x = a[1]::Int32
    b = x+1
    ...
end

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

في حالة عدم معرفة نوع a[1] بدقة، يمكن إعلان x عبر x = convert(Int32, a[1])::Int32. يسمح استخدام دالة convert بأن يكون a[1] أي كائن يمكن تحويله إلى Int32 (مثل UInt8)، مما يزيد من عمومية الكود عن طريق تخفيف متطلبات النوع. لاحظ أن convert نفسها تحتاج إلى توضيح نوع في هذا السياق من أجل تحقيق استقرار النوع. وذلك لأن المترجم لا يمكنه استنتاج نوع قيمة الإرجاع لدالة، حتى convert، ما لم تكن أنواع جميع معطيات الدالة معروفة.

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

function nr(a, prec)
    ctype = prec == 32 ? Float32 : Float64
    b = Complex{ctype}(a)
    c = (b + 1.0f0)::Complex{ctype}
    abs(c)
end

تؤثر التوصيفات لـ c سلبًا على الأداء. لكتابة كود عالي الأداء يتضمن أنواعًا تم إنشاؤها في وقت التشغيل، استخدم function-barrier technique الموصوف أدناه، وتأكد من أن النوع المُنشأ يظهر بين أنواع المعاملات لدالة النواة بحيث يتم تخصيص عمليات النواة بشكل صحيح بواسطة المترجم. على سبيل المثال، في الشيفرة أعلاه، بمجرد إنشاء b، يمكن تمريرها إلى دالة أخرى k، وهي النواة. إذا، على سبيل المثال، أعلنت الدالة k عن b كمعامل من نوع Complex{T}، حيث T هو معلمة نوع، فإن التوصيف النوعي الذي يظهر في عبارة تعيين داخل k من الشكل:

c = (b + 1.0f0)::Complex{T}

لا تعيق الأداء (لكنها لا تساعد أيضًا) حيث يمكن للمترجم تحديد نوع c في الوقت الذي يتم فيه تجميع k.

Be aware of when Julia avoids specializing

كقاعدة إرشادية، تتجنب جوليا تلقائيًا specializing على معلمات نوع الوسائط في ثلاث حالات محددة: Type و Function و Vararg. ستخصص جوليا دائمًا عندما يتم استخدام الوسيط داخل الطريقة، ولكن ليس إذا تم تمرير الوسيط فقط إلى دالة أخرى. عادةً ما يكون لهذا تأثير على الأداء في وقت التشغيل و improves compiler performance. إذا وجدت أن له تأثيرًا على الأداء في وقت التشغيل في حالتك، يمكنك تحفيز التخصيص عن طريق إضافة معلمة نوع إلى إعلان الطريقة. إليك بعض الأمثلة:

هذا لن يتخصص:

function f_type(t)  # or t::Type
    x = ones(t, 10)
    return sum(map(sin, x))
end

لكن هذا سيفعل:

function g_type(t::Type{T}) where T
    x = ones(T, 10)
    return sum(map(sin, x))
end

لن تتخصص هذه:

f_func(f, num) = ntuple(f, div(num, 2))
g_func(g::Function, num) = ntuple(g, div(num, 2))

لكن هذا سيفعل:

h_func(h::H, num) where {H} = ntuple(h, div(num, 2))

هذا لن يتخصص:

f_vararg(x::Int...) = tuple(x...)

لكن هذا سيفعل:

g_vararg(x::Vararg{Int, N}) where {N} = tuple(x...)

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

h_vararg(x::Vararg{Any, N}) where {N} = tuple(x...)

لاحظ أن @code_typed والأصدقاء سيظهرون لك دائمًا كود متخصص، حتى لو لم يكن من المعتاد في جوليا تخصيص استدعاء الطريقة. تحتاج إلى التحقق من method internals إذا كنت تريد أن ترى ما إذا كانت التخصيصات تُولد عند تغيير أنواع المعاملات، أي إذا كان Base.specializations(@which f(...)) يحتوي على تخصيصات للمعامل المعني.

Break functions into multiple definitions

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

إليك مثال على "دالة مركبة" يجب أن تُكتب فعليًا كتعريفات متعددة:

using LinearAlgebra

function mynorm(A)
    if isa(A, Vector)
        return sqrt(real(dot(A,A)))
    elseif isa(A, Matrix)
        return maximum(svdvals(A))
    else
        error("mynorm: invalid argument")
    end
end

يمكن كتابة هذا بشكل أكثر إيجازًا وكفاءة كالتالي:

mynorm(x::Vector) = sqrt(real(dot(x, x)))
mynorm(A::Matrix) = maximum(svdvals(A))

ومع ذلك، يجب ملاحظة أن المترجم فعال جدًا في تحسين الفروع الميتة في الشيفرة المكتوبة كمثال mynorm.

Write "type-stable" functions

عند الإمكان، يساعد التأكد من أن الدالة دائمًا ما تعيد قيمة من نفس النوع. اعتبر التعريف التالي:

pos(x) = x < 0 ? 0 : x

على الرغم من أن هذا يبدو بريئًا بما فيه الكفاية، إلا أن المشكلة هي أن 0 هو عدد صحيح (من نوع Int) و x قد يكون من أي نوع. وبالتالي، اعتمادًا على قيمة x، قد تُرجع هذه الدالة قيمة من نوعين مختلفين. هذا السلوك مسموح به، وقد يكون مرغوبًا في بعض الحالات. ولكن يمكن إصلاحه بسهولة كما يلي:

pos(x) = x < 0 ? zero(x) : x

هناك أيضًا دالة oneunit، ودالة أكثر عمومية oftype(x, y)، والتي تعيد y محولة إلى نوع x.

Avoid changing the type of a variable

توجد مشكلة "استقرار النوع" مماثلة للمتغيرات المستخدمة بشكل متكرر داخل دالة:

function foo()
    x = 1
    for i = 1:10
        x /= rand()
    end
    return x
end

تبدأ المتغير المحلي x كعدد صحيح، وبعد تكرار واحد من الحلقة يصبح عددًا عشريًا (نتيجة عملية /). هذا يجعل من الصعب على المترجم تحسين جسم الحلقة. هناك عدة حلول ممكنة:

  • قم بتهيئة x بـ x = 1.0
  • اعلن عن نوع x بشكل صريح كالتالي x::Float64 = 1
  • استخدم تحويلًا صريحًا بواسطة x = oneunit(Float64)
  • قم بالتهيئة مع أول تكرار للحلقة، إلى x = 1 / rand()، ثم حلقة for i = 2:10

Separate kernel functions (aka, function barriers)

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

julia> function strange_twos(n)
           a = Vector{rand(Bool) ? Int64 : Float64}(undef, n)
           for i = 1:n
               a[i] = 2
           end
           return a
       end;

julia> strange_twos(3)
3-element Vector{Int64}:
 2
 2
 2

هذا يجب أن يُكتب كالتالي:

julia> function fill_twos!(a)
           for i = eachindex(a)
               a[i] = 2
           end
       end;

julia> function strange_twos(n)
           a = Vector{rand(Bool) ? Int64 : Float64}(undef, n)
           fill_twos!(a)
           return a
       end;

julia> strange_twos(3)
3-element Vector{Int64}:
 2
 2
 2

يخصص مترجم جوليا الشيفرة لأنواع المعاملات عند حدود الدوال، لذا في التنفيذ الأصلي لا يعرف نوع a خلال الحلقة (حيث يتم اختياره عشوائيًا). لذلك، فإن النسخة الثانية تكون أسرع عمومًا حيث يمكن إعادة تجميع الحلقة الداخلية كجزء من fill_twos! لأنواع مختلفة من a.

الصيغة الثانية غالبًا ما تكون أفضل من حيث الأسلوب ويمكن أن تؤدي إلى إعادة استخدام المزيد من الشيفرة.

هذا النمط يُستخدم في عدة أماكن في قاعدة جوليا. على سبيل المثال، انظر إلى vcat و hcat في abstractarray.jl، أو دالة fill!، التي كان بإمكاننا استخدامها بدلاً من كتابة fill_twos! الخاصة بنا.

تحدث وظائف مثل strange_twos عند التعامل مع بيانات من نوع غير مؤكد، على سبيل المثال البيانات المحملة من ملف إدخال قد تحتوي على أعداد صحيحة، أو أعداد عشرية، أو سلاسل نصية، أو شيء آخر.

Types with values-as-parameters

لنقل أنك تريد إنشاء مصفوفة N-أبعاد بحجم 3 على طول كل محور. يمكن إنشاء مثل هذه المصفوفات على النحو التالي:

julia> A = fill(5.0, (3, 3))
3×3 Matrix{Float64}:
 5.0  5.0  5.0
 5.0  5.0  5.0
 5.0  5.0  5.0

تعمل هذه الطريقة بشكل جيد جدًا: يمكن للمترجم أن يكتشف أن A هو Array{Float64,2} لأنه يعرف نوع قيمة التعبئة (5.0::Float64) والأبعاد ((3, 3)::NTuple{2,Int}). وهذا يعني أن المترجم يمكنه توليد كود فعال جدًا لأي استخدام مستقبلي لـ A في نفس الدالة.

لكن الآن دعنا نقول أنك تريد كتابة دالة تنشئ مصفوفة بأبعاد 3×3×... في أبعاد عشوائية؛ قد تكون مغريًا أن تكتب دالة

julia> function array3(fillval, N)
           fill(fillval, ntuple(d->3, N))
       end
array3 (generic function with 1 method)

julia> array3(5.0, 2)
3×3 Matrix{Float64}:
 5.0  5.0  5.0
 5.0  5.0  5.0
 5.0  5.0  5.0

هذا يعمل، ولكن (كما يمكنك التحقق بنفسك باستخدام @code_warntype array3(5.0, 2)) المشكلة هي أن نوع الإخراج لا يمكن استنتاجه: الوسيطة N هي قيمة من نوع Int، ولا يمكن لاستنتاج النوع (ولا يمكنه) التنبؤ بقيمتها مسبقًا. هذا يعني أن الكود الذي يستخدم ناتج هذه الدالة يجب أن يكون حذرًا، حيث يتحقق من النوع في كل وصول إلى A؛ سيكون هذا الكود بطيئًا جدًا.

الآن، واحدة من الطرق الجيدة جدًا لحل مثل هذه المشاكل هي باستخدام function-barrier technique. ومع ذلك، في بعض الحالات قد ترغب في القضاء على عدم استقرار النوع تمامًا. في مثل هذه الحالات، إحدى الطرق هي تمرير الأبعاد كمعامل، على سبيل المثال من خلال Val{T}() (انظر "Value types"):

julia> function array3(fillval, ::Val{N}) where N
           fill(fillval, ntuple(d->3, Val(N)))
       end
array3 (generic function with 1 method)

julia> array3(5.0, Val(2))
3×3 Matrix{Float64}:
 5.0  5.0  5.0
 5.0  5.0  5.0
 5.0  5.0  5.0

تتمتع جوليا بإصدار متخصص من ntuple يقبل مثيل Val{::Int} كمعامل ثانٍ؛ من خلال تمرير N كمعامل نوع، تجعل "قيمته" معروفة للمترجم. وبالتالي، فإن هذا الإصدار من array3 يسمح للمترجم بتوقع نوع الإرجاع.

ومع ذلك، فإن استخدام مثل هذه التقنيات يمكن أن يكون دقيقًا بشكل مدهش. على سبيل المثال، لن يكون هناك فائدة إذا قمت باستدعاء array3 من دالة مثل هذه:

function call_array3(fillval, n)
    A = array3(fillval, Val(n))
end

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

مثال على الاستخدام الصحيح لـ Val سيكون:

function filter3(A::AbstractArray{T,N}) where {T,N}
    kernel = array3(1, Val(N))
    filter(A, kernel)
end

في هذا المثال، يتم تمرير N كمعامل، لذا فإن "قيمته" معروفة للمجمع. أساسًا، تعمل Val(T) فقط عندما يكون T إما مشفرًا/ثابتًا (Val(3)) أو محددًا بالفعل في نطاق النوع.

The dangers of abusing multiple dispatch (aka, more on types with values-as-parameters)

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

struct Car{Make, Model}
    year::Int
    ...more fields...
end

ثم قم بإرسال الأوامر على الكائنات مثل Car{:Honda,:Accord}(year, args...).

قد يكون هذا مجديًا عندما يكون أي من التالي صحيحًا:

  • تحتاج إلى معالجة كثيفة الاستخدام لوحدة المعالجة المركزية على كل Car، وتصبح أكثر كفاءة بكثير إذا كنت تعرف Make و Model في وقت الترجمة وعدد مختلف Make أو Model التي سيتم استخدامها ليس كبيرًا جدًا.
  • لديك قوائم متجانسة من نفس نوع Car لمعالجتها، بحيث يمكنك تخزينها جميعًا في Array{Car{:Honda,:Accord},N}.

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

عندما لا تنطبق هذه الشروط، فمن المحتمل أنك لن تحصل على أي فائدة؛ والأسوأ من ذلك، فإن "الانفجار التوافقي للأنواع" الناتج سيكون غير منتج. إذا كان items[i+1] له نوع مختلف عن item[i]، يتعين على جوليا البحث عن النوع في وقت التشغيل، والبحث عن الطريقة المناسبة في جداول الطرق، وتحديد (عبر تقاطع الأنواع) أي منها يتطابق، وتحديد ما إذا كانت قد تم تجميعها JIT حتى الآن (وإجراء ذلك إذا لم يكن كذلك)، ثم إجراء الاستدعاء. في جوهر الأمر، أنت تطلب من نظام الأنواع الكامل وآلية التجميع JIT أن تنفذ بشكل أساسي ما يعادل عبارة switch أو بحث في القاموس في كودك الخاص.

بعض المعايير الزمنية في وقت التشغيل التي تقارن (1) توجيه النوع، (2) البحث في القاموس، و (3) عبارة "التبديل" يمكن العثور عليها on the mailing list.

ربما يكون التأثير في وقت الترجمة أسوأ حتى من التأثير في وقت التشغيل: ستقوم جوليا بترجمة دوال متخصصة لكل نوع مختلف من Car{Make, Model}؛ إذا كان لديك مئات أو آلاف من هذه الأنواع، فإن كل دالة تقبل مثل هذا الكائن كمعامل (من دالة get_year مخصصة قد تكتبها بنفسك، إلى دالة push! العامة في جوليا بيس) سيكون لها مئات أو آلاف من المتغيرات المترجمة لها. كل واحدة من هذه تزيد من حجم ذاكرة التخزين المؤقت للكود المترجم، وطول القوائم الداخلية للطرق، وما إلى ذلك. يمكن أن يؤدي الحماس المفرط للقيم كمعاملات إلى إهدار موارد هائلة بسهولة.

Access arrays in memory order, along columns

تُخزَّن المصفوفات متعددة الأبعاد في جوليا بترتيب رئيسي عمودي. وهذا يعني أن المصفوفات تُكدَّس عمودًا واحدًا في كل مرة. يمكن التحقق من ذلك باستخدام دالة vec أو الصيغة [:] كما هو موضح أدناه (لاحظ أن المصفوفة مرتبة [1 3 2 4]، وليس [1 2 3 4]):

julia> x = [1 2; 3 4]
2×2 Matrix{Int64}:
 1  2
 3  4

julia> x[:]
4-element Vector{Int64}:
 1
 3
 2
 4

هذا التقليد في ترتيب المصفوفات شائع في العديد من اللغات مثل Fortran و Matlab و R (لذكر القليل). البديل لترتيب الأعمدة هو ترتيب الصفوف، وهو التقليد الذي اعتمدته C و Python (numpy) من بين لغات أخرى. تذكر أن ترتيب المصفوفات يمكن أن يكون له تأثيرات كبيرة على الأداء عند التكرار عبر المصفوفات. قاعدة عامة يجب أن تضعها في اعتبارك هي أنه مع المصفوفات ذات ترتيب الأعمدة، يتغير الفهرس الأول بشكل أسرع. في الأساس، يعني ذلك أن التكرار سيكون أسرع إذا كان فهرس الحلقة الداخلية هو الأول الذي يظهر في تعبير الشريحة. تذكر أن فهرسة مصفوفة باستخدام : هي حلقة ضمنية تصل بشكل تكراري إلى جميع العناصر ضمن بعد معين؛ قد يكون من الأسرع استخراج الأعمدة بدلاً من الصفوف، على سبيل المثال.

اعتبر المثال المصطنع التالي. تخيل أننا أردنا كتابة دالة تقبل Vector وتعيد مربع Matrix مع ملء إما الصفوف أو الأعمدة بنسخ من المتجه المدخل. افترض أنه ليس من المهم ما إذا كانت الصفوف أو الأعمدة مليئة بهذه النسخ (ربما يمكن تعديل بقية الكود بسهولة وفقًا لذلك). يمكننا القيام بذلك بطريقة أو بأخرى على الأقل (بالإضافة إلى الاتصال الموصى به بالدالة المدمجة repeat):

function copy_cols(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for i = inds
        out[:, i] = x
    end
    return out
end

function copy_rows(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for i = inds
        out[i, :] = x
    end
    return out
end

function copy_col_row(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for col = inds, row = inds
        out[row, col] = x[row]
    end
    return out
end

function copy_row_col(x::Vector{T}) where T
    inds = axes(x, 1)
    out = similar(Array{T}, inds, inds)
    for row = inds, col = inds
        out[row, col] = x[col]
    end
    return out
end

الآن سنقوم بقياس وقت كل من هذه الدوال باستخدام نفس متجه الإدخال العشوائي 10000 بواسطة 1:

julia> x = randn(10000);

julia> fmt(f) = println(rpad(string(f)*": ", 14, ' '), @elapsed f(x))

julia> map(fmt, [copy_cols, copy_rows, copy_col_row, copy_row_col]);
copy_cols:    0.331706323
copy_rows:    1.799009911
copy_col_row: 0.415630047
copy_row_col: 1.721531501

لاحظ أن copy_cols أسرع بكثير من copy_rows. هذا متوقع لأن copy_cols يحترم تخطيط الذاكرة القائم على الأعمدة لـ Matrix ويملأها عمودًا واحدًا في كل مرة. بالإضافة إلى ذلك، فإن copy_col_row أسرع بكثير من copy_row_col لأنه يتبع قاعدة الإبهام لدينا التي تنص على أن العنصر الأول الذي يظهر في تعبير الشريحة يجب أن يكون مرتبطًا بأعمق حلقة.

Pre-allocating outputs

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

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

julia> function xinc(x)
           return [x, x+1, x+2]
       end;

julia> function loopinc()
           y = 0
           for i = 1:10^7
               ret = xinc(i)
               y += ret[2]
           end
           return y
       end;

مع

julia> function xinc!(ret::AbstractVector{T}, x::T) where T
           ret[1] = x
           ret[2] = x+1
           ret[3] = x+2
           nothing
       end;

julia> function loopinc_prealloc()
           ret = Vector{Int}(undef, 3)
           y = 0
           for i = 1:10^7
               xinc!(ret, i)
               y += ret[2]
           end
           return y
       end;

نتائج التوقيت:

julia> @time loopinc()
  0.529894 seconds (40.00 M allocations: 1.490 GiB, 12.14% gc time)
50000015000000

julia> @time loopinc_prealloc()
  0.030850 seconds (6 allocations: 288 bytes)
50000015000000

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

مأخوذ إلى أقصى حد، يمكن أن تجعل التخصيص المسبق كودك أكثر قبحًا، لذا قد تكون قياسات الأداء وبعض الحكم مطلوبة. ومع ذلك، بالنسبة للدوال "المتجهة" (عنصر-wise)، يمكن استخدام الصياغة المريحة x .= f.(y) للعمليات في المكان مع حلقات مدمجة وبدون مصفوفات مؤقتة (انظر dot syntax for vectorizing functions).

Use MutableArithmetics for more control over allocation for mutable arithmetic types

بعض الأنواع الفرعية Number، مثل BigInt أو BigFloat، قد يتم تنفيذها كأنواع mutable struct، أو قد تحتوي على مكونات قابلة للتغيير. عادةً ما تختار الواجهات الحسابية في جوليا Base الراحة على الكفاءة في مثل هذه الحالات، لذا فإن استخدامها بطريقة ساذجة قد يؤدي إلى أداء دون المستوى. من ناحية أخرى، تجعل التجريدات في حزمة MutableArithmetics من الممكن استغلال قابلية تغيير مثل هذه الأنواع لكتابة كود سريع يخصص فقط ما هو ضروري. كما تجعل MutableArithmetics من الممكن نسخ قيم الأنواع الحسابية القابلة للتغيير بشكل صريح عند الحاجة. MutableArithmetics هي حزمة مستخدم وليست مرتبطة بمشروع جوليا.

More dots: Fuse vectorized operations

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

في سياق الجبر الخطي، يعني هذا أنه على الرغم من أن العمليات مثل vector + vector و vector * scalar محددة، إلا أنه يمكن أن يكون من المفيد بدلاً من ذلك استخدام vector .+ vector و vector .* scalar لأن الحلقات الناتجة يمكن دمجها مع الحسابات المحيطة. على سبيل المثال، اعتبر الدالتين:

julia> f(x) = 3x.^2 + 4x + 7x.^3;

julia> fdot(x) = @. 3x^2 + 4x + 7x^3; # equivalent to 3 .* x.^2 .+ 4 .* x .+ 7 .* x.^3

كلا من f و fdot يحسبان نفس الشيء. ومع ذلك، فإن fdot (المعرف بمساعدة الماكرو @.) أسرع بشكل ملحوظ عند تطبيقه على مصفوفة:

julia> x = rand(10^6);

julia> @time f(x);
  0.019049 seconds (16 allocations: 45.777 MiB, 18.59% gc time)

julia> @time fdot(x);
  0.002790 seconds (6 allocations: 7.630 MiB)

julia> @time f.(x);
  0.002626 seconds (8 allocations: 7.630 MiB)

هذا يعني أن fdot(x) أسرع بعشر مرات ويخصص 1/6 من الذاكرة مقارنة بـ f(x)، لأن كل عملية * و + في f(x) تخصص مصفوفة مؤقتة جديدة وتنفذ في حلقة منفصلة. في هذا المثال، f.(x) سريع مثل fdot(x) ولكن في العديد من السياقات، يكون من الأكثر ملاءمة رش بعض النقاط في تعبيراتك بدلاً من تعريف دالة منفصلة لكل عملية موجهة.

Fewer dots: Unfuse certain intermediate broadcasts

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

julia> x = rand(1000, 1000);

julia> d = sum(abs2, x; dims=2);

julia> @time x ./= sqrt.(d);
  0.002049 seconds (4 allocations: 96 bytes)

سيعمل هذا. ومع ذلك، فإن هذه التعبير سيعيد حساب sqrt(d[i]) في كل عنصر في الصف x[i, :]، مما يعني أنه يتم حساب العديد من الجذور التربيعية أكثر مما هو ضروري. لرؤية بالضبط على أي مؤشرات ستتكرر البث، يمكننا استدعاء Broadcast.combine_axes على معاملات التعبير المدمج. سيعيد هذا مجموعة من النطاقات التي تتوافق مدخلاتها مع محاور التكرار؛ سيكون حاصل ضرب أطوال هذه النطاقات هو العدد الإجمالي لاستدعاءات العملية المدمجة.

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

julia> @time let s = sqrt.(d); x ./= s end;
  0.000809 seconds (5 allocations: 8.031 KiB)

julia> @time x ./= identity(sqrt.(d));
  0.000608 seconds (5 allocations: 8.031 KiB)

julia> @time x ./= map(sqrt, d);
  0.000611 seconds (4 allocations: 8.016 KiB)

أي من هذه الخيارات يؤدي تقريبًا إلى تسريع ثلاثي على حساب تخصيص؛ بالنسبة للبث الكبير، يمكن أن يكون هذا التسريع كبيرًا بشكل أساسي.

Consider using views for slices

في جوليا، تعبير "شريحة" المصفوفة مثل array[1:5, :] ينشئ نسخة من تلك البيانات (باستثناء في الجانب الأيسر من تعبير الإسناد، حيث array[1:5, :] = ... يقوم بالإسناد في المكان لتلك الجزء من array). إذا كنت تقوم بالعديد من العمليات على الشريحة، يمكن أن يكون هذا جيدًا من حيث الأداء لأنه أكثر كفاءة للعمل مع نسخة أصغر متجاورة بدلاً من أن تقوم بالوصول إلى المصفوفة الأصلية. من ناحية أخرى، إذا كنت تقوم فقط ببعض العمليات البسيطة على الشريحة، فإن تكلفة تخصيص الذاكرة وعمليات النسخ يمكن أن تكون كبيرة.

بديل هو إنشاء "عرض" للمصفوفة، وهو كائن مصفوفة (SubArray) يشير فعليًا إلى بيانات المصفوفة الأصلية في المكان، دون إنشاء نسخة. (إذا كتبت إلى عرض، فإنه يعدل بيانات المصفوفة الأصلية أيضًا.) يمكن القيام بذلك لشرائح فردية عن طريق استدعاء view، أو بشكل أبسط لتعبير كامل أو كتلة من الشيفرة عن طريق وضع @views أمام ذلك التعبير. على سبيل المثال:

julia> fcopy(x) = sum(x[2:end-1]);

julia> @views fview(x) = sum(x[2:end-1]);

julia> x = rand(10^6);

julia> @time fcopy(x);
  0.003051 seconds (3 allocations: 7.629 MB)

julia> @time fview(x);
  0.001020 seconds (1 allocation: 16 bytes)

لاحظ كل من تسريع 3× وتقليل تخصيص الذاكرة في نسخة fview من الدالة.

Copying data is not always bad

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

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

julia> using Random

julia> A = randn(3000, 3000);

julia> x = randn(2000);

julia> inds = shuffle(1:3000)[1:2000];

julia> function iterated_neural_network(A, x, depth)
           for _ in 1:depth
               x .= max.(0, A * x)
           end
           argmax(x)
       end

julia> @time iterated_neural_network(view(A, inds, inds), x, 10)
  0.324903 seconds (12 allocations: 157.562 KiB)
1569

julia> @time iterated_neural_network(A[inds, inds], x, 10)
  0.054576 seconds (13 allocations: 30.671 MiB, 13.33% gc time)
1569

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

Consider StaticArrays.jl for small fixed-size vector/matrix operations

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

على سبيل المثال، إذا كنت تقوم بإجراء حسابات مع الهندسات ثنائية الأبعاد، فقد يكون لديك العديد من الحسابات مع المتجهات ذات المكونين. من خلال استخدام نوع SVector من StaticArrays.jl، يمكنك استخدام تدوين المتجهات والعمليات المريحة مثل norm(3v - w) على المتجهات v و w، مع السماح للمترجم بفك شيفرة الكود إلى حسابات بسيطة تعادل @inbounds hypot(3v[1]-w[1], 3v[2]-w[2]).

Avoid string interpolation for I/O

عند كتابة البيانات إلى ملف (أو جهاز إدخال/إخراج آخر)، فإن تشكيل سلاسل وسيطة إضافية هو مصدر للعبء. بدلاً من:

println(file, "$a $b")

استخدم:

println(file, a, " ", b)

الإصدار الأول من الكود يشكل سلسلة نصية، ثم يكتبها إلى الملف، بينما الإصدار الثاني يكتب القيم مباشرة إلى الملف. لاحظ أيضًا أنه في بعض الحالات يمكن أن يكون دمج السلاسل النصية أصعب في القراءة. اعتبر:

println(file, "$(f(a))$(f(b))")

ضد:

println(file, f(a), f(b))

Optimize network I/O during parallel execution

عند تنفيذ دالة عن بُعد بالتوازي:

using Distributed

responses = Vector{Any}(undef, nworkers())
@sync begin
    for (idx, pid) in enumerate(workers())
        @async responses[idx] = remotecall_fetch(foo, pid, args...)
    end
end

أسرع من:

using Distributed

refs = Vector{Any}(undef, nworkers())
for (idx, pid) in enumerate(workers())
    refs[idx] = @spawnat pid foo(args...)
end
responses = [fetch(r) for r in refs]

تؤدي النتيجة الأولى إلى جولة واحدة فقط عبر الشبكة إلى كل عامل، بينما تؤدي النتيجة الثانية إلى مكالمتين عبر الشبكة - الأولى بواسطة @spawnat والثانية بسبب fetch (أو حتى wait). يتم أيضًا تنفيذ 4d61726b646f776e2e436f64652822222c202266657463682229_40726566/4d61726b646f776e2e436f64652822222c2022776169742229_40726566 بشكل تسلسلي مما يؤدي إلى أداء عام أقل.

Fix deprecation warnings

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

Tweaks

هذه بعض النقاط الثانوية التي قد تساعد في الحلقات الداخلية الضيقة.

  • تجنب المصفوفات غير الضرورية. على سبيل المثال، بدلاً من sum([x,y,z]) استخدم x+y+z.
  • استخدم abs2(z) بدلاً من abs(z)^2 للمعقد z. بشكل عام، حاول إعادة كتابة الكود لاستخدام abs2 بدلاً من abs للمعاملات المعقدة.
  • استخدم div(x,y) لقسمة الأعداد الصحيحة بدلاً من trunc(x/y)، وfld(x,y) بدلاً من floor(x/y)، وcld(x,y) بدلاً من ceil(x/y).

Performance Annotations

أحيانًا يمكنك تمكين تحسين أفضل من خلال وعد بخصائص معينة للبرنامج.

  • استخدم @inbounds لإزالة فحص حدود المصفوفات داخل التعبيرات. تأكد تمامًا قبل القيام بذلك. إذا كانت الفهارس خارج الحدود في أي وقت، فقد تتعرض لعمليات تعطل أو فساد صامت.
  • استخدم @fastmath للسماح بتحسينات النقاط العائمة التي تكون صحيحة للأرقام الحقيقية، ولكن تؤدي إلى اختلافات لأرقام IEEE. كن حذرًا عند القيام بذلك، حيث قد يغير هذا النتائج العددية. هذا يتوافق مع خيار -ffast-math في clang.
  • اكتب @simd أمام حلقات for لضمان أن التكرارات مستقلة ويمكن إعادة ترتيبها. لاحظ أنه في العديد من الحالات، يمكن لجوليا تلقائيًا تحويل الكود إلى متجهات دون استخدام ماكرو @simd؛ فهو مفيد فقط في الحالات التي سيكون فيها مثل هذا التحويل غير قانوني، بما في ذلك الحالات مثل السماح بإعادة تجميع النقاط العائمة وتجاهل الوصولات إلى الذاكرة المعتمدة (@simd ivdep). مرة أخرى، كن حذرًا جدًا عند التأكيد على @simd حيث أن وضع علامة خاطئة على حلقة تحتوي على تكرارات معتمدة قد يؤدي إلى نتائج غير متوقعة. بشكل خاص، لاحظ أن setindex! على بعض الأنواع الفرعية من AbstractArray تعتمد بطبيعتها على ترتيب التكرار. هذه الميزة تجريبية وقد تتغير أو تختفي في الإصدارات المستقبلية من جوليا.

العبارة الشائعة لاستخدام 1:n للولوج إلى AbstractArray ليست آمنة إذا كانت المصفوفة تستخدم فهرسة غير تقليدية، وقد تتسبب في حدوث خطأ تقسيم إذا تم إيقاف التحقق من الحدود. استخدم LinearIndices(x) أو eachindex(x) بدلاً من ذلك (انظر أيضًا Arrays with custom indices).

Note

بينما يجب وضع @simd مباشرة أمام حلقة for الأعمق، يمكن تطبيق كل من @inbounds و @fastmath على تعبيرات فردية أو على جميع التعبيرات التي تظهر داخل كتل التعليمات البرمجية المتداخلة، على سبيل المثال، باستخدام @inbounds begin أو @inbounds for ....

هنا مثال مع كل من تنسيق @inbounds و @simd (نستخدم هنا @noinline لمنع المحسّن من محاولة أن يكون ذكيًا جدًا وهزيمة معيارنا):

@noinline function inner(x, y)
    s = zero(eltype(x))
    for i=eachindex(x)
        @inbounds s += x[i]*y[i]
    end
    return s
end

@noinline function innersimd(x, y)
    s = zero(eltype(x))
    @simd for i = eachindex(x)
        @inbounds s += x[i] * y[i]
    end
    return s
end

function timeit(n, reps)
    x = rand(Float32, n)
    y = rand(Float32, n)
    s = zero(Float64)
    time = @elapsed for j in 1:reps
        s += inner(x, y)
    end
    println("GFlop/sec        = ", 2n*reps / time*1E-9)
    time = @elapsed for j in 1:reps
        s += innersimd(x, y)
    end
    println("GFlop/sec (SIMD) = ", 2n*reps / time*1E-9)
end

timeit(1000, 1000)

على جهاز كمبيوتر مزود بمعالج Intel Core i5 بتردد 2.4GHz، ينتج هذا:

GFlop/sec        = 1.9467069505224963
GFlop/sec (SIMD) = 17.578554163920018

(GFlop/sec يقيس الأداء، والأرقام الأكبر أفضل.)

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

function init!(u::Vector)
    n = length(u)
    dx = 1.0 / (n-1)
    @fastmath @inbounds @simd for i in 1:n #by asserting that `u` is a `Vector` we can assume it has 1-based indexing
        u[i] = sin(2pi*dx*i)
    end
end

function deriv!(u::Vector, du)
    n = length(u)
    dx = 1.0 / (n-1)
    @fastmath @inbounds du[1] = (u[2] - u[1]) / dx
    @fastmath @inbounds @simd for i in 2:n-1
        du[i] = (u[i+1] - u[i-1]) / (2*dx)
    end
    @fastmath @inbounds du[n] = (u[n] - u[n-1]) / dx
end

function mynorm(u::Vector)
    n = length(u)
    T = eltype(u)
    s = zero(T)
    @fastmath @inbounds @simd for i in 1:n
        s += u[i]^2
    end
    @fastmath @inbounds return sqrt(s)
end

function main()
    n = 2000
    u = Vector{Float64}(undef, n)
    init!(u)
    du = similar(u)

    deriv!(u, du)
    nu = mynorm(du)

    @time for i in 1:10^6
        deriv!(u, du)
        nu = mynorm(du)
    end

    println(nu)
end

main()

على جهاز كمبيوتر مزود بمعالج Intel Core i7 بتردد 2.7 جيجاهرتز، ينتج هذا:

$ julia wave.jl;
  1.207814709 seconds
4.443986180758249

$ julia --math-mode=ieee wave.jl;
  4.487083643 seconds
4.443986180758249

هنا، الخيار --math-mode=ieee يعطل الماكرو @fastmath، حتى نتمكن من مقارنة النتائج.

في هذه الحالة، فإن التسريع الناتج عن @fastmath هو عامل يبلغ حوالي 3.7. هذا كبير بشكل غير عادي - بشكل عام، سيكون التسريع أصغر. (في هذا المثال المحدد، مجموعة العمل من المعيار صغيرة بما يكفي لتناسب في ذاكرة التخزين المؤقت L1 للمعالج، بحيث لا تلعب فترة وصول الذاكرة دورًا، ويهيمن وقت الحساب على استخدام وحدة المعالجة المركزية. في العديد من البرامج الواقعية، ليس هذا هو الحال.) أيضًا، في هذه الحالة، لا تؤثر هذه التحسينات على النتيجة - بشكل عام، ستكون النتيجة مختلفة قليلاً. في بعض الحالات، خاصة بالنسبة للخوارزميات غير المستقرة عدديًا، يمكن أن تكون النتيجة مختلفة تمامًا.

التعليق @fastmath يعيد ترتيب تعبيرات النقاط العائمة، على سبيل المثال تغيير ترتيب التقييم، أو افتراض أن بعض الحالات الخاصة (inf، nan) لا يمكن أن تحدث. في هذه الحالة (وعلى هذا الكمبيوتر المحدد)، الفرق الرئيسي هو أن التعبير 1 / (2*dx) في الدالة deriv يتم رفعه خارج الحلقة (أي يتم حسابه خارج الحلقة)، كما لو كان المرء قد كتب idx = 1 / (2*dx). في الحلقة، يصبح التعبير ... / (2*dx) بعد ذلك ... * idx، وهو أسرع بكثير في التقييم. بالطبع، تعتمد كل من التحسين الفعلي الذي يطبقه المترجم وكذلك الزيادة الناتجة في السرعة بشكل كبير على الأجهزة. يمكنك فحص التغيير في الكود المولد باستخدام دالة جوليا code_native.

لاحظ أن @fastmath يفترض أيضًا أن NaNs لن تحدث أثناء الحساب، مما قد يؤدي إلى سلوك مفاجئ:

julia> f(x) = isnan(x);

julia> f(NaN)
true

julia> f_fast(x) = @fastmath isnan(x);

julia> f_fast(NaN)
false

Treat Subnormal Numbers as Zeros

الأرقام غير الطبيعية، التي كانت تُسمى سابقًا denormal numbers، مفيدة في العديد من السياقات، ولكنها تتسبب في عقوبة أداء على بعض الأجهزة. يمنح استدعاء set_zero_subnormals(true) الإذن لعمليات النقطة العائمة لمعالجة المدخلات أو المخرجات غير الطبيعية كأصفار، مما قد يحسن الأداء على بعض الأجهزة. يفرض استدعاء set_zero_subnormals(false) سلوك IEEE الصارم للأرقام غير الطبيعية.

فيما يلي مثال حيث تؤثر القيم الفرعية بشكل ملحوظ على الأداء على بعض الأجهزة:

function timestep(b::Vector{T}, a::Vector{T}, Δt::T) where T
    @assert length(a)==length(b)
    n = length(b)
    b[1] = 1                            # Boundary condition
    for i=2:n-1
        b[i] = a[i] + (a[i-1] - T(2)*a[i] + a[i+1]) * Δt
    end
    b[n] = 0                            # Boundary condition
end

function heatflow(a::Vector{T}, nstep::Integer) where T
    b = similar(a)
    for t=1:div(nstep,2)                # Assume nstep is even
        timestep(b,a,T(0.1))
        timestep(a,b,T(0.1))
    end
end

heatflow(zeros(Float32,10),2)           # Force compilation
for trial=1:6
    a = zeros(Float32,1000)
    set_zero_subnormals(iseven(trial))  # Odd trials use strict IEEE arithmetic
    @time heatflow(a,1000)
end

هذا يعطي مخرجات مشابهة لـ

  0.002202 seconds (1 allocation: 4.063 KiB)
  0.001502 seconds (1 allocation: 4.063 KiB)
  0.002139 seconds (1 allocation: 4.063 KiB)
  0.001454 seconds (1 allocation: 4.063 KiB)
  0.002115 seconds (1 allocation: 4.063 KiB)
  0.001455 seconds (1 allocation: 4.063 KiB)

لاحظ كيف أن كل تكرار زوجي أسرع بشكل ملحوظ.

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

يجب استخدام معالجة القيم الفرعية كأصفار بحذر، لأن القيام بذلك يكسر بعض الهويات، مثل x-y == 0 تعني x == y:

julia> x = 3f-38; y = 2f-38;

julia> set_zero_subnormals(true); (x - y, x == y)
(0.0f0, false)

julia> set_zero_subnormals(false); (x - y, x == y)
(1.0000001f-38, false)

في بعض التطبيقات، بديل لتصفير الأعداد الفرعية هو حقن كمية صغيرة من الضوضاء. على سبيل المثال، بدلاً من تهيئة a بالصفر، قم بتهيئته بـ:

a = rand(Float32,1000) * 1.f-9

@code_warntype

الماكرو @code_warntype (أو نسخته الوظيفية code_warntype) يمكن أن يكون مفيدًا أحيانًا في تشخيص المشكلات المتعلقة بالنوع. إليك مثال:

julia> @noinline pos(x) = x < 0 ? 0 : x;

julia> function f(x)
           y = pos(x)
           return sin(y*x + 1)
       end;

julia> @code_warntype f(3.2)
MethodInstance for f(::Float64)
  from f(x) @ Main REPL[9]:1
Arguments
  #self#::Core.Const(f)
  x::Float64
Locals
  y::Union{Float64, Int64}
Body::Float64
1 ─      (y = Main.pos(x))
│   %2 = (y * x)::Float64
│   %3 = (%2 + 1)::Float64
│   %4 = Main.sin(%3)::Float64
└──      return %4

تفسير مخرجات @code_warntype، مثل تلك الخاصة بأقربائه @code_lowered، @code_typed، @code_llvm، و @code_native، يتطلب بعض الممارسة. يتم تقديم الكود الخاص بك في شكل تم هضمه بشكل كبير في طريقه إلى توليد كود الآلة المترجم. يتم تمييز معظم التعبيرات بواسطة نوع، يتم الإشارة إليه بواسطة ::T (حيث قد يكون Float64، على سبيل المثال). السمة الأكثر أهمية لـ 4d61726b646f776e2e436f64652822222c202240636f64655f7761726e747970652229_40726566 هي أن الأنواع غير الملموسة تظهر باللون الأحمر؛ نظرًا لأن هذه الوثيقة مكتوبة بتنسيق Markdown، الذي لا يحتوي على ألوان، في هذه الوثيقة، يتم الإشارة إلى النص الأحمر بواسطة الأحرف الكبيرة.

في الأعلى، يتم عرض نوع الإرجاع المستنتج للدالة كـ Body::Float64. تمثل الأسطر التالية جسم f في شكل SSA IR الخاص بـ Julia. الصناديق المرقمة هي تسميات وتمثل أهدافًا للقفزات (عبر goto) في الكود الخاص بك. عند النظر إلى الجسم، يمكنك أن ترى أن أول شيء يحدث هو استدعاء pos وقد تم استنتاج نوع الإرجاع كنوع Union Union{Float64, Int64} المعروض بأحرف كبيرة لأنه نوع غير ملموس. هذا يعني أننا لا نستطيع معرفة نوع الإرجاع الدقيق لـ pos بناءً على أنواع المدخلات. ومع ذلك، فإن نتيجة y*x هي Float64 بغض النظر عما إذا كان y هو Float64 أو Int64. النتيجة الصافية هي أن f(x::Float64) لن تكون غير مستقرة من حيث النوع في مخرجاتها، حتى لو كانت بعض الحسابات الوسيطة غير مستقرة من حيث النوع.

كيف تستخدم هذه المعلومات يعود إليك. من الواضح أنه سيكون من الأفضل بكثير تثبيت pos ليكون ثابت النوع: إذا قمت بذلك، ستكون جميع المتغيرات في f محددة، وسيكون أداؤها مثالياً. ومع ذلك، هناك ظروف قد لا تكون فيها هذه النوعية الزائلة غير المستقرة مهمة جداً: على سبيل المثال، إذا لم يتم استخدام pos في عزلة، فإن حقيقة أن مخرجات f ثابتة النوع (لـ Float64 المدخلات) ستحمي الشيفرة اللاحقة من آثار عدم استقرار النوع. هذا مهم بشكل خاص في الحالات التي يكون فيها إصلاح عدم استقرار النوع صعباً أو مستحيلاً. في مثل هذه الحالات، فإن النصائح أعلاه (مثل إضافة تعليقات نوعية و/أو تقسيم الدوال) هي أفضل أدواتك للحد من "الأضرار" الناتجة عن عدم استقرار النوع. أيضاً، لاحظ أن حتى قاعدة جوليا تحتوي على دوال غير ثابتة النوع. على سبيل المثال، الدالة findfirst تعيد الفهرس في مصفوفة حيث يتم العثور على مفتاح، أو nothing إذا لم يتم العثور عليه، وهو عدم استقرار واضح في النوع. لتسهيل العثور على عدم استقرار النوع الذي من المحتمل أن يكون مهماً، يتم تمييز Unions التي تحتوي على إما missing أو nothing باللون الأصفر، بدلاً من الأحمر.

قد تساعدك الأمثلة التالية في تفسير التعبيرات المميزة بأنها تحتوي على أنواع غير ورقية:

  • جسم الدالة الذي يبدأ بـ Body::Union{T1,T2})

    • تفسير: دالة بنوع عائد غير مستقر
    • اقتراح: اجعل نوع قيمة الإرجاع ثابتًا، حتى لو كان عليك توضيحه
  • invoke Main.g(%%x::Int64)::Union{Float64, Int64}

    • تفسير: استدعاء لدالة غير مستقرة من حيث النوع g.
    • اقتراح: إصلاح الدالة، أو إذا لزم الأمر، توضيح قيمة الإرجاع
  • invoke Base.getindex(%%x::Array{Any,1}, 1::Int64)::Any

    • تفسير: الوصول إلى عناصر المصفوفات ذات الأنواع الضعيفة
    • اقتراح: استخدم المصفوفات مع أنواع محددة بشكل أفضل، أو إذا لزم الأمر قم بتعليق نوع الوصولات الفردية للعناصر.
  • Base.getfield(%%x, :(:data))::Array{Float64,N} where N

    • تفسير: الحصول على حقل من نوع غير ورقة. في هذه الحالة، كان نوع x، لنقل ArrayContainer، يحتوي على حقل data::Array{T}. لكن Array يحتاج أيضًا إلى البعد N ليكون نوعًا ملموسًا.
    • اقتراح: استخدم أنواعًا محددة مثل Array{T,3} أو Array{T,N}، حيث أن N هو الآن معلمة لـ ArrayContainer

Performance of captured variable

اعتبر المثال التالي الذي يحدد دالة داخلية:

function abmult(r::Int)
    if r < 0
        r = -r
    end
    f = x -> x * r
    return f
end

تُعيد الدالة abmult دالة f التي تضرب وسيطها في القيمة المطلقة لـ r. تُسمى الدالة الداخلية المعينة لـ f "إغلاق". تُستخدم الدوال الداخلية أيضًا من قبل اللغة لـ do-blocks وللتعبيرات المولدة.

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

النقاش في الفقرة السابقة أشار إلى "المحلل"، أي المرحلة من التجميع التي تحدث عندما يتم تحميل الوحدة التي تحتوي على abmult لأول مرة، على عكس المرحلة اللاحقة عندما يتم استدعاؤها لأول مرة. لا "يعرف" المحلل أن Int هو نوع ثابت، أو أن العبارة r = -r تحول Int إلى Int آخر. تحدث سحر استنتاج النوع في المرحلة اللاحقة من التجميع.

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

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

function abmult2(r0::Int)
    r::Int = r0
    if r < 0
        r = -r
    end
    f = x -> x * r
    return f
end

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

function abmult3(r::Int)
    if r < 0
        r = -r
    end
    f = let r = r
            x -> x * r
    end
    return f
end

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

Multithreading and linear algebra

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

تقوم جوليا بترجمة واستخدام نسختها الخاصة من OpenBLAS للجبر الخطي، حيث يتم التحكم في عدد الخيوط بواسطة متغير البيئة OPENBLAS_NUM_THREADS. يمكن تعيينه إما كخيار سطر أوامر عند تشغيل جوليا، أو تعديله خلال جلسة جوليا باستخدام BLAS.set_num_threads(N) (الموديل الفرعي BLAS يتم تصديره بواسطة using LinearAlgebra). يمكن الوصول إلى قيمته الحالية باستخدام BLAS.get_num_threads().

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

  • إذا كان OPENBLAS_NUM_THREADS=1، فإن OpenBLAS يستخدم خيوط جوليّا المستدعية، أي أنه "يعيش في" خيط جوليّا الذي يقوم بالتشغيل.
  • إذا كان OPENBLAS_NUM_THREADS=N>1، فإن OpenBLAS ينشئ ويدير مجموعة خاصة به من الخيوط (N إجمالاً). هناك مجموعة واحدة فقط من خيوط OpenBLAS مشتركة بين جميع خيوط جوليا.

عند بدء استخدام جوليا في وضع متعدد الخيوط مع JULIA_NUM_THREADS=X، يُوصى عمومًا بتعيين OPENBLAS_NUM_THREADS=1. بالنظر إلى السلوك الموصوف أعلاه، فإن زيادة عدد خيوط BLAS إلى N>1 يمكن أن تؤدي بسهولة إلى أداء أسوأ، خاصة عندما يكون N<<X. ومع ذلك، هذه مجرد قاعدة عامة، وأفضل طريقة لتعيين كل عدد من الخيوط هي التجربة على تطبيقك المحدد.

Alternative linear algebra backends

كبديل لـ OpenBLAS، توجد عدة واجهات خلفية أخرى يمكن أن تساعد في أداء الجبر الخطي. تشمل الأمثلة البارزة MKL.jl و AppleAccelerate.jl.

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

Execution latency, package loading and package precompiling time

Reducing time to first plot etc.

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

julia> foo() = rand(2,2) * rand(2,2)
foo (generic function with 1 method)

julia> @time @eval foo();
  0.252395 seconds (1.12 M allocations: 56.178 MiB, 2.93% gc time, 98.12% compilation time)

julia> @time @eval foo();
  0.000156 seconds (63 allocations: 2.453 KiB)

لاحظ أن @time @eval أفضل لقياس وقت التجميع لأنه بدون @eval، قد يتم بالفعل إجراء بعض التجميع قبل بدء التوقيت.

عند تطوير حزمة، قد تتمكن من تحسين تجربة مستخدميك من خلال التحضير المسبق بحيث عندما يستخدمون الحزمة، يكون الكود الذي يستخدمونه قد تم تجميعه بالفعل. لتحضير كود الحزمة بشكل فعال، يُوصى باستخدام PrecompileTools.jl لتشغيل "عبء عمل التحضير المسبق" خلال وقت التحضير المسبق والذي يمثل استخدام الحزمة النموذجي، مما سيخزن الكود المترجم محليًا في ذاكرة التخزين المؤقت pkgimage للحزمة، مما يقلل بشكل كبير من "الوقت حتى التنفيذ الأول" (غالبًا ما يُشار إليه بـ TTFX) لمثل هذا الاستخدام.

لاحظ أن PrecompileTools.jl يمكن تعطيلها وأحيانًا تكوينها عبر التفضيلات إذا كنت لا ترغب في قضاء الوقت الإضافي في الترجمة المسبقة، وهو ما قد يكون الحال أثناء تطوير حزمة.

Reducing package loading time

الحفاظ على الوقت المستغرق لتحميل الحزمة منخفضًا يكون عادةً مفيدًا. تشمل الممارسات الجيدة العامة لمطوري الحزم:

  1. قلل من اعتماداتك إلى تلك التي تحتاجها حقًا. ضع في اعتبارك استخدام package extensions لدعم التوافق مع حزم أخرى دون زيادة حجم اعتماداتك الأساسية.
  2. تجنب استخدام دوال __init__() ما لم يكن هناك بديل، خاصة تلك التي قد تؤدي إلى الكثير من التجميع، أو تستغرق وقتًا طويلاً للتنفيذ.
  3. حيثما كان ذلك ممكنًا، قم بإصلاح invalidations بين التبعيات الخاصة بك ومن كود الحزمة الخاصة بك.

الأداة @time_imports يمكن أن تكون مفيدة في REPL لمراجعة العوامل المذكورة أعلاه.

julia> @time @time_imports using Plots
      0.5 ms  Printf
     16.4 ms  Dates
      0.7 ms  Statistics
               ┌ 23.8 ms SuiteSparse_jll.__init__() 86.11% compilation time (100% recompilation)
     90.1 ms  SuiteSparse_jll 91.57% compilation time (82% recompilation)
      0.9 ms  Serialization
               ┌ 39.8 ms SparseArrays.CHOLMOD.__init__() 99.47% compilation time (100% recompilation)
    166.9 ms  SparseArrays 23.74% compilation time (100% recompilation)
      0.4 ms  Statistics → SparseArraysExt
      0.5 ms  TOML
      8.0 ms  Preferences
      0.3 ms  PrecompileTools
      0.2 ms  Reexport
... many deps omitted for example ...
      1.4 ms  Tar
               ┌ 73.8 ms p7zip_jll.__init__() 99.93% compilation time (100% recompilation)
     79.4 ms  p7zip_jll 92.91% compilation time (100% recompilation)
               ┌ 27.7 ms GR.GRPreferences.__init__() 99.77% compilation time (100% recompilation)
     43.0 ms  GR 64.26% compilation time (100% recompilation)
               ┌ 2.1 ms Plots.__init__() 91.80% compilation time (100% recompilation)
    300.9 ms  Plots 0.65% compilation time (100% recompilation)
  1.795602 seconds (3.33 M allocations: 190.153 MiB, 7.91% gc time, 39.45% compilation time: 97% of which was recompilation)

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

علاوة على ذلك، لاحظ أن امتداد Statistics SparseArraysExt قد تم تفعيله لأن SparseArrays موجود في شجرة الاعتماد. أي، انظر 0.4 ms Statistics → SparseArraysExt.

هذا التقرير يوفر فرصة جيدة لمراجعة ما إذا كانت تكلفة وقت تحميل الاعتماد تستحق الوظائف التي تقدمها. كما يمكن استخدام أداة Pkg why للإبلاغ عن سبب وجود اعتماد غير مباشر.

(CustomPackage) pkg> why FFMPEG_jll
  Plots → FFMPEG → FFMPEG_jll
  Plots → GR → GR_jll → FFMPEG_jll

أو لرؤية التبعيات غير المباشرة التي يجلبها حزمة، يمكنك pkg> rm الحزمة، ورؤية التبعيات التي تمت إزالتها من البيان، ثم التراجع عن التغيير باستخدام pkg> undo.

إذا كان وقت التحميل يهيمن عليه طرق __init__() البطيئة التي تحتوي على تجميع، فإن طريقة مفصلة لتحديد ما يتم تجميعه هي استخدام وسيط julia --trace-compile=stderr الذي سيبلغ عن بيان precompile في كل مرة يتم فيها تجميع طريقة. على سبيل المثال، سيكون الإعداد الكامل:

$ julia --startup-file=no --trace-compile=stderr
julia> @time @time_imports using CustomPackage
...

لاحظ --startup-file=no الذي يساعد في عزل الاختبار عن الحزم التي قد تكون لديك في startup.jl.

يمكن تحقيق مزيد من التحليل لأسباب إعادة الترجمة باستخدام حزمة SnoopCompile.