В течение ряда лет, когда я программировал с использованием Julia, я никогда не задумывался о производительности. Иными словами, я оценил, что другие люди заинтересованы в производительности и доказали, что Джулия может быть так же быстро, как и любой другой язык исполнения. Но я никогда не был одним из тех, кто пролил свет на раздел « Советы по повышению производительности» руководства Джулии, пытаясь выжать все до последней детали.
Но теперь, когда я выпустил OmniSci.jl и, как компания, одной из основных наших выгодных точек зрения является ускоренная аналитика , я решил, что пришло время отказаться от предположения, что я написал приличный код и обратил внимание на производительность. В этом посте рассказывается о моем опыте новичка и, надеюсь, будет показано, как другие могут начать изучать оптимизацию своего кода Джулии.
Вам также может понравиться: Важный веб-тест производительности
Читайте руководства!
Как я упоминал выше, я писал Джулию уже много лет, и в то время я вырос со многими советами в разделе «Советы по производительности» документации. Такие вещи, как «запись стабильных функций типа» и «избегание глобальных переменных» — это те вещи, которые я усвоил как хорошие методы программирования , а не просто потому, что они производительны. Но с этим долгим знакомством с языком приходит лень, и, не читая документацию BenchmarkTools.jl, я неправильно начал тестирование. Рассмотрим этот пример:
Юлия
xxxxxxxxxx
1
julia> using Random, OmniSci, BenchmarkTools, Base.Threads
2
julia> #change defaults, since examples long-running
4
BenchmarkTools.DEFAULT_PARAMETERS.seconds = 1000
5
1000
6
julia> BenchmarkTools.DEFAULT_PARAMETERS.samples = 5
8
5
9
julia> #generate test data
11
gendata(x, T) = [rand(typemin(T):typemax(T)) for y in 1:x]
12
gendata (generic function with 1 method)
13
julia> int64_10x6 = gendata(10^6, Int64);
15
julia> #Test whether broadcasting more/less efficient than pre-allocating results array
17
function preallocate(x)
18
v = Vector{OmniSci.TStringValue}(undef, length(x))
20
for idx in 1:length(x)
22
v[idx] = OmniSci.TStringValue(x[idx])
23
end
24
return v
26
end
27
preallocate (generic function with 1 method)
28
julia> v61 = OmniSci.TStringValue.(int64_10x6)
30
BenchmarkTools.Trial:
31
memory estimate: 297.55 MiB
32
allocs estimate: 6000005
33
--------------
34
minimum time: 750.146 ms (0.00% GC)
35
median time: 1.014 s (29.38% GC)
36
mean time: 1.151 s (28.38% GC)
37
maximum time: 1.794 s (43.06% GC)
38
--------------
39
samples: 5
40
evals/sample: 1
41
julia> v62 = preallocate(int64_10x6)
43
BenchmarkTools.Trial:
44
memory estimate: 297.55 MiB
45
allocs estimate: 6000002
46
--------------
47
minimum time: 753.877 ms (0.00% GC)
48
median time: 1.021 s (28.30% GC)
49
mean time: 1.158 s (28.10% GC)
50
maximum time: 1.806 s (43.17% GC)
51
--------------
52
samples: 5
53
evals/sample: 1
Вышеприведенный тест проверяет, стоит ли предварительно выделять массив результатов, а не использовать более удобный синтаксис точечного вещания . Идея в том, что многократное увеличение массива может быть неэффективным, если вы знаете размер результата с самого начала. Тем не менее, сравнивая вышеприведенное время, для всей статистики предварительное выделение массива несколько хуже , хотя мы передаем компилятору больше знаний заранее. Мне это не понравилось, поэтому я обратился к руководству по BenchmarkTools.jl и обнаружил следующее о интерполяции переменных :
Хорошее эмпирическое правило заключается в том, что внешние переменные должны быть явно интерполированы в выражение сравнения
Интерполяция int64_10x6входного массива в функцию превращает его из глобальной переменной в локальную, и, конечно же, мы видим улучшение примерно на 6% за минимальное время, когда мы предварительно выделяем массив:
Юлия
xxxxxxxxxx
1
julia> v61i = OmniSci.TStringValue.($int64_10x6)
2
BenchmarkTools.Trial:
3
memory estimate: 297.55 MiB
4
allocs estimate: 6000002
5
--------------
6
minimum time: 763.817 ms (0.00% GC)
7
median time: 960.446 ms (24.02% GC)
8
mean time: 1.178 s (28.68% GC)
9
maximum time: 1.886 s (45.11% GC)
10
--------------
11
samples: 5
12
evals/sample: 1
13
julia> v62i = preallocate($int64_10x6)
15
BenchmarkTools.Trial:
16
memory estimate: 297.55 MiB
17
allocs estimate: 6000002
18
--------------
19
minimum time: 721.597 ms (0.00% GC)
20
median time: 1.072 s (30.45% GC)
21
mean time: 1.234 s (32.92% GC)
22
maximum time: 1.769 s (44.51% GC)
23
--------------
24
samples: 5
25
evals/sample: 1
Будет ли это улучшение на 6% сохраняться со временем или нет, по крайней мере, концептуально мы больше не находимся в худшем положении для предварительного распределения, что соответствует моей ментальной модели того, как все должно работать.
Оцените свой эталонный тест по диапазону входных данных
В приведенном выше сравнении я оцениваю контрольный показатель по наблюдениям. Как я выбрал 1 миллион в качестве «правильного» количества событий для тестирования, вместо того, чтобы просто тестировать 1 или 10 событий? Моя общая цель для тестирования этого кода - ускорить методы загрузки данных в базу данных OmniSciDB. TStringValueявляется одним из внутренних методов, выполняющих загрузку таблицы по строкам, преобразование любых данных, присутствующих в массиве или DataFrame, ::Type{T}в String(подумайте, чтобы перебирать текстовый файл построчно). Поскольку пользователи, пытающиеся ускорить свои операции с базами данных, вероятно, будут использовать миллионы или миллиарды точек данных, мне интересно понять, как функции работают с этими объемами данных.
Другим сознательным решением, которое я принял, была среда для тестирования. Я мог бы протестировать это на больших серверах с процессором и графическим процессором, но я тестирую это на своем ноутбуке Dell XPS 15. Почему? Потому что мне интересно, как дела обстоят в более реальных условиях для реалистичного пользователя. Тестирование характеристик производительности высокопроизводительного сервера с тоннами памяти и ядер было бы забавным, но я хочу убедиться, что любые улучшения производительности применимы не только потому, что я использую больше оборудования для решения этой проблемы.
Менее важным для меня было контролировать сборку мусора, использование нового сеанса перед каждым измерением или другие оптимизации «наилучшего сценария». Я ожидаю, что мои пользователи будут больше ориентированы на аналитику и науку о данных, поэтому повторное использование одного и того же сеанса будет обычным делом. Если улучшения производительности не совсем очевидны, я не собираюсь включать их в кодовую базу.
Тематическое исследование: ускорение TStringValue
Для моего теста я оцениваю следующие методы как эталонные:
- Трансляция: текущая библиотека по умолчанию.
- Предварительно выделяя массив результатов.
- Предварительно выделенный массив результатов с
@inboundsмакросом. - Предварительно выделенный массив результатов с потоками.
- Предварительно выделенный массив результатов с потоками и
@inbounds.
10x6 Наблюдения
Для первых трех слева это сравнения однопоточных методов. Вы можете видеть, что предварительное выделение выходного массива незначительно быстрее, чем широковещательная рассылка, и использование @inboundsмакроса все еще происходит постепенно, но ни один из методов не обеспечивает достаточного ускорения, чтобы его стоило реализовать. Разница между красной и синей полосами означает, что происходит сборка мусора, но, опять же, три метода недостаточно отличаются, чтобы заметить что-нибудь интересное.
Для многопоточных тестов я использую 6 потоков (по одному на физическое ядро), и мы наблюдаем примерно 3-кратное ускорение . Как и в случае однопоточных тестов, описанных выше, использование @inboundsпроисходит лишь незначительно быстрее, но этого недостаточно для широкого внедрения за счет увеличения сложности кода. Интересно, что выполнение этих многопоточных тестов вообще не запускало сборку мусора на всех пяти итерациях; не уверен, является ли это определенным из-за потока или нет, но кое-что исследовать за пределами этого сообщения в блоге.
10х7 Наблюдения
Чтобы увидеть, как эти методы расчета могут измениться в большем масштабе, я увеличил наблюдения на порядок и увидел следующие результаты:
Как и в диапазоне данных 1 миллион, между тремя однопоточными методами нет большой разницы. Все три из них находятся в пределах нескольких процентов в любом направлении (все три метода запускают сборку мусора в каждом из своих пяти запусков).
Для многопоточных тестов появился интересный сценарий производительности. Как и в тестах с 1 млн. Баллов, можно запустить прогон, в котором сбор мусора не запускается, что приводит к большой разнице между минимальными и средними значениями в многопоточных тестах. Если вы можете избежать сборки мусора, то использование шести потоков дает почти 10-кратное ускорение , а при медиане, когда и однопоточный, и многопоточный триггерный сбор мусора, вы все равно получаете 2-кратное ускорение .
Параллелизм> Подсказка компилятора
В приведенном выше примере я продемонстрировал, что для этой проблемы многопоточность - это первый способ ускорить методы таблицы загрузки OmniSci.jl. Хотя предварительное выделение размера выходного массива и использование @inboundsдействительно показали небольшое ускорение, использование потоков для выполнения вычислений - это то, где произошли наибольшие улучшения.
Включение этапа предварительного выделения естественно вытекает из того, как я написал методы потоков, поэтому я также включу это. Отключение проверки границ при использовании массивов @inboundsкажется более опасным, чем оно того стоит, хотя ни один из этих методов никогда не должен выходить за их пределы.
В целом, я надеюсь, что этот пост продемонстрировал, что вам не нужно воображать себя высокочастотным трейдером или немного хитрым человеком, чтобы найти способы улучшить свой код Джулии. Первым шагом является чтение руководств по бенчмаркингу, а затем, как и любое другое занятие, единственный способ почувствовать, что работает, - это попробовать что-то.
Весь код для этого блога можно найти в этом GitHub Gist .
Дальнейшее чтение
Как (и почему) оценить производительность вашего публичного облака