В статье Наблюдение за выполнением конкурирующих задач в Go и Rust коллега cpmonster привёл весьма интересные результаты:
Программа на Rust показала намного большую производительность при вычислении членов возвратной последовательности, чем программа на Go: 367 млн. итераций в секунду против 44 млн.
Ну, в 1.5 раза… Ну, в 2 раза… Но семь гвардейцев за два дня — это слишком, тем более что тут "гвардейцев" больше восьми!
Или нет, не слишком? В общем, потенциал любопытства пересилил другие потенциалы и я провёл своё исследование.
Повторение — мать учения и основа научного метода
Для начала попробуем воспроизвести результаты. Нужны исходники, а также Go и Rust (у меня версии 1.18 и 1.61, соответственно).
Идём в папку go/src
и запускаем go run concgo.go s
:
Cycles per second 70,621,468
Теперь в папке rust
выполним cargo run s
:
Cycles per second 25,562,372
Надо же, производительность версии на Rust в три раза ниже, чем версии на Go!
А, нет — это же debug
, вот так надо: cargo run --release s
. Совсем другое дело:
Cycles per second 603,500,301
Да, всё повторилось, те же 8+ раз. У меня и до чтения рассматриваемой статьи сложилось мнение, что Rust "готовит" более быстрые "числодробилки", но полученный результат — это же настоящее "унижение" для Go. Да неужто все именно так?! Будем разбираться.
Куда смотреть?
Смотреть сюда:
Именно эти функции вычисления последовательности триплетов подвергаются испытаниям.
При запуске с ключом s
испытание происходит в функции count_cycles_per_sec(). Испытание, надо заметить, происходит "вне конкуренции" — т.е. в одном потоке. Что, конечно, сильно упрощает анализ.
Что пишут?
Сам автор статьи приводит такое соображение:
Еще одно важное различие между Go и Rust, на которое мне указал внимательный читатель первой версии этой статьи, заключается в том, что разработчики языка Rust в принципе отказались от использования сборщика мусора.
В принципе, да. Значения переменных в Go легко и зачастую незаметно "убегают в кучу", именно с этого я и начал свои эксперименты:
func BenchmarkIterate(b *testing.B) {
for i := 0; i < b.N; i++ {
iterate(random_triplet(), 1000000)
}
}
Нет, тут все чисто:
cpu: Intel(R) Core(TM) i5-3570 CPU @ 3.40GHz
BenchmarkIterate
BenchmarkIterate-4
79 14,405,887 ns/op 0 B/op 0 allocs/op
PASS
ok
Версия из комментариев:
А почему версия Go такая старая (1.14.2 выпущена 2020-04-08)?
На Go 1.18 результаты не лучше.
Несколько раз высказывались сомнения в корректности испытаний, по типу такого:
На самом деле сравнение не корректно. И все результаты фактически вытекают из этого.
Более корректно было бы сравнить горутины с async кодом, в идеале — наверное на голом tokio
Всё это интересно, но запуск в режиме go run concgo.go s
ведёт к тестированию всего лишь в одном потоке, так что феномен проявляется и может быть изучен без привлечения горутин и tokio.
Попытка номер 2
С ключами оптимизации у компилятора Go негусто, скорее есть ключи "деоптимизации" (-l -N
) — так что остаётся работать с исходными текстами.
Сравнение текстов показало, что тип Triplet
в Rust объявлен как кортеж (tuple):
type Triplet = (f64, f64, f64);
В то время как для Go используется массив:
type Triplet = [3]float64
В Go более близким к кортежу типом является структура:
type Triplet struct{ f0, f1, f2 float64 }
При помощи чудесного инструмента godbolt посмотрим, есть ли разница в ассемблерном коде для работы с такими определениями:
type Triplet = [3]float64
type Triplet2 struct{ f0, f1, f2 float64 }
...
var t Triplet
t[0] = 10.
t[1] = 11.
t[2] = 12.
printTriplet(t)
var t2 Triplet2
t2.f0 = 20.
t2.f1 = 21.
t2.f2 = 22.
printTriplet2(t2)
Оказывается, разница есть:
MOVSD $f64.4024000000000000(SB), X0
MOVSD X0, (SP)
MOVSD $f64.4026000000000000(SB), X0
MOVSD X0, 8(SP)
MOVSD $f64.4028000000000000(SB), X0
MOVSD X0, 16(SP)
CALL "".printTriplet(SB)
MOVSD $f64.4034000000000000(SB), X0
MOVSD $f64.4035000000000000(SB), X1
MOVSD $f64.4036000000000000(SB), X2
CALL "".printTriplet2(SB)
То есть работа со структурой происходит через регистры, а с массивом фиксированной длины — через стек. Это, конечно, может повлиять на производительность!
Я сделал fork оригинального репозитория, в нём папочку go2/src
, переопределил Triplet
. Результат работает так:
210,349,179
Разница теперь всего в 3 гвардейца, уже не так обидно. Смотрим в ассемблер функций iterate()
:
Go встраивает вызов get_next_triplet()
, но не делает этого для is_convergent()
:
;*** concgo.go#76 > if is_convergent(triplet, next_triplet) && !prokukarek {
0x4ab357 0f10d9 MOVUPS X1, X3
0x4ab35a 0f10e2 MOVUPS X2, X4
0x4ab35d 0f10e8 MOVUPS X0, X5
0x4ab360 0f10c6 MOVUPS X6, X0
0x4ab363 e8b8feffff CALL main.is_convergent(SB)
А вот Rust полностью оснащает iterate.asm
встроенной вычислительной техникой. Внешними остались только вызовы типа call std::io::stdio::_print
, но они на скорость не влияют, так как последовательность не сходится и условие is_convergent(triplet, next_triplet)
никогда не выполняется.
Отсюда и разница.
Для дальнейшего повышения производительности версии Go функцию is_convergent()
можно встроить вручную:
//if is_convergent(triplet, next_triplet) && !prokukarek {
if approx_eq(triplet.f0, next_triplet.f0) &&
approx_eq(triplet.f1, next_triplet.f1) &&
approx_eq(triplet.f2, next_triplet.f2) && !prokukarek {
print_convergency(initial_triplet, step, triplet.f2)
prokukarek = true
}
Получилась папка go3/src
, запуск из нее:
Cycles per second 393,081,761
Все равно 1.5 гвардейца, и это при том, что все вычисления встроены, см. go3/src/iterate.asm.
В качестве вишенки на торте попробуем переопределить Triplet
в версии для Rust таким образом:
type Triplet = [f64; 3];
Будет ли разница? Нет. Ассемблер раз:
let applicant = triplet.0 + triplet.1 - triplet.2;
movapd xmm0, xmm7
movapd xmm7, xmm8
movapd xmm8, xmm6
movapd xmm6, xmm0
addsd xmm6, xmm7
subsd xmm6, xmm8
movapd xmm1, xmm6
andpd xmm1, xmm9
Ассемблер два:
let applicant = triplet[0] + triplet[1] - triplet[2];
movapd xmm0, xmm7
movapd xmm7, xmm8
movapd xmm8, xmm6
movapd xmm6, xmm0
addsd xmm6, xmm7
subsd xmm6, xmm8
movapd xmm1, xmm6
andpd xmm1, xmm9
Некоторые размышления
- Путём небольшой модификации исходного кода разницу удалось свести от "Rust на голову быстрее Go" к "Rust заметно быстрее Go"
- Понятно, что речь идёт о конкретном вычислительном случае
- В данном случае бо́льшая часть проигрыша по производительность упирается в стратегию встраивания в Go: function should be simple enough, the number of AST nodes must less than the budget (80)
- С ходу возникает предложение завести директиву компилятора
//go:inline
, которая отменяла бы бюджетные ограничения - Такое предложение уже было сделано и висит в статусе FrozenDueToAge, первый комментарий гласит: "This proposal has basically no chance of being accepted" :)
- Видимо, более подходящим названием директивы было бы
//go:tryinline
- Но даже с учетом встраивания остается разница в полтора раза
Комментарии (85)
Amomum
27.05.2022 14:13Лично мне было тяжело воспринимать данные измерений, если они были записаны без разделителей десятичных разрядов (т.е. как 210349179, а не 603,500,301), слишком уж числа длинные. А в разных местах написано то так, то эдак, что еще сильнее затрудняет восприятие.
sciomenihilscire
27.05.2022 14:54+1С чего начать копания в кишках Go, чтобы при возникновении подобных вопросов понимать откуда ноги растут?
maxim_ge Автор
27.05.2022 15:43+8В таких вопросах очень полезны базовые познания как в ассемблере так и в технологиях получения этого ассемблера из исходников.
Сибираюсь "черкнуть" пару статей про обобщенные типы в Go, там этот вопрос будет рассматриваться.
impwx
27.05.2022 16:00+9Практически все тесты вида "язык X быстрее языка Y" сводятся к тому, что "одинаковые" программы на самом деле используют разные структуры данных
eyudkin
27.05.2022 16:17+5Скорее, почти все бенчмарки написаны плохо и с грубыми ошибками. Максимум, что они проверяют - оптимизацию языком максимально наивной версии кода.
Starche
27.05.2022 19:09+4Максимум, что они проверяют - оптимизацию языком максимально наивной версии кода.
Так может в этом и суть перфоманса языка? Суметь оптимизировать наивный код среднестатистического девелопера
blind_oracle
27.05.2022 20:21-2Это суть компилятора. А язык это просто спецификация обычно, как она реализована под капотом - это уже вторично.
Sulerad
27.05.2022 22:08+1Значит ли это, что для условного питона можно написать реализацию компилятора, который будет выдавать программы, сравнимые с условным rustc по производительности? Чтобы обычный наивный код с kwargs и декораторами работал также, как и обычный наивный код с traits и generics. И ещё интересно во сколько раз этот гипотетический компилятор будет сложнее условного gcc или LLVM.
blind_oracle
27.05.2022 23:03-2Где я про такое сказал? Я просто указал что спецификация языка и её реализация - это две параллельные вещи.
Есть условный Си - есть куча разных компиляторов под его различные стандарты (C89 и далее). Есть Borland C, есть GCC, есть Clang, есть ещё бог весть что.
Если у вас в рамках спецификации получится написать быстрый питон - честь и хвала. В принципе, есть куча реализаций Питона разной степени соответствия спецификации - PyPy, Jython и прочее.
Тот же Go из сабжа изначально собирался компилятором написанным на Си (или плюсах, не помню), потом эволюционировал до самосборки. И с каждым релизом становится чуть быстрее там и сям, при этом соответствуя спецификации.
slonopotamus
28.05.2022 01:59+4Как ни старайся, реализовать Java/Python/Ruby/JS/Go/C#/PHP без сборщика мусора не выйдет, например. Ну тупо потому что операцию создания объектов в языки завезли, а удаление нет. И это тут же немедленно накладывает ограничения на то какой может быть реализация. И в языках полно других конструкций, накладывающих ограничения на эффективность реализации (про kwargs в питоне уже сказали выше).
demoth
28.05.2022 04:12Ну в го в большинство аллокаций происходят на стеке/регистрах за счёт escape анализа на этапе компиляции. По этому принципу в го есть много zero-allocation либ оптимизировнных таким образом, чтобы в процессе их работы память на куче вообще не выделялась, а значит и для сборщика мусора меньше работы. Но в целом согласен, что языки накладывают свои ограничения на возможности оптимизации компилятором. В го вообще разработчики компилятора осознанно не добавляют оптимизации, которые могут сильно замедлить скорость компиляции.
Simipa
28.05.2022 09:07-4Kotlin и Swift же смогли сделать без сборщика мусора, так в чем проблема то?
slonopotamus
28.05.2022 09:37+7Про котлин не верю, дайте пруфлинк. Про свифт - ну так он течёт на циклических ссылках, потому что вместо полноценного GC там всего лишь рефкаунтинг. И в спецификации языка прописано, что это забота программиста следить за циклами: https://docs.swift.org/swift-book/LanguageGuide/AutomaticReferenceCounting.html
DeniSix
28.05.2022 09:49Он, вероятно, про Kotlin/Native. Там тоже ARC + коллектор циклических ссылок.
slonopotamus
28.05.2022 12:38+2Ну так "коллектор циклических ссылок" это и есть GC, со всеми его минусами.
lrrr11
28.05.2022 19:13сравнивать Go и Rust в числодробильных задачах просто бессмысленно. Go не для этого создан, его компилятор не делает многие оптимизации (собственно в статье об этом написано) и заведомо проиграет LLVM.
cadovvl
27.05.2022 16:27+5В мое время это были "окончательные точки над i в сравнении производительности С++ и С#".
Было же время...
YuryB
27.05.2022 22:09-14какой их смысл сравнивать, если go даже java медленнее? :) любая программа которая должна работать больше нескольких секунд и которая хоть как-то создаёт объекты работает медленней...
YuryB
28.05.2022 13:46-5о, я смотрю пригорело :) минусы летят, а конструктивно никто поспорить не может :) факт есть факт, в go очень простой сборщик мусора, лет через 10 или 15 может будет нормальный (это если ещё в него как следует вложатся), но сегодня он в роли гири и конкурировать с хорошо оптимизированным рантаймом java не может (зачем тогда вообще сравнивать с rust?). а если уж вы решите писать программу таким образом, чтобы она генерировала минимум мусора, то и на java у вас будет производительность около нативная (наверняка и на .net будет не кардинально хуже). по этому go и попал с спец нишу сетевых программ, а не стал заменой плюсам например. прасцице если кого обидел или расстроил :)
PsyHaSTe
28.05.2022 14:39+2То что гц го намного проще джавового и вероятно крутой — допустим факт. А вот то что это автоматически делает все программы на го медленными — это ваши выдумки.
И это к слову вам говорит человек который го очень… недолюбливает.
YuryB
28.05.2022 16:38а чему ещё тормозить? есть на гите проект CardRaytracerBenchmark (кстати джавовский вариант там плохой по части IO, но не суть) можете посмотреть на разницу между платформами и языками. ну и погуглить про производительность go, тут на самом деле есть заметные проблемы, хотя похоже многие их не ожидают, ведь go компилируемый. если в таком вычислительном бенчмарке go сливает java то зачем он вообще нужен тогда? тем более что go по отзывам совсем не язык мечты...
PsyHaSTe
28.05.2022 17:25Чему угодно. Если вы брутфосите SHA256 хэш для стркои известного размера в 50 символов то ГЦ у вас мешать не будет совершенно, к примеру.
YuryB
29.05.2022 16:09:| какой смысл приводить крайние примеры? что они опровергают?
PsyHaSTe
29.05.2022 17:18У кого крайние, а у кого жизненные. Мы тут на 20% снизили общее время работы программы раставив 3 "лишних" ифа с проверкой размеров массивов в паре мест. Бывает и такое.
gohrytt
29.05.2022 19:49Можно просто _ = slice[тут самый большой элемент массива который будет использован] ( конечно если есть увереность что длина именно та). В стандартной библиотеке этот костыль в десятках мест, особенно в crypto.
Ну либо просто в начале функции l := len(slice) и по нему играть, компилятору этого достаточно чтобы не подставлять везде проверку.PsyHaSTe
29.05.2022 21:49там бывает хитрее, например мне каком-то месте пришлось писать условие
if i <= len - 1 && i + 1 <= len { ... }
. Если любую часть условия убрать то у компилятора там эвристика ломалась и он хреново генерировал код.
aceofspades88
28.05.2022 15:43+3минусы вам летят скорее всего за то что вы сделали голословное заявление без какой либо конкретики, ссылок и пруфов, а теперь хотите чтобы с вами кто-то спорил, да еще и конструктивно
Nbx
27.05.2022 22:16+4Тут сам бог велел позвать 0xd34df00d чтобы он порвал всех своим хаскелем.
0xd34df00d
27.05.2022 23:18+6Я бы с радостью, люблю такие упражнения, но я так и не понял, какую задачу решают авторы. Здесь только ссылка на другую статью, а в другой статье я прочитал ее первую половину и нашел только формулу расчета вместе с общими словами о методике эксперимента. Видимо, за подробностями придется лезть в исходники.
Inspector-Due
28.05.2022 10:36+5(А также @PsyHaSTe) Так, ну, я немного покопался в коде и вот что понял:
data Triplet = Triplet !Double !Double !Double deriving Show getNextTriplet :: Triplet -> Triplet getNextTriplet (Triplet a b c) | abs app <= 1.0 = Triplet b c app | otherwise = Triplet b c (1.0 / app) where app = a + b - c iterateOver' :: Triplet -> Int -> Triplet iterateOver' trip n = foldl' (\t _ -> getNextTriplet t) trip [1..n]
Наверняка тут можно уйти в unboxed types...
Примечание
В оригинальном коде, конечно, чуть сложнее (если текущий и следующий
Triplet
s очень схожи, то об этом будет написано в консоли и всё (то есть можно братьiterateOver'
и не париться):(~=) :: Double -> Double -> Bool a ~= b = abs (a - b) < 1e-14 isConvergent :: Triplet -> Triplet -> Bool isConvergent (Triplet a b c) (Triplet x y z) = (a ~= x) && (b ~= y) && (c ~= z) iterateOver :: Triplet -> Int -> (Triplet, Maybe (Int, Double)) iterateOver triplet cycles = foldl' f (triplet, Nothing) [1..cycles] where f (trip, r) n = (nextTrip, nextR) where nextTrip = getNextTriplet trip Triplet a b c = nextTrip nextR = if isConvergent trip nextTrip then r <|> Just (n, b) else r
Далее поговорим про конкурентность. Вообще там много кода, направленного на создание собственного бенчмарка. Суть в следующем: есть
series_size
, который характеризует, как много таких вотiterateOver'
будет запущенно параллельно. Далее для каждого делаем следующее: запускаем раз вseries_size
параллельных потоковiterateOver'
с рандомно сгенерированнымTriplet
и фиксированнымn
. Ну и собираем какие-то метрики, которые агрегируем и выводим на экран.Кстати, в коде, судя по всему, есть логическая ошибка. Допустим,
tasks_max = 10
, аseries_size = 3
. Тогда дляr = 1
лишь один раз запуститсяiterateOver'
, хотя мы, вроде как, тестируем конкурентность иiterateOver'
должен конкурентно запуститься вseries_size = 3
потоках. Ну, так же и для всехr
, не кратныхseries_size
.Пояснение к предыдущему абзацу
Перепишем вот этот код так, чтобы он просто печатал нам, какая
task
в какой серии запускается.Получится
package main import "fmt" func count_series(n_tasks, series_size int) int { n_series := n_tasks / series_size if series_size*n_series < n_tasks { n_series++ } return n_series } func main() { n_tasks := 10 series_size := 3 n_series := count_series(n_tasks, series_size) task_idx := 0 for series_idx := 0; series_idx < n_series; series_idx++ { count_tasks_series := 0 for task_idx < n_tasks && count_tasks_series < series_size { fmt.Printf("Running task # %d in series # %d\n", task_idx, series_idx) count_tasks_series++ task_idx++ } } }
Ну и что мы получим?
Running task # 0 in series # 0 Running task # 1 in series # 0 Running task # 2 in series # 0 Running task # 3 in series # 1 Running task # 4 in series # 1 Running task # 5 in series # 1 Running task # 6 in series # 2 Running task # 7 in series # 2 Running task # 8 in series # 2 Running task # 9 in series # 3
То есть таски 0..2, 3..5, 6..8 реально запускаются по-нормальному так, что параллельно будут выполняться ровно три (
series_size
) функции. А вот таска # 9 будет выполняться одна. Очевидно, одна параллельная задача выполнится быстрее трёх параллельных, так что это будет влиять на результаты бенчмарка.Да и в чём смысл сравнивать горутины с каким-то там crossbeam в Rust, я не знаю. Ведь горутины нужны не для того, чтобы дробить числа, а для IO.
0xd34df00d
29.05.2022 00:19+6Спасибо, что покопались, у меня терпения не хватило. Теперь моя очередь копаться!
Итак, сначала бейзлайн: раст на моей машине даёт примерно 390 миллионов циклов в секунду. Будем к этому стремиться.
Итак, сначала однопоток.
Взял второй код, с
iterateOver
— напрямую он генерит кучу мусора, и программа почти всё время сидит в GC. Если добавить-XStrict
и-O2
, то 100 миллионов циклов на моей машине занимает 1.2 секунды (в один поток, да). Что любопытно,-fllvm
здесь не особо играет, несмотря на очевидную числодробильность кода — видимо, особо повода для векторизации здесь нет, а это основное отличие между NCG и LLVM в GHC. При этом код всё равно что-то аллоцирует, и 22 миллисекунды в GC проводит. Разбираться с этим в рамках комментария мне, конечно, лень.Давайте лучше
перейдём на goнапишем рекурсию явно и руками:iterateOverUp :: Triplet -> Int -> (Triplet, Maybe (Int, Double)) iterateOverUp triplet cycles = go Nothing triplet 0 where go acc trip n | n == cycles = (trip, acc) | otherwise = go acc' trip' (n + 1) where trip'@(Triplet _ b _) = getNextTriplet trip acc' = if isConvergent trip trip' then acc <|> Just (n, b) else acc
(весь остальной код плюс-минус как у вас, кроме
OPTIONS_GHC -O2 -fllvm
иLANGUAGE Strict
).
Результат: 480 мс непосредственно вычислений и 5 мс в GC на 100 миллионов циклов. Что ж ты тут-то аллоцируешь, скотина,на три гигабайта и три тыщи gen0-сборок?3,200,125,888 bytes allocated in the heap 37,808 bytes copied during GC 44,408 bytes maximum residency (2 sample(s)) 29,320 bytes maximum slop 2 MiB total memory in use (0 MB lost due to fragmentation) Tot time (elapsed) Avg pause Max pause Gen 0 3074 colls, 0 par 0.005s 0.005s 0.0000s 0.0000s Gen 1 2 colls, 0 par 0.000s 0.000s 0.0001s 0.0001s
Ладно, с этим потом при случае поиграюсь, это любопытно. В любом случае это всё равно втрое быстрее по скорости и вчетверо — по аллокациям, чем вариант с
foldl'
.А давайте теперь напишем рекурсию не от 0 к
cycles
, а отcycles
к 0? Сравнение с 0 процессоры любят больше, да иcycles
тогда в замыкание захватывать не нужно, компилятору будет прощеgo
соптимизировать:iterateOverDown :: Triplet -> Int -> (Triplet, Maybe (Int, Double)) iterateOverDown triple cycles = go Nothing triple cycles where go acc trip 0 = (trip, acc) go acc trip n = go acc' trip' (n - 1) where trip'@(Triplet _ b _) = getNextTriplet trip acc' = if isConvergent trip trip' then acc <|> Just (cycles - n, b) else acc
Хмм, ерунда какая-то, целых 880 мс,
но зато никаких аллокаций и GC!125,856 bytes allocated in the heap 3,312 bytes copied during GC 44,408 bytes maximum residency (1 sample(s)) 25,224 bytes maximum slop 2 MiB total memory in use (0 MB lost due to fragmentation) Tot time (elapsed) Avg pause Max pause Gen 0 0 colls, 0 par 0.000s 0.000s 0.0000s 0.0000s Gen 1 1 colls, 0 par 0.000s 0.000s 0.0001s 0.0001s INIT time 0.000s ( 0.000s elapsed) MUT time 0.881s ( 0.881s elapsed) GC time 0.000s ( 0.000s elapsed) EXIT time 0.000s ( 0.000s elapsed) Total time 0.881s ( 0.881s elapsed) %GC time 0.0% (0.0% elapsed) Alloc rate 142,832 bytes per MUT second Productivity 100.0% of total user, 100.0% of total elapsed
А, блин, я ж всё равно захватываю
cycles
в замыкание, оно вон там вJust (cycles - n, b)
торчит! Кстати, там ещё наверняка торчит ошибка на плюс-минус единицу, но мне лень об этом думать, давайте лучше поменяем, и будем вычитать послеgo
, а не внутри:iterateOverDown :: Triplet -> Int -> (Triplet, Maybe (Int, Double)) iterateOverDown triplet cycles = second (fmap $ first (cycles -)) $ go Nothing triplet cycles where go acc trip 0 = (trip, acc) go acc trip n = go acc' trip' (n - 1) where trip'@(Triplet _ b _) = getNextTriplet trip acc' = if isConvergent trip trip' then acc <|> Just (n, b) else acc
О, совсем другое дело! 380 мс и по-прежнему никаких аллокаций!
обязательный +RTS -sstderr125,824 bytes allocated in the heap 3,312 bytes copied during GC 44,408 bytes maximum residency (1 sample(s)) 25,224 bytes maximum slop 2 MiB total memory in use (0 MB lost due to fragmentation) Tot time (elapsed) Avg pause Max pause Gen 0 0 colls, 0 par 0.000s 0.000s 0.0000s 0.0000s Gen 1 1 colls, 0 par 0.000s 0.000s 0.0001s 0.0001s INIT time 0.000s ( 0.000s elapsed) MUT time 0.379s ( 0.379s elapsed) GC time 0.000s ( 0.000s elapsed) EXIT time 0.000s ( 0.000s elapsed) Total time 0.379s ( 0.379s elapsed) %GC time 0.0% (0.0% elapsed) Alloc rate 331,816 bytes per MUT second
Давайте поиграемся с флагами оптимизации? Во-первых, теперь
-fllvm
роляет: без него 450 мс. Во-вторых, теперь-O2
скорее вредит: без него 320-325 мс.Итого, 320 мс на 100 миллионов циклов, или чуть больше 310 миллионов циклов в секунду. Ну, не фонтан, но сойдёт.
Давайте теперь многопоток. Как выглядел
main
до этой поры? Мне лень парсить аргументы и генерировать случайные тройки в рамках комментария, поэтомуmain = print $ iterateOverDown (Triplet 0.1 0.2 0.3) 312_000_000
Что для многопотока? Ну, просто добавим зависимость от модуля
async
и заменимmain
наmain = forConcurrently_ [1 .. fromIntegral numCapabilities] $ \cnt -> print $ iterateOverDown (Triplet (0.1 / cnt) (0.2 / cnt) (0.3 / cnt)) 312_000_000
Технически оно раскидывает не по ядрам, а по числу runtime capabilities, которое примерно равно числу ОС-потоков, которое выделяет рантайм хаскеля. У меня 6 физических ядер и 12 логических. Что, если запустить на одном ядре?
INIT time 0.000s ( 0.000s elapsed) MUT time 1.023s ( 1.022s elapsed) GC time 0.000s ( 0.000s elapsed) EXIT time 0.000s ( 0.007s elapsed) Total time 1.024s ( 1.030s elapsed)
На шести?
MUT time 6.302s ( 1.087s elapsed)
Почти нет оверхеда, хорошо — 6.3 секунды суммарного времени по всем ядрам, и 1.09 секунд времени по часам.
На семи?
MUT time 8.284s ( 1.516s elapsed)
Попёр оверхед. Время по часам выросло на 0.5 секунд. Гипертрединг с числодробилками не очень дружен.
На 12?
MUT time 22.289s ( 1.935s elapsed)
Почти вдвое больше, чем на шести — как и ожидалось от гипертрединга и CPU-bound-задач.
На 36, чисто поржать? Должно быть в районе 6 секунд wall time:
MUT time 67.104s ( 5.738s elapsed)
Ну да, как и ожидалось.
PsyHaSTe
28.05.2022 00:03+5Посмотрел код, плюсую товарища 0xd34df00d выше, ничего не понял. Какие-то кроссбимы, мутируют при этом шаренные переменные...
Вопрос, если в расте в томл дописать:
[profile.release] lto = true codegen-units = 1
И запускать как
RUSTFLAGS="-C target-cpu=native" cargo run --release s
то результаты станут лучше? У меня просто мак с М1, и компиляция всегда идет под текущий проц. А на х86 может быть приличная разница.Fenex
28.05.2022 11:21+1Взял похожий процессор
i5-3470@3.2
(как я понял, у автораi5-3570@3.4
).- запуск как у автора:
597,728,631
- изменён Cargo.toml:
597,728,631
- изменён Cargo.toml + target-cpu=native:
690,607,734
Выигрыш виден, но Ivy Bridge — довольно старая архитектура (~10 лет), поэтому полагаю что на более современных процессорах разница между
target-cpu=native\generic
будет ещё больше.Ещё я решил посмотреть, что будет если вместо
target-cpu=native
попробовать включить оптимизации с упором на компактность бинарника, а не производительность. Получилось следующее: скорость работы снижается существенно, а вот размер — не очень.- s:
143,389,733
(2635K) - z:
143,410,296
(2665K) - 3:
597,728,631
(2692K)
Результаты размера без strip.
- запуск как у автора:
nuald
29.05.2022 08:25+1На самом деле, не такая уж приличная разница (что подтверждается другими бенчмарками, которые я курирую). Мои данные:
Современная архитектура: Intel Xeon E-2324G
Go (go3,
go run concgo.go s
): 748,502,994
Rust (rust2,cargo run --release s
, запуск как у автора): 941,619,585
Rust (rust2,cargo run --release s
, изменён Cargo.toml): 939,849,624
Rust (rust2,RUSTFLAGS="-C target-cpu=native" cargo run --release s
, Cargo.toml + target-cpu=native): 965,250,965В данном случае, lto дает даже чуть худшие результаты, но это прогрешности, и я никогда не видел, чтобы были реально лучше результаты (использование cpu=native совершенно другая история, и это реально может давать улучшения). Насколько я видел, LTO имеет смысл когда линкуются много файлов, и для этого данного теста это не особо важно.
PsyHaSTe
29.05.2022 09:54Лто там так, до кучи, прост на всякий случай. Меньший бинарник должен давать лушее кеширование инструкций, в теории. НА практике разница и правда невелика.
Inspector-Due
28.05.2022 11:47По поводу кода, написанном на Golang: я попробовал написать пару версий.
Тот же вариант, что и описан в оригинальной статье.
type SickTriplet = [3]float64
Вариант, использующий
type Triplet struct { a, b, c float64 }
, но создающий новую копию при каждой итерацииТо же, что и (2), но только данные мутируются на месте.
Код
package triplet import ( "math/rand" "math" ) type SickTriplet = [3]float64 func randomSickTriplet() *SickTriplet { return &SickTriplet{rand.Float64(), rand.Float64(), rand.Float64()} } func getNextSickTriplet(trip *SickTriplet) *SickTriplet { app := trip[0] + trip[1] - trip[2] if math.Abs(app) <= 1.0 { return &SickTriplet{trip[1], trip[2], app} } else { return &SickTriplet{trip[1], trip[2], 1.0 / app} } } func calcSickTriplet(trip *SickTriplet, num int) *SickTriplet { for i := 0; i < num; i++ { trip = getNextSickTriplet(trip) } return trip } func fastCalcSickTriplet(trip *SickTriplet, num int) *SickTriplet { for i := 0; i < num; i++ { app := trip[0] + trip[1] - trip[2] trip[0] = trip[1] trip[1] = trip[2] if math.Abs(app) <= 1.0 { trip[2] = app } else { trip[2] = 1.0 / app } } return trip } type Triplet struct { a, b, c float64 } func randomTriplet() *Triplet { return &Triplet{rand.Float64(), rand.Float64(), rand.Float64()} } func fastCalcTriplet(trip *Triplet, num int) *Triplet { for i := 0; i < num; i++ { app := trip.a + trip.b - trip.c trip.a = trip.b trip.b = trip.c if math.Abs(app) <= 1.0 { trip.c = app } else { trip.c = 1.0 / app } } return trip } func getNextTriplet(trip *Triplet) *Triplet { app := trip.a + trip.b - trip.c if math.Abs(app) <= 1.0 { return &Triplet{trip.b, trip.c, app} } else { return &Triplet{trip.b, trip.c, 1.0 / app} } } func slowCalcTriplet(trip *Triplet, num int) *Triplet { for i := 0; i < num; i++ { trip = getNextTriplet(trip) } return trip }
Сам бенчмарк:
package triplet import "testing" const TIMES = 1000000 func BenchmarkFastCalcTriplet(b *testing.B) { for i := 0; i < b.N; i++ { fastCalcTriplet(randomTriplet(), TIMES) } } func BenchmarkSlowCalcTriplet(b *testing.B) { for i := 0; i < b.N; i++ { slowCalcTriplet(randomTriplet(), TIMES) } } func BenchmarkCalcSickTriplet(b *testing.B) { for i := 0; i < b.N; i++ { calcSickTriplet(randomSickTriplet(), TIMES) } } func BenchmarkFastCalcSickTriplet(b *testing.B) { for i := 0; i < b.N; i++ { fastCalcSickTriplet(randomSickTriplet(), TIMES) } }
Результаты такие:
BenchmarkFastCalcTriplet-4 548 2177936 ns/op 24 B/op 1 allocs/op BenchmarkSlowCalcTriplet-4 62 18613275 ns/op 24000044 B/op 1000001 allocs/op BenchmarkCalcSickTriplet-4 62 18929575 ns/op 24000036 B/op 1000001 allocs/op BenchmarkFastCalcSickTriplet-4 534 2225462 ns/op 24 B/op 1 allocs/op
Получается, выбор структуры данных не особо и влияет?
mishapoiuytrewq
28.05.2022 17:49+1В golang gcflags="-l=4" включит middle stack inline
maxim_ge Автор
28.05.2022 19:40Интересный вариант. Попробовал так:
go run -gcflags="-l=4 -m -m" concgo.go s > o 2>&1
- На результаты не повлияло
- В
o
пишут:.\concgo.go:60:6: cannot inline is_convergent: function too complex: cost 87 exceeds budget 80
- Т.е. таки проблема с
is_convergent
не во "вложенности", а в "сложности"? - В исходниках пишут:
making 1 the default and -l disable. Additional levels (beyond -l) may be buggy and
// are not supported.
- Вроде нет указаний, что
-l=4
можно использовать "в бою"?
Kekmefek
Срочно переписываем микросервисы на Rust (шутка)!
Думаю сервисам работающих с потоковой обработкой данных, например такими как звук и видео, стоит обратить внимание на Rust.
В остальном Go будет удобнее.
m03r
Кстати, а чем Go удобнее для Вас?
blind_oracle
Неинопланетным синтаксисом, как минимум
m03r
Это, кажется, дело вкуса. По моим ощущениям в сравнении с C/Java/PHP оба достаточно инопланетные.
Кстати, меня удивило, что количество строк практическо одинаковое (700 и 711), при том, что Go продвигается именно под соусом "простоты".
А в чём ещё проще?
DeniSix
Не могу сказать, что знаю один из них сильно больше другого, но вот читать случайный проект на гитхабе проще, если он написан на Go. У Rust'а много плюсов, но читабельность я бы туда не записывал.
AnthonyMikh
И о чём это говорит? О том, что вы лучше знакомы с Go.
vabka
Скорее Go просто имеет чуть меньше неочевидных операторов, да и добавляет не так много новых концептов.
Например так сразу и не поймёшь, что значат эти ваши лайфтаймы, turbofish, восклицательные и вопросительные знаки, если ни разу до этого Rust не видел.
blind_oracle
Ну не знаю, у Go вполне C-подобный синтаксис, только чуть упрощён.
Простота - так тут как раз обычно чем строк больше - тем язык проще. Но я код из статьи не сравнивал, хз почему там так. Скорее всего задачи базовые.
Ещё чем? Ну, простотой работы с горутинами - забываешь про треды и async как страшный сон.
m03r
Лично меня очень путала разница
=
/:=
(и ещё можно пользоватьсяvar
, а можно и нет). А читать мешает то, что тип указывается после переменной/параметра, но без двоеточия. Для меня привычно смотрятся вариантыbool a
(как в C/Java/PHP) иa: bool
(как в Паскале/Python/TypeScript). А вот Go в обоих случаях идёт особым путём.dream_designer
Число строк и простота -- это совершенно несвязанные вещи, скорее даже наоборот, код, условно, на Perl или Haskell будет короче, а на APL еще короче. И длина будет обратно пропорциональна простоте.
0xd34df00d
Длина коррелирует с простотой, но не обратно пропорциональна. Замена слов на смешные символы в кое-каком моем коде снизила сложность чтения, а не повысила ее.
Fenex
Много кто вначале своего пути изучения раста плюётся от синтаксиса. Однако практика показывает, что уже спустя месяц неторопливого изучения языка, все эти претензии куда-то магически улетучиваются )) Мне кажется это в основном потому что синтаксис на самом деле не слишком отличается, просто визуальная составляющая обматывает.
Нет смысла делать ещё один язык с таким же синтаксисом, когда можно этот самый синтаксис чуть-чуть изменить и получить взамен огромный выигрыш в удобстве использования языка. К сожалению, это удобство не сразу становится очевидным.
gohrytt
всем всегда становится неочевидным*
PsyHaSTe
В очередной раз попрошу предложить улучшения чтобы синтаксис был не инопланетным. Что стоит поменять, как считаете? Что конкретно инопланетное и нужно убрать?
Leopotam
Переделать неймспейсы и вот все это с двоеточий, двойных двоеточий на точки.
Унифицировать скобки (лямбды с вертикальными палками - это кто-то специально постарался).
Убрать/упростить всю пунктуацию, какую можно - это не только упростит восприятие, но и уменьшит сложность топтания по клавиатуре.
DeniSix
Турборыбу трогать нельзя!
ainu
Вау. Действительно инопланетянский. Хоть и красиво.
inferrna
По вертикальным палкам лямбду издали видать, если же сделать круглые скобки, будет больше путаницы.
Раст, это не про "тяп-ляп и в продакшн", это про вдумчивую расстановку скобочек, точечек, апострофов, чёрточек и двоеточий, но так, чтобы потом работало веками.
Leopotam
Почему другим языкам это не мешает? Насколько было действительно целесообразно вводить настолько разнообразный синтаксис?
Ну так вопрос был про "что упростить", чтобы быстрее решало вопросы бизнеса путем ускорения разработки и того самого тяп-ляп. Веками не нужно чтобы работало - ТЗ может меняться довольно часто и инструмент должен помогать бежать быстрее уже сейчас.
PsyHaSTe
Жсу мешает, он потому заставляет расставлять скобочки где по хорошему хотелось бы их избежать. Например
foos.map([x,y] => {x, y})
написать не выйдет.Leopotam
Это был ответ на "вдумчивую расстановку точек-запятых" - на это часто нет достаточного количества времени, а код не компилится, потому что нужно сконвертить мутабельный в немутабельный тип + обхитрить оунинг.
Тут скорее набор вот таких неприятных мелочей, которые by design и не будут меняться, но они сильно портят впечатление при первом-втором-третьем знакомстве и приходится привыкать через страдания, чтобы хоть как-то двигаться вперед. Тот же голанг оказался настолько простым, я бы даже сказал деревянным, что порог входа просто нулевой. Да, горотины, каналы и все это - нужно изучать отдельно (как и вообще мультитред в принципе), но основы просты и приятны - через 3-4 дня человек садится и пишет работающий код, а не борется с компилятором со stackoveflow наперевес на каждый чих.
PsyHaSTe
Вы какие-то страсти расказываете, я вот теряюсь что вы имеете в виду. Точки с запятой обычно ставятся 1 на строку там где заканчивается стейтмент, как и у всех. Под сконвертить мутабельный тип в немутабельный нельзя, потому что мутабельность не свойство типа. а биндинга. Но если вы про
То поясните что вам тут не нравится и что стоило бы опять же сделать иначе в таком случае?
Вот у меня противоположенный опыт какой-то. Скобки/звездочки — как-то странно намешаны без особой логики, ничего не понятно.
0xd34df00d
Это, на мой взгляд, несущественное преимущество, потому что язык вы изучаете один раз, а не каждый день.
freecoder_xx
В отличие от обычных функций, список аргументов у замыканий выделяется не круглыми скобками, а вертикальными чертами:
Текущий синтаксис прошел определенную историю развития, в процессе которого он избавился от всего лишнего и приобрел эту окончательную, легковесную и лаконичную форму. Использование вертикальных черт улучшает читаемость кода: замыкание хорошо выделяется в группе аргументов функций, где оно используется чаще всего, при этом его сложно спутать с кортежем или массивом.
freecoder_xx
Предоставляемые Rust возможности требуют явного синтаксического разделения статических обращений
xx::yy::zz
и динамическихxx.yy.zz
, где результат будет зависеть от значения объектовxx
,yy
иzz
во время выполнения. Возможны ситуации, когда программист должен использовать канонический путь для обращения к методу, когда ему нужно явно указать тип или типаж, на котором будет вызван данный метод:Такое происходит, когда разные типажи, реализованные для типа, используют одинаковые имена методов, и разрешить конфликт имен необходимо статическим указанием на конкретный типаж или тип, к которому относится метод. Подобная запись вызовов оказывается незаменимой при обобщенном программировании. Если использовать для статического и динамического обращения один и тот же разделитель, то становится невозможно различать подобные вызовы:
Программа, написанная на Rust, не имеет информации о типах во время выполнения, так как при компиляции происходит "стирание типов". Поэтому возникает необходимость различать динамические и статические обращения: механизм их работы сильно отличается, как и последствия, к которым они приводят, так что программист должен видеть это в коде явно.
Однако в качестве утешения появляется возможность использовать одни и те же идентификаторы для имен переменных и модулей, что очень удобно.
PsyHaSTe
Ну строго говоря ничего не мешало сделать точку тогда было бы. Пример
в расте не прошел бы потому что переменная не может называться Foo, а только foo или FOO. Если мы применяем дефолтные правила форматирования, конечно же.
А производить же заточку языка под маргиналов которые будут именовать как попало возможно не очень нужно.
mayorovp
Вот никогда-никогда запись Foo не может оказаться выражением, создающим экземпляр типа Foo без полей?
PsyHaSTe
Нет, тогда было бы
Foo {}
AnthonyMikh
Да
PsyHaSTe
Когда проектировались неймспейсы через :: ничто не мешало просто запретить так писать и требовать {} в конце.
EragonRussia
struct Foo {}
иstruct Foo
— несколько разные конструкции: https://rust-lang.github.io/rfcs/0218-empty-struct-with-braces.html.PsyHaSTe
Это просто немного сахара закостылили для кодогенерации. И как раз с предложением выше этого бы не надо было делать — всегда была бы только одна запись
struct Foo {}
dunmaksim
Здесь принято дрочить на всё, что делают Google и Apple. Golang — это Google Oberon. Дальше надо объяснять?
JPEG
Стыдно признаться, но сборкой мусора.