Comments 82
rust в общем случае не должен уступать по скорости C
Это почему это? В Rust используется bound checking при обращении к массивам по индексам, тогда как в C — нет. В ряде случаев Rust-овый компилятор способен избавиться от таких проверок (например, при итерациях), но не всегда.
Плюс в Rust-е есть Drop-ы, которые аналоги плюсовых деструкторов, и Drop-ы должны вызываться при выходе из скоупа. Что так же не бесплатно.
Плюс в Rust-е практикуется возврат Result-ов, т.е. пар значений. И в Result-е запросто может оказаться динамически созданный объект на месте Err. Что так же не дешево.
Плюс в Rust-е иногда может применяться динамический диспатчинг вызовов методов трайтов, косвенный вызов дороже прямого.
Скорее в общем случае Rust должен хоть немного, но отставать от C.
Если уж сравнивать числодробительные возможности, то тогда надо смотреть на бенчмарки. Особенно забавно выглядит первая десятка в K-Nucleotide. И это притом что в Rust-е на данный момент еще только предстоит написать те оптимизации, которые будут на 100% пользоваться гарантиями его системы типов.
как в С++ нулевой стоимости
Абстракции в C++ далеко не нулевой стоимости.
Пары значений ни при каком раскладе к динамически создаваемому объекту не приведут.
Я этого и не утверждал. Только вот тот, кто вызывает метод f() не может знать, положит ли метод f() в результат простой Err или же это будет созданный динамически объект, который реализует нужный трайт.
Дропы тоже будут использоваться только там где они реально нужны.
Наличие паник подразумевает, что должен быть какой-то механизм автоматического раскручивания Drop-ов при выбросе паники по аналогии с плюсовыми деструкторами и исключениями. Это не бесплатно, даже если паники не бросаются.
Если уж сравнивать числодробительные возможности, то тогда надо смотреть на бенчмарки.
Речь шла про общий случай, а не про числодробилки.
Только вот тот, кто вызывает метод f() не может знать, положит ли метод f() в результат простой Err или же это будет созданный динамически объект, который реализует нужный трайт.
Вот этого не может быть. Нельзя положить "какой-то объект реализующий трейт" по значению: размер объекта должен быть известен. Так что мы всегда увидим или конкретный тип или Box<Error>
.
И какой тип получается вот в этом примере из стандартной документации? Неужели Box<Error>?
Возможно, ошибаюсь.
Возможно, вы обратили внимание на какой-то из вариантов, где использовались trait объекты.
В расте, как и в С++, нельзя хранить по значению тип "переменного размера". T
всегда будет конкретным типом, "интерфейс" надо представлять ссылками или указателями.
Положить трейт в Err
можно, но это будет уже Result<T, Box<Error>>
, в общем, из сигнатуры можно делать однозначные выводы.
Абстракции в C++ далеко не нулевой стоимости.Если вам нужно вызвать функцию напрямую, то это будет ровно тот же самый call, что в C что в C++ что в Rust. Если вы заранее не знаете адресата (косвенный вызов), то опять же все три приведут одинаковой задержке. При всем желании C тут не сможет быть быстрее. Методы объектов Glib как пример.
В C++ может быть оверхед по объему кода при инстанциировании шаблона. То же самое и в Rust. Но это цена за возможность мономорфизации кода и оптимизаций при инлайнинге. В C шаблонов нет, но это не значит, что он будет быстрее в этом случае (инлайнинг в C++/Rust наоборот может оказаться быстрее). Наконец, и в C++ и в Rust можно написать код в plain C стиле и получить ту же производительность.
Я этого и не утверждал. Только вот тот, кто вызывает метод f() не может знать, положит ли метод f() в результат простой Err или же это будет созданный динамически объект, который реализует нужный трайт.Неверно. Это записано в типе функции и известно статически уже на этапе компиляции.
Речь шла про общий случай, а не про числодробилки.Вы говорите про общий случай, но упоминаете почему-то частности.
Наличие паник подразумевает, что должен быть какой-то механизм автоматического раскручивания Drop-ов при выбросе паники по аналогии с плюсовыми деструкторами и исключениями. Это не бесплатно, даже если паники не бросаются.Опять мимо кассы. Паники построены с помощью механизма исключений LLVM на базе Itanium EABI. В отличие от setjmp/longjmp этот механизм не вносит задержек при нормальном развитии событий. Оверхед возникает только при фактической диспетчеризации исключения.
Вы похоже не понимаете, что такое zero cost abstraction.
Да куда уж мне.
Вы говорите про общий случай, но упоминаете почему-то частности.
А вы подумайте, как эти частности скажутся в сумме.
Опять мимо кассы.
У табличного способа поиска исключений есть своя цена, даже если исключения не бросаются. Хотя бы в необходимости хранения этих самых таблиц.
У табличного способа поиска исключений есть своя цена, даже если исключения не бросаются. Хотя бы в необходимости хранения этих самых таблиц.Стоп, стоп. То мы говорили о скорости исполнения кода, а теперь уже о размере.
Скорее в общем случае Rust должен хоть немного, но отставать от C.Я не говорю, что всегда и везде Rust будет быстрее. Нет конечно, его компилятор еще очень молодой и многих оптимизаций там просто нет. Но утверждать, что он будет медленнее в общем случае просто потому, что у него есть некие механизмы — нельзя.
Судить можно только на примере конкретной задачи, конкретной ее реализации и конкретного компилятора. Иначе это холивор.
То мы говорили о скорости исполнения кода, а теперь уже о размере.
С учетом того, как дорого сейчас стоит промах мимо кэша процессора, размер кода и данных программы оказывают прямое влияние на скорость.
Но утверждать, что он будет медленнее в общем случае просто потому, что у него есть некие механизмы — нельзя.
Ну вот в реальности C++, например, хоть чуть-чуть, но медленее C. Как раз потому, что абстракции на практике не бесплатны (чтобы там не говорили евангелисты). Нравится верить, что в Rust-е, не смотря на более высокий уровень абстракции, будет что-то по-другому, ну OK, нет проблем.
Я не предлагаю верить. Я предлагаю смотреть на конкретные примеры. А там код с абстракциями может уже быть как медленнее так и быстрее наивной реализации. It depends.
Вот как раз верить во что-то, без каких либо оснований, это и есть настоящий фанатизм.
Ну так споры на счет скоростей C и C++ ведутся уже не одно десятилетие. И опыт показывает, что на C++ можно получить код даже быстрее, чем на C, но это не в общем случае.
Теперь те же самые споры будут на счет C и Rust. С ожидаемым результатом.
Если говорить совсем точно, то C можно описать с помощью простого типированного лямбда исчисления (с дополнениями). На уровне типов он ничего особенного не предоставляет.
C++, являясь тьюринг полным и на уровне системы типов, чуть сложнее: его можно представить простым типированным λ-исчислением на уровне термов и простым нетипированным λ-исчислением на уровне типов. Нетипированное оно потому, что в С++ нет возможности задать ограничения параметры шаблонов. То что в комитете называют концептами и уже несколько версий не могут сделать.
Rust в этой иерархии стоит еще выше (если учитывать возможность полной специализации дженериков): у него есть ограничения на уровне типов. Поэтому при неверной попытке специализации шаблона (дженерика) Rust дает внятное сообщение об ошибке, а не пресловутые «три экрана шаблонов».
Круче только языки с продвинутыми системами типов на базе λ-исчисления высших порядков: Haskell, Coq, Agda и прочее.
Короче. Я веду к тому, что Rust, в отличие от C++, может грамотно распорядиться своей системой типов и инвариантами, которые из нее можно вывести. А это хорошая пища для оптимизатора. То есть, в тех случаях, когда компилятор C++ пасует и вынужден генерировать общий код, компилятор Rust сможет безопасно закодировать более производительный вариант, либо провести более агрессивный инлайнинг, векторизацию и т.п.
P.S.: Кому интересна эта тема, может посмотреть мой доклад на одной из конференций C++ Siberia.
extern fn foo(input: &T);
fn bar(input: &T) { ... }
fn baz(input: &T) {
foo(input);
bar(input);
foo(input);
}
Здесь в коде есть одна внешняя функция с неопределенным контрактом и две внутренние которые пользуются ссылкой на T.
В Rust если функция принимает &T это значит, что объект на момент выполнения является замороженным и гарантировано не будет меняться где-то еще. Поэтому оптимизатор может с чистой совестью один раз прочитать значение из памяти и положить его в регистр (если это выгоднее и не создаст нежелательного register pressure).
Функция bar() сможет быть заинлайнена внутрь baz() и пользоваться тем же значением из регистра, потому что компилятор может быть уверен, что input случайно не поменяется (interior mutability — отдельная история). То есть компилятору тут даже escape анализ проводить не нужно, чтобы это понять.
Разумеется в C++ все сильно сложнее и в общем случае const T& не является достаточным основанием для подобных оптимизаций. Алиасинг указателей вообще больная тема.
потому что компилятор может быть уверен, что input >случайно не поменяется (interior mutability — отдельная >история)
А почему можно так лихо убрать из рассуждений interior mutability
, вполне может быть:
struct T {
data: RefCell<u32>,
}
fn foo(input: &T) {
let mut data = input.data.borrow_mut();
*data += 1;
}
и компилятор должен либо уметь понимать все возможные unsafe
блоки, чтобы определять анлоги RefCell
написанные программистом, либо забыть про оптимизации связанные с неизменяемостью?
Вроде бы…
Если вы объявили тип
T
как Send+Sync
то это означает, что вы позаботитесь о синхронизации и видимости.В однопоточном случае это значит, что вы динамически проконтролируете баланс ссылок на объект. По этой причине
RefCell
кидает панику если при попытке взять мутабельную ссылку счетчик shared ссылок ненулевой.Подробнее можно почитать в документации и в номиконе.
Я веду к тому, что Rust, в отличие от C++, может грамотно распорядиться своей системой типов и инвариантами, которые из нее можно вывести.
А может и не распорядиться и работать с трайтами, как с таблицей виртуальных функций, а так же выполнять все bound checks и возвращать через Result пользовательские enum-ы размером в сотни байт, при этом ничуть не избавляясь от цепочки if-ов, которые спрятаны за синтаксическим сахаром try! и?..
Я пытаюсь говорить о фундаментальных преимуществах, которые может дать система типов Rust, аналогов которых в С/C++ нет. Вы — про текущие недостатки реализации.
Какой-то бессмысленный спор выходит.
rust в общем случае не должен уступать по скорости C
На мой взгляд, это утверждение неверно. Т.к. в общем случае (а не тогда, когда какой-то код затачивается под максимальную производительность в микробенчмарке посредством ухода в unsafe) пользователи будут пользоваться преимуществами Rust-а, как то:
— встроенные проверки выхода за пределы векторов;
— более высокие уровни абстракции, т.е. trait-ы, которые в ряде случаев будут работать так же, как и виртуальные методы в обычных ОО-языках;
— RAII через Drop, в том числе и при паниках;
— возврат значений (в том числе и не маленьких) через Result, вместо использования привычных для C-шников параметров-указателей.
Все эти вещи не бесплатны. И пусть их стоимость невелика, она все таки не нулевая. Плюс к тому, эти вещи позволяют решать более сложные задачи, что в итоге приводит к тому, что программист оперирует на более высоком уровне абстракции, смиряясь с некоторыми неоптимальностями внизу. Это нормально, поскольку потеря нескольких процентов производительности — это небольшая цена за сокращение сроков разработки, повешение качества и надежности.
Все это уже проходилось в других языках программирования, в том числе и нативных.
При этом в Rust-е вполне можно, если задаться целью, получить и более быстрый код, чем на C (в C++ это так же возможно и продемонстрировано), но речь будет идти не про общий случай, а про частную задачу, вероятно, не очень большого объема.
Ну и да, речь не про фундаментальные преимущества, которые _могут_, а про постылую обыденность, в которой идеальный быстрый код пишут словами в камментах на профильных форумах.
RAII через Drop, в том числе и при паниках;
Но ведь в C вы всё-равно будете освобождать ресурс тогда, когда он вам не нужен. Имеено об этом идёт речь, когда говорят о zero-cost abstractions. То есть, корректная программа на C не будет ничем быстрее корректной программы на, например, C++ с деструкторами. Просто если вы используете C вам придётся те же самые деструкторы вызывать в явном виде, что намного сложнее, или же и вовсе невозможно.
void f() {
File a(...);
...
File b(...);
...
} // (1)
а в каждом деструкторе делается вызов close(), то код с деструкторами будет чуть-чуть дороже, чем код с прямым вызовом close():
void f() {
int a = open(...);
...
int b = open(...);
...
close(b);
close(a);
}
Во-вторых, запись действий по очистке вручную может позволить записать действия более компактно. Т.е. если в коде на C++ между вызовами деструкторов a и b пройдет вызов еще нескольких деструкторов, то данные и код для вызова деструктора b уже могут уйти из кэша. Тогда как в C может быть записано что-то вроде:
void f() {
int a, b;
...
a = open(...);
...
b = open(...);
...
cleanup:
... // Какие-то другие действия.
close(a);
close(b);
}
Так что на практике выплывают некоторые мелочи из-за которых незначительное преимущества в скорости у C все-таки образуются.
Другое дело — стоят ли они того…
Во-первых, в том же C++ деструкторы не всегда инлайнятся.
Если они не инлайнятся — значит, компилятор считает, что так будет быстрее и вполне возможно, что он прав.
Т.е. если в коде на C++ между вызовами деструкторов a и b пройдет вызов еще нескольких деструкторов, то данные и код для вызова деструктора b уже могут уйти из кэша.
Какая-то странная ситуация. Если там столько всего произошло между этими двумя вызовами то у вас и на С данные точно так же "остынут". Какая разница, как это записать?
Вообще, понятно, что микрооптимизациями можно кое-каких выигрышей добиться. Только мне кажется, в местах, где такие мелочи играют значение, используют даже не C, а asm.
Если они не инлайнятся — значит, компилятор считает, что так будет быстрее и вполне возможно, что он прав.Или в принципе не может это сделать, т.к. они лежат в отдельной dll-е.
Какая разница, как это записать?Ну если вы не увидели разницы между приведенными двумя примерами кода, значит ее нет, а я во всем неправ.
Или в принципе не может это сделать, т.к. они лежат в отдельной dll-е.
Если у вас функция деинициализации лежит в отдельно библиотеке то вы и в C будете бессильны.
Ну если вы не увидели разницы между приведенными двумя примерами кода, значит ее нет, а я во всем неправ.
Ну я признаю, что если у вас перед вызовами close() идёт, например, особождения массива динамически выделенных объектов, то данные могут действительно уйти из кэша. Просто когда я представляю себе гипотетически такую ситуацию и представляю, что я пишу этот код на C, я понимаю что не знаю как его лучше написать. Вообще не представляю, какой вариант будет быстрее. И вряд ли вам кто-то скажет точно кроме серии бенчмарков. Отсюда вывод — потенциально вы может и можете написать на C более быстрый код. На практике, не изучая конкретный случай, компилятор, процессор — нет, не можете.
То мы говорили о скорости исполнения кода, а теперь уже о размере.Ну справедливости ради размер кода влияет на его скорость его исполнения из-за наличия кеша.
Landing pads же не разбавляют каждый метод, равномерно его раздувая. А будет call/ret на 12k от текущей позиции или на 13k — разницы практически никакой.
Ну и для желающих от них полностью избавиться, можно запретить panic'и (использовать panic = abort
вместо panic = unwind
).
Я потому и говорю, что не существует никакого «общего случая». Всегда надо смотреть на конкретный пример.
Потом на практике оказалось, что кумулятивно, одна только эта операция по скорости отбрасывала JIT код далеко назад даже по сравнению с софтовым исполнением! То есть буквально: нет инструкций blockReturn — JIT в 50 раз быстрее. Есть — настолько же медленнее.
После того, как вместо выбрасывания исключения результат вызова метода я стал кодировать в паре регистров (обычный результат и контекст нелокального выхода, если таковой есть) JIT VM стала опережать софтовую VM на всех типах инструкций.
Подробнее можно тут почитать в описании релиза 0.4.
… по аналогии с плюсовыми деструкторами и исключениями. Это не бесплатно, даже если паники не бросаются.
The main model used today for exceptions (Itanium ABI, VC++ 64 bits) is the Zero-Cost model exceptions.… the Zero-Cost model, as the name implies, is free when no exception occurs
http://stackoverflow.com/questions/13835817/are-exceptions-in-c-really-slow
Я подозреваю, что большая потеря может происходить при перехода от v8 string -> bytes -> adler -> string -> bytes -> v8 complementary type.
По крайней мере, я бы копал в направлении работать напрямую с входными байтами в представлении v8, отдавать родную строку в v8 (речь ведь о hex).
В ряде случаев Rust-овый компилятор способен избавиться от таких проверок (например, при итерациях), но не всегда.
Там, где не может компилятор, но очень хочется, то можно сделать руками через get_unchecked
.
и Drop-ы должны вызываться при выходе из скоупа. Что так же не бесплатно.
Дык, если нам надо выполнять какой-то код на выходе из скопа, то и в C код будет, только написанный руками.
Насчёт динамических ошибок и диспатчинга: это всё делается руками, в зависимости от ситуации. Конечно, нет гарантий на то, что в библиотеках оно будет реализовано так, как нам хотелось бы, но ведь это и для С справедливо.
Хотя с выводом я даже соглашусь с тем уточнением, что в общем случае код на расте будет безопаснее. (:
Там, где не может компилятор, но очень хочется, то можно сделать руками через get_unchecked.
Это будет не общий случай.
что в общем случае код на расте будет безопаснее
Это подразумевается по умолчанию. Если намеренно игнорировать безопасность Rust-а, смысла в его использовании нет.
Это будет не общий случай.
С этим не спорю, просто хотел уточнить, что если такое необходимо, то делается довольно легко.
Если намеренно игнорировать безопасность Rust-а, смысла в его использовании нет.
Как сказать. Если выбор между С и Rust, то даже в гипотетической ситуации, когда у нас код на 90% состоит из unsafe блоков, я бы всё равно предпочёл последний. Если, конечно, нет других факторов.
Если не нужен произвольный доступ и известен size_hint, то итераторы тоже не будут проверять границы
Повторю то, что уже говорил:
При этом в Rust-е вполне можно, если задаться целью, получить и более быстрый код, чем на C (в C++ это так же возможно и продемонстрировано), но речь будет идти не про общий случай, а про частную задачу, вероятно, не очень большого объема.
Какой будет следующий намек?
Вы спорите с утверждением:
rust в общем случае не должен уступать по скорости C
Я вас спрашиваю, какая реализация сортировки в общем случае используется в С?
В расте в общем случае используется обобщенная. И что то мне подсказывает что в С в общем случае используется qsort который легко может оказаться на порядок медленнее. В результате раст не то что не уступает в общем случае, а оставляет С далеко позади.
Ну а если задаться целью, то да, можно и еще более быстрый код получить, но мы же не об этом сейчас?
Зачем вы увиливаете от ответа?
Ответ вам был дан. Почему вы не можете его прочитать и понять?
Я вас спрашиваю, какая реализация сортировки в общем случае используется в С?
Тогда позвольте вас спросить: мы будем под общий случай выдавать конкретную ситуацию? Тогда давайте посмотрим на такой вот «общий» случай:
void f(char * b, size_t i) { b[i] = 0; }
Значения b и i определяются в run-time, заранее они не известны.
Ну и, видимо, нужно более четко обозначить свою точку зрения, ибо очевидно, что для местных комментаторов она не очевидна:
— в Rust-е не так уж много особенностей, которые бы не позволили компилятору Rust-а сгенерировать такой же эффективный код, как и компилятору C. Одна особенность — это включенный по умолчанию bounds checking, вторая — это генерация таблиц для обработки panic (хотя это лишь косвенное влияние оказывает). А так, в принципе, у компилятора Rust-а достаточно информации, чтобы сгенерировать даже более оптимальный код;
— однако, производительность кода будет определяться не столько возможностями компилятора, сколько программистом. Rust является языком более высокого уровня, позволяет решать более сложные задачи, что дает возможность разработчику использовать более высокие уровни абстракции. Чем выше, тем меньше внимания уделяется тому, что внизу, откуда и проявляется некоторый проигрыш в производительности/ресурсоемкости по сравнению с более низкоуровневыми языками. Это не уникально для Rust-а, это уже проходилось, на моей памяти, как минимум с Ada, Eiffel и C++ (если говорить про то, что транслируется в нативный код);
— под общий случай лучше брать не какой-то микробенчмарк и уж тем более не часть микробенчмарка (вроде упомянутой вами сортировки), а решение какой-то большой задачи. Например, реализация MQ-шного брокера (хоть MQTT-брокера, хоть AMQP) или сервера СУБД. На таком объеме языки более высокого уровня сильно выигрывают в трудозатратах, качестве и надежности, но на какие-то проценты проигрывают в производительности тому же C (причины см. в предыдущем пункте). Если не верите, попробуйте пообщаться с разработчиками PostgreSQL или Tarantool-а.
от проверки границ можно избавиться при помощи арифметики указателей в unsafe-блоках
К счастью, от проверки границ можно избавиться более простым способом. (:
Интересный опыт, как насчет опубликовать это как npm пакет?
Думал, что для nodejs есть https://github.com/neon-bindings/neon. Было бы любопытно взглянуть на его результат. Или это что-то другое?
neon — это несколько иная штука. Они предоставляют абстракции только для node.js. То есть, код который будет написан на расте с использованием neon — теряет универсальность. Библиотечка, которую я там написал, подключается и к node.js и к python и другим — по универсальному интерфейсу. Впрочем, есть смысл попробовать, возможно он был бы быстрее за счёт лучшей адаптированности под задачу
-> *mut c_char
И именно в этом виде, с *mut оно компилируемая и работает. Если правильнее сделать как-то иначе, то будет любопытно узнать.
char * result = adler(url);
info.GetReturnValue().Set(Nan::New<String>(result).ToLocalChecked());
free(result);
То есть, вот этот вот free — вызывать опасно?
Но вроде как тут как раз не должно быть разных аллокаторов именно по причине того, что код вызывается сначала в C++, а уже потом значение передаётся в ноду. А они используют, насколько я понимаю, один и тот же аллокатор. И в документации на этот счёт как раз сказано, что при компиляции в библиотеки всё должно работать корректно.
Dynamic and static libraries, however, will use alloc_system by default. Here Rust is typically a 'guest' in another application or another world where it cannot authoritatively decide what allocator is in use. As a result it resorts back to the standard APIs (e.g. malloc and free) for acquiring and releasing memory.
Короче, говоря, освобождать отданное значение на вызывающей стороне — это должно быть вполне корректным решением.
Если я правильно понял, там говорится про разные кучи, а не про разные аллокаторы. Впрочем, есть и другой момент — там идёт речь о том, как взаимодействуют библиотеки написанные на С/С++. И в этом смысле, у меня есть подозрение, что логика работы может отличаться. В данном случае, было бы разумно поставить под сомнение и то, что написано в документации и то, что написано на стэке. Существует ли воспроизводимый сценарий получить проблему? Если да, то можно было бы поставить какой-то эксперимент и посмотреть, что будет. Если откровенно, то мне вообще не вполне понятно, что именно должно в данном случае пойти не так.
Тут я как раз описал некоторый такой вариант диффузной миграции. С его помощью можно постепенно наработать некоторый объём нужного мне функционала и как-то попривыкнуть к местным обычаям. А в какой-то момент просто окажется, что всё, что мне нужно — есть в расте и я понимаю как это эффективно использовать. Вот тогда-то можно будет и выкинуть что-то. И то, что-то всё равно останется, просто потому как есть проекты переменчивые, а есть в стиле «Работает — не трогай», которые я годами не обновляю, поскольку просто нет нужды. Зачем их переписывать? Что я с этого получу?
А главное, как я буду объяснять людям, которые работают со мной совместно, что я взял и выкинул ноду? Они-то раста не знают. Подключаемую библиотеку они ещё могут воспринять, а полный отказ от ноды будет означать отказ ещё и от их участия в проекте. К чему такой тоталитаризм?
Должно получиться интересно
Getting Started With WebAssembly in Node.js
Ускоряем Node.js с помощью Rust