Документация Engee

Часто задаваемые вопросы

Общие

Название Julia выбрано в честь кого-то или чего-то?

Нет.

Почему вы не компилируете код 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

Параметр env -S впервые появился во FreeBSD 6.0 (2005), macOS Sierra (2016) и GNU/Linux coreutils 8.30 (2018).

Почему run не поддерживает * или конвейеры для запуска внешних программ из скриптов?

Функция Julia run запускает внешние программы напрямую, без вызова оболочки операционной системы (в отличие от функции system("...") в других языках, таких как Python, R или C). Это означает, что run не выполняет расширение подстановочных знаков * («глоббинг»), а также не интерпретирует конвейеры оболочки типа | или >.

Однако вы по-прежнему можете использовать глоббинг и конвейеры с помощью функций Julia. Например, встроенная функция pipeline позволяет объединять в цепочки внешние программы и файлы так же, как это делают конвейеры оболочки, а пакет Glob.jl реализует глоббинг, совместимый с POSIX.

Вы, конечно же, можете запускать программы через оболочку, явно передавая оболочку и строку команды в run, например run(`sh -c ls  files.txt)` для применения 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 требует более дисциплинированного подхода к глобальным переменным. У вас есть минимум три варианта.

  1. Поместить код в функцию (чтобы x являлся локальной переменной в функции). В целом использовать функции вместо глобальных скриптов является хорошей практикой в программировании (поищите в Интернете, «почему глобальные переменные — это плохо», и вы найдете множество объяснений). В Julia глобальные переменные еще и работают медленно.

  2. Обернуть код в блок let. (Это делает x локальной переменной в выражении let ... end, что опять же устраняет необходимость в global.)

  3. Явно указать 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 внутри функции запрещено. Если вы хотите импортировать модуль, но только используете его символы внутри конкретной функции или набора функций, у вас есть два варианта.

  1. Использовать 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, весьма вероятно, что причина различий следующая:

  1. библиотека BLAS, которую использует каждый язык;

  2. количество параллельных потоков.

Julia компилирует и использует собственную копию OpenBLAS с количеством потоков, ограниченным в настоящее время 8 (или числом ядер).

Изменение параметров OpenBLAS или компилирование Julia с помощью другой библиотеки BLAS, например Intel MKL, может привести к повышению производительности. Вы можете использовать MKL.jl, пакет, который заставляет линейную алгебру Julia использовать Intel MKL BLAS и LAPACK вместо OpenBLAS, или поискать на форуме обсуждений советы по ручной настройке. Обратите внимание, что Intel MKL не может быть включен в пакет Julia, так как он не базируется на открытом исходном коде.

Вычислительный кластер

Как управлять кэшами предварительной компиляции в распределенных файловых системах?

При использовании julia в высокопроизводительных вычислительных центрах вызов n julia процессов одновременно создает n временных копий предварительно скомпилированных файлов кэша. Если это представляет собой проблему (у вас медленная и (или) небольшая распределенная файловая система), вы можете:

  1. Использовать julia с флагом --compiled-modules=no, чтобы отключить предварительную компиляцию.

  2. Настроить частное хранилище с возможностью записи pushfirst!(DEPOT_PATH, private_path) , где private_path — путь, уникальный для этого процесса julia. Это также можно сделать, задав для переменной среды JULIA_DEPOT_PATH значение $private_path:$HOME/.julia.

  3. Создать символическую ссылку из ~/.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.