Часто задаваемые вопросы
Общие
Почему вы не компилируете код Matlab/Python/R/… в Julia?
Так как многие люди знакомы с синтаксисом других динамических языков и на этих языках уже написан большой объем кода, естественно поинтересоваться, почему мы просто не подключили фронтенд Matlab или Python к бэкенду Julia (или «транспилировали» код в Julia), чтобы получить все преимущества производительности Julia без необходимости для программистов изучать новый язык. Просто, да?
Основной вопрос состоит в том, что в компиляторе Julia нет ничего особенного: мы используем обычный компилятор (LLVM), в котором нет ничего такого, о чем не знали бы разработчики, использующие другие языки. На самом деле компилятор Julia во многих отношениях гораздо проще, чем компиляторы других динамических языков (например, PyPy или LuaJIT). Преимущество производительности Julia проистекает практически главным образом из фронтенда этого языка: семантика языка позволяет хорошо написанной на Julia программе предоставить компилятору дополнительные возможности создавать эффективный код и шаблоны памяти. Если бы вы попытались скомпилировать код Matlab или Python в Julia, наш компилятор был бы ограничен семантикой Matlab или Python и генерировал бы код не лучше, чем существующие компиляторы для этих языков (а, возможно, и хуже). Ключевая роль семантики также объясняет, почему несколько существующих компиляторов Python (например, Numba и Pythran) пытаются оптимизировать только небольшое подмножество языка (например, операции с массивами и скалярами Numpy), а для этого подмножества они уже работают как минимум так же хорошо, как и Julia для той же семантики. Люди, работающие над этими проектами, очень умные и создали чудесные вещи, но модификация компилятора для языка, который был разработан как интерпретируемый, представляет собой весьма сложную задачу.
Преимущество языка Julia состоит в том, что хорошая производительность не ограничена небольшим подмножеством «встроенных» типов и операций, и он позволяет создавать высокоуровневый общий для типа код, который работает на произвольных определяемых пользователем типах, оставаясь при этом быстрым и эффективно использующим память. Типы в таких языках, как Python, просто не предоставляют компилятору достаточно информации, чтобы обеспечить аналогичные возможности, поэтому, как только вы начинаете использовать эти языки в качестве фронтенда Julia, возникают препятствия.
По аналогичным причинам автоматический перевод на Julia обычно также генерировал бы нечитаемый, медленный, неидиоматичный код, который не был бы хорошим началом для собственного порта Julia с другого языка.
С другой стороны, интероперабельность языка чрезвычайно полезна: нам нужно использовать высококачественный код из Julia в других языках (и наоборот)! Лучший способ гарантировать это — не использовать «транспилятор», а скорее применить простые средства вызова между языками. Мы усердно работаем над этим, начиная со встроенного средства ccall
(для вызова библиотек C и Fortran) и заканчивая пакетами JuliaInterop, подключающими Julia к Python, Matlab, C++ и т. д.
Общедоступный API
Как Julia определяет свой общедоступный API?
Единственные стабильные интерфейсы по отношению к версии SemVer julia
— это Julia Base
и интерфейсы стандартных библиотек, описанные в документации и не отмеченные как нестабильные (например, экспериментальные и внутренние). Функции, типы и ограничения не являются частью общедоступного API, если они не включены в документацию, даже если в них имеются docstrings.
Имеется полезная недокументированная функция/тип/константа. Могу ли я их использовать?
Обновление Julia может сломать ваш код, если вы используете необщедоступный API. Если код является автономным, полезно скопировать его в ваш проект. Если вы хотите положиться на сложный необщедоступный API, особенно при его использовании из стабильного пакета, полезно открыть вопрос или запрос на вытягивание, чтобы начать обсуждение о преобразовании его в общедоступный API. Однако мы не препятствуем попыткам создать пакеты, предоставляющие стабильные общедоступные интерфейсы, полагаясь на необщедоступные детали реализации julia
и сглаживающие различия различных версий julia
.
Документация недостаточно точная. Могу ли я полагаться на существующее поведение?
Откройте вопрос или запрос на вытягивание, чтобы начать новое обсуждение о включении существующего поведения в общедоступный API.
Сеансы и REPL
Как удалить объект в памяти?
В языке Julia отсутствует аналог функции MATLAB clear
; как только имя определяется в сеансе Julia (технически в модуле Main
), оно присутствует всегда.
Если вас беспокоит использование памяти, вы всегда можете заменить объекты другими, потребляющими меньше памяти. Например, если A
— это массив, имеющий размер, исчисляющийся гигабайтами, который вам больше не нужен, вы можете освободить память с помощью A = nothing
. Память будет освобождена в следующий раз, когда будет запущен сборщик мусора; принудительно это можно сделать с помощью GC.gc()
. Более того, попытка использовать A
с большой степенью вероятности приведет к ошибке, так как большинство методов не определены в типе Nothing
.
Как изменить объявление типа в моем сеансе?
Возможно, вы определили тип, а затем поняли, что вам нужно добавить новое поле. Если вы попробуете это в REPL, вы получите ошибку:
ERROR: invalid redefinition of constant MyType ОШИБКА: недопустимое переопределение константы MyType
Типы в модуле Main
невозможно переопределить.
Хотя это может быть неудобно при разработке нового кода, существует отличное обходное решение. Модули можно заменить, переопределив их, и если вы помещаете весь ваш новый код внутри модуля, вы можете переопределить типы и константы. Вы не можете импортировать имена типов в Main
, а затем ожидать, что сможете переопределить их там, но вы можете использовать имя модуля для разрешения области. Другими словами, при разработке вы можете использовать примерно такой рабочий процесс:
include("mynewcode.jl") # это определяет модуль MyModule
obj1 = MyModule.ObjConstructor(a, b)
obj2 = MyModule.somefunction(obj1)
# Получаем ошибку. Меняем что-то в mynewcode.jl
include("mynewcode.jl") # перезагружаем модуль
obj1 = MyModule.ObjConstructor(a, b) # старые объекты более не действительны, необходимо повторное конструирование
obj2 = MyModule.somefunction(obj1) # в этот раз все сработало!
obj3 = MyModule.someotherfunction(obj2, c)
...
Скрипты
Как проверить, что текущий файл выполняется в качестве главного скрипта?
Когда файл выполняется в качестве главного скрипта с помощью julia file.jl
, может возникнуть желание активировать дополнительные функции, например обработку аргументов командной строки. Чтобы определить, что файл выполняется таким образом, нужно убедиться, что abspath(PROGRAM_FILE) == @__FILE__
имеет значение true
.
Как перехватить CTRL-C в скрипте?
Когда вы пытаетесь завершить выполнение скрипта Julia, запущенного с помощью julia file.jl
, сочетанием клавиш CTRL+C (SIGINT), исключение InterruptException
не выдается. Чтобы запустить определенный код до прекращения выполнения скрипта Julia, которое может быть вызвано CTRL-C, а может, и чем-то другим, используйте atexit
. Как вариант вы можете использовать julia -e 'include(popfirst!(ARGS))' file.jl
для выполнения скрипта и перехватить InterruptException
в блоке try
. Имейте в виду, что при такой стратегии PROGRAM_FILE
не будет задан.
Как передать параметры julia
с помощью #!/usr/bin/env
?
Передача параметров в julia
в так называемой shebang-строке, например #!/usr/bin/env julia --startup-file=no
, не работает на многих платформах (BSD, macOS, Linux), ядро которых, в отличие от оболочки, не разделяет аргументы по символам пробела. Простым обходным решением является использование параметра env -S
, при котором строка аргументов разделяется на несколько отдельных аргументов по пробелам, так же как это происходит в оболочке:
#!/usr/bin/env -S julia --color=yes --startup-file=no
@show ARGS # вставьте сюда любой код Julia
Параметр |
Почему run
не поддерживает *
или конвейеры для запуска внешних программ из скриптов?
Функция Julia run
запускает внешние программы напрямую, без вызова оболочки операционной системы (в отличие от функции system("...")
в других языках, таких как Python, R или C). Это означает, что run
не выполняет расширение подстановочных знаков *
(«глоббинг»), а также не интерпретирует конвейеры оболочки типа |
или >
.
Однако вы по-прежнему можете использовать глоббинг и конвейеры с помощью функций Julia. Например, встроенная функция pipeline
позволяет объединять в цепочки внешние программы и файлы так же, как это делают конвейеры оболочки, а пакет Glob.jl реализует глоббинг, совместимый с POSIX.
Вы, конечно же, можете запускать программы через оболочку, явно передавая оболочку и строку команды в run
, например run(`sh -c
)` для применения Bourne shell Unix, но обычно следует использовать настоящие скрипты Julia, например run(pipeline(`ls
, "files.txt"))`. Причина, по которой мы по умолчанию избегаем использовать оболочку, состоит в том, что это связано со многими недостатками: запуск процессов через оболочку медленный и сопряжен с цитированием специальных символов, ошибки обрабатываются плохо и есть проблемы с переносимостью. (Разработчики Python пришли к похожему заключению.)
Переменные и присваивание
Почему я получаю UndefVarError
из простого цикла?
У вас может быть примерно такой код:
x = 0 while x < 10 x += 1 end
и вы замечаете, что он отлично работает в интерактивной среде (такой как Julia REPL), но вы получаете $UndefVarError:
x` not defined$, когда пытаетесь выполнить этот код в скрипте или другом файле. Происходит это из-за того, что Julia обычно требует от вас явно присваивать значения глобальным переменным в локальной области.
Здесь x
является глобальной переменной, while
определяет локальную область, а x += 1
— это присваивание значения глобальной переменной в этой локальной области.
Как упоминалось выше, Julia (версии 1.5 или более поздние) позволяет опускать ключевое слово global
для кода в REPL (и многих других интерактивных средах), чтобы упростить освоение (например, копирование и вставку кода из функции для интерактивного запуска). Однако когда вы переходите к коду в файлах, Julia требует более дисциплинированного подхода к глобальным переменным. У вас есть минимум три варианта.
-
Поместить код в функцию (чтобы
x
являлся локальной переменной в функции). В целом использовать функции вместо глобальных скриптов является хорошей практикой в программировании (поищите в Интернете, «почему глобальные переменные — это плохо», и вы найдете множество объяснений). В Julia глобальные переменные еще и работают медленно. -
Обернуть код в блок
let
. (Это делаетx
локальной переменной в выраженииlet ... end
, что опять же устраняет необходимость вglobal
.) -
Явно указать
x
какglobal
внутри локальной области до присваивания ей значения, например написатьglobal x += 1
.
Дополнительные объяснения можно найти в разделе руководства О мягкой сфере действия.
Функции
Я передал аргумент x
в функцию, изменил его внутри этой функции, но снаружи переменная x
по-прежнему остается без изменений. Почему?
Предположим, вы вызываете функцию следующим образом:
julia> x = 10
10
julia> function change_value!(y)
y = 17
end
change_value! (generic function with 1 method)
julia> change_value!(x)
17
julia> x # x остается без изменений!
10
В Julia связывание переменной x
не может быть изменено передачей x
в качестве аргумента в функцию. При вызове change_value!(x)
в примере выше y
— это вновь созданная переменная, изначально связанная со значением x
, то есть 10
. Затем y
повторно связывается с константой 17
, в то время как переменная x
внешней области остается без изменений.
Однако если x
связана с объектом типа Array
(или любого другого изменяемого типа), из этой функции вы не можете «отменить связывание» x
из этого массива, но можете изменить ее содержимое. Пример:
julia> x = [1,2,3]
3-element Vector{Int64}:
1
2
3
julia> function change_array!(A)
A[1] = 5
end
change_array! (generic function with 1 method)
julia> change_array!(x)
5
julia> x
3-element Vector{Int64}:
5
2
3
Здесь мы создали функцию change_array!
, которая присваивает значение 5
первому элементу переданного массива (связанного с x
в месте вызова и связанного с A
в функции). Обратите внимание, что после вызова функции x
по-прежнему связана с тем же массивом, но содержимое этого массива изменено: переменные A
и x
были отдельными связями, ссылающимися на один и тот же изменяемый Array
объект.
Могу ли я использовать using
или import
внутри функции?
Нет, использование выражений using
или import
внутри функции запрещено. Если вы хотите импортировать модуль, но только используете его символы внутри конкретной функции или набора функций, у вас есть два варианта.
-
Использовать
import
:import Foo function bar(...) # …ссылка на символы Foo через Foo.baz… end
Этот код загружает модуль
Foo
и определяет переменнуюFoo
, ссылающуюся на модуль, но не импортирует любой из других символов из модуля в текущее пространство имен. Вы ссылаетесь на символыFoo
по их квалифицированным именамFoo.bar
и т. д. 2. Обернуть вашу функцию в модуль:module Bar export bar using Foo function bar(...) # …ссылка на Foo.baz как просто на baz… end end using Bar
Этот код импортирует все символы из
Foo
, но только внутри модуляBar
.
Что делает оператор ...
?
Два способа использования оператора ...
: слияние и разделение
Многих начинающих пользователей Julia использование оператора ...
вводит в заблуждение. Частично это обусловлено тем фактом, что оператор ...
может означать две разные вещи в зависимости от контекста.
...
объединяет множество аргументов в один в определениях функций
В контексте определения функций оператор ...
используется для объединения многих различных аргументов в один аргумент. Использование ...
для объединения многих различных аргументов в один аргумент называется «слиянием»:
julia> function printargs(args...)
println(typeof(args))
for (i, arg) in enumerate(args)
println("Arg #$i = $arg")
end
end
printargs (generic function with 1 method)
julia> printargs(1, 2, 3)
Tuple{Int64, Int64, Int64}
Arg #1 = 1
Arg #2 = 2
Arg #3 = 3
Если бы в языке Julia символы ASCII использовались более свободно, оператор слияния целиком мог бы записываться как <-...
, а не ...
.
...
разделяет один аргумент на множество разных аргументов в вызовах функций
В отличие от использования оператора ...
, для обозначения слияния множества разных аргументов в один аргумент при определении функции оператор ...
также используется для разделения одного аргумента функции на множество разных аргументов при применении в контексте вызова функции. Такое использование ...
называется «разделением»:
julia> function threeargs(a, b, c)
println("a = $a::$(typeof(a))")
println("b = $b::$(typeof(b))")
println("c = $c::$(typeof(c))")
end
threeargs (generic function with 1 method)
julia> x = [1, 2, 3]
3-element Vector{Int64}:
1
2
3
julia> threeargs(x...)
a = 1::Int64
b = 2::Int64
c = 3::Int64
Если бы в языке Julia символы ASCII использовались более свободно, оператор разделения мог бы записываться как ...->
, а не ...
.
Каково возвращаемое значение присваивания?
Оператор =
всегда возвращает правую часть выражения, следовательно:
julia> function threeint()
x::Int = 3.0
x # возвращает переменную x
end
threeint (generic function with 1 method)
julia> function threefloat()
x::Int = 3.0 # возвращает 3,0
end
threefloat (generic function with 1 method)
julia> threeint()
3
julia> threefloat()
3.0
и аналогично:
julia> function twothreetup()
x, y = [2, 3] # присваивает значения 2 – x, а 3 – y
x, y # возвращает кортеж
end
twothreetup (generic function with 1 method)
julia> function twothreearr()
x, y = [2, 3] # возвращает массив
end
twothreearr (generic function with 1 method)
julia> twothreetup()
(2, 3)
julia> twothreearr()
2-element Vector{Int64}:
2
3
Типы, объявления и конструкторы
Что означает «стабильный для типа»?
Это означает, что тип выходных данных прогнозируется на основе типов входных данных. В частности, это означает, что тип выходных данных не может изменяться в зависимости от значений входных данных. Следующий код не является стабильным для типа:
julia> function unstable(flag::Bool)
if flag
return 1
else
return 1.0
end
end
unstable (generic function with 1 method)
Он возвращает либо Int
, либо Float64
в зависимости от значения его аргумента. Так как Julia не может прогнозировать тип возвращаемого значения этой функции во время компиляции, любые вычисления, которые его используют, должны иметь возможность обрабатывать значения обоих типов, что осложняет создание быстрого машинного кода.
Почему Julia выдает DomainError
для некоторых, казалось бы, имеющих смысл операций?
Некоторые операции имеют математический смысл, но завершаются с ошибками:
julia> sqrt(-2.0)
ERROR: DomainError with -2.0:
sqrt will only return a complex result if called with a complex argument. Try sqrt(Complex(x)).
Stacktrace:
[...]
Такое поведение является неудобным следствием требования стабильности для типа. В случае sqrt
большинство пользователей хотят, чтобы sqrt(2.0)
выдавал вещественное число, и будут недовольны, если он выдаст комплексное число 1.4142135623730951 + 0.0im
. Можно было бы написать функцию sqrt
для переключения на выходные данные, содержащие комплексные числа, только при передаче отрицательного числа (это то, что sqrt
делает в некоторых других языках), но в этом случае результат не будет стабильным для типа, а у функции sqrt
будет плохая производительность.
В этом и других случаях вы можете получить нужный вам результат, выбрав тип входных данных, который передает вашу готовность принять тип выходных данных, с использованием которого можно представить результат:
julia> sqrt(-2.0+0im)
0.0 + 1.4142135623730951im
Как ограничить или вычислить параметры типа?
Параметры параметрического типа могут содержать либо типы, либо значения битов, а сам тип определяет, как он использует эти параметры. Например, Array{Float64, 2}
параметризируется типом Float64
, чтобы выразить его тип элемента, и целочисленным значением 2
, чтобы выразить количество его измерений. При определении собственного параметрического типа вы можете использовать ограничения подтипов, чтобы объявить, что определенный параметр должен быть подтипом (<:
) какого-то абстрактного типа или предыдущего параметра типа. Однако выделенный синтаксис для объявления, что параметр должен быть значением данного типа, отсутствует, то есть вы не можете прямо объявить, что, например, параметр размерности isa
является Int
в определении struct
. Аналогично, вы не можете выполнять вычисления (включая простые операции, такие как сложение или вычитание) с параметрами типов. Кроме того, эти виды ограничений и отношений могут выражаться через дополнительные параметры, которые вычисляются и принудительно применяются в конструкторах типа.
Например, рассмотрим следующее.
struct ConstrainedType{T,N,N+1} # ПРИМЕЧАНИЕ. НЕДОПУСТИМЫЙ СИНТАКСИС
A::Array{T,N}
B::Array{T,N+1}
end
где пользователь хотел бы принудительно применить, что третий тип параметра всегда второй плюс один. Это может быть реализовано с явным параметром типа, проверяемым внутренним методом конструктора (где его можно совместить с другими проверками):
struct ConstrainedType{T,N,M}
A::Array{T,N}
B::Array{T,M}
function ConstrainedType(A::Array{T,N}, B::Array{T,M}) where {T,N,M}
N + 1 == M || throw(ArgumentError("second argument should have one more axis" ))
new{T,N,M}(A, B)
end
end
Эта проверка обычно не требует затрат, так как компилятор может опускать проверку для допустимых конкретных типов. Если второй аргумент также вычисляется, может быть выгодным предоставить внешний метод-конструктор, который выполняет это вычисление:
ConstrainedType(A) = ConstrainedType(A, compute_B(A))
Почему Julia использует собственную машинную целочисленную арифметику?
Julia использует машинную арифметику для вычисления целых чисел. Это означает, что диапазон значений Int
ограничен и оборачивается с обоих концов, чтобы сложение, вычитание и умножение целых чисел могли приводить к переполнению или антипереполнению, выдавая некоторые результаты, которые, на первый взгляд, могут привести в недоумение:
julia> x = typemax(Int)
9223372036854775807
julia> y = x+1
-9223372036854775808
julia> z = -y
-9223372036854775808
julia> 2*z
0
Ясно, что это сильно не похоже на то, как ведут себя математические целые числа, и вы можете подумать, что для высокоуровневого языка программирования далеко не идеально выставлять это пользователю напоказ. Однако для работы с числами, где эффективность и прозрачность крайне востребованы, альтернативные варианты еще хуже.
Одной из возможных альтернатив является проверка каждой операции с целыми числами на переполнение и продвижение результатов в большие целочисленные типы, такие как Int128
или BigInt
в случае переполнения. К сожалению, это приводит к серьезным затратам на каждую операцию с целыми числами (подумайте о приращении в счетчике циклов) — это требует генерации кода для проведения проверок на переполнение во время выполнения после арифметических инструкций и веток для обработки потенциальных переполнений. Что еще хуже, это делает каждое вычисление, в котором используются целые числа, нестабильным для типа. Как мы упоминали раньше, стабильность для типа важна для эффективной генерации производительного кода. Если вы не можете быть уверенными, что результаты операций с целыми числами будут целыми, невозможно сгенерировать быстрый и простой код, как это делают компиляторы C и Fortran.
Вариант этого подхода, при котором избегают появления нестабильности типа, — это слияние типов Int
и BigInt
в один гибридный целочисленный тип, который внутренне меняет представление, когда результат более не подходит по размеру машинному целому числу. Хотя этот подход, на первый взгляд, помогает избежать нестабильности типа на уровне кода Julia, на самом деле он приводит к замалчиванию проблемы, перекладывая все те же трудности на код C, который реализует этот гибридный целочисленный тип. При этом подходе можно заставить код работать, и во многих случаях довольно быстро, но есть некоторые недостатки. Одна проблема состоит в том, что представление в памяти целых чисел и целочисленных массивов более не соответствуют естественному представлению, используемому C, Fortran и другими языками с собственными машинными целым числами. Таким образом, для взаимодействия с этими языками нам в конечном итоге в любом случае потребовалось бы вводить собственные целочисленные типы. Любые неограниченные представления целых чисел не могут иметь фиксированное число бит, и, следовательно, не могут храниться встроенными в массив со слотами фиксированного размера — большие целочисленные значения всегда будут требовать отдельного хранилища, размещенного в динамической памяти. И, конечно, насколько интеллектуальную реализацию гибридного целочисленного типа вы бы ни использовали, всегда существуют «ловушки» производительности — ситуации, когда производительность неожиданно ухудшается. Сложные представления, недостаточная интероперабельность с C и Fortran, невозможность представлять целочисленные массивы без дополнительного хранилища в динамической памяти и непредсказуемые характеристики производительности делают даже самые интеллектуальные реализации гибридного целочисленного типа чисел плохим выбором для высокопроизводительной работы с числами.
Альтернативой использованию гибридных целочисленных типов или продвижению до BigInts является применение насыщенной целочисленной арифметики, в которой сложение с наибольшим целочисленным значением оставляет его без изменений (и аналогично для вычитания из наименьшего целочисленного значения). Это ровно то, что делает Matlab™:
>> int64(9223372036854775807) ans = 9223372036854775807 >> int64(9223372036854775807) + 1 ans = 9223372036854775807 >> int64(-9223372036854775808) ans = -9223372036854775808 >> int64(-9223372036854775808) - 1 ans = -9223372036854775808
На первый взгляд, это кажется достаточно разумным, так как 9223372036854775807 гораздо ближе к 9223372036854775808, чем к --9223372036854775808, а целые числа по-прежнему представляются с фиксированным размером естественным способом, совместимым с C и Fortran. Однако с насыщенной целочисленной арифметикой имеется множество проблем. Первая и наиболее очевидная проблема состоит в том, что такая арифметика работает не так, как машинная целочисленная арифметика, поэтому реализация насыщенных операций требует создания инструкций после каждой машинной операции с целыми числами для проверки на антипереполнение или переполнение и замены результата на typemin(Int)
или typemax(Int)
в зависимости от ситуации. Это само по себе превращает каждую операцию с целыми числами из простой быстрой инструкции в полдюжины инструкций, возможно, включающих ветки. Ой. Но все еще хуже — насыщенная целочисленная арифметика не является ассоциативной. Рассмотрим следующее вычисление Matlab.
>> n = int64(2)^62 4611686018427387904 >> n + (n - 1) 9223372036854775807 >> (n + n) - 1 9223372036854775806
Это осложняет написание многих базовых алгоритмов работы с целыми числами, так как многие распространенные методики основаны на том, что машинное сложение с переполнением является ассоциативным. Рассмотрим нахождение срединной точки между целочисленными значениями lo
и hi
в Julia с использованием выражения (lo + hi) >>> 1
:
julia> n = 2^62
4611686018427387904
julia> (n + 2n) >>> 1
6917529027641081856
Понимаете? Нет проблем. Это правильная срединная точка между 2^62 и 2^63, несмотря на тот факт, что n + 2n
равно --4611686018427387904. Теперь попробуем это в Matlab:
>> (n + 2*n)/2 ans = 4611686018427387904
Ой. Добавление оператора >>>
в Matlab не поможет, потому что насыщение, которое происходит при сложении n
и 2n
, уже уничтожило информацию, необходимую для вычисления правильной срединной точки.
И дело не только в отсутствии ассоциативности, из-за чего программисты не могут использовать насыщенную арифметику в подобных методиках. Она еще сводит на нет почти все, что компиляторы могли бы сделать для оптимизации целочисленной арифметики. Например, так как целые числа в Julia используют нормальную машинную целочисленную арифметику, LLVM имеет возможность агрессивно оптимизировать простые маленькие функции типа f(k) = 5k-1
. Машинный код для этой функции следующий:
julia> code_native(f, Tuple{Int})
.text
Filename: none
pushq %rbp
movq %rsp, %rbp
Source line: 1
leaq -1(%rdi,%rdi,4), %rax
popq %rbp
retq
nopl (%rax,%rax)
Фактически тело функции — это простая инструкция leaq
, которая одновременно вычисляет умножение и сложение целых чисел. Когда f
оказывается встроенной в другую функцию, это даже еще полезней:
julia> function g(k, n)
for i = 1:n
k = f(k)
end
return k
end
g (generic function with 1 methods)
julia> code_native(g, Tuple{Int,Int})
.text
Filename: none
pushq %rbp
movq %rsp, %rbp
Source line: 2
testq %rsi, %rsi
jle L26
nopl (%rax)
Source line: 3
L16:
leaq -1(%rdi,%rdi,4), %rdi
Source line: 2
decq %rsi
jne L16
Source line: 5
L26:
movq %rdi, %rax
popq %rbp
retq
nop
Так как вызов f
становится встроенным, тело цикла заканчивается инструкцией leaq
. Далее рассмотрим, что происходит, если мы исправляем число итераций цикла:
julia> function g(k)
for i = 1:10
k = f(k)
end
return k
end
g (generic function with 2 methods)
julia> code_native(g,(Int,))
.text
Filename: none
pushq %rbp
movq %rsp, %rbp
Source line: 3
imulq $9765625, %rdi, %rax # imm = 0x9502F9
addq $-2441406, %rax # imm = 0xFFDABF42
Source line: 5
popq %rbp
retq
nopw %cs:(%rax,%rax)
Так как компилятор знает, что сложение и умножение целых чисел является ассоциативным и что умножение имеет приоритет над сложением (и то, и другое неверно для насыщенной арифметики), это может оптимизировать весь цикл, сведя его к простому умножению и сложению. Насыщенная арифметика полностью сводит на нет этот вид оптимизации, так как может привести к сбою ассоциативности и дистрибутивности на каждой итерации цикла, приводя к различным результатам в зависимости от того, на какой итерации произошел сбой. Компилятор может свернуть цикл, но не может алгебраически свести множество операций к меньшему количеству эквивалентных операций.
Наиболее разумной альтернативой использования целочисленной арифметики для автоматического переполнения является повсеместное применение арифметики с проверкой, выдающей ошибки, когда операции сложения вычитания и умножения приводят к переполнению, производя значения, не являющиеся корректными для значения. В этом посте в блоге Дэн Луу (Dan Luu) анализирует эту проблему и приходит к выводу, что вместо очевидных затрат, к которым должен приводить этот подход в теории, в действительности он приводит к существенным затратам из-за того, что компиляторы (LLVM и GCC) небезопасно оптимизируют проверки переполнения при сложении. Если это будет улучшено в будущем, мы сможем рассмотреть использование в Julia целочисленной арифметики с проверками по умолчанию, но на данный момент мы вынуждены учитывать возможность переполнения.
В настоящее же время безопасные, не приводящие к переполнению операции с целыми числами можно выполнять с помощью внешних библиотек, таких как SaferIntegers.jl. Обратите внимание, что, как указывалось ранее, использование этих библиотек существенно увеличивает время выполнения кода с использованием целочисленных типов с проверкой. Однако при ограниченном использовании это является проблемой в значительно меньшей степени, чем если бы они использовались для всех операций с целыми числами. Вы можете следить за состоянием обсуждения здесь.
Каковы возможные причины появления UndefVarError
во время удаленного выполнения?
Как говорит сообщение об ошибке, непосредственной причиной UndefVarError
на удаленном узле является то, что связь по этому имени не существует. Изучим некоторые возможные причины.
julia> module Foo
foo() = remotecall_fetch(x->x, 2, "Hello")
end
julia> Foo.foo()
ERROR: On worker 2:
UndefVarError: `Foo` not defined
Stacktrace:
[...]
Закрытие x->x
несет ссылку на Foo
, а так как Foo
недоступна на узле 2, выдается UndefVarError
.
Глобальные переменные в модулях, отличных от Main
, не сериализуются по значению в удаленный узел. Отправляется только ссылка. Функции, которые создают глобальные связи (за исключением находящихся в модуле Main
), могут позже привести к выдаче UndefVarError
.
julia> @everywhere module Foo
function foo()
global gvar = "Hello"
remotecall_fetch(()->gvar, 2)
end
end
julia> Foo.foo()
ERROR: On worker 2:
UndefVarError: `gvar` not defined
Stacktrace:
[...]
В примере выше @everywhere module Foo
определил Foo
на всех узлах. Однако вызов Foo.foo()
создал новую глобальную связь gvar
на локальном узле, но она не была обнаружена на узле 2, что привело к ошибке UndefVarError
.
Обратите внимание, что это не относится к глобальным переменным, созданным в модуле Main
. Глобальные переменные в модуле Main
сериализуются, и новые связи создаются в модуле Main
на удаленном узле.
julia> gvar_self = "Node1"
"Node1"
julia> remotecall_fetch(()->gvar_self, 2)
"Node1"
julia> remotecall_fetch(varinfo, 2)
name size summary
––––––––– –––––––– –––––––
Base Module
Core Module
Main Module
gvar_self 13 bytes String
Это не относится к декларациям function
или struct
. Однако анонимные функции, связанные с глобальными переменными, сериализуются, как видно ниже.
julia> bar() = 1
bar (generic function with 1 method)
julia> remotecall_fetch(bar, 2)
ERROR: On worker 2:
UndefVarError: `#bar` not defined
[...]
julia> anon_bar = ()->1
(::#21) (универсальная функция с 1 методом)
julia> remotecall_fetch(anon_bar, 2)
1
Устранение неполадок «метод не совпадает»: инвариантность параметрических типов и ошибки MethodError
Почему не получится объявить foo(bar::Vector{Real}) = 42
, а затем вызвать foo([1])
?
Как видно, если попробовать выполнить этот код, результатом будет MethodError
:
julia> foo(x::Vector{Real}) = 42
foo (generic function with 1 method)
julia> foo([1])
ERROR: MethodError: no method matching foo(::Vector{Int64})
Closest candidates are:
foo(!Matched::Vector{Real})
@ Main none:1
Stacktrace:
[...]
Это происходит, потому что Vector{Real}
не является супертипом Vector{Int}
! Вы можете решить эту проблему, используя что-то вроде foo(bar::Vector{T}) where {T<:Real}
(или краткую форму foo(bar::Vector{<:Real})
, если статический параметр T
не требуется в теле функции). T
— это подстановочный символ: сначала следует указать, что он должен иметь подтип Real, а затем указать, что функция принимает Vector с элементами этого типа.
Та же самая проблема существует для любого составного типа Comp
, не только для Vector
. Если Comp
имеет объявленный параметр типа Y
, то другой тип Comp2
с параметром типа X<:Y
не является подтипом Comp
. Это инвариантность типа (в противоположность этому кортеж представляет собой ковариант типа в своих параметрах). Дополнительные объяснения см. в разделе Параметрические составные типы.
Почему Julia использует *
для конкатенации строк? Почему не +
или что-то другое?
Основной аргумент против +
состоит в том, что конкатенация строк не является коммутативной, в то время как +
обычно используется как коммутативный оператор. Хотя сообщество Julia признает, что другие языки используют различные операторы и символ *
может быть не знаком некоторым пользователям, он передает определенные алгебраические свойства.
Имейте в виду, что вы также можете использовать string(...)
для конкатенации строк (и других значений, преобразованных в строки); аналогично, можно использовать repeat
вместо ^
для повтора строк. Синтаксис интерполяции также полезен для конструирования строк.
Пакеты и модули
В чем разница между using и import?
Существует только одно различие, и на поверхности (на уровне синтаксиса) оно может показаться очень небольшим. Разница между using
и import
состоит в том, что вместе с using
вы должны употребить function Foo.bar(..
, чтобы расширить function bar модуля Foo новым методом, а вместе с import Foo.bar
вы должны употребить только function bar(...
, и function bar модуля Foo будет автоматически обновлен.
Причина, по которой довольно важно иметь для этого отдельный синтаксис, состоит в том, что вам не нужно случайно расширять функцию, о существовании которой вы не знали, потому что это может легко привести к ошибке. Наиболее вероятно, что это может произойти с методом, который принимает общий тип, например строку или целое число, потому что как вы, так и другой модуль могли определить метод для обработки такого общего типа. Если вы используете import
, то замените реализацию bar(s::AbstractString)
другого модуля вашей новой реализацией, которая может легко делать что-то совершенно другое (и нарушить все или многие использования других функций в модуле Foo, которые зависят от вызова bar).
Пустые или отсутствующие значения
Как значения null, пустые или отсутствующие значения работают в Julia?
В отличие от многих языков (например, C и Java), объекты Julia не могут иметь значение null по умолчанию. Когда для ссылки (переменной, поля объектов или элемента массива) отменена инициализация, при доступе к ней будет немедленно выдана ошибка. Эту ситуацию можно обнаружить с помощью функции isdefined
или isassigned
.
Некоторые функции используются только ради их побочных эффектов, и им не нужно возвращать значение. В этих случаях используется соглашение возвращать значение nothing
, которое представляет собой одинарный объект типа Nothing
. Это обычный тип без полей; в нем нет ничего особенного, за исключением этого соглашения и того, что REPL не выводит для него никаких данных. Некоторые конструкции языка, которые в противном случае не имели бы значения, также выдают nothing
, например if false; end
.
В ситуациях, в которых значение x
типа T
существует только время от времени, тип Union{T, Nothing}
может использоваться для типов аргументов функций, полей объектов и элементов массива в качестве эквивалента Nullable
, Option
или Maybe
в других языках. Если само значение может быть nothing
(особенно когда T
равно Any
), тип Union{Some{T}, Nothing}
является более подходящим, поскольку тогда x == nothing
означает отсутствие значения, а x == Some(nothing)
— наличие значения, равного nothing
. Функция something
позволяет отменять оборачивание объектов Some
и использовать значение по умолчанию вместо аргументов nothing
. Имейте в виду, что компилятор способен генерировать эффективный код при работе с аргументами или полями Union{T, Nothing}
.
Чтобы представить отсутствующие данные в статистическом смысле (NA
в R или NULL
в SQL), используйте объект missing
. Дополнительные сведения см. в разделе Missing Values
.
В некоторых языках пустой кортеж (()
) считается канонической формой пустых значений. Однако в Julia его лучше рассматривать просто как обычный кортеж, который почему-то содержит ноль значений.
Пустой (или «низший») тип, записываемый как Union{}
(пустой тип объединения), — это тип без значений и подтипов (за исключением его самого). В общем случае вам не понадобится использовать этот тип.
Память
Почему x += y
выделяет память, а x
и y
— это массивы?
В Julia x += y
заменяется во время снижения на x = x + y
. Для массивов это имеет такое последствие, что вместо хранения результата в том же расположении в памяти, что и x
, для хранения результата выделяется новый массив. Если вы предпочитаете изменять x
, используйте x .+= y
для обновления каждого элемента по отдельности.
Хотя некоторых это поведение может удивить, такой выбор сделан преднамеренно. Основная причина — наличие в Julia неизменяемых объектов, которые не могут изменять свое значение после создания. Действительно, число является неизменяемым объектом; выражения x = 5; x += 1
не изменяют значение 5
, они изменяют значение, связанное с x
. Для неизменяемого объекта единственный способ изменить значение — это повторно присвоить его.
Чтобы немного усилить впечатление, рассмотрим следующую функцию.
function power_by_squaring(x, n::Int)
ispow2(n) || error("This implementation only works for powers of 2")
while n >= 2
x *= x
n >>= 1
end
x
end
После вызова, подобного x = 5; y = power_by_squaring(x, 4)
, вы получите ожидаемый результат: x == 5 && y == 625
. Однако предположим теперь, что *=
при использовании с матрицами изменило вместо этого левую часть выражения. Здесь возникают две проблемы.
-
Для общих квадратных матриц
A = A*B
не может быть реализовано без временного хранилища:A[1,1]
вычисляется и сохраняется в левой части выражения до того, как вы закончили использовать его в правой. -
Предположим, вы хотите выделить временную память для вычисления (что в значительной степени лишит смысла заставлять
*=
работать на месте); если вы использовали изменяемостьx
, то эта функция будет вести себя по-разному при вводе из изменяемых и неизменяемых объектов. В частности, для неизменяемогоx
после вызова вы получили бы (в общем случае)y != x
, а для изменяемогоx
—y == x
.
Поскольку поддержка универсального программирования считается более важным, чем потенциальные оптимизации производительности, которых можно достичь другими средствами (например, с помощью транслирования или явных циклов), такие операторы, как +=
и *=
, работают, повторно связывая новые значения.
Асинхронный ввод-вывод и параллельные синхронные операции записи
Почему параллельные операции записи в тот же поток приводят к перемешанному выводу?
В то время как API ввода-вывода потоковой передачи данных работает синхронно, базовая реализация полностью асинхронна.
Рассмотрим выходные данные следующего кода:
julia> @sync for i in 1:3
@async write(stdout, string(i), " Foo ", " Bar ")
end
123 Foo Foo Foo Bar Bar Bar
Это происходит, потому что вызов write
синхронный, запись каждого аргумента влияет на другие задачи, ожидая, когда завершится эта часть ввода-вывода.
print
и println
«блокируют» поток во время вызова. Следовательно, изменение write
на println
в примере выше приводит к следующему коду:
julia> @sync for i in 1:3
@async println(stdout, string(i), " Foo ", " Bar ")
end
1 Foo Bar
2 Foo Bar
3 Foo Bar
Вы можете заблокировать свои операции записи с помощью ReentrantLock
следующим образом.
julia> l = ReentrantLock();
julia> @sync for i in 1:3
@async begin
lock(l)
try
write(stdout, string(i), " Foo ", " Bar ")
finally
unlock(l)
end
end
end
1 Foo Bar 2 Foo Bar 3 Foo Bar
Массивы
В чем различия между нульмерными массивами и скалярами?
Нульмерные массивы являются массивами вида Array{T,0}
. Они ведут себя аналогично скалярам, но имеются важные различия. Они заслуживают специального упоминания, поскольку это особый случай, который имеет смысл с точки зрения логики, принимая во внимание универсальное определение массивов, но, на первый взгляд, работа с ними может показаться немного неинтуитивной. Нульмерный массив определяется следующей строкой:
julia> A = zeros() 0-dimensional Array{Float64,0}: 0.0
В этом примере A
— это изменяемый контейнер, содержащий один элемент, который может быть задан A[] = 1.0
и получен с помощью A[]
. Все нульмерные массивы имеют одинаковые размеры (size(A) == ()
) и длину (length(A) == 1
). В частности, нульмерные массивы не являются пустыми. Если вы считаете это неинтуитивным, вот несколько идей, которые могут помочь понять определение Julia.
-
Нульмерные массивы являются «точкой» для «линии» вектора и «плоскости» матрицы. Так же как в случае с линией, у которой отсутствует площадь (но которая все равно представляет набор объектов), у точки отсутствует длина и вообще какие-либо измерения (тем не менее она представляет объект).
-
Мы определяем
prod(())
как 1, а общее количество элементов в массиве — произведение размера. Размер нульмерного массива —()
, и поэтому его длина —1
. -
У нульмерных массивов изначально отсутствуют измерения, по которым вы выполняете индексирование, они просто являются
A[]
. Мы можем применить для них то же правило trailing one, как и для массивов всех других размерностей, так что вы действительно можете индексировать их какA[1]
,A[1,1]
и т. д.; см. раздел Опущенные и дополнительные индексы.
Также важно понимать отличия от обычных скаляров. Скаляры не являются изменяемыми контейнерами (даже несмотря на то, что они являются итерируемыми и определяют такие вещи как length
, getindex
, например 1[] == 1
). В частности, если x = 0.0
определено как скаляр, будет ошибкой пытаться изменить его значение через x[] = 1.0
. Скаляр x
можно преобразовать в нульмерный массив, содержащий его через fill(x)
, и наоборот, нульмерный массив a
можно преобразовать в содержащий его скаляр через a[]
. Другое отличие состоит в том, что скаляр может участвовать в операциях линейной алгебры, таких как 2 * rand(2,2)
, но аналогичная операция c нульмерным массивом fill(2) * rand(2,2)
является ошибкой.
Почему мои тесты производительности для операций линейной алгебры отличаются от тестов для других языков?
Вы можете обнаружить, что простые стандартные блоки тестов производительности для линейной алгебры, такие как
using BenchmarkTools
A = randn(1000, 1000)
B = randn(1000, 1000)
@btime $A \ $B
@btime $A * $B
могут отличаться при сравнении с другими языками, например Matlab или R.
Так как подобные операции представляют собой очень тонкие оболочки вокруг соответствующих функций BLAS, весьма вероятно, что причина различий следующая:
-
библиотека BLAS, которую использует каждый язык;
-
количество параллельных потоков.
Julia компилирует и использует собственную копию OpenBLAS с количеством потоков, ограниченным в настоящее время 8
(или числом ядер).
Изменение параметров OpenBLAS или компилирование Julia с помощью другой библиотеки BLAS, например Intel MKL, может привести к повышению производительности. Вы можете использовать MKL.jl, пакет, который заставляет линейную алгебру Julia использовать Intel MKL BLAS и LAPACK вместо OpenBLAS, или поискать на форуме обсуждений советы по ручной настройке. Обратите внимание, что Intel MKL не может быть включен в пакет Julia, так как он не базируется на открытом исходном коде.
Вычислительный кластер
Как управлять кэшами предварительной компиляции в распределенных файловых системах?
При использовании julia
в высокопроизводительных вычислительных центрах вызов n julia
процессов одновременно создает n временных копий предварительно скомпилированных файлов кэша. Если это представляет собой проблему (у вас медленная и (или) небольшая распределенная файловая система), вы можете:
-
Использовать
julia
с флагом--compiled-modules=no
, чтобы отключить предварительную компиляцию. -
Настроить частное хранилище с возможностью записи
pushfirst!(DEPOT_PATH, private_path)
, гдеprivate_path
— путь, уникальный для этого процессаjulia
. Это также можно сделать, задав для переменной средыJULIA_DEPOT_PATH
значение$private_path:$HOME/.julia
. -
Создать символическую ссылку из
~/.julia/compiled
на каталог в рабочей области.
Выпуски Julia
Мне нужно использовать стабильную версию, LTS-версию или ночную версию Julia?
Стабильная версия Julia — это последняя выпущенная версия Julia, которая подойдет большинству пользователей. Она содержит последние функции и обладает улучшенной производительностью. Стабильная версия Julia указывается в соответствии с SemVer как v1.x.y. Новый вспомогательный (minor) выпуск Julia, соответствующий новой стабильной версии, выходит приблизительно каждые 4—5 месяцев через несколько недель тестирования в качестве релиз-кандидата. В отличие от LTS-версии, стабильная версия обычно не будет получать исправления ошибок после выпуска другой стабильной версии Julia. Однако всегда будет возможность обновления до следующего стабильного выпуска, так как каждый выпуск Julia v1.x продолжит выполнять код, написанный для более ранних версий.
Вы можете предпочесть версию LTS (Long Term Support, с долговременной поддержкой) Julia, если вам нужна очень стабильная база кода. Текущая LTS-версия Julia определена в соответствии с SemVer как v1.6.x; эта ветка продолжит получать исправления ошибок, пока не будет выбрана новая ветка LTS, после чего серия v1.6.x более не будет получать регулярные исправления ошибок, и всем пользователям, кроме самых консервативных, будет рекомендовано выполнить обновление до новой серии LTS-версии. Как разработчик пакетов вы можете предпочесть выполнять разработку для LTS-версии, чтобы максимально увеличить количество пользователей, которые могут использовать ваш пакет. В соответствии с SemVer код, написанный для v1.0, продолжит работать во всех будущих стабильных и LTS-версиях. В общем, если целевой платформой является LTS, можно разрабатывать и выполнять код в последней стабильной версии, чтобы использовать улучшенную производительность, пока не применяются новые функции (такие как добавленные функции библиотек или новые методы).
Вы можете предпочесть ночную версию Julia, если вы хотите использовать последние обновления языка и не возражаете, если доступная на сегодня версия иногда не будет работать. Как видно из названия, выпуски ночной версии выходят почти каждую ночь (в зависимости от стабильности инфраструктуры сборки). В целом использовать ночные выпуски достаточно безопасно — ваш код не загорится. Однако иногда могут случаться ухудшения и (или) возникать проблемы, которые будут устраняться после более тщательного тестирования перед выпуском. Возможно, вы захотите выполнить тестирование в ночной версии, чтобы гарантировать, что такие ухудшения, которые влияют на ваш вариант использования, будут обнаружены до выпуска.
Наконец, вы также можете рассмотреть возможность самостоятельной сборки Julia из исходного кода. Этот вариант предназначен в основном для пользователей, которым комфортно работать в командной строке или интересно учиться. Если такой пользователь — вы, вам также может быть интересно прочитать наши инструкции по сотрудничеству.
Ссылки на каждый из этих загружаемых типов можно найти на странице загрузки по адресу https://julialang.org/downloads/. Имейте в виду, что не все версии Julia доступны для всех платформ.
Как перенести список установленных пакетов после обновления моей версии Julia?
Каждая вспомогательная (minor) версия Julia имеет собственную среду по умолчанию. Как результат, после установки новой вспомогательной (minor) версии Julia пакеты, добавленные с помощью предыдущей вспомогательной версии, не будут доступны по умолчанию. Среда для данной версии Julia определяется файлами Project.toml
и Manifest.toml
в папке, имя которой соответствует номеру версии в .julia/environments/
, например .julia/environments/v1.3
.
Если вы устанавливаете новую вспомогательную (minor) версию Julia, скажем, 1.4
, и хотите использовать в ее среде по умолчанию те же пакеты, что и в предыдущей версии (например, 1.3
), вы можете скопировать содержимое файла Project.toml
из папки 1.3
в 1.4
. Затем в сеансе в новой версии Julia войдите в «режим управления пакетами», нажав клавишу ]
, и выполните команду instantiate
.
Эта операция разрешит набор допустимых пакетов из скопированного файла, совместимых с целевой версией Julia, и установит или обновит их, если они подходят. Если вы хотите воспроизвести не только набор пакетов, но также и версии, которые использовали в предыдущей версии Julia, вам также нужно скопировать файл Manifest.toml
перед выполнением команды Pkg instantiate
. Однако имейте в виду, что пакеты могут определять ограничения совместимости, на которые может повлиять изменение версии Julia, так что точный набор версий, которые были у вас в 1.3
, может не работать в 1.4
.