Performance Tips
In den folgenden Abschnitten gehen wir kurz auf einige Techniken ein, die dazu beitragen können, dass Ihr Julia-Code so schnell wie möglich läuft.
Performance critical code should be inside a function
Jeder Code, der leistungs kritisch ist, sollte in einer Funktion sein. Code innerhalb von Funktionen läuft in der Regel viel schneller als Code auf der obersten Ebene, aufgrund der Funktionsweise des Compilers von Julia.
Die Verwendung von Funktionen ist nicht nur wichtig für die Leistung: Funktionen sind wiederverwendbarer und testbarer und verdeutlichen, welche Schritte unternommen werden und was ihre Eingaben und Ausgaben sind, Write functions, not just scripts ist auch eine Empfehlung des Julia Styleguides.
Die Funktionen sollten Argumente entgegennehmen, anstatt direkt auf globale Variablen zuzugreifen, siehe den nächsten Punkt.
Avoid untyped global variables
Der Wert einer untypisierten globalen Variablen kann sich jederzeit ändern, was möglicherweise zu einer Änderung ihres Typs führt. Dies erschwert es dem Compiler, Code, der globale Variablen verwendet, zu optimieren. Dies gilt auch für typisierte Variablen, d.h. Typalias auf globaler Ebene. Variablen sollten lokal sein oder, wenn möglich, als Argumente an Funktionen übergeben werden.
Wir stellen fest, dass globale Namen häufig Konstanten sind, und sie als solche zu deklarieren, verbessert die Leistung erheblich:
const DEFAULT_VAL = 0
Wenn ein globaler Wert immer vom gleichen Typ ist, the type should be annotated.
Die Verwendung von untypisierten Globals kann optimiert werden, indem ihre Typen an der Stelle der Verwendung annotiert werden:
global x = rand(1000)
function loop_over_global()
s = 0.0
for i in x::Vector{Float64}
s += i
end
return s
end
Das Übergeben von Argumenten an Funktionen ist der bessere Stil. Es führt zu wiederverwendbarerem Code und macht die Eingaben und Ausgaben klarer.
Alle Codes im REPL werden im globalen Kontext ausgewertet, sodass eine auf oberster Ebene definierte und zugewiesene Variable eine globale Variable sein wird. Variablen, die im oberen Bereich von Modulen definiert sind, sind ebenfalls global.
In der folgenden REPL-Sitzung:
julia> x = 1.0
ist gleichbedeutend mit:
julia> global x = 1.0
Also gelten alle zuvor besprochenen Leistungsprobleme.
Measure performance with @time
and pay attention to memory allocation
Ein nützliches Werkzeug zur Messung der Leistung ist das @time
Makro. Hier wiederholen wir das Beispiel mit der globalen Variablen oben, aber diesmal ohne die Typannotation:
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
Beim ersten Aufruf (@time sum_global()
) wird die Funktion kompiliert. (Wenn Sie @time
in dieser Sitzung noch nicht verwendet haben, werden auch die für das Timing benötigten Funktionen kompiliert.) Sie sollten die Ergebnisse dieses Durchlaufs nicht ernst nehmen. Beim zweiten Durchlauf ist zu beachten, dass zusätzlich zur Zeitberichterstattung auch angezeigt wurde, dass eine erhebliche Menge an Speicher zugewiesen wurde. Wir berechnen hier lediglich eine Summe über alle Elemente in einem Vektor von 64-Bit-Gleitkommazahlen, sodass es keinen Bedarf geben sollte, (Heap-)Speicher zuzuweisen.
Wir sollten klarstellen, dass das, was @time
berichtet, speziell Heap-Allokationen sind, die typischerweise für entweder veränderliche Objekte oder für das Erstellen/Wachsen von variabel großen Containern (wie Array
oder Dict
, Strings oder "typ-instabilen" Objekten, deren Typ erst zur Laufzeit bekannt ist) benötigt werden. Das Allokieren (oder Deallokieren) solcher Speicherblöcke kann einen teuren Funktionsaufruf an libc erfordern (z. B. über malloc
in C), und sie müssen für die Garbage Collection verfolgt werden. Im Gegensatz dazu können unveränderliche Werte wie Zahlen (außer Bignums), Tupel und unveränderliche struct
s viel günstiger gespeichert werden, z. B. im Stack- oder CPU-Register-Speicher, sodass man sich normalerweise nicht um die Leistungskosten des "Allokierens" dieser Werte kümmern muss.
Unerwartete Speicherzuweisungen sind fast immer ein Zeichen für ein Problem mit Ihrem Code, normalerweise ein Problem mit der Typstabilität oder das Erstellen vieler kleiner temporärer Arrays. Folglich ist es neben der Zuweisung selbst sehr wahrscheinlich, dass der für Ihre Funktion generierte Code weit von optimal entfernt ist. Nehmen Sie solche Hinweise ernst und befolgen Sie die untenstehenden Ratschläge.
In diesem speziellen Fall ist die Speicherzuweisung auf die Verwendung einer typunstabilen globalen Variablen x
zurückzuführen. Wenn wir stattdessen x
als Argument an die Funktion übergeben, wird kein Speicher mehr zugewiesen (die verbleibende Zuweisung, die unten gemeldet wird, ist auf die Ausführung des @time
-Makros im globalen Gültigkeitsbereich zurückzuführen) und ist nach dem ersten Aufruf deutlich schneller:
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
Die 1 Zuweisung, die gesehen wird, stammt von der Ausführung des @time
Makros selbst im globalen Geltungsbereich. Wenn wir stattdessen das Timing in einer Funktion ausführen, können wir sehen, dass tatsächlich keine Zuweisungen vorgenommen werden:
julia> time_sum(x) = @time sum_arg(x);
julia> time_sum(x)
0.000002 seconds
523.0007221951678
In einigen Situationen muss Ihre Funktion möglicherweise Speicher als Teil ihrer Operation zuweisen, und dies kann das einfache Bild oben komplizieren. In solchen Fällen sollten Sie eine der tools unten verwenden, um Probleme zu diagnostizieren, oder eine Version Ihrer Funktion schreiben, die die Zuweisung von ihren algorithmischen Aspekten trennt (siehe Pre-allocating outputs).
Für ernsthafteres Benchmarking sollten Sie das BenchmarkTools.jl-Paket in Betracht ziehen, das unter anderem die Funktion mehrfach auswertet, um Rauschen zu reduzieren.
Tools
Julia und sein Paket-Ökosystem umfasst Werkzeuge, die Ihnen helfen können, Probleme zu diagnostizieren und die Leistung Ihres Codes zu verbessern:
- Profiling ermöglicht es Ihnen, die Leistung Ihres laufenden Codes zu messen und Zeilen zu identifizieren, die als Engpässe dienen. Für komplexe Projekte kann das ProfileView-Paket Ihnen helfen, Ihre Profilierungsergebnisse zu visualisieren.
- Das JET Paket kann Ihnen helfen, häufige Leistungsprobleme in Ihrem Code zu finden.
- Unerwartet große Speicherzuweisungen – wie berichtet von
@time
,@allocated
oder dem Profiler (durch Aufrufe der Garbage-Collection-Routinen) – deuten darauf hin, dass es Probleme mit Ihrem Code geben könnte. Wenn Sie keinen anderen Grund für die Zuweisungen sehen, vermuten Sie ein Typproblem. Sie können auch Julia mit der Option--track-allocation=user
starten und die resultierenden*.mem
-Dateien untersuchen, um Informationen darüber zu erhalten, wo diese Zuweisungen auftreten. Siehe Memory allocation analysis. @code_warntype
erzeugt eine Darstellung Ihres Codes, die hilfreich sein kann, um Ausdrücke zu finden, die zu Typunsicherheit führen. Siehe@code_warntype
unten.
Avoid containers with abstract type parameters
Beim Arbeiten mit parametrisierten Typen, einschließlich Arrays, ist es am besten, wo möglich, die Parametrisierung mit abstrakten Typen zu vermeiden.
Bitte den folgenden Text einfügen.
julia> a = Real[]
Real[]
julia> push!(a, 1); push!(a, 2.0); push!(a, π)
3-element Vector{Real}:
1
2.0
π = 3.1415926535897...
Weil a
ein Array des abstrakten Typs Real
ist, muss es in der Lage sein, jeden Real
-Wert zu halten. Da Real
-Objekte beliebige Größen und Strukturen haben können, muss a
als ein Array von Zeigern auf individuell zugewiesene Real
-Objekte dargestellt werden. Wenn wir jedoch stattdessen nur Zahlen desselben Typs, z. B. Float64
, in a
speichern erlauben, können diese effizienter gespeichert werden:
julia> a = Float64[]
Float64[]
julia> push!(a, 1); push!(a, 2.0); push!(a, π)
3-element Vector{Float64}:
1.0
2.0
3.141592653589793
Das Zuweisen von Zahlen zu a
konvertiert sie jetzt in Float64
, und a
wird als ein zusammenhängender Block von 64-Bit-Gleitkommawerten gespeichert, die effizient bearbeitet werden können.
Wenn Sie Container mit abstrakten Werttypen nicht vermeiden können, ist es manchmal besser, mit Any
zu parametrisieren, um die Überprüfung des Typs zur Laufzeit zu vermeiden. Zum Beispiel ist IdDict{Any, Any}
leistungsfähiger als IdDict{Type, Vector}
.
Siehe auch die Diskussion unter Parametric Types.
Type declarations
In vielen Sprachen mit optionalen Typdeklarationen ist das Hinzufügen von Deklarationen der Hauptweg, um den Code schneller auszuführen. Das ist nicht der Fall in Julia. In Julia kennt der Compiler im Allgemeinen die Typen aller Funktionsargumente, lokalen Variablen und Ausdrücke. Es gibt jedoch einige spezifische Fälle, in denen Deklarationen hilfreich sind.
Avoid fields with abstract type
Typen können deklariert werden, ohne die Typen ihrer Felder anzugeben:
julia> struct MyAmbiguousType
a
end
Dies ermöglicht es, dass a
von jedem Typ sein kann. Dies kann oft nützlich sein, hat jedoch einen Nachteil: Für Objekte des Typs MyAmbiguousType
kann der Compiler keinen hochleistungsfähigen Code generieren. Der Grund dafür ist, dass der Compiler die Typen der Objekte und nicht deren Werte verwendet, um zu bestimmen, wie der Code erstellt werden soll. Leider kann über ein Objekt des Typs MyAmbiguousType
nur sehr wenig abgeleitet werden:
julia> b = MyAmbiguousType("Hello")
MyAmbiguousType("Hello")
julia> c = MyAmbiguousType(17)
MyAmbiguousType(17)
julia> typeof(b)
MyAmbiguousType
julia> typeof(c)
MyAmbiguousType
Die Werte von b
und c
haben denselben Typ, dennoch ist ihre zugrunde liegende Datenrepräsentation im Speicher sehr unterschiedlich. Selbst wenn Sie nur numerische Werte im Feld a
gespeichert haben, bedeutet die Tatsache, dass die Speicherrepräsentation von UInt8
sich von Float64
unterscheidet, dass die CPU sie mit zwei verschiedenen Arten von Anweisungen behandeln muss. Da die erforderlichen Informationen im Typ nicht verfügbar sind, müssen solche Entscheidungen zur Laufzeit getroffen werden. Dies verlangsamt die Leistung.
Sie können es besser machen, indem Sie den Typ von a
deklarieren. Hier konzentrieren wir uns auf den Fall, in dem a
einer von mehreren Typen sein könnte, in diesem Fall ist die natürliche Lösung, Parameter zu verwenden. Zum Beispiel:
julia> mutable struct MyType{T<:AbstractFloat}
a::T
end
Dies ist eine bessere Wahl als
julia> mutable struct MyStillAmbiguousType
a::AbstractFloat
end
weil die erste Version den Typ von a
anhand des Typs des Wrapper-Objekts angibt. Zum Beispiel:
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
Der Typ des Feldes a
kann leicht aus dem Typ von m
bestimmt werden, jedoch nicht aus dem Typ von t
. Tatsächlich ist es in t
möglich, den Typ des Feldes a
zu ändern:
julia> typeof(t.a)
Float64
julia> t.a = 4.5f0
4.5f0
julia> typeof(t.a)
Float32
Im Gegensatz dazu kann sich der Typ von m.a
nicht ändern, sobald m
konstruiert ist:
julia> m.a = 4.5f0
4.5f0
julia> typeof(m.a)
Float64
Die Tatsache, dass der Typ von m.a
aus dem Typ von m
bekannt ist – zusammen mit der Tatsache, dass sich sein Typ während der Funktion nicht ändern kann – ermöglicht es dem Compiler, hochoptimierten Code für Objekte wie m
zu generieren, jedoch nicht für Objekte wie t
.
Natürlich ist all dies nur wahr, wenn wir m
mit einem konkreten Typ konstruieren. Wir können dies brechen, indem wir es explizit mit einem abstrakten Typ konstruieren:
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
Für alle praktischen Zwecke verhalten sich solche Objekte identisch zu denen von MyStillAmbiguousType
.
Es ist ziemlich lehrreich, die schiere Menge an Code zu vergleichen, die für eine einfache Funktion generiert wird.
func(m::MyType) = m.a+1
benutzen
code_llvm(func, Tuple{MyType{Float64}})
code_llvm(func, Tuple{MyType{AbstractFloat}})
Aus Gründen der Länge werden die Ergebnisse hier nicht angezeigt, aber Sie möchten dies möglicherweise selbst ausprobieren. Da der Typ im ersten Fall vollständig spezifiziert ist, muss der Compiler keinen Code generieren, um den Typ zur Laufzeit aufzulösen. Dies führt zu kürzerem und schnellerem Code.
Man sollte auch im Hinterkopf behalten, dass nicht-vollständig parametrisierte Typen sich wie abstrakte Typen verhalten. Zum Beispiel, obwohl ein vollständig spezifiziertes Array{T,n}
konkret ist, ist Array
selbst ohne angegebene Parameter nicht konkret:
julia> !isconcretetype(Array), !isabstracttype(Array), isstructtype(Array), !isconcretetype(Array{Int}), isconcretetype(Array{Int,1})
(true, true, true, true, true)
In diesem Fall wäre es besser, MyType
nicht mit einem Feld a::Array
zu deklarieren, sondern das Feld als a::Array{T,N}
oder als a::A
zu deklarieren, wobei {T,N}
oder A
Parameter von MyType
sind.
Der vorherige Rat ist besonders nützlich, wenn die Felder einer Struktur Funktionen oder allgemeiner aufrufbare Objekte sein sollen. Es ist sehr verlockend, eine Struktur wie folgt zu definieren:
struct MyCallableWrapper
f::Function
end
Aber da Function
ein abstrakter Typ ist, erfordert jeder Aufruf von wrapper.f
eine dynamische Dispatch, aufgrund der Typinstabilität beim Zugriff auf das Feld f
. Stattdessen sollten Sie etwas schreiben wie:
struct MyCallableWrapper{F}
f::F
end
was nahezu identisches Verhalten aufweist, aber viel schneller sein wird (da die Typinstabilität beseitigt ist). Beachten Sie, dass wir F<:Function
nicht auferlegen: Das bedeutet, dass auch aufrufbare Objekte, die nicht von Function
abgeleitet sind, für das Feld f
erlaubt sind.
Avoid fields with abstract containers
Die gleichen Best Practices gelten auch für Containertypen:
julia> struct MySimpleContainer{A<:AbstractVector}
a::A
end
julia> struct MyAmbiguousContainer{T}
a::AbstractVector{T}
end
julia> struct MyAlsoAmbiguousContainer
a::Array
end
Zum Beispiel:
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})
Für MySimpleContainer
ist das Objekt vollständig durch seinen Typ und seine Parameter spezifiziert, sodass der Compiler optimierte Funktionen generieren kann. In den meisten Fällen wird dies wahrscheinlich ausreichen.
Während der Compiler jetzt seine Aufgabe perfekt erfüllen kann, gibt es Fälle, in denen du dir wünschen könntest, dass dein Code je nach Elementtyp von a
unterschiedliche Dinge tun könnte. In der Regel ist der beste Weg, dies zu erreichen, deine spezifische Operation (hier foo
) in eine separate Funktion zu kapseln:
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)
Dies hält die Dinge einfach, während der Compiler in allen Fällen optimierten Code generieren kann.
Es gibt jedoch Fälle, in denen Sie möglicherweise verschiedene Versionen der äußeren Funktion für unterschiedliche Elementtypen oder Typen des AbstractVector
des Feldes a
in MySimpleContainer
deklarieren müssen. Sie könnten es so machen:
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
Es ist oft praktisch, mit Datenstrukturen zu arbeiten, die Werte beliebigen Typs enthalten können (Arrays vom Typ Array{Any}
). Aber wenn Sie eine dieser Strukturen verwenden und zufällig den Typ eines Elements kennen, hilft es, dieses Wissen mit dem Compiler zu teilen:
function foo(a::Array{Any,1})
x = a[1]::Int32
b = x+1
...
end
Hier wussten wir zufällig, dass das erste Element von a
ein Int32
sein würde. Eine Annotation wie diese hat den zusätzlichen Vorteil, dass sie einen Laufzeitfehler auslöst, wenn der Wert nicht vom erwarteten Typ ist, was potenziell bestimmte Fehler früher aufdecken kann.
Im Falle, dass der Typ von a[1]
nicht genau bekannt ist, kann x
über x = convert(Int32, a[1])::Int32
deklariert werden. Die Verwendung der convert
-Funktion ermöglicht es, dass a[1]
jedes Objekt sein kann, das in ein Int32
konvertierbar ist (wie z.B. UInt8
), wodurch die Allgemeingültigkeit des Codes erhöht wird, indem die Typanforderung gelockert wird. Beachten Sie, dass convert
in diesem Kontext selbst eine Typannotation benötigt, um Typstabilität zu erreichen. Dies liegt daran, dass der Compiler den Typ des Rückgabewerts einer Funktion, selbst convert
, nicht ableiten kann, es sei denn, die Typen aller Argumente der Funktion sind bekannt.
Typannotationen verbessern (und können tatsächlich die Leistung beeinträchtigen), wenn der Typ abstrakt oder zur Laufzeit erstellt wird. Dies liegt daran, dass der Compiler die Annotation nicht verwenden kann, um den nachfolgenden Code zu spezialisieren, und die Typüberprüfung selbst Zeit in Anspruch nimmt. Zum Beispiel im Code:
function nr(a, prec)
ctype = prec == 32 ? Float32 : Float64
b = Complex{ctype}(a)
c = (b + 1.0f0)::Complex{ctype}
abs(c)
end
die Annotation von c
schadet der Leistung. Um leistungsfähigen Code zu schreiben, der zur Laufzeit konstruierte Typen verwendet, nutzen Sie die function-barrier technique, die unten besprochen wird, und stellen Sie sicher, dass der konstruierte Typ unter den Argumenttypen der Kernel-Funktion erscheint, damit die Kernel-Operationen vom Compiler ordnungsgemäß spezialisiert werden. Zum Beispiel kann b
, sobald es konstruiert ist, an eine andere Funktion k
, den Kernel, übergeben werden. Wenn die Funktion k
beispielsweise b
als Argument vom Typ Complex{T}
deklariert, wobei T
ein Typparameter ist, dann erscheint eine Typannotation in einer Zuweisungsanweisung innerhalb von k
in der Form:
c = (b + 1.0f0)::Complex{T}
beeinträchtigt die Leistung nicht (hilft aber auch nicht), da der Compiler den Typ von c
zum Zeitpunkt der Kompilierung von k
bestimmen kann.
Be aware of when Julia avoids specializing
Als Heuristik vermeidet Julia automatisch specializing bei Argumenttyp-Parametern in drei spezifischen Fällen: Type
, Function
und Vararg
. Julia wird immer spezialisieren, wenn das Argument innerhalb der Methode verwendet wird, jedoch nicht, wenn das Argument nur an eine andere Funktion weitergegeben wird. Dies hat normalerweise keine Leistungsbeeinträchtigung zur Laufzeit und improves compiler performance. Wenn Sie feststellen, dass es in Ihrem Fall eine Leistungsbeeinträchtigung zur Laufzeit hat, können Sie die Spezialisierung auslösen, indem Sie einen Typ-Parameter zur Methodendeklaration hinzufügen. Hier sind einige Beispiele:
Das wird nicht spezialisiert:
function f_type(t) # or t::Type
x = ones(t, 10)
return sum(map(sin, x))
end
aber das wird:
function g_type(t::Type{T}) where T
x = ones(T, 10)
return sum(map(sin, x))
end
Diese werden sich nicht spezialisieren:
f_func(f, num) = ntuple(f, div(num, 2))
g_func(g::Function, num) = ntuple(g, div(num, 2))
aber das wird:
h_func(h::H, num) where {H} = ntuple(h, div(num, 2))
Das wird nicht spezialisiert:
f_vararg(x::Int...) = tuple(x...)
aber das wird:
g_vararg(x::Vararg{Int, N}) where {N} = tuple(x...)
Man muss nur einen einzigen Typ-Parameter einführen, um eine Spezialisierung zu erzwingen, selbst wenn die anderen Typen nicht eingeschränkt sind. Zum Beispiel wird dies auch spezialisieren und ist nützlich, wenn die Argumente nicht alle vom gleichen Typ sind:
h_vararg(x::Vararg{Any, N}) where {N} = tuple(x...)
Beachten Sie, dass @code_typed
und Freunde Ihnen immer spezialisierten Code anzeigen, selbst wenn Julia diesen Methodenaufruf normalerweise nicht spezialisieren würde. Sie müssen method internals überprüfen, wenn Sie sehen möchten, ob Spezialisierungen generiert werden, wenn sich die Argumenttypen ändern, d.h. ob Base.specializations(@which f(...))
Spezialisierungen für das betreffende Argument enthält.
Break functions into multiple definitions
Das Schreiben einer Funktion als viele kleine Definitionen ermöglicht es dem Compiler, den am besten geeigneten Code direkt aufzurufen oder ihn sogar inline zu setzen.
Hier ist ein Beispiel für eine "Zusammengesetzte Funktion", die wirklich als mehrere Definitionen geschrieben werden sollte:
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
Dies kann präziser und effizienter formuliert werden als:
mynorm(x::Vector) = sqrt(real(dot(x, x)))
mynorm(A::Matrix) = maximum(svdvals(A))
Es sollte jedoch beachtet werden, dass der Compiler ziemlich effizient darin ist, die toten Zweige im Code, der wie das mynorm
-Beispiel geschrieben ist, zu optimieren.
Write "type-stable" functions
Wenn möglich, hilft es sicherzustellen, dass eine Funktion immer einen Wert des gleichen Typs zurückgibt. Betrachten Sie die folgende Definition:
pos(x) = x < 0 ? 0 : x
Obwohl dies harmlos genug erscheint, besteht das Problem darin, dass 0
eine Ganzzahl (vom Typ Int
) ist und x
von jedem Typ sein könnte. Je nach Wert von x
könnte diese Funktion also einen Wert eines von zwei Typen zurückgeben. Dieses Verhalten ist zulässig und kann in einigen Fällen wünschenswert sein. Aber es kann leicht wie folgt behoben werden:
pos(x) = x < 0 ? zero(x) : x
Es gibt auch eine oneunit
-Funktion und eine allgemeinere oftype(x, y)
-Funktion, die y
in den Typ von x
umwandelt.
Avoid changing the type of a variable
Ein analoges Problem der "Typ-Stabilität" besteht für Variablen, die innerhalb einer Funktion wiederholt verwendet werden:
function foo()
x = 1
for i = 1:10
x /= rand()
end
return x
end
Lokale Variable x
beginnt als Ganzzahl und wird nach einer Schleifeniteration zu einer Fließkommazahl (das Ergebnis des /
Operators). Dies erschwert es dem Compiler, den Körper der Schleife zu optimieren. Es gibt mehrere mögliche Lösungen:
- Initialisiere
x
mitx = 1.0
- Deklariere den Typ von
x
explizit alsx::Float64 = 1
- Verwenden Sie eine explizite Konvertierung durch
x = oneunit(Float64)
- Initialisieren Sie mit der ersten Schleifeniteration, um
x = 1 / rand()
zu setzen, dann Schleifefor i = 2:10
Separate kernel functions (aka, function barriers)
Viele Funktionen folgen einem Muster, bei dem zunächst einige Vorbereitungsarbeiten durchgeführt werden und dann viele Iterationen ablaufen, um eine Kernberechnung durchzuführen. Wo immer möglich, ist es eine gute Idee, diese Kernberechnungen in separate Funktionen zu packen. Zum Beispiel gibt die folgende erfundene Funktion ein Array eines zufällig gewählten Typs zurück:
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
Das sollte so geschrieben werden:
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
Julias Compiler spezialisiert Code für Argumenttypen an Funktionsgrenzen, sodass er in der ursprünglichen Implementierung den Typ von a
während der Schleife nicht kennt (da er zufällig gewählt wird). Daher ist die zweite Version im Allgemeinen schneller, da die innere Schleife als Teil von fill_twos!
für verschiedene Typen von a
neu kompiliert werden kann.
Die zweite Form ist auch oft der bessere Stil und kann zu mehr Code-Wiederverwendung führen.
Dieses Muster wird an mehreren Stellen in Julia Base verwendet. Zum Beispiel siehe vcat
und hcat
in abstractarray.jl
, oder die fill!
Funktion, die wir anstelle von fill_twos!
hätten verwenden können.
Funktionen wie strange_twos
treten auf, wenn man mit Daten unsicherer Typen umgeht, zum Beispiel mit Daten, die aus einer Eingabedatei geladen wurden, die entweder Ganzzahlen, Fließkommazahlen, Zeichenfolgen oder etwas anderes enthalten könnte.
Types with values-as-parameters
Angenommen, Sie möchten ein N
-dimensionales Array erstellen, das entlang jeder Achse die Größe 3 hat. Solche Arrays können wie folgt erstellt werden:
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
Dieser Ansatz funktioniert sehr gut: Der Compiler kann herausfinden, dass A
ein Array{Float64,2}
ist, da er den Typ des Füllwerts (5.0::Float64
) und die Dimensionalität ((3, 3)::NTuple{2,Int}
) kennt. Das bedeutet, dass der Compiler sehr effizienten Code für jede zukünftige Verwendung von A
in derselben Funktion generieren kann.
Aber jetzt nehmen wir an, Sie möchten eine Funktion schreiben, die ein 3×3×... Array in beliebigen Dimensionen erstellt; Sie könnten versucht sein, eine Funktion zu schreiben
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
Das funktioniert, aber (wie Sie selbst mit @code_warntype array3(5.0, 2)
überprüfen können) besteht das Problem darin, dass der Ausgabetyp nicht abgeleitet werden kann: Das Argument N
ist ein Wert vom Typ Int
, und die Typableitung kann dessen Wert im Voraus nicht vorhersagen. Das bedeutet, dass Code, der die Ausgabe dieser Funktion verwendet, vorsichtig sein muss und den Typ bei jedem Zugriff auf A
überprüfen muss; solcher Code wird sehr langsam sein.
Jetzt ist eine sehr gute Möglichkeit, solche Probleme zu lösen, die Verwendung von function-barrier technique. In einigen Fällen möchten Sie jedoch die Typinstabilität ganz beseitigen. In solchen Fällen ist ein Ansatz, die Dimensionalität als Parameter zu übergeben, zum Beispiel durch Val{T}()
(siehe "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
Julia hat eine spezialisierte Version von ntuple
, die eine Val{::Int}
-Instanz als zweiten Parameter akzeptiert; indem Sie N
als Typ-Parameter übergeben, machen Sie dessen "Wert" dem Compiler bekannt. Folglich ermöglicht diese Version von array3
dem Compiler, den Rückgabetyp vorherzusagen.
Es kann jedoch überraschend subtil sein, solche Techniken zu verwenden. Zum Beispiel wäre es nicht hilfreich, wenn Sie array3
aus einer Funktion wie dieser aufrufen:
function call_array3(fillval, n)
A = array3(fillval, Val(n))
end
Hier haben Sie das gleiche Problem erneut geschaffen: Der Compiler kann nicht erraten, was n
ist, daher kennt er den Typ von Val(n)
. Der Versuch, Val
zu verwenden, aber dies falsch zu tun, kann in vielen Situationen die Leistung verschlechtern. (Nur in Situationen, in denen Sie Val
effektiv mit dem Funktionsbarrieretrick kombinieren, um die Kernel-Funktion effizienter zu gestalten, sollte Code wie der oben verwendete eingesetzt werden.)
Ein Beispiel für die korrekte Verwendung von Val
wäre:
function filter3(A::AbstractArray{T,N}) where {T,N}
kernel = array3(1, Val(N))
filter(A, kernel)
end
In diesem Beispiel wird N
als Parameter übergeben, sodass sein "Wert" dem Compiler bekannt ist. Im Wesentlichen funktioniert Val(T)
nur, wenn T
entweder fest codiert/literal (Val(3)
) oder bereits im Typbereich angegeben ist.
The dangers of abusing multiple dispatch (aka, more on types with values-as-parameters)
Sobald man lernt, multiple Dispatch zu schätzen, gibt es eine verständliche Tendenz, über das Ziel hinauszuschießen und zu versuchen, es für alles zu verwenden. Zum Beispiel könnte man sich vorstellen, es zu verwenden, um Informationen zu speichern, z.B.
struct Car{Make, Model}
year::Int
...more fields...
end
und dann auf Objekte wie Car{:Honda,:Accord}(Jahr, args...)
dispatchen.
Dies könnte lohnenswert sein, wenn einer der folgenden Punkte zutrifft:
- Sie benötigen CPU-intensive Verarbeitung für jedes
Auto
, und es wird erheblich effizienter, wenn Sie dieMarke
und dasModell
zur Compile-Zeit kennen und die Gesamtzahl der verschiedenenMarken
oderModelle
, die verwendet werden, nicht zu groß ist. - Sie haben homogene Listen desselben Typs
Car
zu verarbeiten, sodass Sie sie alle in einemArray{Car{:Honda,:Accord},N}
speichern können.
Wenn Letzteres zutrifft, kann eine Funktion, die ein solches homogenes Array verarbeitet, produktiv spezialisiert werden: Julia kennt den Typ jedes Elements im Voraus (alle Objekte im Container haben denselben konkreten Typ), sodass Julia die richtigen Methodenaufrufe beim Kompilieren der Funktion "nachschlagen" kann (was die Notwendigkeit einer Überprüfung zur Laufzeit überflüssig macht) und somit effizienten Code zur Verarbeitung der gesamten Liste erzeugen kann.
Wenn diese Bedingungen nicht erfüllt sind, ist es wahrscheinlich, dass Sie keinen Nutzen daraus ziehen; schlimmer noch, die resultierende "kombinatorische Explosion der Typen" wird kontraproduktiv sein. Wenn items[i+1]
einen anderen Typ hat als item[i]
, muss Julia den Typ zur Laufzeit nachschlagen, nach der entsprechenden Methode in den Methodentabellen suchen, entscheiden (über die Typ-Intersection), welche übereinstimmt, feststellen, ob sie bereits JIT-compiliert wurde (und dies tun, falls nicht), und dann den Aufruf tätigen. Im Wesentlichen fordern Sie das gesamte Typsystem und die JIT-Kompilierungsmechanismen auf, im Grunde das Äquivalent einer Switch-Anweisung oder eines Wörterbuch-Lookups in Ihrem eigenen Code auszuführen.
Einige Laufzeit-Benchmarks, die (1) Typdispatch, (2) Wörterbuchsuche und (3) eine "Switch"-Anweisung vergleichen, sind zu finden on the mailing list.
Vielleicht ist der Compile-Zeit-Einfluss sogar schlimmer als der Laufzeit-Einfluss: Julia wird spezialisierte Funktionen für jeden unterschiedlichen Car{Make, Model}
kompilieren; wenn Sie Hunderte oder Tausende solcher Typen haben, dann wird jede Funktion, die ein solches Objekt als Parameter akzeptiert (von einer benutzerdefinierten get_year
-Funktion, die Sie selbst schreiben könnten, bis zur generischen push!
-Funktion in Julia Base), Hunderte oder Tausende von Varianten dafür kompiliert haben. Jede dieser Varianten erhöht die Größe des Caches für kompilierte Codes, die Länge interner Listen von Methoden usw. Übermäßiger Enthusiasmus für Werte als Parameter kann leicht enorme Ressourcen verschwenden.
Access arrays in memory order, along columns
Mehrdimensionale Arrays in Julia werden in Spalten-Hauptreihenfolge gespeichert. Das bedeutet, dass Arrays spaltenweise gestapelt werden. Dies kann mit der Funktion vec
oder der Syntax [:]
überprüft werden, wie unten gezeigt (beachten Sie, dass das Array in der Reihenfolge [1 3 2 4]
und nicht [1 2 3 4]
angeordnet ist):
julia> x = [1 2; 3 4]
2×2 Matrix{Int64}:
1 2
3 4
julia> x[:]
4-element Vector{Int64}:
1
3
2
4
Diese Konvention zur Anordnung von Arrays ist in vielen Sprachen wie Fortran, Matlab und R (um nur einige zu nennen) verbreitet. Die Alternative zur spaltenbasierten Anordnung ist die zeilenbasierte Anordnung, die von C und Python (numpy
) sowie anderen Sprachen übernommen wird. Sich an die Anordnung von Arrays zu erinnern, kann erhebliche Leistungseffekte beim Durchlaufen von Arrays haben. Eine Faustregel, die man im Hinterkopf behalten sollte, ist, dass bei spaltenbasierten Arrays der erste Index am schnellsten wechselt. Im Wesentlichen bedeutet dies, dass das Durchlaufen schneller ist, wenn der innerste Schleifenindex der erste ist, der in einem Slice-Ausdruck erscheint. Beachten Sie, dass das Indizieren eines Arrays mit :
eine implizite Schleife ist, die iterativ auf alle Elemente innerhalb einer bestimmten Dimension zugreift; es kann schneller sein, Spalten als Zeilen zu extrahieren, zum Beispiel.
Betrachten Sie das folgende konstruierte Beispiel. Stellen Sie sich vor, wir wollten eine Funktion schreiben, die einen Vector
akzeptiert und ein Quadrat Matrix
zurückgibt, wobei entweder die Zeilen oder die Spalten mit Kopien des Eingangsvektors gefüllt sind. Angenommen, es ist nicht wichtig, ob die Zeilen oder Spalten mit diesen Kopien gefüllt sind (vielleicht kann der Rest des Codes entsprechend leicht angepasst werden). Wir könnten dies auf mindestens vier Arten tun (neben dem empfohlenen Aufruf der integrierten 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
Jetzt werden wir jede dieser Funktionen mit demselben zufälligen 10000
mal 1
Eingangsvektor zeitlich messen:
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
Beachten Sie, dass copy_cols
viel schneller ist als copy_rows
. Das ist zu erwarten, da copy_cols
das spaltenbasierte Speicherlayout der Matrix
respektiert und es eine Spalte nach der anderen ausfüllt. Darüber hinaus ist copy_col_row
viel schneller als copy_row_col
, da es unserer Faustregel folgt, dass das erste Element, das in einem Slice-Ausdruck erscheint, mit der innersten Schleife gekoppelt werden sollte.
Pre-allocating outputs
Wenn Ihre Funktion ein Array
oder einen anderen komplexen Typ zurückgibt, muss möglicherweise Speicher zugewiesen werden. Leider sind die Speicherzuweisung und deren Gegenteil, die Müllabfuhr, oft erhebliche Engpässe.
Manchmal können Sie die Notwendigkeit umgehen, bei jedem Funktionsaufruf Speicher zuzuweisen, indem Sie die Ausgabe vorab zuweisen. Als triviales Beispiel vergleichen Sie
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;
mit
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;
Timing-Ergebnisse:
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
Die Vorabzuweisung hat weitere Vorteile, zum Beispiel, indem sie dem Aufrufer ermöglicht, den "Ausgabe"-Typ eines Algorithmus zu steuern. Im obigen Beispiel hätten wir anstelle von Array
auch ein SubArray
übergeben können, wenn wir das gewünscht hätten.
Bis zu einem gewissen Grad kann die Vorabzuweisung Ihren Code unansehnlich machen, sodass Leistungsmessungen und etwas Urteilsvermögen erforderlich sein können. Für "vektorisierte" (elementweise) Funktionen kann jedoch die bequeme Syntax x .= f.(y)
für In-Place-Operationen mit fusionierten Schleifen und ohne temporäre Arrays verwendet werden (siehe die dot syntax for vectorizing functions).
Use MutableArithmetics
for more control over allocation for mutable arithmetic types
Einige Number
Subtypen, wie BigInt
oder BigFloat
, können als mutable struct
Typen implementiert werden oder sie können veränderbare Komponenten haben. Die arithmetischen Schnittstellen in Julia Base
entscheiden sich in solchen Fällen normalerweise für Bequemlichkeit über Effizienz, sodass die Verwendung in naiver Weise zu suboptimaler Leistung führen kann. Die Abstraktionen des MutableArithmetics
Pakets hingegen ermöglichen es, die Veränderlichkeit solcher Typen auszunutzen, um schnellen Code zu schreiben, der nur so viel allokiert, wie notwendig ist. MutableArithmetics
ermöglicht es auch, Werte von veränderbaren arithmetischen Typen bei Bedarf explizit zu kopieren. MutableArithmetics
ist ein Benutzerpaket und ist nicht mit dem Julia-Projekt verbunden.
More dots: Fuse vectorized operations
Julia hat eine spezielle dot syntax, die jede skalare Funktion in einen "vektorisierten" Funktionsaufruf und jeden Operator in einen "vektorisierten" Operator umwandelt, mit der besonderen Eigenschaft, dass geschachtelte "Punktaufrufe" fusionieren: sie werden auf der Syntaxebene in eine einzige Schleife kombiniert, ohne temporäre Arrays zuzuweisen. Wenn Sie .=
und ähnliche Zuweisungsoperatoren verwenden, kann das Ergebnis auch in einem vorab zugewiesenen Array in-place gespeichert werden (siehe oben).
In einem linearen Algebra-Kontext bedeutet dies, dass, obwohl Operationen wie vector + vector
und vector * scalar
definiert sind, es vorteilhaft sein kann, stattdessen vector .+ vector
und vector .* scalar
zu verwenden, da die resultierenden Schleifen mit umgebenden Berechnungen fusioniert werden können. Betrachten Sie zum Beispiel die beiden Funktionen:
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
Sowohl f
als auch fdot
berechnen dasselbe. Allerdings ist fdot
(definiert mit Hilfe des @.
Makros) erheblich schneller, wenn es auf ein Array angewendet wird:
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)
Das heißt, fdot(x)
ist zehnmal schneller und benötigt 1/6 des Speichers von f(x)
, da jede *
- und +
-Operation in f(x)
ein neues temporäres Array allokiert und in einer separaten Schleife ausgeführt wird. In diesem Beispiel ist f.(x)
so schnell wie fdot(x)
, aber in vielen Kontexten ist es praktischer, einige Punkte in Ihren Ausdrücken zu streuen, als für jede vektorisierte Operation eine separate Funktion zu definieren.
Fewer dots: Unfuse certain intermediate broadcasts
Die oben erwähnte Punkt-Schleifenfusion ermöglicht einen prägnanten und idiomatischen Code, um hochleistungsfähige Operationen auszudrücken. Es ist jedoch wichtig, sich daran zu erinnern, dass die fusionierte Operation bei jeder Iteration des Broadcasts berechnet wird. Das bedeutet, dass in einigen Situationen, insbesondere in Anwesenheit von zusammengesetzten oder mehrdimensionalen Broadcasts, ein Ausdruck mit Punktaufrufen eine Funktion möglicherweise häufiger berechnet, als beabsichtigt. Zum Beispiel, sagen wir, wir möchten eine Zufallsmatrix erstellen, deren Zeilen die euklidische Norm eins haben. Wir könnten etwas wie Folgendes schreiben:
julia> x = rand(1000, 1000);
julia> d = sum(abs2, x; dims=2);
julia> @time x ./= sqrt.(d);
0.002049 seconds (4 allocations: 96 bytes)
Das wird funktionieren. Diese Ausdruck wird jedoch tatsächlich sqrt(d[i])
für jedes Element in der Zeile x[i, :]
neu berechnen, was bedeutet, dass viel mehr Quadratwurzeln berechnet werden, als notwendig. Um genau zu sehen, über welche Indizes die Übertragung iterieren wird, können wir Broadcast.combine_axes
auf die Argumente des zusammengefassten Ausdrucks aufrufen. Dies wird ein Tupel von Bereichen zurückgeben, deren Einträge den Iterationsachsen entsprechen; das Produkt der Längen dieser Bereiche wird die Gesamtzahl der Aufrufe der zusammengefassten Operation sein.
Es folgt, dass wenn einige Komponenten des Broadcast-Ausdrucks entlang einer Achse konstant sind—wie das sqrt
entlang der zweiten Dimension im vorhergehenden Beispiel—das Potenzial für eine Leistungsverbesserung besteht, indem man diese Komponenten gewaltsam "entfused", d.h. das Ergebnis der broadcasteten Operation im Voraus zuzuweisen und den zwischengespeicherten Wert entlang seiner konstanten Achse wiederzuverwenden. Einige solcher potenziellen Ansätze sind die Verwendung von temporären Variablen, das Einwickeln von Komponenten eines Punktausdrucks in identity
oder die Verwendung einer äquivalenten intrinsisch vektorisierte (aber nicht fusionierte) Funktion.
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)
Jede dieser Optionen führt zu einer ungefähr dreifachen Beschleunigung auf Kosten einer Zuweisung; bei großen Broadcastables kann diese Beschleunigung asymptotisch sehr groß sein.
Consider using views for slices
In Julia erstellt ein Array "Slice"-Ausdruck wie array[1:5, :]
eine Kopie dieser Daten (außer auf der linken Seite einer Zuweisung, wo array[1:5, :] = ...
in-place in diesen Teil von array
zuweist). Wenn Sie viele Operationen auf dem Slice durchführen, kann dies gut für die Leistung sein, da es effizienter ist, mit einer kleineren zusammenhängenden Kopie zu arbeiten, als in das ursprüngliche Array zu indizieren. Andererseits, wenn Sie nur ein paar einfache Operationen auf dem Slice durchführen, können die Kosten für die Zuweisung und Kopieroperationen erheblich sein.
Eine Alternative besteht darin, eine "Ansicht" des Arrays zu erstellen, die ein Array-Objekt (ein SubArray
) ist, das tatsächlich die Daten des ursprünglichen Arrays vor Ort referenziert, ohne eine Kopie zu erstellen. (Wenn Sie in eine Ansicht schreiben, wird auch die Daten des ursprünglichen Arrays geändert.) Dies kann für einzelne Schnitte erfolgen, indem Sie view
aufrufen, oder einfacher für einen ganzen Ausdruck oder Block von Code, indem Sie @views
vor diesen Ausdruck setzen. Zum Beispiel:
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)
Beachten Sie sowohl die 3× Beschleunigung als auch die verringerte Speicherzuweisung der fview
-Version der Funktion.
Copying data is not always bad
Arrays werden zusammenhängend im Speicher gespeichert, was sie für die CPU-Vektorisierung und weniger Speicherzugriffe aufgrund von Caching geeignet macht. Dies sind die gleichen Gründe, aus denen empfohlen wird, Arrays in spaltenmajorer Reihenfolge zuzugreifen (siehe oben). Unregelmäßige Zugriffsarten und nicht zusammenhängende Ansichten können Berechnungen auf Arrays drastisch verlangsamen, da der Speicher nicht sequenziell zugegriffen wird.
Das Kopieren von unregelmäßig zugegriffenem Daten in ein zusammenhängendes Array, bevor es wiederholt zugegriffen wird, kann zu einer erheblichen Geschwindigkeitssteigerung führen, wie im folgenden Beispiel. Hier wird eine Matrix an zufällig gemischten Indizes zugegriffen, bevor sie multipliziert wird. Das Kopieren in einfache Arrays beschleunigt die Multiplikation, selbst mit den zusätzlichen Kosten für das Kopieren und die Zuweisung.
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
Vorausgesetzt, es ist genügend Speicher vorhanden, überwiegt die Kosten für das Kopieren der Ansicht in ein Array den Geschwindigkeitsvorteil, der sich aus den wiederholten Matrixmultiplikationen auf einem zusammenhängenden Array ergibt.
Consider StaticArrays.jl for small fixed-size vector/matrix operations
Wenn Ihre Anwendung viele kleine (< 100
Elemente) Arrays fester Größen (d.h. die Größe ist vor der Ausführung bekannt) umfasst, sollten Sie in Betracht ziehen, das StaticArrays.jl package-Paket zu verwenden. Dieses Paket ermöglicht es Ihnen, solche Arrays auf eine Weise darzustellen, die unnötige Heap-Zuweisungen vermeidet und es dem Compiler ermöglicht, den Code für die Größe des Arrays zu optimieren, z.B. durch vollständiges Entrollen von Vektoroperationen (Eliminierung der Schleifen) und das Speichern von Elementen in CPU-Registern.
Zum Beispiel, wenn Sie Berechnungen mit 2D-Geometrien durchführen, haben Sie möglicherweise viele Berechnungen mit 2-Komponenten-Vektoren. Durch die Verwendung des SVector
-Typs aus StaticArrays.jl können Sie bequeme Vektornotation und Operationen wie norm(3v - w)
auf den Vektoren v
und w
verwenden, während der Compiler den Code so umschreibt, dass er einer minimalen Berechnung entspricht, die gleichbedeutend ist mit @inbounds hypot(3v[1]-w[1], 3v[2]-w[2])
.
Avoid string interpolation for I/O
Beim Schreiben von Daten in eine Datei (oder ein anderes I/O-Gerät) ist die Bildung zusätzlicher Zwischenstrings eine Quelle für Overhead. Anstatt:
println(file, "$a $b")
benutzen:
println(file, a, " ", b)
Die erste Version des Codes bildet einen String und schreibt ihn dann in die Datei, während die zweite Version die Werte direkt in die Datei schreibt. Beachten Sie auch, dass in einigen Fällen die String-Interpolation schwerer zu lesen sein kann. Betrachten Sie:
println(file, "$(f(a))$(f(b))")
gegen:
println(file, f(a), f(b))
Optimize network I/O during parallel execution
Beim Ausführen einer Remote-Funktion parallel:
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
ist schneller als:
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]
Das frühere Ergebnis führt zu einem einzigen Netzwerk-Round-Trip zu jedem Worker, während letzteres zu zwei Netzwerkaufrufen führt - zuerst durch die @spawnat
und den zweiten aufgrund der fetch
(oder sogar einer wait
). Die 4d61726b646f776e2e436f64652822222c202266657463682229_40726566
/4d61726b646f776e2e436f64652822222c2022776169742229_40726566
wird ebenfalls seriell ausgeführt, was zu einer insgesamt schlechteren Leistung führt.
Fix deprecation warnings
Eine veraltete Funktion führt intern eine Suche durch, um eine relevante Warnung nur einmal auszugeben. Diese zusätzliche Suche kann zu einer erheblichen Verlangsamung führen, daher sollten alle Verwendungen veralteter Funktionen wie in den Warnungen vorgeschlagen geändert werden.
Tweaks
Dies sind einige kleinere Punkte, die in engen inneren Schleifen hilfreich sein könnten.
- Vermeiden Sie unnötige Arrays. Zum Beispiel, anstatt
sum([x,y,z])
verwenden Siex+y+z
. - Verwenden Sie
abs2(z)
anstelle vonabs(z)^2
für komplexez
. Im Allgemeinen versuchen Sie, den Code so umzuschreiben, dassabs2
anstelle vonabs
für komplexe Argumente verwendet wird. - Verwenden Sie
div(x,y)
für die ganzzahlige Truncation-Division anstelle vontrunc(x/y)
,fld(x,y)
anstelle vonfloor(x/y)
, undcld(x,y)
anstelle vonceil(x/y)
.
Performance Annotations
Manchmal können Sie eine bessere Optimierung ermöglichen, indem Sie bestimmte Programmeigenschaften versprechen.
- Verwenden Sie
@inbounds
, um die Überprüfung der Array-Grenzen innerhalb von Ausdrücken zu eliminieren. Seien Sie sich dessen sicher, bevor Sie dies tun. Wenn die Indizes jemals außerhalb der Grenzen liegen, können Sie Abstürze oder stille Korruption erleiden. - Verwenden Sie
@fastmath
, um Fließkomma-Optimierungen zuzulassen, die für reelle Zahlen korrekt sind, aber zu Abweichungen bei IEEE-Zahlen führen. Seien Sie vorsichtig, wenn Sie dies tun, da dies die numerischen Ergebnisse ändern kann. Dies entspricht der-ffast-math
-Option von clang. - Schreiben Sie
@simd
vorfor
-Schleifen, um zu versprechen, dass die Iterationen unabhängig sind und umgeordnet werden können. Beachten Sie, dass Julia in vielen Fällen den Code automatisch vektorisieren kann, ohne dass das@simd
-Makro erforderlich ist; es ist nur in Fällen vorteilhaft, in denen eine solche Transformation sonst illegal wäre, einschließlich Fällen wie der Erlaubnis von Gleitkomma-Neuassoziierung und dem Ignorieren abhängiger Speicherzugriffe (@simd ivdep
). Seien Sie erneut sehr vorsichtig, wenn Sie@simd
angeben, da das fälschliche Annotieren einer Schleife mit abhängigen Iterationen zu unerwarteten Ergebnissen führen kann. Insbesondere beachten Sie, dasssetindex!
bei einigenAbstractArray
-Subtypen von Natur aus von der Iterationsreihenfolge abhängig ist. Dieses Feature ist experimentell und könnte sich in zukünftigen Versionen von Julia ändern oder verschwinden.
Die gängige Redewendung, 1:n zu verwenden, um in ein AbstractArray zu indizieren, ist nicht sicher, wenn das Array unkonventionelle Indizes verwendet, und kann zu einem Segmentierungsfehler führen, wenn die Grenzkontrolle deaktiviert ist. Verwenden Sie stattdessen LinearIndices(x)
oder eachindex(x)
(siehe auch Arrays with custom indices).
Während @simd
direkt vor einer innersten for
-Schleife platziert werden muss, können sowohl @inbounds
als auch @fastmath
auf entweder einzelne Ausdrücke oder alle Ausdrücke angewendet werden, die innerhalb von geschachtelten Codeblöcken erscheinen, z. B. mit @inbounds begin
oder @inbounds for ...
.
Hier ist ein Beispiel mit sowohl @inbounds
als auch @simd
Markup (wir verwenden hier @noinline
, um den Optimierer daran zu hindern, zu clever zu sein und unser Benchmark zu untergraben):
@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)
Auf einem Computer mit einem 2,4 GHz Intel Core i5-Prozessor ergibt dies:
GFlop/sec = 1.9467069505224963
GFlop/sec (SIMD) = 17.578554163920018
(GFlop/sec
misst die Leistung, und größere Zahlen sind besser.)
Hier ist ein Beispiel mit allen drei Arten von Markup. Dieses Programm berechnet zuerst die endliche Differenz eines eindimensionalen Arrays und bewertet dann die L2-Norm des Ergebnisses:
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()
Auf einem Computer mit einem 2,7 GHz Intel Core i7-Prozessor ergibt dies:
$ julia wave.jl;
1.207814709 seconds
4.443986180758249
$ julia --math-mode=ieee wave.jl;
4.487083643 seconds
4.443986180758249
Hier deaktiviert die Option --math-mode=ieee
das @fastmath
-Makro, sodass wir die Ergebnisse vergleichen können.
In diesem Fall beträgt die Beschleunigung durch @fastmath
den Faktor etwa 3,7. Dies ist ungewöhnlich groß – im Allgemeinen wird die Beschleunigung kleiner sein. (In diesem speziellen Beispiel ist der Arbeitsbereich des Benchmarks klein genug, um in den L1-Cache des Prozessors zu passen, sodass die Speicherzugriffsverzögerung keine Rolle spielt und die Rechenzeit von der CPU-Nutzung dominiert wird. In vielen realen Programmen ist dies nicht der Fall.) Außerdem ändert diese Optimierung in diesem Fall nicht das Ergebnis – im Allgemeinen wird das Ergebnis leicht unterschiedlich sein. In einigen Fällen, insbesondere bei numerisch instabilen Algorithmen, kann das Ergebnis sehr unterschiedlich sein.
Die Annotation @fastmath
reorganisiert Fließkommaausdrücke, z. B. ändert sie die Reihenfolge der Auswertung oder geht davon aus, dass bestimmte Sonderfälle (inf, nan) nicht auftreten können. In diesem Fall (und auf diesem speziellen Computer) besteht der Hauptunterschied darin, dass der Ausdruck 1 / (2*dx)
in der Funktion deriv
aus der Schleife herausgehoben wird (d. h. außerhalb der Schleife berechnet wird), als hätte man idx = 1 / (2*dx)
geschrieben. In der Schleife wird der Ausdruck ... / (2*dx)
dann zu ... * idx
, was viel schneller auszuwerten ist. Natürlich hängen sowohl die tatsächliche Optimierung, die vom Compiler angewendet wird, als auch die resultierende Beschleunigung sehr stark von der Hardware ab. Sie können die Änderung im generierten Code untersuchen, indem Sie Julias code_native
Funktion verwenden.
Beachten Sie, dass @fastmath
auch davon ausgeht, dass während der Berechnung keine NaN
s auftreten, was zu überraschendem Verhalten führen kann:
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
Subnormale Zahlen, früher denormal numbers, sind in vielen Kontexten nützlich, verursachen jedoch auf einigen Hardware eine Leistungseinbuße. Ein Aufruf set_zero_subnormals(true)
gewährt die Erlaubnis, dass Fließkommaoperationen subnormale Eingaben oder Ausgaben als Null behandeln, was die Leistung auf bestimmter Hardware verbessern kann. Ein Aufruf set_zero_subnormals(false)
erzwingt striktes IEEE-Verhalten für subnormale Zahlen.
Unten ist ein Beispiel, bei dem Subnormale die Leistung auf bestimmter Hardware merklich beeinflussen:
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
Dies gibt eine Ausgabe ähnlich wie
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)
Beachte, wie jede gerade Iteration deutlich schneller ist.
Dieses Beispiel erzeugt viele subnormale Zahlen, da die Werte in a
eine exponentiell abnehmende Kurve bilden, die sich im Laufe der Zeit langsam abflacht.
Die Behandlung von Subnormalen als Nullen sollte mit Vorsicht erfolgen, da dies einige Identitäten bricht, wie zum Beispiel x-y == 0
impliziert 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)
In einigen Anwendungen ist eine Alternative zum Nullsetzen von subnormalen Zahlen, ein winziges bisschen Rauschen einzufügen. Zum Beispiel, anstatt a
mit Nullen zu initialisieren, initialisieren Sie es mit:
a = rand(Float32,1000) * 1.f-9
@code_warntype
Das Makro @code_warntype
(oder seine Funktionsvariante code_warntype
) kann manchmal hilfreich sein, um typbezogene Probleme zu diagnostizieren. Hier ist ein Beispiel:
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
Die Interpretation der Ausgabe von @code_warntype
, ähnlich wie bei seinen Verwandten @code_lowered
, @code_typed
, @code_llvm
und @code_native
, erfordert ein wenig Übung. Ihr Code wird in einer Form präsentiert, die auf dem Weg zur Generierung von kompiliertem Maschinencode stark verarbeitet wurde. Die meisten Ausdrücke sind mit einem Typ annotiert, der durch ::T
angezeigt wird (wobei T
beispielsweise Float64
sein könnte). Das wichtigste Merkmal von 4d61726b646f776e2e436f64652822222c202240636f64655f7761726e747970652229_40726566
ist, dass nicht-konkrete Typen in ROT angezeigt werden; da dieses Dokument in Markdown verfasst ist, das keine Farben hat, wird in diesem Dokument roter Text durch GROSSBUCHSTABEN dargestellt.
Am oberen Rand wird der abgeleitete Rückgabetyp der Funktion als Body::Float64
angezeigt. Die nächsten Zeilen repräsentieren den Körper von f
in Julias SSA-IR-Form. Die nummerierten Kästchen sind Labels und repräsentieren Ziele für Sprünge (über goto
) in Ihrem Code. Wenn man sich den Körper ansieht, kann man erkennen, dass als erstes pos
aufgerufen wird und der Rückgabewert als der Union
-Typ Union{Float64, Int64}
abgeleitet wurde, der in Großbuchstaben angezeigt wird, da es sich um einen nicht-konkreten Typ handelt. Das bedeutet, dass wir den genauen Rückgabetyp von pos
basierend auf den Eingabetypen nicht kennen können. Das Ergebnis von y*x
ist jedoch ein Float64
, egal ob y
ein Float64
oder Int64
ist. Das Nettoergebnis ist, dass f(x::Float64)
in seiner Ausgabe nicht typ-instabil sein wird, auch wenn einige der Zwischenberechnungen typ-instabil sind.
Wie Sie diese Informationen nutzen, liegt ganz bei Ihnen. Offensichtlich wäre es am besten, pos
typstabil zu machen: Wenn Sie dies tun, wären alle Variablen in f
konkret und ihre Leistung wäre optimal. Es gibt jedoch Umstände, unter denen diese Art von ephemerer Typinstabilität nicht allzu sehr ins Gewicht fallen könnte: Wenn pos
beispielsweise nie isoliert verwendet wird, wird die Tatsache, dass die Ausgabe von f
typstabil ist (für Float64
Eingaben), späteren Code vor den propagierenden Effekten der Typinstabilität schützen. Dies ist besonders relevant in Fällen, in denen es schwierig oder unmöglich ist, die Typinstabilität zu beheben. In solchen Fällen sind die oben genannten Tipps (z. B. das Hinzufügen von Typannotationen und/oder das Aufteilen von Funktionen) Ihre besten Werkzeuge, um den "Schaden" durch Typinstabilität zu begrenzen. Beachten Sie auch, dass selbst die Julia-Basis Funktionen hat, die typinstabil sind. Zum Beispiel gibt die Funktion findfirst
den Index in ein Array zurück, wo ein Schlüssel gefunden wird, oder nothing
, wenn er nicht gefunden wird, eine klare Typinstabilität. Um es einfacher zu machen, die Typinstabilitäten zu finden, die wahrscheinlich wichtig sind, werden Union
s, die entweder missing
oder nothing
enthalten, in Gelb hervorgehoben, anstatt in Rot.
Die folgenden Beispiele können Ihnen helfen, Ausdrücke zu interpretieren, die als nicht-blattartige Typen gekennzeichnet sind:
Funktionstext, der mit
Body::Union{T1,T2})
beginnt- Interpretation: Funktion mit instabilem Rückgabewert
- Vorschlag: Machen Sie den Rückgabewert typstabil, auch wenn Sie ihn annotieren müssen.
invoke Main.g(%%x::Int64)::Union{Float64, Int64}
- Interpretation: Aufruf einer typ-instabilen Funktion
g
. - Vorschlag: Beheben Sie die Funktion oder annotieren Sie gegebenenfalls den Rückgabewert.
- Interpretation: Aufruf einer typ-instabilen Funktion
invoke Base.getindex(%%x::Array{Any,1}, 1::Int64)::Any
- Interpretation: Zugriff auf Elemente von schlecht typisierten Arrays
- Vorschlag: Verwenden Sie Arrays mit besser definierten Typen oder annotieren Sie gegebenenfalls den Typ der einzelnen Elementzugriffe.
Base.getfield(%%x, :(:data))::Array{Float64,N} where N
- Interpretation: ein Feld zu erhalten, das keinen Blatttyp hat. In diesem Fall hatte der Typ
x
, sagen wirArrayContainer
, ein Felddata::Array{T}
. AberArray
benötigt auch die DimensionN
, um ein konkreter Typ zu sein. - Vorschlag: Verwenden Sie konkrete Typen wie
Array{T,3}
oderArray{T,N}
, wobeiN
jetzt ein Parameter vonArrayContainer
ist.
- Interpretation: ein Feld zu erhalten, das keinen Blatttyp hat. In diesem Fall hatte der Typ
Performance of captured variable
Betrachten Sie das folgende Beispiel, das eine innere Funktion definiert:
function abmult(r::Int)
if r < 0
r = -r
end
f = x -> x * r
return f
end
Die Funktion abmult
gibt eine Funktion f
zurück, die ihr Argument mit dem Absolutwert von r
multipliziert. Die innere Funktion, die f
zugewiesen ist, wird als "Closure" bezeichnet. Innere Funktionen werden auch von der Sprache für do
-Blöcke und für Generatorausdrücke verwendet.
Dieser Code-Stil stellt Leistungsherausforderungen für die Sprache dar. Der Parser reorganisiert beim Übersetzen in niedrigere Anweisungen den obigen Code erheblich, indem er die innere Funktion in einen separaten Codeblock extrahiert. "Eingefangene" Variablen wie r
, die von inneren Funktionen und ihrem umgebenden Geltungsbereich geteilt werden, werden ebenfalls in eine heap-zugewiesene "Box" extrahiert, die sowohl für innere als auch für äußere Funktionen zugänglich ist, da die Sprache vorschreibt, dass r
im inneren Geltungsbereich identisch mit r
im äußeren Geltungsbereich sein muss, selbst nachdem der äußere Geltungsbereich (oder eine andere innere Funktion) r
ändert.
Die Diskussion im vorhergehenden Absatz bezog sich auf den "Parser", das heißt, die Phase der Kompilierung, die stattfindet, wenn das Modul, das abmult
enthält, zum ersten Mal geladen wird, im Gegensatz zu der späteren Phase, wenn es zum ersten Mal aufgerufen wird. Der Parser "weiß" nicht, dass Int
ein fester Typ ist oder dass die Anweisung r = -r
ein Int
in ein anderes Int
umwandelt. Die Magie der Typinferenz findet in der späteren Phase der Kompilierung statt.
Somit weiß der Parser nicht, dass r
einen festen Typ (Int
) hat, noch dass r
seinen Wert nicht ändert, sobald die innere Funktion erstellt wird (so dass die Box nicht benötigt wird). Daher erzeugt der Parser Code für eine Box, die ein Objekt mit einem abstrakten Typ wie Any
enthält, was zur Laufzeit eine Typdispatch für jede Verwendung von r
erfordert. Dies kann überprüft werden, indem @code_warntype
auf die obige Funktion angewendet wird. Sowohl das Boxen als auch der Laufzeit-Typdispatch können zu einem Leistungsverlust führen.
Wenn erfasste Variablen in einem leistungskritischen Abschnitt des Codes verwendet werden, helfen die folgenden Tipps, sicherzustellen, dass ihre Verwendung leistungsfähig ist. Zuerst, wenn bekannt ist, dass eine erfasste Variable ihren Typ nicht ändert, kann dies ausdrücklich mit einer Typannotation deklariert werden (auf der Variablen, nicht auf der rechten Seite):
function abmult2(r0::Int)
r::Int = r0
if r < 0
r = -r
end
f = x -> x * r
return f
end
Die Typannotation stellt teilweise die verlorene Leistung aufgrund der Erfassung wieder her, da der Parser einen konkreten Typ mit dem Objekt in der Box verknüpfen kann. Darüber hinaus kann angezeigt werden, dass die erfasste Variable überhaupt nicht in eine Box gepackt werden muss (da sie nach der Erstellung des Closures nicht mehr neu zugewiesen wird), indem let
-Blöcke wie folgt verwendet werden.
function abmult3(r::Int)
if r < 0
r = -r
end
f = let r = r
x -> x * r
end
return f
end
Der let
-Block erstellt eine neue Variable r
, deren Gültigkeitsbereich nur die innere Funktion ist. Die zweite Technik stellt die volle Sprachleistung im Beisein von erfassten Variablen wieder her. Beachten Sie, dass dies ein schnelllebiger Aspekt des Compilers ist, und es ist wahrscheinlich, dass zukünftige Versionen nicht diesen Grad an Programmiererannotation erfordern, um Leistung zu erzielen. In der Zwischenzeit automatisieren einige benutzergenerierte Pakete wie FastClosures die Einfügung von let
-Anweisungen wie in abmult3
.
Multithreading and linear algebra
Dieser Abschnitt gilt für mehrsträngige Julia-Code, der in jedem Thread lineare Algebraoperationen durchführt. Tatsächlich beinhalten diese linearen Algebraoperationen BLAS / LAPACK-Aufrufe, die selbst mehrsträngig sind. In diesem Fall muss sichergestellt werden, dass die Kerne aufgrund der beiden verschiedenen Arten von Mehrsträngigkeit nicht überlastet werden.
Julia kompiliert und verwendet eine eigene Kopie von OpenBLAS für lineare Algebra, deren Anzahl an Threads durch die Umgebungsvariable OPENBLAS_NUM_THREADS
gesteuert wird. Sie kann entweder als Befehlszeilenoption beim Starten von Julia festgelegt oder während der Julia-Sitzung mit BLAS.set_num_threads(N)
geändert werden (das Untermodul BLAS
wird durch using LinearAlgebra
exportiert). Der aktuelle Wert kann mit BLAS.get_num_threads()
abgerufen werden.
Wenn der Benutzer nichts angibt, versucht Julia, einen angemessenen Wert für die Anzahl der OpenBLAS-Threads auszuwählen (z. B. basierend auf der Plattform, der Julia-Version usw.). Es wird jedoch allgemein empfohlen, den Wert manuell zu überprüfen und festzulegen. Das Verhalten von OpenBLAS ist wie folgt:
- Wenn
OPENBLAS_NUM_THREADS=1
ist, verwendet OpenBLAS die aufrufenden Julia-Threads, d.h. es "lebt in" dem Julia-Thread, der die Berechnung ausführt. - Wenn
OPENBLAS_NUM_THREADS=N>1
ist, erstellt und verwaltet OpenBLAS seinen eigenen Pool von Threads (insgesamtN
). Es gibt nur einen OpenBLAS-Thread-Pool, der von allen Julia-Threads gemeinsam genutzt wird.
Wenn Sie Julia im Multithreaded-Modus mit JULIA_NUM_THREADS=X
starten, wird allgemein empfohlen, OPENBLAS_NUM_THREADS=1
zu setzen. Angesichts des oben beschriebenen Verhaltens kann eine Erhöhung der Anzahl der BLAS-Threads auf N>1
sehr leicht zu schlechterer Leistung führen, insbesondere wenn N<<X
. Dies ist jedoch nur eine Faustregel, und der beste Weg, jede Anzahl von Threads festzulegen, besteht darin, in Ihrer spezifischen Anwendung zu experimentieren.
Alternative linear algebra backends
Als Alternative zu OpenBLAS gibt es mehrere andere Backends, die bei der Leistung der linearen Algebra helfen können. Prominente Beispiele sind MKL.jl und AppleAccelerate.jl.
Dies sind externe Pakete, daher werden wir sie hier nicht im Detail besprechen. Bitte beziehen Sie sich auf die jeweiligen Dokumentationen (insbesondere, weil sie sich in Bezug auf Multithreading anders verhalten als OpenBLAS).
Execution latency, package loading and package precompiling time
Reducing time to first plot etc.
Die erste Zeit, wenn eine Julia-Methode aufgerufen wird, wird sie (und alle Methoden, die sie aufruft, oder die statisch bestimmt werden können) kompiliert. Die @time
Makrofamilie veranschaulicht dies.
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)
Beachten Sie, dass @time @eval
besser geeignet ist, um die Kompilierungszeit zu messen, da ohne @eval
einige Kompilierungen möglicherweise bereits abgeschlossen sind, bevor die Zeitmessung beginnt.
Beim Entwickeln eines Pakets können Sie die Erfahrung Ihrer Benutzer mit Vorkompilierung verbessern, sodass der Code, den sie verwenden, bereits kompiliert ist. Um den Paketcode effektiv vorzukompilieren, wird empfohlen, PrecompileTools.jl
auszuführen, um während der Vorkompilierungszeit eine "Vorkompilierungsarbeitslast" zu erstellen, die repräsentativ für die typische Nutzung des Pakets ist. Dies wird den nativ kompilierten Code im pkgimage
-Cache des Pakets speichern und die "Zeit bis zur ersten Ausführung" (oft als TTFX bezeichnet) für eine solche Nutzung erheblich reduzieren.
Beachten Sie, dass PrecompileTools.jl
Arbeitslasten deaktiviert und manchmal über die Einstellungen konfiguriert werden können, wenn Sie nicht die zusätzliche Zeit für die Vorabkompilierung aufwenden möchten, was während der Entwicklung eines Pakets der Fall sein kann.
Reducing package loading time
Die Ladezeit des Pakets niedrig zu halten, ist in der Regel hilfreich. Allgemeine gute Praktiken für Paketentwickler umfassen:
- Reduzieren Sie Ihre Abhängigkeiten auf die, die Sie wirklich benötigen. Erwägen Sie die Verwendung von package extensions, um die Interoperabilität mit anderen Paketen zu unterstützen, ohne Ihre wesentlichen Abhängigkeiten aufzublähen.
- Vermeiden Sie die Verwendung von
__init__()
-Funktionen, es sei denn, es gibt keine Alternative, insbesondere solche, die viel Kompilierung auslösen könnten oder einfach lange zum Ausführen benötigen. - Wo möglich, beheben Sie invalidations unter Ihren Abhängigkeiten und aus Ihrem Paketcode.
Das Tool @time_imports
kann im REPL nützlich sein, um die oben genannten Faktoren zu überprüfen.
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)
Beachten Sie, dass in diesem Beispiel mehrere Pakete geladen werden, einige mit __init__()
-Funktionen, von denen einige eine Kompilierung verursachen, während andere eine Neukompilierung verursachen. Die Neukompilierung wird durch frühere Pakete verursacht, die Methoden ungültig machen. In diesen Fällen, wenn die folgenden Pakete ihre __init__()
-Funktion ausführen, kommt es bei einigen zu einer Neukompilierung, bevor der Code ausgeführt werden kann.
Darüber hinaus beachten Sie, dass die Statistics
-Erweiterung SparseArraysExt
aktiviert wurde, da SparseArrays
im Abhängigkeitsbaum enthalten ist. d.h. siehe 0.4 ms Statistics → SparseArraysExt
.
Dieser Bericht bietet eine gute Gelegenheit, zu überprüfen, ob die Kosten der Abhängigkeitsladezeit den Nutzen der Funktionalität rechtfertigen. Auch das Pkg
-Dienstprogramm why
kann verwendet werden, um zu berichten, warum eine indirekte Abhängigkeit besteht.
(CustomPackage) pkg> why FFMPEG_jll
Plots → FFMPEG → FFMPEG_jll
Plots → GR → GR_jll → FFMPEG_jll
Um die indirekten Abhängigkeiten zu sehen, die ein Paket mit sich bringt, können Sie pkg> rm
das Paket entfernen, die Abhängigkeiten sehen, die aus dem Manifest entfernt werden, und dann die Änderung mit pkg> undo
rückgängig machen.
If loading time is dominated by slow __init__()
methods having compilation, one verbose way to identify what is being compiled is to use the julia args --trace-compile=stderr
which will report a precompile
statement each time a method is compiled. For instance, the full setup would be:
$ julia --startup-file=no --trace-compile=stderr
julia> @time @time_imports using CustomPackage
...
Beachten Sie die --startup-file=no
, die hilft, den Test von Paketen zu isolieren, die Sie möglicherweise in Ihrer startup.jl
haben.
Eine weitere Analyse der Gründe für die Neukompilierung kann mit dem SnoopCompile
-Paket erreicht werden.