Fukusuke Takahashi
Hayabusaは、日本のYamato Securityグループにより開発されたファストフォレンジックツールです。隼のように高速に脅威ハンティングできることを目指し、Rust で開発されています。Rust はそれ自体が高速な言語ですが、その特徴を十分に活かすためのポイントがあります。この文書は、Hayabusa開発史の中の改善事例をもとに、ハイパフォーマンスなRust プログラムを開発するためのテクニックを説明し、今後の開発に役立てることを目的としています。
既定のメモリアロケーターを変更するだけで、大幅に速度改善をできる場合があります。 たとえばこちらのベンチマークによると、以下2つのメモリアロケーターは、
既定のメモリアロケーターより、高速という結果です。Hayabusaでもmimallocを採用することで、大幅な速度改善を確認できたため、バージョン1.8.0からmimallocを利用しています。
# とくになし(規定でメモリアロケータ宣言は不要)
メモリアロケーターの変更手順は、以下の2ステップのみです。
- mimallocクレートを
Cargo.toml
の[dependencies]セクションで指定する[dependencies] mimalloc = { version = "*", default-features = false }
- プログラム中のどこかで、#[global_allocator]でmimalloc利用を明示する
use mimalloc::MiMalloc; #[global_allocator] static GLOBAL: MiMalloc = MiMalloc;
以上で、メモリアロケーターがmimallocに変更されます。
改善効果はプログラムの特性に依りますが、以下の事例では、
上記手順でメモリアロケーターをmimallocに変更することで、Intel系OSで20-30%速度を改善しました。
ディスクIO処理はメモリ上で完結する処理と比較して、非常に低速です。そのため、とくにループの中でのIO処理は極力避けることが望ましいです。
たとえば、ループの中でファイルオープンが100万回発生する以下の処理は、
use std::fs;
fn main() {
for _ in 0..1000000 {
let f = fs::read_to_string("sample.txt").unwrap();
f.len();
}
}
以下のように、ループの外でファイルオープンさせることで、
use std::fs;
fn main() {
let f = fs::read_to_string("sample.txt").unwrap();
for _ in 0..1000000 {
f.len();
}
}
変更前と比較して1000倍ほど速くなります。
以下の事例では、検知結果を1件ずつ扱うときのIO処理をループ外にだすことで、
20%ほどの速度改善を実現しました。
正規表現のコンパイルは、正規表現のマッチングと比較して、非常にコストがかかる処理です。そのため、とくにループ中での正規表現コンパイルは極力避けることが望ましいです。
たとえば、ループの中で正規表現マッチを10万回試行する以下の処理は、
extern crate regex;
use regex::Regex;
fn main() {
let text = "1234567890";
let match_str = "abc";
for _ in 0..100000 {
if Regex::new(match_str).unwrap().is_match(text){ // ループの中で正規表現コンパイル
println!("matched!");
}
}
}
以下のように、ループの外で正規表現コンパイルをすることで、
extern crate regex;
use regex::Regex;
fn main() {
let text = "1234567890";
let match_str = "abc";
let r = Regex::new(match_str).unwrap(); // ループの外で正規表現コンパイル
for _ in 0..100000 {
if r.is_match(text) {
println!("matched!");
}
}
}
変更前と比較して100倍ほど速くなります。
以下の事例では、正規表現コンパイルをループ外で実施し、キャッシュすることで
大幅な速度改善を実現しました。
バッファーIOを使わない場合のファイルIOは、低速です。バッファーIOを使うとメモリ上のバッファーを介して、IO処理が行われ、システムコール回数を削減でき、速度を改善できます。
たとえば、writeが100万回発生する以下の処理は、
use std::fs::File;
use std::io::{BufWriter, Write};
fn main() {
let mut f = File::create("sample.txt").unwrap();
for _ in 0..1000000 {
f.write(b"hello world!");
}
}
以下のように、BufWriterを使うことで、
use std::fs::File;
use std::io::{BufWriter, Write};
fn main() {
let mut f = File::create("sample.txt").unwrap();
let mut writer = BufWriter::new(f);
for _ in 0..1000000 {
writer.write(b"some text");
}
writer.flush().unwrap();
}
変更前と比較して50倍ほど速くなります。
以下の事例では、上記手法により、
出力処理の大幅な速度改善を実現しました。
正規表現は複雑なマッチングパターンを網羅できる一方、String標準のメソッドと比較すると低速です。そのため、以下のような単純な文字列マッチングには、String標準のメソッドを使ったほうが高速です。
- 前方一致(正規表現では、
foo.*
)-> String::starts_with() - 後方一致(正規表現では、
.*foo
)-> String::ends_with() - 部分一致(正規表現では、
.*foo.*
)-> String::contains()
たとえば、100万回正規表現で後方一致マッチを試行する以下の処理は、
extern crate regex;
use regex::Regex;
fn main() {
let text = "1234567890";
let match_str = ".*abc";
let r = Regex::new(match_str).unwrap();
for _ in 0..1000000 {
if r.is_match(text) {
println!("matched!");
}
}
}
以下のように、String::ends_with()を使うことで、
fn main() {
let text = "1234567890";
let match_str = "abc";
for _ in 0..1000000 {
if text.ends_with(match_str) {
println!("matched!");
}
}
}
変更前と比較して10倍ほど速くなります。
Hayabusaでは、大文字小文字を区別しない文字列比較をする必要があるため、to_lowercase()を実施したうえで、上記手法を適用しています。その場合でも以下の事例では、
- Imporving speed by changing wildcard search process from regular expression match to starts_with/ends_with match #890
- Improving speed by using eq_ignore_ascii_case() before regular expression match #884
正規表現を使った場合と比較して、15%ほどの速度改善を実現しました。
扱う文字列の特性に依っては、簡単なフィルターを加えることで、文字列マッチング試行回数を減らし、高速化できる場合があります。 文字列長が非固定長かつ不一致の文字列を比較することが多い場合、文字列長を一次フィルターに使うことで処理を高速化できます。
たとえば、100万回正規表現マッチを試行する以下の処理は、
extern crate regex;
use regex::Regex;
fn main() {
let text = "1234567890";
let match_str = "abc";
let r = Regex::new(match_str).unwrap();
for _ in 0..1000000 {
if r.is_match(text) {
println!("matched!");
}
}
}
以下のように、String::len()を一次フィルターに使うことで、
extern crate regex;
use regex::Regex;
fn main() {
let text = "1234567890";
let match_str = "abc";
let r = Regex::new(match_str).unwrap();
for _ in 0..1000000 {
if text.len() == match_str.len() { //文字列長で1次フィルター
if r.is_match(text) {
println!("matched!");
}
}
}
}
変更前と比較して20倍ほど速くなります。
以下の事例では、上記手法により、
15%ほどの速度改善を実現しました。
Rustのパフォーマンス最適化に関する多くの記事では、[profile.release]
セクションに codegen-units = 1
を追加することが推奨されています。
デフォルトでは並列にコンパイルされるため、コンパイルにかかる時間は遅くなりますが、理論的にはより最適化された高速なコードが得られるはずです。
しかし、この設定を有効にした場合、Hayabusaの動作が遅くなり、コンパイルに時間がかかるため、無効のままにしています。
実行ファイルのバイナリサイズが100kb程度小さくなるので、ハードディスクの容量が限られている組み込みシステムには最適かもしれません。
所有権に関連するコンパイルエラーの解消手段として、clone()やto_string()を安易に使うと、保持するデータ量や頻度に依り、ボトルネックになる可能性があります。 低コストで動作する参照で代替できるかを先に検討することが望ましいです。
たとえば、同一のVec
を複数回イテレーションしたい場合、clone()でコンパイルエラーを解消することもできますが
fn main() {
let lst = vec![1, 2, 3];
for x in lst.clone() { // コンパイルエラー解消のために
println!("{x}");
}
for x in lst {
println!("{x}");
}
}
以下のように、参照を利用することでclone()による不要なコピーをなくすことができます。
fn main() {
let lst = vec![1, 2, 3];
for x in &lst { // 参照でコンパイルエラー解消
println!("{x}");
}
for x in lst {
println!("{x}");
}
}
変更前と比較して最大メモリ使用量が50%ほど削減されます。
以下の事例では、不要なclone()、to_string()、to_owned()を置き換えることで、
大幅なメモリ使用量削減を実現しました。
Vecは全要素をメモリ上に保持するため、要素数に比例して多くのメモリを使います。一要素ずつの処理で事足りる場合は、代わりにIteratorを使用することで、メモリ使用量を大幅に削減できます。
たとえば、1GBほどのファイルを読み出し、Vecを返す以下のreturn_lines()
関数は、
use std::fs::File;
use std::io::{BufRead, BufReader};
fn return_lines() -> Vec<String> {
let f = File::open("sample.txt").unwrap();
let buf = BufReader::new(f);
buf.lines()
.map(|l| l.expect("Could not parse line"))
.collect()
}
fn main() {
let lines = return_lines();
for line in lines {
println!("{}", line)
}
}
以下のように、Iteratorトレイトを返す、
use std::fs::File;
use std::io::{BufRead, BufReader};
fn return_lines() -> impl Iterator<Item=String> {
let f = File::open("sample.txt").unwrap();
let buf = BufReader::new(f);
buf.lines()
.map(|l| l.expect("Could not parse line"))
// ここでcollect()せずに、Iteratorを戻り値として返す
}
fn main() {
let lines = return_lines();
for line in lines {
println!("{}", line)
}
}
また処理の分岐により、型が異なる場合は、以下のようにBox<dyn Iterator<Item = T>>
を返すことで
use std::fs::File;
use std::io::{BufRead, BufReader};
fn return_lines(need_filter:bool) -> Box<dyn Iterator<Item = String>> {
let f = File::open("sample.txt").unwrap();
let buf = BufReader::new(f);
if need_filter {
let result= buf.lines()
.filter_map(|l| l.ok())
.map(|l| l.replace("A", "B"));
return Box::new(result)
}
let result= buf.lines()
.map(|l| l.expect("Could not parse line"));
Box::new(result)
}
fn main() {
let lines = return_lines(true);
for line in lines {
println!("{}", line)
}
}
変更前のメモリ使用量は1GBほどでしたが、3MBほどのメモリ使用量になり、大幅に削減できます。
以下の事例では上記手法により、
1.7GBのJSONファイルの処理時のメモリ使用量を75%削減しています。
24byte未満の短い文字列を大量に扱う場合は、compact_strクレートを利用することで、メモリ使用量の削減効果があります。
たとえば、1000万個のStringを持つ以下のVecは、
fn main() {
let v: Vec<String> = vec![String::from("ABCDEFGHIJKLMNOPQRSTUV"); 10000000];
// なにか処理
}
以下のように、CompactStringに置き換えることで、
use compact_str::CompactString;
fn main() {
let v: Vec<CompactString> = vec![CompactString::from("ABCDEFGHIJKLMNOPQRSTUV"); 10000000];
// なにか処理
}
変更前と比較してメモリ使用量が50%ほど削減されます。
以下の事例では、短い文字列に対して、CompactStringを利用することで、
20%ほどのメモリ使用量削減を実現しました。
プロセス起動中、メモリ上に保持し続ける構造体は、全体のメモリ使用量に影響を及ぼしている可能性があります。Hayabusaでは、とくに以下の構造体(バージョン2.2.2時点)は保持数が多いため、
上記構造体に関連するフィールドの削除は、全体のメモリ使用量削減に一定の効果がありました。
たとえば、DetectInfo
のフィールドはバージョン1.8.1までは、以下でしたが、
#[derive(Debug, Clone)]
pub struct DetectInfo {
pub rulepath: CompactString,
pub ruletitle: CompactString,
pub level: CompactString,
pub computername: CompactString,
pub eventid: CompactString,
pub detail: CompactString,
pub record_information: CompactString,
pub ext_field: Vec<(CompactString, Profile)>,
pub is_condition: bool,
}
以下のように、record_information
フィールドを削除することで、
#[derive(Debug, Clone)]
pub struct DetectInfo {
pub rulepath: CompactString,
pub ruletitle: CompactString,
pub level: CompactString,
pub computername: CompactString,
pub eventid: CompactString,
pub detail: CompactString,
// remove record_information field
pub ext_field: Vec<(CompactString, Profile)>,
pub is_condition: bool,
}
検知結果レコード1件あたり、数バイトのメモリ使用量削減が見込めます。
以下の事例では、検知結果レコード件数が150万件ほどのデータに対して、
- Reduced memory usage of DetectInfo/EvtxRecordInfo #837
- Reduce memory usage by removing unnecessary regex #894
それぞれどちらも、300MB程度メモリ使用量を削減しています。
メモリアロケーターの中には、自身のメモリ使用統計情報を保持するものがあります。たとえばmimallocでは、mi_stats_print_out()関数を呼び出すことで、メモリ使用量が取得できます。
前提: メモリアロケーターを変更するでmimallocを設定している場合の手順です。
-
Cargo.toml
のdependenciesセクションでlibmimalloc-sysクレート指定する[dependencies] libmimalloc-sys = { version = "*", features = ["extended"] }
-
メモリ使用量を測定したい箇所で、以下コードを書き、
unsafe
ブロックでmi_stats_print_out()を呼び出すと標準出力にメモリ使用統計情報が出力されますuse libmimalloc_sys::mi_stats_print_out; use std::ptr::null_mut; fn main() { // Write the following code where you want to measure memory usage unsafe { mi_stats_print_out(None, null_mut()); } }
-
mi_stats_print_out()の出力結果が以下の通り得られます。左上の
peak/reserved
の値が最大メモリ使用量です。
以下で上記実装を適用し、
Hayabusaでは、--debug
オプションつきで実行した場合、メモリ使用量を確認できるようにしています。
OS側で取得できる統計情報から各種リソース使用状況を確認できます。この場合は、以下の2点に注意が必要です。
- アンチウイルスソフトの影響
- 初回実行のみスキャンの影響を受けて、遅くなるため、ビルド後2回目以降の測定結果が比較に適します。
- ファイルキャッシュの影響
- OS起動後、2回目以降の測定結果は、evtxなどのファイルIOがメモリ上のファイルキャッシュから読み出される分、1回目より速くなるため、OS起動後初回の測定結果が比較に適します。
前提:以下はWindowsでPowerShell7
がインストール済みの環境でのみ有効な手順です。
- OSを再起動する
PowerShell7
のGet-Counterコマンドを実行し、パフォーマンスカウンター(下記以外のリソースを計測したい場合は、こちらの記事が参考になります)を1秒間隔で取得し続け、CSVに記録しますGet-Counter -Counter "\Memory\Available MBytes", "\Processor(_Total)\% Processor Time" -Continuous | ForEach { $_.CounterSamples | ForEach { [pscustomobject]@{ TimeStamp = $_.TimeStamp Path = $_.Path Value = $_.CookedValue } } } | Export-Csv -Path PerfMonCounters.csv -NoTypeInformation
- 計測したい処理を実行する
以下は、Hayabusaで、パフォーマンスを計測する際の手順例です。
heaptrackは、LinuxおよびmacOSで利用可能な高機能なメモリプロファイラーです。heaptrackを使うことで、詳細にボトルネックを調査できます。
前提: 以下はUbuntu 22.04の場合の手順です。heaptrackはWindowsでは使えません。
-
以下の2コマンドで、heaptrackをインストール
sudo apt install heaptrack sudo apt install heaptrack-gui
-
Hayabusaのコードから、mimalloc関連の以下箇所のコードを削除する(mimallocではheaptrackによるメモリプロファイルが取得できないため)
-
Hayabusaの
Cargo.toml
の[profile.release]セクションを削除し、以下に変更する[profile.release] debug = true
-
cargo build --release
でリリースビルドをする -
heaptrack hayabusa csv-timeline -d sample -o out.csv
を実行する
以上で、Hayabusaの実行が完了すると、自動でheaptrack解析結果のGUIが立ち上がります。
heaptrack解析結果の例は以下です。Flame Graph
タブやTop-Down
タブで視覚的にメモリ使用量の多い処理を確認することができます。
この文書は、Hayabusaの実際の改善事例から得た知見をもとに作成していますが、誤りやよりパフォーマンスを出せるテクニックがありましたら、issueやプルリクエストを頂けると幸いです!