
В последнее время в сети довольно часто упоминается «молодой и перспективный» язык Rust. Он пробудил во мне любопытство и желание сделать на нём что-то более-менее полезное, чтобы как-то примерить — впору ли он мне. Это вылилось в достаточно любопытный, как мне кажется, опыт скрещивания ужа с ежом при содействии кукушки.
И так, делал я вот что. Есть проект на node.js. Там есть функционал, который требует считать хэш. При том, довольно часто — почти на каждый входящий запрос. Поскольку хэш этот не является чем-то, что должно уберечь меня от коллизий и вообще нужен не безопасности ради, а удобства для, то используется алгоритм adler32. Он предоставляет короткое выходное значение.
По какой-то нелепости, в node.js его нет. Поясню, почему это нелепо. Этот алгоритм обычно используется в компрессии, в частности его использует gzip. В node.js есть стандартная реализация gzip в модуле zlib. То есть, adler32 там вообще-то есть, но в неявном виде. В Python, для сравнения, в аналогичном модуле он имеется и им можно пользоваться.
Ну, да ладно. Берём сторонний пакет из npm. Я взял вот этот: adler32 — в основном потому, что он умеет интегрироваться с модулем crypto и его можно использовать так же, как и остальные хэш-алгоритмы. Это удобно. О производительности в данном случае я особенно не задумывался. Какой бы она ни была — это копейки. Но поскольку у меня намечался эксперимент, то этот самый adler32 был выбран жертвой.
В общем, приступим. Ставится Rust просто. Документация тоже достаточно внятная как на русском, так и на английском. Rust взят версии 1.15. Забавный факт: документация на русском не является прямым переводом английской и немного отличается по структуре. В частности, в неё добавлен пример работы с потоками.
Кроме самого Rust, стоит так же node.js версии 6.8.0, Visual Studio 2015 и Python 2.7 — это всё понадобится.
Теперь проведём предварительный замер.
Node.js
for (var i=0; i<5000000; i++) {
var m = crypto.createHash('adler32');
m.update("Какая-то не очень длинная строка, для которой надо посчитать контрольную сумму");
m.digest('hex');
}
Средний результат трёх запусков: 41,601 секунда. Лучший результат: 40,206
Чтобы с чем-то сравнить, давайте возьмём для начала нативную реализацию хэша в node.js. Скажем, sha1. Выполнив точно тот же самый код, но указав в качестве алгоритма sha1, я получил такие цифры:
Средний результат трёх запусков: 9,737 секунд. Лучший результат: 9,321
Может ну его вообще этот адлер? Но погодите, погодите. Давайте всё-таки попробуем что-нибудь сделать на Rust.
Rust
И так, на Rust есть сторонняя библиотека compress, которая доступна в этом их Cargo. Она тоже умеет gzip и предоставляет возможность считать adler32. Примерно так это выглядит:
for i in 0..5000_000 {
let mut state = adler::State32::new();
state.feed("Какая-то не очень длинная строка, для которой надо посчитать контрольную сумму".as_bytes())
}
Средний результат трёх запусков: 2,314 секунды. Лучший результат: 2,309
Неплохо!
Node.js и FFI
Поскольку Rust компилируется в код, совместимый с Си, то его можно скомпилировать в динамическую библиотеку и подключать с помощью FFI. У node.js для этого есть специальный пакет, который нужно ставить отдельно:
npm install ffi
Если у вас всё хорошо, то после этого можно будет подключать внешние библиотеки написанные на Си или совместимые с ним.
Значит, надо эту дробилку на расте преобразовать теперь в библиотеку. Если коротко, то код выглядит примерно так:
extern crate compress;
extern crate libc;
use libc::c_char;
use std::ffi::CStr;
use std::ffi::CString;
use compress::checksum::adler;
#[no_mangle]
pub extern "C" fn adler(url: *const c_char) -> *mut c_char {
let c_str = unsafe {
CStr::from_ptr(url).to_bytes()
};
let mut state = adler::State32::new();
state.feed(c_str);
let s:String = format!("{:x}", state.result());
let s = CString::new(s).unwrap();
s.into_raw()
}
Как видите, всё стало чуточку сложнее. На вход функция получает Си-строку, которую перегоняет в байты, считает хэш, преобразует в hex, после чего опять перегоняет в Си-строку и только после этого отдаёт обратно.
Кроме того, в файле Cargo.toml нужно указать, что компилировать нужно в динамическую библиотеку. Там же указываются зависимости:
[package]
name = "adler"
version = "0.1.0"
authors = ["juralis"]
[lib]
name = "adler"
crate-type = ["dylib"]
[dependencies]
compress = "*"
libc = "*"
Вот. Теперь это будет компилироваться в библиотеку. Какого типа — зависит от целевой платформы. У меня на выходе получилась dll, поскольку занимался я всем этим из под Windows и указал соответствующие параметры компиляции:
cargo build --release --target x86_64-pc-windows-msvc
Ну что ж. Хватаем эту самую dll, кладём куда-нибудь ближе к проекту на node.js и кое-что добавляем в код:
var ffi = require('ffi');
var lib = ffi.Library('adler.dll', {
adler: ['string', ['string']]
})
for (var i=0; i<5000000; i++) {
lib.adler("Какая-то не очень длинная строка, для которой надо посчитать контрольную сумму")
}
Средний результат трёх запусков: 27,882 секунд. Лучший результат: 26,642
Ну… Что-то как-то не то, что хотелось бы. Видимо, все эти радости с внешними вызовами стоят довольно дорого. Тем не менее, это всё-таки работает быстрее. Но можно ли сделать ещё быстрее? Можно.
Node.js и С++ аддон
В node.js, как известно, поддерживаются так называемые аддоны. Почему бы и не попробовать? Единственная проблема, что я вообще говоря в С++ ни в зуб ногой. Впрочем, есть добрые люди, которые написали немного справки. Вот тут примерно рассказано о том, как оно работает. Как оказалось, я не первый, кто решил таким образом поразвлечься. Впрочем, там довольно тривиальный пример с вычислением чисел Фибоначчи и соответственно, там многое остаётся неясным. А поскольку C++ я не знаю, то это конечно представляло проблему.
Но оказалось, что человечество пошло гораздо дальше в вопросе придумывания всевозможных извращений и некий добрый человек написал небольшой генератор Cpp-обёрток для Rust-библиотек. Он анализирует исходники на Rust, берёт те функции, которые подходят по критериям и формирует какой-то код на плюсах. И вот для того Rust-кода, который был приведён выше, получился такой вот кусок кода на C++
//Header
//This could go into separate header file defining interface:
#ifndef NATIVE_EXTENSION_GRAB_H
#define NATIVE_EXTENSION_GRAB_H
#include <nan.h>
#include <string>
#include <iostream>
#include <node.h>
#include <stdio.h>
using namespace std;
using namespace v8;
using v8::Function;
using v8::Local;
using v8::Number;
using v8::Value;
using Nan::AsyncQueueWorker;
using Nan::AsyncWorker;
using Nan::Callback;
using Nan::New;
using Nan::Null;
using Nan::To;
#endif
/* extern interface for Rust functions */
extern "C" {
extern "C" char * adler(char * url);
}
NAN_METHOD(adler) {
Nan::HandleScope scope;
String::Utf8Value cmd_url(info[0]);
string s_url = string(*cmd_url);
char *url = (char*) malloc (s_url.length() + 1);
strcpy(url, s_url.c_str());
char * result = adler(url);
info.GetReturnValue().Set(Nan::New<String>(result).ToLocalChecked());
free(result);
free(url);
}
NAN_MODULE_INIT(InitAll) {
Nan::Set(
target, New("adler").ToLocalChecked(),
Nan::GetFunction(New<FunctionTemplate>(adler)).ToLocalChecked()
);
}
NODE_MODULE(addon, InitAll)
Кроме того, у предыдущего товарища я взял пример файла bindings.gyp:
{
"targets": [{
"target_name": "adler",
"sources": ["adler.cc" ],
"libraries": [
"/path/to/lib/adler.dll"
]
}]
}
Ещё нужен файл index.js с одержанием:
module.exports = require('./build/Release/addon');
Теперь надо всю эту радость собрать с помощью node-gyp. Но у меня оно компилироваться с наскока отказалось. Пришлось немного поразбираться в том, что там происходит.
Для начала надо поставить пакет nan (Native Abstractions for Node.js ):
npm install nan -g
И добавить путь до него в bindings.gyp (где-нибудь на одном уровне с libraries):
"include_dirs" : [
"<!(node -e \"require('nan')\")"
]
Там компилятор будет искать заголовочный файл от этого самого nan. После этого нужно было ещё немного поковырять плюсовый файл. Вот конечная версия, которая у меня таки соизволила скомпилироваться:
#include <nan.h>
#include <string>
#include <node.h>
#pragma comment(lib,"Ws2_32.lib")
#pragma comment(lib,"userenv.lib")
using std::string;
using v8::String;
using Nan::New;
extern "C" {
extern "C" char * adler(char * url);
}
NAN_METHOD(adler) {
Nan::HandleScope scope;
String::Utf8Value cmd_url(info[0]);
string s_url = string(*cmd_url);
char *url = (char*) malloc (s_url.length() + 1);
strcpy(url, s_url.c_str());
char * result = adler(url);
info.GetReturnValue().Set(Nan::New<String>(result).ToLocalChecked());
free(result);
free(url);
}
NAN_MODULE_INIT(InitAll) {
Nan::Set(
target, New("adler").ToLocalChecked(),
Nan::GetFunction(New<FunctionTemplate>(adler)).ToLocalChecked()
);
}
NODE_MODULE(addon, InitAll)
Впрочем, прежде чем это случилось, обнаружилась ещё одна штука. Библиотека у меня была скомпилирована как динамическая, а node-gyp требовал статическую. Поэтому, в Cargo.toml нужно поменять вот эту строку:
crate-type = [«dylib»]
на вот эту:
crate-type = [«staticlib»]
После чего опять надо откомпилировать:
cargo build --release --target x86_64-pc-windows-msvc
Кроме того, надо не забыть теперь поменять путь до библиотеки в bindings.gyp на lib-версию:
"libraries": [
"/path/to/lib/adler.lib"
]
И вот тогда-то всё должно собраться и получиться заветный файлик adler.node.
В node опять меняем код для генерации хэша:
var adler = require('/path/to/adler.node');
for (var i=0; i<5000000; i++) {
adler.adler("Какая-то не очень длинная строка, для которой надо посчитать контрольную сумму");
}
Средний результат трёх запусков: 7,802 секунд. Лучший результат: 7,658
О, это уже на пару секунд быстрее, чем даже нативный способ вычисления sha1! Выглядит очень даже симпатично!
В принципе, ведь что такое 5 миллионов раз хэш посчитать и потратить на это 40 секунд? Это примерно как если бы к вам пришло за секунду чуть меньше ста тысяч запросов, а приложение всю эту секунду потратило бы на подсчёт хэшей. То есть, ничем больше оно заниматься бы не успевало. А с таким вот ускорением уже вполне будет успевать заняться и чем-то кроме хэшей. Не думаю, что этот проект когда-нибудь получит такую нагрузку в 100 тысяч запросов в секунду, но тем не менее, опыт считаю достаточно полезным.
Кстати, что там у питона?
В начале статьи упоминался python, почему бы и с ним тоже не попробовать, раз уж всё равно оказался под рукой? Там, как я уже говорил, adler32 можно посчитать прямо из коробки. Примерно такой вот будет код:
# -*- coding: utf-8 -*-
import zlib
st = b'Какая-то не очень длинная строка, для которой надо посчитать контрольную сумму'
for i in range(5000000):
hex(zlib.adler32(st))[2:]
Средний результат трёх запусков: 2,100 секунды. Лучший результат: 2,072
Нет, это не ошибка и запятая нигде не перепутана. По всей видимости, дело всё в том, что поскольку это часть стандартной библиотеки и по сути просто обёртка над Си-шным GNU zip, то это даёт преимущество в скорости. Иными словами, это сравнивается не Python и Rust, а Cи и Rust. И Си получается немного быстрее.
UPD
В Python тоже есть возможность использовать FFI, так что вот небольшое дополнение по этому поводу, по просьбе ynlvko.
Понадобилось перекомпилировать библиотеку под win32, так как у меня стоит 32-битная версия python:
cargo build --release --target i686-pc-windows-msvc
Код:
from ctypes import cdll
lib = cdll.LoadLibrary("adler32.dll")
for i in range(5000000):
lib.adler(b'Какая-то не очень длинная строка, для которой надо посчитать контрольную сумму')
Средний результат трёх запусков: 6,398 секунды. Лучший результат: 6,393То есть, получается, что питоний FFI работает в несколько раз более эффективно, чем node-ffi и даже эффективнее, чем «родные» аддоны
Выводы
Технология | Среднее время, с | Лучшее время, с |
---|---|---|
Node.js | 41,601 | 40,206 |
Node.js+ffi+Rust | 27,882 | 26,642 |
Node.js (sha1) | 9,737 | 9,321 |
Node.js+C++Rust | 7,802 | 7,658 |
Python+ffi+Rust | 6,398 | 6,393 |
Rust | 2,314 | 2,309 |
C/Python (zlib) | 2,100 | 2,072 |
Комментарии (80)
SDSWanderer
02.03.2017 18:11+3Интересный опыт, как насчет опубликовать это как npm пакет?
Juralis
02.03.2017 18:52В принципе, это наверное можно сделать. единственное, я пока не совсем хорошо понимаю, как там правильно сделать кросс-платформенную сборку самим npm всего этого дела вместе с растовским кодом. Если для сборки с/с++ там есть node-gyp, то для сборки раста надо как-то cargo вызывать. Или затаскивать сразу под все платформы уже скомпилированные библиотеки? Но это как-то мне кажется перебор, ради такой плёвой функции. хотя, можно было бы подумать над тем, чтобы сделать какую-то обёртку, которая удобно подключала бы через аддоны библиотеки, совместимые с FFI. Но это немного другая уже история.
ynlvko
02.03.2017 18:25Если бы ещё было сравнение Python + Rust
Halt
02.03.2017 18:49Опять же, можно посмотреть бенчмарки и сравнить. Там и на питоне реализации есть.
Tiberiumk
02.03.2017 20:37Там сравнение скорости вычислений, и понятно, что у компилируемых языков или языков с JIT она будет быстрее.
Halt
03.03.2017 11:57Ну разумеется. А разве автор поста что-то другое делал? Хеширование — самая что ни на есть вычислительная задача.
Какое еще сравнение вы ожидаете увидеть?
zolkko
02.03.2017 21:54Думал, что для nodejs есть https://github.com/neon-bindings/neon. Было бы любопытно взглянуть на его результат. Или это что-то другое?
Juralis
02.03.2017 22:16neon — это несколько иная штука. Они предоставляют абстракции только для node.js. То есть, код который будет написан на расте с использованием neon — теряет универсальность. Библиотечка, которую я там написал, подключается и к node.js и к python и другим — по универсальному интерфейсу. Впрочем, есть смысл попробовать, возможно он был бы быстрее за счёт лучшей адаптированности под задачу
gearbox
02.03.2017 23:44имхо не стоит использовать неон для подключения либы. Он скорее нужен когда надо ускорить уже существующий js код, то есть какой то кусок тупит, его надо вынести в раст, но при этом не потерять обвязку из формата объектов/классов. Чисто диванно-теоретическое имхо.
stepik777
02.03.2017 23:14Код на Расте из статьи не компилируется.
pub extern "C" fn adler(url: *const c_char) -> c_char
Эта функция возвращает
c_char
, хотяs.into_raw()
вернёт указатель на строку, а не отдельный символ. Нельзя возвращать указатель на строку, которая будет деаллоцирована до выхода из функции.
В C++ используется несовместимая сигнатура функции:
extern "C" char * adler(char * url);
Juralis
02.03.2017 23:31Да, прошу прощение. Должно быть:
-> *mut c_charstepik777
02.03.2017 23:51При выходе из функции будет вызван деструктор для переменной
s
, в результате освободится паямять и функция вернёт указатель на освобождённую память.Juralis
03.03.2017 00:03Откровенно говоря, я ещё не до конца проникся всеми тонкостями работы с типами раста, но конкретно в данном случае, я опирался вот на этот пример: http://jakegoulding.com/rust-ffi-omnibus/string_return/
И именно в этом виде, с *mut оно компилируемая и работает. Если правильнее сделать как-то иначе, то будет любопытно узнать.stepik777
03.03.2017 02:12+4Ошибся, деструктор здесь не будет вызван.
В документации к методу CString::into_raw написано, что нужно передать полученный указатель обрато в раст и там освободить. Именно так и делается в примере, который вы скинули.
Потенциально движок ноды и раст могут использовать разные аллокаторы, если нода попытается сама освободить память, выделенную в расте, то программа может в этот момент упасть или ещё чего хуже. Возможно в вашем случае используется один и тот же аллокатор (при использовании MSVC версии раста под виндой такое может быть), но в общем случае могут быть разные.
Juralis
03.03.2017 12:09+1Правильно ли я понимаю, что это касается вот этой части кода на с++
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.
Короче, говоря, освобождать отданное значение на вызывающей стороне — это должно быть вполне корректным решением.stepik777
03.03.2017 19:37На Stack Overflow есть по этому поводу вопрос и ответ.
Может оказаться, что аллокаторы одинаковые, а может, что разные.
Juralis
03.03.2017 20:46Если я правильно понял, там говорится про разные кучи, а не про разные аллокаторы. Впрочем, есть и другой момент — там идёт речь о том, как взаимодействуют библиотеки написанные на С/С++. И в этом смысле, у меня есть подозрение, что логика работы может отличаться. В данном случае, было бы разумно поставить под сомнение и то, что написано в документации и то, что написано на стэке. Существует ли воспроизводимый сценарий получить проблему? Если да, то можно было бы поставить какой-то эксперимент и посмотреть, что будет. Если откровенно, то мне вообще не вполне понятно, что именно должно в данном случае пойти не так.
impowski
03.03.2017 11:07-2Выброси ты эту ноду, нагородил тут. Пошел бы лучше PR в Rocket сделал.
Juralis
03.03.2017 12:25+2Пусть они сначала своё API стабилизируют и переведут его на асинхронный режим работы. Они обещали это сделать в будущем. Кроме того, выкинуть-то я её может быть и выкинул бы, но кто мне старые проекты перепишет с ноды и питона на раст?
Тут я как раз описал некоторый такой вариант диффузной миграции. С его помощью можно постепенно наработать некоторый объём нужного мне функционала и как-то попривыкнуть к местным обычаям. А в какой-то момент просто окажется, что всё, что мне нужно — есть в расте и я понимаю как это эффективно использовать. Вот тогда-то можно будет и выкинуть что-то. И то, что-то всё равно останется, просто потому как есть проекты переменчивые, а есть в стиле «Работает — не трогай», которые я годами не обновляю, поскольку просто нет нужды. Зачем их переписывать? Что я с этого получу?
А главное, как я буду объяснять людям, которые работают со мной совместно, что я взял и выкинул ноду? Они-то раста не знают. Подключаемую библиотеку они ещё могут воспринять, а полный отказ от ноды будет означать отказ ещё и от их участия в проекте. К чему такой тоталитаризм?
KingOfNothing
04.03.2017 23:23Есть такой проект Neon https://github.com/neon-bindings/neon для написания модулей для ноды на rust. Можете тоже попробовать для интереса. Я пробовал (https://github.com/OrKoN/base-x-native) и получилось быстрее, чем js реализация для моего случая. Правда не сравнивал с другими нативными реализациями.
Laney1
тут дело не в языках (rust в общем случае не должен уступать по скорости C), а в разных реализациях. Если сравнить реализации, то в rust-овской compress она самая наивная, а в zlib — сильно оптимизированная.
lieff
Я пробовал низкоуровневые вещи написать на rust, все же он пока не готов, всплывают некоторые вещи, например проблемы с alloca.
eao197
Это почему это? В Rust используется bound checking при обращении к массивам по индексам, тогда как в C — нет. В ряде случаев Rust-овый компилятор способен избавиться от таких проверок (например, при итерациях), но не всегда.
Плюс в Rust-е есть Drop-ы, которые аналоги плюсовых деструкторов, и Drop-ы должны вызываться при выходе из скоупа. Что так же не бесплатно.
Плюс в Rust-е практикуется возврат Result-ов, т.е. пар значений. И в Result-е запросто может оказаться динамически созданный объект на месте Err. Что так же не дешево.
Плюс в Rust-е иногда может применяться динамический диспатчинг вызовов методов трайтов, косвенный вызов дороже прямого.
Скорее в общем случае Rust должен хоть немного, но отставать от C.
Halt
Что-то вы написали вроде и по делу, но не в тему. Абстракции в Rust точно так же как в С++ нулевой стоимости. Сам по себе компилятор не будет ни с того ни с сего добавлять сложность. Пары значений ни при каком раскладе к динамически создаваемому объекту не приведут. Только если вы явно их положили в кучу. В обычном случае же это будет или запись в стек или возврат в регистрах (гуглить по reg struct return). Дропы тоже будут использоваться только там где они реально нужны.
Если уж сравнивать числодробительные возможности, то тогда надо смотреть на бенчмарки. Особенно забавно выглядит первая десятка в K-Nucleotide. И это притом что в Rust-е на данный момент еще только предстоит написать те оптимизации, которые будут на 100% пользоваться гарантиями его системы типов.
eao197
Абстракции в C++ далеко не нулевой стоимости.
Я этого и не утверждал. Только вот тот, кто вызывает метод f() не может знать, положит ли метод f() в результат простой Err или же это будет созданный динамически объект, который реализует нужный трайт.
Наличие паник подразумевает, что должен быть какой-то механизм автоматического раскручивания Drop-ов при выбросе паники по аналогии с плюсовыми деструкторами и исключениями. Это не бесплатно, даже если паники не бросаются.
Речь шла про общий случай, а не про числодробилки.
DarkEld3r
Вот этого не может быть. Нельзя положить "какой-то объект реализующий трейт" по значению: размер объекта должен быть известен. Так что мы всегда увидим или конкретный тип или
Box<Error>
.eao197
> Так что мы всегда увидим или конкретный тип или Box<Error>
И какой тип получается вот в этом примере из стандартной документации? Неужели Box<Error>?
DarkEld3r
Нет.
Лучше смотреть вот сюда:
parse
возвращает конкретный (ассоциированный) тип из трейтаFromStr
. Какой именно — зависит от того, что (вернее, во что) парсим. Дляbool
это будетParseBoolError
и т.д.eao197
Странно, у меня почему-то было ощущение (явно с чьих-то слов) о том, что в Result Err может быть именно что трейтом, а не конкретной структурой.
Возможно, ошибаюсь.
Halt
Result<T, E> это практически тот же шаблон что в C++. Так что в принципе можно объявить любой тип в качестве E.
Возможно, вы обратили внимание на какой-то из вариантов, где использовались trait объекты.
DarkEld3r
В расте, как и в С++, нельзя хранить по значению тип "переменного размера".
T
всегда будет конкретным типом, "интерфейс" надо представлять ссылками или указателями.Положить трейт в
Err
можно, но это будет ужеResult<T, Box<Error>>
, в общем, из сигнатуры можно делать однозначные выводы.Halt
Вы похоже не понимаете, что такое zero cost abstraction.
Если вам нужно вызвать функцию напрямую, то это будет ровно тот же самый call, что в C что в C++ что в Rust. Если вы заранее не знаете адресата (косвенный вызов), то опять же все три приведут одинаковой задержке. При всем желании C тут не сможет быть быстрее. Методы объектов Glib как пример.В C++ может быть оверхед по объему кода при инстанциировании шаблона. То же самое и в Rust. Но это цена за возможность мономорфизации кода и оптимизаций при инлайнинге. В C шаблонов нет, но это не значит, что он будет быстрее в этом случае (инлайнинг в C++/Rust наоборот может оказаться быстрее). Наконец, и в C++ и в Rust можно написать код в plain C стиле и получить ту же производительность.
Неверно. Это записано в типе функции и известно статически уже на этапе компиляции.
Вы говорите про общий случай, но упоминаете почему-то частности.
Опять мимо кассы. Паники построены с помощью механизма исключений LLVM на базе Itanium EABI. В отличие от setjmp/longjmp этот механизм не вносит задержек при нормальном развитии событий. Оверхед возникает только при фактической диспетчеризации исключения.
eao197
Да куда уж мне.
А вы подумайте, как эти частности скажутся в сумме.
У табличного способа поиска исключений есть своя цена, даже если исключения не бросаются. Хотя бы в необходимости хранения этих самых таблиц.
Halt
Я не говорю, что всегда и везде Rust будет быстрее. Нет конечно, его компилятор еще очень молодой и многих оптимизаций там просто нет. Но утверждать, что он будет медленнее в общем случае просто потому, что у него есть некие механизмы — нельзя.
Судить можно только на примере конкретной задачи, конкретной ее реализации и конкретного компилятора. Иначе это холивор.
eao197
С учетом того, как дорого сейчас стоит промах мимо кэша процессора, размер кода и данных программы оказывают прямое влияние на скорость.
Ну вот в реальности C++, например, хоть чуть-чуть, но медленее C. Как раз потому, что абстракции на практике не бесплатны (чтобы там не говорили евангелисты). Нравится верить, что в Rust-е, не смотря на более высокий уровень абстракции, будет что-то по-другому, ну OK, нет проблем.
Halt
Вот как раз верить во что-то, без каких либо оснований, это и есть настоящий фанатизм.
Я не предлагаю верить. Я предлагаю смотреть на конкретные примеры. А там код с абстракциями может уже быть как медленнее так и быстрее наивной реализации. It depends.
eao197
Ну так споры на счет скоростей C и C++ ведутся уже не одно десятилетие. И опыт показывает, что на C++ можно получить код даже быстрее, чем на C, но это не в общем случае.
Теперь те же самые споры будут на счет C и Rust. С ожидаемым результатом.
Halt
Стоит все же упомянуть, что и C и C++ с точки зрения теории типов находятся ниже Rust.
Если говорить совсем точно, то C можно описать с помощью простого типированного лямбда исчисления (с дополнениями). На уровне типов он ничего особенного не предоставляет.
C++, являясь тьюринг полным и на уровне системы типов, чуть сложнее: его можно представить простым типированным ?-исчислением на уровне термов и простым нетипированным ?-исчислением на уровне типов. Нетипированное оно потому, что в С++ нет возможности задать ограничения параметры шаблонов. То что в комитете называют концептами и уже несколько версий не могут сделать.
Rust в этой иерархии стоит еще выше (если учитывать возможность полной специализации дженериков): у него есть ограничения на уровне типов. Поэтому при неверной попытке специализации шаблона (дженерика) Rust дает внятное сообщение об ошибке, а не пресловутые «три экрана шаблонов».
Круче только языки с продвинутыми системами типов на базе ?-исчисления высших порядков: Haskell, Coq, Agda и прочее.
Короче. Я веду к тому, что Rust, в отличие от C++, может грамотно распорядиться своей системой типов и инвариантами, которые из нее можно вывести. А это хорошая пища для оптимизатора. То есть, в тех случаях, когда компилятор C++ пасует и вынужден генерировать общий код, компилятор Rust сможет безопасно закодировать более производительный вариант, либо провести более агрессивный инлайнинг, векторизацию и т.п.
P.S.: Кому интересна эта тема, может посмотреть мой доклад на одной из конференций C++ Siberia.
Halt
Синтетический пример:
Здесь в коде есть одна внешняя функция с неопределенным контрактом и две внутренние которые пользуются ссылкой на T.
В Rust если функция принимает &T это значит, что объект на момент выполнения является замороженным и гарантировано не будет меняться где-то еще. Поэтому оптимизатор может с чистой совестью один раз прочитать значение из памяти и положить его в регистр (если это выгоднее и не создаст нежелательного register pressure).
Функция bar() сможет быть заинлайнена внутрь baz() и пользоваться тем же значением из регистра, потому что компилятор может быть уверен, что input случайно не поменяется (interior mutability — отдельная история). То есть компилятору тут даже escape анализ проводить не нужно, чтобы это понять.
Разумеется в C++ все сильно сложнее и в общем случае const T& не является достаточным основанием для подобных оптимизаций. Алиасинг указателей вообще больная тема.
Halt
Тупанул. В теле baz() должно быть bar(); foo(); bar(), а не наоборот.
apro
А почему можно так лихо убрать из рассуждений
interior mutability
, вполне может быть:и компилятор должен либо уметь понимать все возможные
unsafe
блоки, чтобы определять анлогиRefCell
написанные программистом, либо забыть про оптимизации связанные с неизменяемостью?splav_asv
То, что внутри immutable объекта есть указатель по существу не сильно влияет. Само значение указателя, его можно сложить в регистр и не трогать. Можно так же инлайнить.
Вроде бы…
Halt
Все проще. Вводя unsafe, вы грубо говоря, подписываетесь под тем, что обеспечите все необходимые условия для корректной работы.
Если вы объявили тип
T
какSend+Sync
то это означает, что вы позаботитесь о синхронизации и видимости.В однопоточном случае это значит, что вы динамически проконтролируете баланс ссылок на объект. По этой причине
RefCell
кидает панику если при попытке взять мутабельную ссылку счетчик shared ссылок ненулевой.Подробнее можно почитать в документации и в номиконе.
eao197
А может и не распорядиться и работать с трайтами, как с таблицей виртуальных функций, а так же выполнять все bound checks и возвращать через Result пользовательские enum-ы размером в сотни байт, при этом ничуть не избавляясь от цепочки if-ов, которые спрятаны за синтаксическим сахаром try! и?..
Halt
Я вас в упор не понимаю.
Я пытаюсь говорить о фундаментальных преимуществах, которые может дать система типов Rust, аналогов которых в С/C++ нет. Вы — про текущие недостатки реализации.
Какой-то бессмысленный спор выходит.
eao197
Давайте вернемся к истокам спора. Началось все с фразы:
На мой взгляд, это утверждение неверно. Т.к. в общем случае (а не тогда, когда какой-то код затачивается под максимальную производительность в микробенчмарке посредством ухода в unsafe) пользователи будут пользоваться преимуществами Rust-а, как то:
— встроенные проверки выхода за пределы векторов;
— более высокие уровни абстракции, т.е. trait-ы, которые в ряде случаев будут работать так же, как и виртуальные методы в обычных ОО-языках;
— RAII через Drop, в том числе и при паниках;
— возврат значений (в том числе и не маленьких) через Result, вместо использования привычных для C-шников параметров-указателей.
Все эти вещи не бесплатны. И пусть их стоимость невелика, она все таки не нулевая. Плюс к тому, эти вещи позволяют решать более сложные задачи, что в итоге приводит к тому, что программист оперирует на более высоком уровне абстракции, смиряясь с некоторыми неоптимальностями внизу. Это нормально, поскольку потеря нескольких процентов производительности — это небольшая цена за сокращение сроков разработки, повешение качества и надежности.
Все это уже проходилось в других языках программирования, в том числе и нативных.
При этом в Rust-е вполне можно, если задаться целью, получить и более быстрый код, чем на C (в C++ это так же возможно и продемонстрировано), но речь будет идти не про общий случай, а про частную задачу, вероятно, не очень большого объема.
Ну и да, речь не про фундаментальные преимущества, которые _могут_, а про постылую обыденность, в которой идеальный быстрый код пишут словами в камментах на профильных форумах.
stack_trace
Но ведь в C вы всё-равно будете освобождать ресурс тогда, когда он вам не нужен. Имеено об этом идёт речь, когда говорят о zero-cost abstractions. То есть, корректная программа на C не будет ничем быстрее корректной программы на, например, C++ с деструкторами. Просто если вы используете C вам придётся те же самые деструкторы вызывать в явном виде, что намного сложнее, или же и вовсе невозможно.
eao197
Не так все просто. Во-первых, в том же C++ деструкторы не всегда инлайнятся. Т.е. если где-то в коде написано что-то вроде:
а в каждом деструкторе делается вызов close(), то код с деструкторами будет чуть-чуть дороже, чем код с прямым вызовом close():
Во-вторых, запись действий по очистке вручную может позволить записать действия более компактно. Т.е. если в коде на C++ между вызовами деструкторов a и b пройдет вызов еще нескольких деструкторов, то данные и код для вызова деструктора b уже могут уйти из кэша. Тогда как в C может быть записано что-то вроде:
Так что на практике выплывают некоторые мелочи из-за которых незначительное преимущества в скорости у C все-таки образуются.
Другое дело — стоят ли они того…
stack_trace
Если они не инлайнятся — значит, компилятор считает, что так будет быстрее и вполне возможно, что он прав.
Какая-то странная ситуация. Если там столько всего произошло между этими двумя вызовами то у вас и на С данные точно так же "остынут". Какая разница, как это записать?
Вообще, понятно, что микрооптимизациями можно кое-каких выигрышей добиться. Только мне кажется, в местах, где такие мелочи играют значение, используют даже не C, а asm.
eao197
Ну если вы не увидели разницы между приведенными двумя примерами кода, значит ее нет, а я во всем неправ.
stack_trace
Если у вас функция деинициализации лежит в отдельно библиотеке то вы и в C будете бессильны.
Ну я признаю, что если у вас перед вызовами close() идёт, например, особождения массива динамически выделенных объектов, то данные могут действительно уйти из кэша. Просто когда я представляю себе гипотетически такую ситуацию и представляю, что я пишу этот код на C, я понимаю что не знаю как его лучше написать. Вообще не представляю, какой вариант будет быстрее. И вряд ли вам кто-то скажет точно кроме серии бенчмарков. Отсюда вывод — потенциально вы может и можете написать на C более быстрый код. На практике, не изучая конкретный случай, компилятор, процессор — нет, не можете.
tgz
Никто не против потерять 1% в скорости при гарантиях data safe. В этом и фишка.
eao197
А разве кто-то против этого спорит? Иметь гарантии безопасности от Rust-а при разнице в скорости в районе процента-двух — это просто замечательно.
tgz
Ну вот и спорить о том, какой конь сферичеснее в вакууме тоже не стоит.
Rust — это самое прекрасное, что создала компиляторная индустрия за последние лет 10.
DaylightIsBurning
grossws
Landing pads же не разбавляют каждый метод, равномерно его раздувая. А будет call/ret на 12k от текущей позиции или на 13k — разницы практически никакой.
Ну и для желающих от них полностью избавиться, можно запретить panic'и (использовать
panic = abort
вместоpanic = unwind
).Halt
И наоборот: удачная мономорфизация может привести к каскадному инлайингу и фактическому уменьшению размера горячего кода, что может положительно сказаться на производительности. Cи же будет вынужден вставлять косвенный вызов всегда.
Я потому и говорю, что не существует никакого «общего случая». Всегда надо смотреть на конкретный пример.
akzhan
Я так понимаю, просмотр таблиц идет только при панике? При нормальном развитии событий никакой потери производительности, при этом допущении, нет?
Halt
Да, это просто статические данные, как и строковые литералы. Только пожалуйста не забывайте, что сам механизм диспетчеризации исключения является очень медленным.
Halt
Первый вариант реализации нелокального возврата из функции в моей JIT VM был сделан с помощью выброса исключения. Для прототипа большего и не надо было, да и на первый взгляд, инструмент был самый подходящий: бросаем исключение в дебрях, в объекте исключения кодируем некоторым образом информацию о точке, докуда надо размотать стек и выбрасываем его. Остальное сделает автоматика.
Потом на практике оказалось, что кумулятивно, одна только эта операция по скорости отбрасывала JIT код далеко назад даже по сравнению с софтовым исполнением! То есть буквально: нет инструкций blockReturn — JIT в 50 раз быстрее. Есть — настолько же медленнее.
После того, как вместо выбрасывания исключения результат вызова метода я стал кодировать в паре регистров (обычный результат и контекст нелокального выхода, если таковой есть) JIT VM стала опережать софтовую VM на всех типах инструкций.
Подробнее можно тут почитать в описании релиза 0.4.
DaylightIsBurning
http://stackoverflow.com/questions/13835817/are-exceptions-in-c-really-slow
akzhan
К сожалению, пока не знаю Rust, но по ходу статьи читал о преобразованиях типов попутно при переложении алгоритма под Node.
Я подозреваю, что большая потеря может происходить при перехода от v8 string -> bytes -> adler -> string -> bytes -> v8 complementary type.
По крайней мере, я бы копал в направлении работать напрямую с входными байтами в представлении v8, отдавать родную строку в v8 (речь ведь о hex).
Juralis
Попробовал вызвать библиотечную версию функции из раста — получил среднее время на уровне 3,5 секунд. То есть, издержки на преобразования достаточно большие. Но мне кажется. что даже если их убрать совсем, то разница между вызовом в рамках раста и по тому или иному интерфейсу всё равно будет раза в два. То есть, будет примерно как в питоне ±. Впрочем, это конечно всё равно привлекательно, поскольку получается, что даже современный весьма хорошо оптимизированный js на порядок медленнее, чем вызываемая раст-функция через все эти обёртки и преобразования.
DarkEld3r
Там, где не может компилятор, но очень хочется, то можно сделать руками через
get_unchecked
.Дык, если нам надо выполнять какой-то код на выходе из скопа, то и в C код будет, только написанный руками.
Насчёт динамических ошибок и диспатчинга: это всё делается руками, в зависимости от ситуации. Конечно, нет гарантий на то, что в библиотеках оно будет реализовано так, как нам хотелось бы, но ведь это и для С справедливо.
Хотя с выводом я даже соглашусь с тем уточнением, что в общем случае код на расте будет безопаснее. (:
eao197
Это будет не общий случай.
Это подразумевается по умолчанию. Если намеренно игнорировать безопасность Rust-а, смысла в его использовании нет.
DarkEld3r
С этим не спорю, просто хотел уточнить, что если такое необходимо, то делается довольно легко.
Как сказать. Если выбор между С и Rust, то даже в гипотетической ситуации, когда у нас код на 90% состоит из unsafe блоков, я бы всё равно предпочёл последний. Если, конечно, нет других факторов.
Gorthauer87
Если не нужен произвольный доступ и известен size_hint, то итераторы тоже не будут проверять границы
Laney1
от проверки границ можно избавиться при помощи арифметики указателей в unsafe-блоках, причем бенчмарки показывают, что в большинстве случаев это бессмысленно, т.к. компилятор все оптимизирует.
от дропов можно избавиться при помощи специальной функции mem::forget
eao197
Тогда забываем «в общем случае». В общем случае в Rust-е не будут использоваться unsafe-блоки и mem::forget.
Laney1
это почему? Общий случай подразумевает, что мы пользуемся всеми возможностями языка
eao197
Интересное определение общего случая. Тогда понятно, почему у вас в общем случае Rust имеет производительность C.
Laney1
а у вас какое определение?))
DaylightIsBurning
Ну логично при сравнении двух языков сравнивать те варианты использования языка, которые показывают лучший результат в каждом из них при одинаковой функциональности полученных программ. Если «лучший» значит «более производительный» (как в данном контексте), то в Rust есть способы писать код так, что бы он не уступал C по производительности. При этом, следующим вопросом может быть, будет ли такой производительной Rust-код проигрывать C-коду по другим критериям (удобство написания и поддержки, например). Я не знаю случаев, когда Rust-код будет уступать по этому критерию C-коду, хотя может они и есть.
eao197
Общий случай — это когда используются самые распространенные практики и приемы. Переход в unsafe в Rust-е для выжимания производительности вряд ли можно считать распространенной практикой.
gizme
Расскажите пожалуйста, какова распространенная практика сортировки массива в С?
eao197
Подозреваю, что это намек на то, что обобщенные реализации sort-а быстрее qsort с косвенными вызовами.
Повторю то, что уже говорил:
Какой будет следующий намек?
gizme
Зачем вы увиливаете от ответа?
Вы спорите с утверждением:
Я вас спрашиваю, какая реализация сортировки в общем случае используется в С?
В расте в общем случае используется обобщенная. И что то мне подсказывает что в С в общем случае используется qsort который легко может оказаться на порядок медленнее. В результате раст не то что не уступает в общем случае, а оставляет С далеко позади.
Ну а если задаться целью, то да, можно и еще более быстрый код получить, но мы же не об этом сейчас?
eao197
Ответ вам был дан. Почему вы не можете его прочитать и понять?
Тогда позвольте вас спросить: мы будем под общий случай выдавать конкретную ситуацию? Тогда давайте посмотрим на такой вот «общий» случай:
Значения b и i определяются в run-time, заранее они не известны.
Ну и, видимо, нужно более четко обозначить свою точку зрения, ибо очевидно, что для местных комментаторов она не очевидна:
— в Rust-е не так уж много особенностей, которые бы не позволили компилятору Rust-а сгенерировать такой же эффективный код, как и компилятору C. Одна особенность — это включенный по умолчанию bounds checking, вторая — это генерация таблиц для обработки panic (хотя это лишь косвенное влияние оказывает). А так, в принципе, у компилятора Rust-а достаточно информации, чтобы сгенерировать даже более оптимальный код;
— однако, производительность кода будет определяться не столько возможностями компилятора, сколько программистом. Rust является языком более высокого уровня, позволяет решать более сложные задачи, что дает возможность разработчику использовать более высокие уровни абстракции. Чем выше, тем меньше внимания уделяется тому, что внизу, откуда и проявляется некоторый проигрыш в производительности/ресурсоемкости по сравнению с более низкоуровневыми языками. Это не уникально для Rust-а, это уже проходилось, на моей памяти, как минимум с Ada, Eiffel и C++ (если говорить про то, что транслируется в нативный код);
— под общий случай лучше брать не какой-то микробенчмарк и уж тем более не часть микробенчмарка (вроде упомянутой вами сортировки), а решение какой-то большой задачи. Например, реализация MQ-шного брокера (хоть MQTT-брокера, хоть AMQP) или сервера СУБД. На таком объеме языки более высокого уровня сильно выигрывают в трудозатратах, качестве и надежности, но на какие-то проценты проигрывают в производительности тому же C (причины см. в предыдущем пункте). Если не верите, попробуйте пообщаться с разработчиками PostgreSQL или Tarantool-а.
DarkEld3r
К счастью, от проверки границ можно избавиться более простым способом. (: