В течение ряда лет, когда я программировал с использованием 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 .
Дальнейшее чтение
Как (и почему) оценить производительность вашего публичного облака