Почему Джулия быстрее, чем Python? Потому что концепция природы более продвинута.

Python Julia
Почему Джулия быстрее, чем Python? Потому что концепция природы более продвинута.

Язык Julia известен как «быстрый» и «простой» одновременно, и мы можем достичь производительности, подобной C, с помощью изящных операторов, подобных Python. Итак, вы знаете, почему Джулия быстрее, чем Python? Это не из-за лучших компиляторов, а из-за более новой философии дизайна Python, который фокусируется на том, что «жизнь слишком коротка», не включает его.

Выбрано с Github, собрано сердцем машины, участие: Siyuan, Li Yazhou.

На самом деле, как и в прошлом, C или другие основные языки должны объявлять конкретный тип переменной перед ее использованием, но Python не нуждается в том, какие данные присваиваются, какой тип переменной. Неожиданно, однако, именно этот тип стабильности делаетJuliaпо сравнению сPythonиметь лучшую производительность.

Основная причина выбора Julia: он намного быстрее, чем другие скриптовые языки, позволяет использовать Python/Matlab/R развиваться так же быстро, как C/Fortan в то же время.

Новички в Джулии могут быть немного осторожны со следующими описаниями:

  1. Почему другие языки не могут быть быстрее? Юлия умеет, другие языки нет?

  2. Как вы интерпретируете тесты скорости Джулии? (Сложно и для многих других языков?)

  3. Это звучит как нарушение Закона о запрете бесплатных обедов, есть ли потери в других отношениях?

Многие считают, что Julia работает быстро, потому что использует JIT-компилятор, т. е. каждый оператор перед использованием компилируется с помощью функции компиляции, независимо от того, компилируется ли он непосредственно перед ним или кешируется перед ним. Это создает проблему, заключающуюся в том, что языки сценариев, такие как Python/R и MATLAB, также могут использовать JIT-компиляторы, которые оптимизируют даже дольше, чем язык Julia. Так почему же мы безумно верим, что Джулия будет оптимизирована по сравнению с другими языками сценариев в течение короткого периода времени? Это полное непонимание языка Джулии.

В этой статье мы узнаем, что Джулия быстрая благодаря своим дизайнерским решениям. Его основное конструктивное решение: стабильность типов за счет множественной диспетчеризации лежит в основе того, что позволяет Julia быстро компилироваться и эффективно работать, и я объясню, почему это быстро, позже в этой статье. Кроме того, это основное решение также позволяет использовать очень лаконичный синтаксис, такой как язык сценариев, что дает очень значительный прирост производительности.

Однако в этой статье мы видим, что Julia не всегда похож на другие языки сценариев, и нам нужно понимать, что у языка Julia есть некоторая «потеря» из-за этого основного решения. Понимание того, как это дизайнерское решение влияет на то, как вы программируете, очень важно для создания кода Julia.

Чтобы увидеть разницу, мы можем кратко рассмотреть математический пример.

Математические операции в Джулии

В целом математические операции в Джулии выглядят так же, как и в других скриптовых языках. Стоит отметить одну деталь: значения Julia являются «истинными значениями», которые в Float64 действительно совпадают с 64-битным значением с плавающей запятой или «числом с плавающей запятой двойной точности» в C. Расположение памяти в Vector{Float64} эквивалентно массиву чисел с плавающей запятой двойной точности C, что упрощает взаимодействие с C (действительно, Julia в некотором смысле построен поверх C) и обеспечивает высокую производительность. производительность (также для массивов NumPy).

Немного математики в Джулии:

a = 2+2b = a/3c = a÷3 #\div tab completion, means integer divisiond = 4*5println([a;b;c;d])
output: [4.0, 1.33333, 1.0, 20.0]

Кроме того, числовое умножение разрешено без оператора *, когда за ним следует переменная, например, из кода Джулии можно выполнить следующие вычисления:

α = 0.5∇f(u) = α*u; ∇f(2)sin(2π)
output: -2.4492935982947064e-16

Стабильность типов и самоанализ кода

Стабильный тип, то есть из метода может быть выведен только один тип. Например, разумным типом для вывода из *(::Float64, ::Float64) является Float64. Он вернет Float64 независимо от того, что вы ему дадите. Вот механизм множественной отправки: оператор * вызывает разные методы в зависимости от типа, который он видит. Когда он видит поплавки, он возвращает поплавки. Джулия предоставляет макросы для самоанализа кода, чтобы вы могли увидеть, что на самом деле скомпилировано. Итак, Julia — это не просто язык сценариев, это язык сценариев, который позволяет вам работать с ассемблером! Как и многие другие языки, Julia компилируется в LLVM (LLVM — переносимый язык ассемблера).

@code_llvm 2*5; Function *; Location: int.jl:54define i64 @"julia_*_33751"(i64, i64) {top:  %2 = mul i64 %1, %0  ret i64 %2}

Этот вывод означает выполнение умножения с плавающей запятой и возврат ответа. Можем даже взглянуть на сборку:

@code_llvm 2*5    .text; Function * {; Location: int.jl:54    imulq   %rsi, %rdi    movq    %rdi, %rax    retq    nopl    (%rax,%rax);}

Это означает, что *функция была скомпилирована так, чтобы делать то же самое, что и в C/Fortran, что означает, что она обеспечивает ту же производительность (даже несмотря на то, что она определена в Julia). Таким образом, вы можете не только «приблизиться» к производительности C, но и фактически получить тот же самый код C. Так при каких обстоятельствах это происходит?

Что интересно в Джулии, так это то, что нам нужно знать, при каких обстоятельствах код компилируется не так эффективно, как C/Fortran? Ключевым моментом здесь является стабильность типов. Если функция является стабильной по типу, то компилятор может знать типы всех узлов в функции и разумно оптимизировать ее для той же сборки, что и C/Fortran. Если он не стабилен по типу, Джулия должна добавить дорогостоящую «упаковку», чтобы гарантировать, что тип найден или явно известен до операции.

Это самое важное отличие Julia от других языков сценариев!

Преимущество заключается в том, что функции Julia в основном такие же, как функции C/Fortran, когда они стабильны по типу. Итак, ^(возведение в степень) работает быстро, но поскольку ^(::Int64, ::Int64) является стабильным типом, какой тип он должен выводить?

2^5
output: 32
2^-5
output: 0.03125

Здесь мы получаем ошибку. Компилятор должен выдать ошибку, чтобы гарантировать, что ^ вернет Int64. Если вы сделаете это в MATLAB, Python или R, ошибка не будет выдана, потому что эти языки не строили целые языки вокруг стабильности типов.

Что происходит, когда у нас нет стабильности типов? Давайте посмотрим на этот код:

@code_native ^(2,5)    .text; Function ^ {; Location: intfuncs.jl:220    pushq   %rax    movabsq $power_by_squaring, %rax    callq   *%rax    popq    %rcx    retq    nop;}

Теперь давайте определим возведение целых чисел в степень, чтобы сделать его «безопасным», как это видно на других языках сценариев:

function expo(x,y)    if y>0        return x^y    else        x = convert(Float64,x)        return x^y    endend
output: expo (generic function with 1 method)

Убедитесь, что это работает:

println(expo(2,5))expo(2,-5)
output: 32 
0.03125

Что происходит, когда мы проверяем этот код?

@code_native expo(2,5).text; Function expo {; Location: In[8]:2    pushq   %rbx    movq    %rdi, %rbx; Function >; {; Location: operators.jl:286; Function <; {; Location: int.jl:49    testq   %rdx, %rdx;}}    jle L36; Location: In[8]:3; Function ^; {; Location: intfuncs.jl:220    movabsq $power_by_squaring, %rax    movq    %rsi, %rdi    movq    %rdx, %rsi    callq   *%rax;}    movq    %rax, (%rbx)    movb    $2, %dl    xorl    %eax, %eax    popq    %rbx    retq; Location: In[8]:5; Function convert; {; Location: number.jl:7; Function Type; {; Location: float.jl:60L36:    vcvtsi2sdq  %rsi, %xmm0, %xmm0;}}; Location: In[8]:6; Function ^; {; Location: math.jl:780; Function Type; {; Location: float.jl:60    vcvtsi2sdq  %rdx, %xmm1, %xmm1    movabsq $__pow, %rax;}    callq   *%rax;}    vmovsd  %xmm0, (%rbx)    movb    $1, %dl    xorl    %eax, %eax; Location: In[8]:3    popq    %rbx    retq    nopw    %cs:(%rax,%rax);}

Эта демонстрация является очень наглядной демонстрацией того, почему Джулия использует вывод типов для достижения более высокой производительности, чем другие языки сценариев.

Основная концепция: множественная отправка + стабильность типов => скорость + читабельность.

Стабильность типов — важная особенность, которая отличает язык Julia от других языков сценариев. На самом деле, основные концепции Джулии таковы:

(ссылка) Множественная отправка позволяет языку отправлять вызовы функций в функции с стабильным типом.

Здесь начинаются все возможности языка Julia, так что нам нужно потратить некоторое время на его изучение. Если внутри функции есть стабильность типов, т. е. любой вызов функции внутри функции также является типостабильным, то компилятор знает тип переменной на каждом шаге. Поскольку код на этом этапе в основном такой же, как код C/Fortran, компилятор может скомпилировать функцию со всеми оптимизациями.

Мы можем объяснить множественную диспетчеризацию случаем, если оператор умножения * является типостабильной функцией: она различается в зависимости от представления ввода. Но если компилятор знает типы a и b перед вызовом *, то он знает, какой * метод использовать, поэтому компилятор также знает тип вывода c=a * b. Таким образом, если информация о типе распространяется по разным операциям, то Джулия будет знать тип всего процесса, а также позволит провести полную оптимизацию. Множественная диспетчеризация позволяет представлять правильный тип каждый раз, когда используется *, а также волшебным образом разрешает все оптимизации.

Мы можем многому у него научиться. Во-первых, чтобы достичь такого уровня операционной оптимизации, мы должны иметь стабильность типов. Это не функция, которая есть в большинстве стандартных библиотек языков программирования, это просто выбор, который необходимо сделать, чтобы упростить работу пользователя. Во-вторых, типы функций требуют специализации множественной диспетчеризации, что позволяет языку сценариев стать «более явным, а не просто более читаемым». Наконец, нам также нужна надежная система типов. Чтобы построить экспоненциальные функции с нестабильным типом (которые могут пригодиться), нам также нужны такие функции, как преобразователи.

Поэтому языки программирования должны быть спроектированы как стабильные по типам языки с множественной диспетчеризацией, а также должны быть сосредоточены вокруг надежной системы типов, чтобы достичь производительности базового языка при сохранении синтаксиса и простоты использования сценариев. языки. Мы можем внедрить JIT в Python, но если нам нужно встроить в Julia, нам нужно действительно спроектировать его как часть Julia.

Юлия ориентиры

Бенчмарк Julia на веб-сайте Julia тестирует различные модули языка программирования в надежде стать быстрее. Это не значит, что бенчмарк Julia будет тестировать самую быструю реализацию, что является нашим основным заблуждением по этому поводу. Другие языки программирования делают то же самое: тестируют базовые модули языка программирования и видят, насколько они быстры на самом деле.

Язык Julia построен на механизме множественной диспетчеризации стабильных по типу функций. Таким образом, даже исходная версия Julia позволяла компилятору быстро оптимизировать производительность для C/Fortran. Очевидно, что производительность Джулии в большинстве случаев очень близка к C. Но есть несколько деталей, которые на самом деле не достигают производительности языка C, во-первых, это проблема последовательности Фибоначчи, Джулия занимает в 2,11 раза больше времени, чем C. Это в основном из-за рекурсивного тестирования, Джулия не полностью оптимизирует рекурсивные операции, но все же делает это довольно хорошо.

Самый быстрый метод оптимизации для подобных рекурсивных задач — это Tail-Call Optimization, который язык Julia может добавить в любое время. Но Джулия не добавила его по нескольким причинам, главным образом: в любом случае, когда необходимо использовать оптимизацию хвостового вызова, также можно использовать оператор цикла. Но циклы более устойчивы к оптимизации, потому что многие рекурсии не могут быть оптимизированы с помощью Tail-Call, поэтому Джулия по-прежнему рекомендует циклы вместо менее стабильных TCO.

Есть также случаи, когда Джулия не справляется, например, тесты rand_mat_stat и parse_int. Однако это в значительной степени связано с функцией, называемой проверкой границ. В большинстве языков сценариев, если мы проиндексируем массив за пределами индекса, программа выдаст ошибку. Язык Julia по умолчанию делает следующее:

function test1()    a = zeros(3)    for i=1:4        a[i] = i    endendtest1()BoundsError: attempt to access 3-element Array{Float64,1} at index [4]Stacktrace: [1] setindex! at ./array.jl:769 [inlined] [2] test1() at ./In[11]:4 [3] top-level scope at In[11]:7

Однако язык Julia позволяет отключить определение границ с помощью макроса @inbounds:

function test2()    a = zeros(3)    @inbounds for i=1:4        a[i] = i    endendtest2()

Это дает нам то же небезопасное поведение, что и C/Fortran, но с той же скоростью. Если мы сравним код с выключенным определением границ, мы можем получить скорость, аналогичную C. Это еще одна интересная особенность языка Julia: он обеспечивает такую ​​же безопасность, как и другие языки сценариев по умолчанию, но в определенных случаях (после тестирования и отладки) отключение этих функций обеспечивает полную производительность.

Небольшое расширение основной концепции: строго типизированные формы.

Стабильность типов — не единственное требование, нам также нужны строгие формы типов. В Python мы можем поместить любой тип данных в массив, но в Julia мы можем поместить только тип T в Vector{T}. Для обеспечения общности язык Julia предоставляет различные типы нестрогих форм. Самый очевидный случай — Any.Любой тип, который удовлетворяет T:

a = Vector{Any}(undef,3)a[1] = 1.0a[2] = "hi!"a[3] = :Symbolica
output:  3-element Array{Any,1}:
 1.0       
 "hi!"    
 :Symbolic

Менее экстремальной формой абстрактного типа является тип Union, например:

a = Vector{Union{Float64,Int}}(undef,3)a[1] = 1.0a[2] = 3a[3] = 1/4a
output: 3-element Array{Union{Float64, Int64},1}:
 1.0 
 3   
 0.25

Этот случай принимает только значения с плавающей запятой и целые числа, однако это все еще абстрактный тип. В общем, вызов функций для абстрактных типов не знает конкретного типа какого-либо элемента, например, в приведенном выше случае каждый элемент может быть числом с плавающей запятой или целым числом. Поэтому при многократной диспетчеризации для достижения оптимизации компилятор не знает тип каждого шага. Из-за неполной оптимизации язык Julia, как и любой другой язык сценариев, тормозит.

Это принцип высокой производительности: по возможности используйте строгую типизацию. Следование этому принципу имеет и другие преимущества: строгий тип Vector{Float64} на самом деле байт-совместим с C/Fortran, поэтому его можно использовать непосредственно в программах на C/Fortran без преобразования.

высокая стоимость производительности

Ясно, что язык Julia принял мудрое дизайнерское решение для достижения целей производительности, оставаясь при этом языком сценариев. Однако что оно потеряло? В следующем разделе будут показаны некоторые функции Julia, появившиеся в результате этого дизайнерского решения, а также некоторые инструменты решения в языке Julia.

дополнительная производительность

Как было показано ранее, Julia достигает высокой производительности многими способами (например, @inbounds), но их не обязательно использовать. Мы можем использовать функции с нестабильным типом, которые станут такими же медленными, как MATLAB/R/Python. Если нам не нужна максимальная производительность, мы можем использовать эти удобные методы.

Проверить стабильность типа

Поскольку стабильность типов чрезвычайно важна, язык Julia предоставляет инструменты для проверки стабильности типов функций, особенно в макросе @code_warntype. Ниже мы можем проверить стабильность типов:

@code_warntype 2^5Body::Int64│220 1 ─ %1 = invoke Base.power_by_squaring(_2::Int64, _3::Int64)::Int64│    └──      return %1

Обратите внимание, что это показывает, что все переменные в функции строго типизированы, так что насчет функции expo?

@code_warntype 2^5Body::Union{Float64, Int64}│╻╷ >2 1 ─ %1  = (Base.slt_int)(0, y)::Bool│    └──       goto #3 if not %1│  3 2 ─ %3  = π (x, Int64)│╻  ^  │   %4  = invoke Base.power_by_squaring(%3::Int64, _3::Int64)::Int64│    └──       return %4│  5 3 ─ %6  = π (x, Int64)││╻  Type  │   %7  = (Base.sitofp)(Float64, %6)::Float64│  6 │   %8  = π (%7, Float64)│╻  ^  │   %9  = (Base.sitofp)(Float64, y)::Float64││   │   %10 = $(Expr(:foreigncall, "llvm.pow.f64", Float64, svec(Float64, Float64), :(:llvmcall), 2, :(%8), :(%9), :(%9), :(%8)))::Float64│    └──       return %10

Возврат функции может быть 4% и 10%, это разные типы, поэтому тип возврата можно вывести как Union{Float64,Int64}. Чтобы отследить, где именно возникает нестабильность, мы можем использовать Traceur.jl:

using Traceur@trace expo(2,5)┌ Warning: x is assigned as Int64└ @ In[8]:2┌ Warning: x is assigned as Float64└ @ In[8]:5┌ Warning: expo returns Union{Float64, Int64}└ @ In[8]:2
output: 32

Это показывает, что в строке 2 x передается как целое число Int, а в строке 5 — как число с плавающей запятой Float64, поэтому тип можно вывести как Union{Float64,Int64}. В строке 5 явным образом вызывается функция convert, так что это идентифицирует проблему для нас. В исходном тексте также рассказывается, как работать с нестабильными типами, а глобальная переменная Globals имеет низкую производительность.Читатели, которые хотят узнать о ней больше, могут обратиться к исходному тексту.

в заключении

Джулия быстрая по дизайну. Стабильность типов и множественная диспетчеризация необходимы для специализации компиляции Julia, что делает ее очень эффективной. Кроме того, надежная система типов также должна правильно работать с типами на мелкозернистом уровне, чтобы достичь максимально возможной стабильности типов и максимально возможной оптимизации в случае нестабильности типов.