はじめに
どうも、レガシー組込みエンジニアの@yagisawaです。
Rustに関してネットサーフィンしていると、
結局Cには敵わない。組込みはアセンブリで速度チューニングする世界だ。
みたいな書込みを見かけることがあります。
そこで、敵わないのはそうなのかもしれないけど、実際どんだけ敵わないのよ?というのを調べてみました。
Rustの速度について調べていたら以下の記事を見つけたので、比較する処理はモンテカルロ法にしました。
組込みでは(速度的な理由で)浮動小数点演算を避ける傾向にあるため、本記事では100倍した整数値(つまり計算結果が314付近になる)を扱っています。
本記事の趣旨はCとRustで同等な実装(言語仕様上合わせられない部分を除く)をし、速度比較をするところにあります。したがって、実装の正確性やアルゴリズムの精度は本質ではありませんので予めご了承下さい。
環境
共通
-
- OS
Windows 10 Home 21H2(64bit)
ボード
NUCLEO-F401RE
クロック:最大84MHz
ROM:512KB
RAM:96KB
C
-
- arm-none-eabi-gcc
10.3.1 20210824
最適化
-Ofast
Rust
-
- rustc・cargo
1.65.0-nightly
最適化
–release(速度最適化のMAX)
コード
アルゴリズムの実行時間をSysTickで1ms単位で計測するコードを書きました。
ライブラリも含めて言語の実力なのかもしれませんが、差が出ないように乱数生成は線形合同法で自作しました。
static int cnt = 0;
void SysTick_Handler() {
cnt++;
}
int32_t rand() {
static int64_t x = 10;
const int64_t a = 48271;
const int64_t b = 2147483647;
x = (a * x) & b;
return (int32_t)x;
}
int32_t montecarlo() {
const int32_t times = 100000;
int32_t cnt = 0;
for (int32_t i = 0; i < times; i++) {
const int32_t x = rand() % 1000;
const int32_t y = rand() % 1000;
if ((x * x + y * y) <= 1000000) {
cnt++;
}
}
return cnt * 400 / times;
}
int main() {
const int32_t times = 10;
volatile int32_t result = 0;
volatile uint32_t* stk_ctrl_addr = (uint32_t*)0xE000E010;
volatile uint32_t* stk_load_addr = (uint32_t*)0xE000E014;
volatile uint32_t* stk_val_addr = (uint32_t*)0xE000E018;
*stk_val_addr = 0;
*stk_load_addr = 2000;
*stk_ctrl_addr = 0x3;
for (int32_t i = 0; i < times; i++) {
result += montecarlo();
}
result = (result + times - 1) / times;
while ( 1 );
}
static mut CNT: i32 = 0;
#[no_mangle]
unsafe extern "C" fn SysTick() {
CNT += 1;
}
fn rand() -> i32 {
static mut X: i64 = 10;
let a: i64 = 48271;
let b: i64 = 2147483647;
unsafe {
X = (a * X) & b;
X as i32
}
}
fn montecarlo() -> i32 {
let times = 100000;
let mut cnt = 0;
for _ in 0..times {
let x = rand() % 1000;
let y = rand() % 1000;
if (x * x + y * y) <= 1000_000 {
cnt += 1;
}
}
cnt * 400 / times
}
fn main() -> ! {
let times = 10;
let mut result = 0;
let stk_ctrl_addr = 0xE000_E010 as *mut usize;
let stk_load_addr = 0xE000_E014 as *mut usize;
let stk_val_addr = 0xE000_E018 as *mut usize;
unsafe {
write_volatile(stk_val_addr, 0);
write_volatile(stk_load_addr, 2_000);
write_volatile(stk_ctrl_addr, 0x3);
}
for _ in 0..times {
result += montecarlo();
}
unsafe {
write_volatile(&mut result, (result + times - 1) / times);
}
loop {}
}
結果
一先ず最適化なしで比較してみたところ、以下のようになりました。
ぁ、これあかんやつや…
なんと2倍以上遅い結果に。
ほら、やっぱり遅い!とか言われそう…
まぁ組込みソフトで最適化かけないとかありえないので、本命の最適化ありで比較してみたところ、以下のようになりました。
おー、殆ど差がない!
同じ処理を10回繰り返しているので1回あたり6ms差が出ています。
正直組込みで6msの差はでかいですが、結構頑張っている方ではないでしょうか。
言い訳するとスタートアップが手抜き実装で、本来84MHzで動けるところ16MHzで動いている(1/5.25)ため、本気出したら約1msの差ということになります。作るシステムにもよりますが、最近は数百Mhz~1GHzぐらいのマイコンもあるため、そのようなマイコンでは更に差は小さくなります。
まぁ遅かったという事実は変わりませんが_| ̄|○
検証
時間があったらどんなマシン語が生成されているか比較してみようと思ってます。
一先ずリリースビルドのセクションサイズを晒しておきますと、
Sections:
Name Size VMA LMA File off Algn
.isr_vector 00000040 08000000 08000000 00010000 2**0
.text 000001b4 08000040 08000040 00010040 2**2
.rodata 00000000 080001f4 080001f4 00020008 2**0
.data 00000008 20000000 080001fc 00020000 2**3
.bss 00000020 20000008 08000204 00020008 2**2
Sections:
Name Size VMA LMA Type
.vector_table 00000040 08000000 08000000 DATA
.text 000017b6 08000040 08000040 TEXT
.rodata 00000000 080017f6 080017f6 TEXT
.bss 00000004 20000000 20000000 BSS
.data 00000008 20000008 080017f6 DATA
という結果になりました。
Rustの最適化はバイナリサイズを犠牲にして速度を最適化しているので、textセクションのサイズが6,070Byte、対してCは436Byte…これで速度で負けてるんだから敗北感しかない_| ̄|○
おわりに
まだまだ調整の余地はあるのかもしれませんが、普通にやった場合やっぱりRustは(Cよりは)遅かったです。
が、絶望的に差が出たわけでもないですし、Rustが生きる場面は中~大規模システムではないかと思っているので、悲観はしないでおこうと思います。
また今回の比較ではゼロコスト抽象化等Rustのいい面を生かしたコードを書いていないため、エラーチェック等安全面に配慮したコードを書いていくとまた違ってくると思います(Rustはビルド時にエラーチェックできることが多いので)。
リソースが貧弱でゴリゴリに速度チューンしたコードを書きたい場合はC、リソースは余裕があるから安全性の高いコードを書きたい場合はRust、という棲み分けをすればよいのではと思います。
どっちも欲しい欲張りさんはRustでもアセンブリは書けますので、キモの部分はアセンブリその他はRust、という選択肢もあるかもしれません。
追記
@fujitanozomuさんからご提案いただいたコードを試してみました。
変更点は
// rand内
int64_t → uint32_t
// montecarlo内
% 1000 → % 1024U
<= 1000000 → <= 1046529U
// rand内
i64 → u32
X = (a * X) & b → X = a.wrapping_mul(X) & b // オーバーフロー対策
// montecarlo内
% 1000 → % 1024u32
<= 1000000 → <= 1_046_529u32
となります。
結果は以下のようになりました。
RustがCを抜いたぞ!
コンパイラによって最適化の得意不得意がありそうですね。
gccだとバックエンドがLLVMではなくフェアではないとのことですので、この結果が全てではない旨ご承知おきください。
時間ができたらclangでの速度比較もやってみようと思います。
コメントにも書きましたが、速度比較は言語 vs 言語という単純な話ではなく、実装者の知識や腕に左右される世界なんだな…というのが今回の検証で見えてきました。
チープなコードを書いて「この言語つかえねー」とか言わないように気をつけたいと思いました。