Что могло бы понравиться программисту на C ++ в таком языке высокого уровня, как Джулия?

Я немного странный в том смысле, что мне нравится как программирование очень высокого уровня, так и вещи действительно низкого уровня. Я нахожу такие языки, как LISP и метапрограммирование, увлекательными, но в то же время меня привлекает ассемблерный код. На самом деле, при программировании Arduino меня беспокоило, насколько сильно они абстрагировались. Конечно, это сделало вещи более доступными для всех. Но для меня часть привлекательности программирования микроконтроллеров заключается в том, что у вас есть этот маленький островок, где вы все контролируете и видите все, что происходит. Операционной системы нет. Планировщика задач нет. Все работает на голом металле. Чтобы дать представление, вот пример программы ассемблерного кода AVR, которая заставляет мигать светодиод:

.include "tn13def.inc"   ;(attiny13 definitions)
.def a = r16             ;general purpose accumulator
.def i = r20             ;index
.def n = r22             ;counter
.org 0000
on_reset:
    sbi DDRB,0           ;set portb0 for output
;--------------;
; main routine ;
;--------------;
main_loop:
      sbi   PINB,0       ;toggle bit 0. Writing to PIN rather than PORT toggles.
      rcall pause        ;wait/pause
      rjmp main_loop     ;go back and do it again
;----------------;
;pause routines  ;
;----------------;
pause: ldi n,0           ;do nothing loop
plupe: rcall mpause      ;calls another do nothing loop
       dec n             ;check if we come back to zero  
        brne plupe       ;if not loop again
       ret               ;return from call
mpause:ldi i,0           ;start at zero
mplup: dec i             ;subtract one
        brne mplup       ;keep looping until we hit zero
       ret               ;return from call

Когда вы пишете ассемблерный код AVR для вашего Arduino, вы можете буквально рассчитать время в миллисекундах, которое потребуется для выполнения кода, путем подсчета инструкций машинного кода. Так как это не система с временным разделением, в которой внезапно берут на себя другие программы, здесь не срабатывает сборщик мусора, а только ваш код. И у AVR нет причудливой структуры кеша или конвейерной обработки. В RISC-процессоре каждая инструкция в основном занимает одинаковое количество циклов. Таким образом, вы можете посчитать их и посмотреть, сколько времени занимает один цикл для той тактовой частоты, на которой вы работаете.

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

Одно из преимуществ таких языков, как C / C ++, заключается в том, что вы можете понять, что на самом деле делает машина. Может показаться очень странным, что вы можете сделать это с помощью высокоуровневого языка, скомпилированного Just-in-Time, такого как Julia, но вы можете.

Как Джулия позволяет вам заглянуть под капот

Вот не такой уж секретный секрет, который нужно знать о Julia JIT. Это не трассирующая JIT, как JavaScript V8 или Pythons PyPy. При отслеживании JIT остается только догадываться, какой код будет сгенерирован. Все зависит от всей среды выполнения. Так что, пока ваш код не будет запущен, вы мало представляете, во что его превратит JIT.

Но с Юлей это не так. Юля использует так называемый метод-JIT. В Юлии функция - это просто имя типа foo. Однако foo может принимать другое количество аргументов или разные типы. foo(1) и f(2, 3) оба вызывают функцию foo, и foo("hello") тоже. Однако каждый из них мы в Julia называем методом функции foo.

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

foo(x) = 4x
foo(x, y) = 2x + 3y
foo(xs::AbstractArray) = length(xs) + 10

Вот пример вызова этих функций из интерактивной среды REPL Джулии:

julia> foo(3)
12
julia> foo(1, 2)
8
julia> foo([3, 4, 2])
13

Теперь предположим, что мы хотим знать, во что компилируется каждый из этих методов функции foo. Затем мы можем использовать макрос Джулии @code_native. Я отредактировал вывод для удобства чтения:

julia> @code_native foo(3)
 leaq (,%rdi,4), %rax
 retq

Таким образом, этот вызов превратился в одну простую инструкцию ассемблерного кода. А как насчет двух аргументов?

julia> @code_native foo(1, 2)
 leaq (%rsi,%rsi,2), %rax
 leaq (%rax,%rdi,2), %rax
 retq

Или как насчет того, чтобы изменить входные данные с целых чисел на числа с плавающей запятой?

julia> @code_native foo(1.0, 2.0)
 vaddsd %xmm0, %xmm0, %xmm0
 movabsq $4594014952, %rax       ## imm = 0x111D31AE8
 vmulsd (%rax), %xmm1, %xmm1
 vaddsd %xmm1, %xmm0, %xmm0
 retq

Мы видим, что получаем немного другой код - как и ожидалось. Как насчет версии с использованием массива?

julia> @code_native foo([3, 4, 2])
 movq 8(%rdi), %rax
 addq $10, %rax
 retq

Обратите внимание, как вызов функции length становится встроенным как одна простая инструкция кода сборки.

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

function looper(xs::T...) where T<:Number
    sum = zero(T)
    for x in xs
        sum += x
    end
    return sum
end

В этом примере я говорю, что хочу, чтобы sum был инициализирован нулем. Но есть много видов нулей. Например, для вас, программистов C / C ++, есть 0, 0.0 и 0.0f . Мы не хотим выполнять преобразование, потому что тип не соответствует типам, найденным во входных данных. Следовательно, мы используем функцию zero с типом в качестве аргумента.

Если вы новичок в Юлии, трудно определить, насколько это высокий уровень или абстрактность. Например, через сколько обручей компилятор должен справиться с этим? Что ж, мы можем быстро посмотреть, сделав это:

julia> @code_native looper(3, 4, 10, 100)
 leaq (%rdi,%rsi), %rax
 addq %rdx, %rax
 addq %rcx, %rax
 retq

Сумасшедший, правда? Этот цикл сократился до 3 жалких инструкций по ассемблерному коду. Что, если мы активизируем нашу игру, добавив больше цифр. И, как особенность, мы делаем их с плавающей запятой вместо целых чисел:

julia> @code_native looper(3.5, 4.1, 10.5, 100.0)
 vxorpd %xmm4, %xmm4, %xmm4
 vaddsd %xmm4, %xmm0, %xmm0
 vaddsd %xmm1, %xmm0, %xmm0
 vaddsd %xmm2, %xmm0, %xmm0
 vaddsd %xmm3, %xmm0, %xmm0
 retq

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

Нет необходимости в настройке системы сборки и запуске процесса компиляции длины или всего файла исходного кода. Нет, мы имеем дело только с одной маленькой функцией за раз.

Это дает душевное спокойствие. Я не уверен, каковы другие программисты старой школы C / C ++, но я думаю, что у меня немного повредили мозг из-за того, что я слишком много думал о том, что компилятор сделал с моим кодом. Может быть, потому, что я начал программировать игры и мне пришлось преобразовывать умножения в комбинации двоичных сдвигов и сложений, чтобы игра работала достаточно быстро.

Подобные вещи часто заставляют задуматься о том, что компилятор на самом деле делает с вашим кодом. У Джулии это компульсивное поведение уходит. Ты свободен. Возможно, сначала вы начинаете слишком много проверять, но в конце концов понимаете, что находитесь в надежных руках. Вы понимаете, что по большей части вы можете просто написать любой приятный на вид код, который вам нравится, и JIT сделает свое волшебство и создаст высоко оптимизированный код.

Конечно, это случается не всегда, но вы понимаете, когда этого не происходит, и вы также учитесь не заботиться об этом. Вы понимаете, что когда захотите, можете настроить код, чтобы получить гораздо лучший результат, но зачем беспокоиться, если это не критично?

Чудесный мир своевременной компиляции

Признаюсь, я очень медленно принимал JIT-компиляторы. Я всегда не доверял Java. Я хотел полный контроль. Вот что замечательно в Юлии JIT. Вы можете видеть, что он делает, когда захотите. Это не полный черный ящик. Это как бы возвращает вас к ответственности.

В то же время вам постоянно напоминают, что JIT может выполнять так много видов оптимизации, которые просто невозможно сделать при предварительной компиляции. Вы можете передать какую-то произвольную функцию другой, и JIT просто удалит всю эту абстракцию. Например, здесь мы передаем анонимную функцию reduce, которая выполняет итерацию по ряду элементов, применяя эту анонимную функцию к каждому числу:

julia> @code_native reduce((4, 5, 8)) do x, y
           4x + 2y
       end
movq (%rdi), %rax
movq 8(%rdi), %rcx
shlq $2, %rax
leaq (%rax,%rcx,2), %rax
movq 16(%rdi), %rcx
addq %rcx, %rcx
leaq (%rcx,%rax,4), %rax
retq

В C ++ нет способа создать такого рода высокооптимизированный код для функции, о которой вы не знаете во время компиляции. Но для чего-то вроде Джулии вы могли бы прочитать этот код с диска во время выполнения, скомпилировать его и передать reduce, и JIT сделает свое чудо.

Если вы из тех, кто помешан на производительности, вы знаете, что не сможете превзойти это. Независимо от того, насколько вы оптимизируете, у вас просто нет доступа к той информации, которую JIT имеет во время выполнения.

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

Есть еще место для предварительной компиляции. Иногда важны ограничения памяти и поведение в реальном времени. Но если вы просто хотите вычислить много чисел, я не понимаю, как C / C ++, Fortran и другие могут в долгосрочной перспективе конкурировать с языками на основе JIT.

Введение в Юлию как язык для разработчиков C ++: Рыцари, копейщики, лучники и множественная рассылка.

Почему Джуила такой отличный язык: Почему я написал книгу по программированию Джулии.

Нужно ли многократно компилировать функцию?

Когда я писал это изначально, я понял, что всем моим читателям может быть не совсем понятно, что именно происходит с скомпилированным кодом? Это выброшено? Придется ли Julia JIT повторять весь тот же процесс, когда функция вызывается позже?

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

Скажем, в Julia у нас есть несколько параметризованных типов, как показано ниже. У нас есть параметр типа T, который используется, чтобы убедиться, что отдельные типы элементов используют одни и те же числовые типы.

struct Point{T<:Number}
   x::T
   y::T
end
struct Circle{T<:Number}
   position::Point{T}
   radius::T
end
struct Hexagon{T<:Number}
    center::Point{T}
    side::T
end

В REPL Джулии, если я создам эти типы, вы увидите, что Джулия может вывести параметр типа T, и мы ограничили его числовым типом:

julia> circle = Circle(Point(2, 3), 5)
Circle{Int64}(Point{Int64}(2, 3), 5)
julia> circle = Circle(Point(1.5, 2.2), 10.8)
Circle{Float64}(Point{Float64}(1.5, 2.2), 10.8)

Обратите внимание, что довольно-таки начертание типа показывает предполагаемый параметр типа, выраженный как {Int64} и {Float64}. Однако нам не нужно писать код для каждого возможного значения параметра типа. Я могу просто написать код, чтобы проверить, находится ли точка внутри круга, например:

isinside(c::Circle, p::Point) = norm(p - c.center) < c.radius

Если я вызываю это с помощью объектов, где T = Int64, тогда JIT Julia выполнит все поиски и скомпилирует машинный код для этого случая. Однако этот машинный код будет кэширован в таблице методов. Джулия добавит запись в таблицу методов для функции isinside, где тип числа - Int64. В следующий раз, когда мы вызовем эту функцию с объектами на основе целых чисел, мы просто найдем эти кэшированные данные машинного кода. Эта конкретная запись метода не будет указывать на AST, но будет оценивать двоичный машинный код.

По мере того, как ваш код работает дольше, мы начнем получать кешированные версии машинного кода всех комбинаций, которые используются в нашей программе.

Фактически, в большинстве случаев фактический поиск в таблице методов не потребуется. JIT при анализе и компиляции метода, вызывающего другой метод, может определить, что типы аргументов никогда не меняются.

Рассмотрим нашу isinside функцию. Он вызывает norm, у которого также есть много методов. Однако Джулия знает, что, учитывая, что c относится к типу Circle{Int64}, а p относится к типу Point{Int64}, мы всегда будем искать один и тот же метод для norm каждый раз. Следовательно, мы можем пропустить весь поиск и либо встроить код перехода прямо к методу.

Таким образом, для длительно работающей программы Julia 90% кода превратится в машинный код, который не выполняет никакого динамического поиска методов. Таким образом, вы получаете некоторую начальную задержку, но как только JIT получит время, чтобы сделать свою работу, почти все будет чистым машинным кодом, и вы будете работать на полной скорости.

На самом деле большую часть этой работы можно кэшировать. Джулия обычно хранит частично скомпилированные модули.