Zum Inhalt

Rust-Performance-Leitfaden für Hayabusa-Entwickler

Autor

Fukusuke Takahashi

Englische Übersetzung

Zach Mathis (@yamatosecurity)

Über dieses Dokument

Hayabusa (Englisch: "peregrine falcon", deutsch: "Wanderfalke") ist ein schnelles Forensik-Analysewerkzeug, das von der Gruppe Yamato Security in Japan entwickelt wird. Es wird in Rust entwickelt, um (Bedrohungen) so schnell wie ein Wanderfalke aufzuspüren. Rust ist an sich eine schnelle Sprache, doch es gibt viele Fallstricke, die zu langsamer Geschwindigkeit und hohem Speicherverbrauch führen können. Wir haben dieses Dokument auf Grundlage tatsächlicher Performance-Verbesserungen in Hayabusa erstellt (siehe das Changelog hier), aber diese Techniken sollten auch auf andere Rust-Programme anwendbar sein. Wir hoffen, dass Sie von dem Wissen profitieren können, das wir durch unsere Versuche und Fehler gewonnen haben.

Geschwindigkeitsverbesserung

Den Speicher-Allocator ändern

Allein das Ändern des standardmäßigen Speicher-Allocators kann die Geschwindigkeit erheblich verbessern. Laut diesen Benchmarks sind beispielsweise die folgenden beiden Speicher-Allocatoren

deutlich schneller als der standardmäßige Speicher-Allocator. Wir konnten eine erhebliche Geschwindigkeitsverbesserung feststellen, indem wir unseren Speicher-Allocator von jemalloc auf mimalloc umgestellt haben, daher haben wir mimalloc seit Version 1.8.0 zum Standard gemacht. (Allerdings verbraucht mimalloc etwas mehr Speicher als jemalloc.)

Vorher

# Not applicable. (You do not need to declare anything to use the default memory allocator.)

Nachher

Sie müssen nur die folgenden 2 Schritte ausführen, um den globalen Speicher-Allocator zu ändern:

  1. Fügen Sie das mimalloc-Crate zum [dependencies]-Abschnitt der Datei Cargo.toml hinzu:
    [dependencies]
    mimalloc = { version = "*", default-features = false }
    
  2. Legen Sie unter #[global_allocator] irgendwo im Programm fest, dass Sie mimalloc verwenden möchten:
    use mimalloc::MiMalloc;
    
    #[global_allocator]
    static GLOBAL: MiMalloc = MiMalloc;
    
    Mehr müssen Sie nicht tun, um den Speicher-Allocator zu ändern.

Wirksamkeit(Reales Beispiel aus einem Pull Request)

Wie stark sich die Geschwindigkeit verbessert, hängt vom Programm ab, aber im folgenden Beispiel

führte das Ändern des Speicher-Allocators auf mimalloc zu einer Performance-Steigerung von 20-30 % auf Intel-CPUs. (Aus irgendeinem Grund gab es auf ARM-basierten macOS-Geräten keine so deutliche Performance-Steigerung.)

IO-Verarbeitung in Schleifen reduzieren

Festplatten-IO-Verarbeitung ist viel langsamer als Verarbeitung im Speicher. Daher ist es wünschenswert, IO-Verarbeitung so weit wie möglich zu vermeiden, insbesondere in Schleifen.

Vorher

Das Beispiel unten zeigt, wie ein Datei-Öffnen eine Million Mal in einer Schleife auftritt:

use std::fs;

fn main() {
    for _ in 0..1000000 {
        let f = fs::read_to_string("sample.txt").unwrap();
        f.len();
    }
}

Nachher

Indem die Datei wie folgt außerhalb der Schleife geöffnet wird

use std::fs;

fn main() {
    let f = fs::read_to_string("sample.txt").unwrap();
    for _ in 0..1000000 {
        f.len();
    }
}
ergibt sich eine etwa 1000-fache Geschwindigkeitssteigerung.

Wirksamkeit(Reales Beispiel aus einem Pull Request)

Im folgenden Beispiel konnte die IO-Verarbeitung bei der Behandlung eines Erkennungsergebnisses nach dem anderen außerhalb der Schleife durchgeführt werden:

Dies führte zu einer Geschwindigkeitsverbesserung von etwa 20 %.

Kompilierung regulärer Ausdrücke in Schleifen vermeiden

Die Kompilierung regulärer Ausdrücke ist im Vergleich zum Abgleich regulärer Ausdrücke ein sehr kostspieliger Vorgang. Daher ist es ratsam, die Kompilierung regulärer Ausdrücke so weit wie möglich zu vermeiden, insbesondere in Schleifen.

Vorher

Der folgende Vorgang erzeugt zum Beispiel 100.000 Versuche, einen regulären Ausdruck in einer Schleife abzugleichen:

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){ // Regular expression compilation in a loop
            println!("matched!");
        }
    }
}

Nachher

Indem die Kompilierung des regulären Ausdrucks außerhalb der Schleife durchgeführt wird, wie unten gezeigt

extern crate regex;
use regex::Regex;

fn main() {
    let text = "1234567890";
    let match_str = "abc";
    let r = Regex::new(match_str).unwrap(); // Compile the regular expression outside the loop
    for _ in 0..100000 {
        if r.is_match(text) {
            println!("matched!");
        }
    }
}
ist der aktualisierte Code etwa 100-mal schneller.

Wirksamkeit(Reales Beispiel aus einem Pull Request)

Im folgenden Beispiel wird die Kompilierung des regulären Ausdrucks außerhalb der Schleife durchgeführt und zwischengespeichert.

Dies führte zu erheblichen Geschwindigkeitsverbesserungen.

Buffer-IO verwenden

Ohne Buffer-IO ist Datei-IO langsam. Mit Buffer-IO werden IO-Operationen über Puffer im Speicher durchgeführt, wodurch die Anzahl der Systemaufrufe reduziert und die Geschwindigkeit verbessert wird.

Vorher

Im folgenden Vorgang tritt zum Beispiel write 1.000.000 Mal auf.

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!");
    }
}

Nachher

Durch die Verwendung von BufWriter wie folgt

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();
}
ergibt sich eine etwa 50-fache Geschwindigkeitsverbesserung.

Wirksamkeit(Reales Beispiel aus einem Pull Request)

Die oben beschriebene Methode wurde hier implementiert

und hat zu erheblichen Geschwindigkeitsverbesserungen bei der Ausgabeverarbeitung geführt.

Standard-String-Methoden anstelle von regulären Ausdrücken verwenden

Während reguläre Ausdrücke komplexe Abgleichsmuster abdecken können, sind sie langsamer als Standard-String-Methoden. Daher ist es schneller, für einfache String-Abgleiche wie die folgenden Standard-String-Methoden zu verwenden.

Vorher

Der folgende Code führt zum Beispiel eine Million Mal einen Ends-with-Abgleich mit einem regulären Ausdruck durch.

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!");
        }
    }
}

Nachher

Durch die Verwendung von String::ends_with() wie folgt

fn main() {
    let text = "1234567890";
    let match_str = "abc";
    for _ in 0..1000000 {
        if text.ends_with(match_str) {
            println!("matched!");
        }
    }
}
wird die Verarbeitung 10-mal schneller.

Wirksamkeit(Reales Beispiel aus einem Pull Request)

Da Hayabusa einen Groß-/Kleinschreibung-unabhängigen String-Vergleich benötigt, verwenden wir to_lowercase() und wenden dann die obige Methode an. Selbst dann hat sich in den folgenden Beispielen

die Geschwindigkeit im Vergleich zu vorher um etwa 15 % verbessert.

Nach String-Länge filtern

Je nach den Eigenschaften der verarbeiteten Strings kann das Hinzufügen eines einfachen Filters die Anzahl der String-Abgleichsversuche reduzieren und den Vorgang beschleunigen. Wenn Sie häufig Strings mit nicht festgelegter und nicht übereinstimmender String-Länge vergleichen, können Sie den Vorgang beschleunigen, indem Sie die String-Länge als primären Filter verwenden.

Vorher

Der folgende Code versucht zum Beispiel eine Million Abgleiche regulärer Ausdrücke.

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!");
        }
    }
}

Nachher

Durch die Verwendung von String::len() als primären Filter, wie unten gezeigt

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() { // Primary filter by string length
            if r.is_match(text) {
                println!("matched!");
            }
        }
    }
}
wird sich die Geschwindigkeit um etwa das 20-Fache verbessern.

Wirksamkeit(Reales Beispiel aus einem Pull Request)

Im folgenden Beispiel wird die obige Methode verwendet.

Dies verbesserte die Geschwindigkeit um etwa 15 %.

Nicht mit codegen-units=1 kompilieren

Viele Artikel über Performance-Optimierung mit Rust raten dazu, codegen-units = 1 unter dem Abschnitt [profile.release] hinzuzufügen. Dies führt zu langsameren Kompilierungszeiten, da standardmäßig parallel kompiliert wird, sollte aber theoretisch zu optimierterem und schnellerem Code führen. In unseren Tests läuft Hayabusa mit dieser aktivierten Option jedoch tatsächlich langsamer und die Kompilierung dauert länger, daher lassen wir dies deaktiviert. Die Binärgröße der ausführbaren Datei ist etwa 100 kb kleiner, daher kann dies für eingebettete Systeme ideal sein, bei denen der Festplattenspeicher begrenzt ist.

Speicherverbrauch reduzieren

Unnötige Verwendung von clone(), to_string() und to_owned() vermeiden

Die Verwendung von clone() oder to_string() ist eine einfache Möglichkeit, Kompilierungsfehler im Zusammenhang mit Ownership zu beheben. Sie führen jedoch in der Regel zu hohem Speicherverbrauch und sollten vermieden werden. Es ist immer am besten, zuerst zu prüfen, ob Sie sie durch kostengünstige Referenzen ersetzen können.

Vorher

Wenn Sie zum Beispiel über denselben Vec mehrmals iterieren möchten, können Sie clone() verwenden, um Kompilierungsfehler zu beseitigen.

fn main() {
    let lst = vec![1, 2, 3];
    for x in lst.clone() { // In order to eliminate compile errors
        println!("{x}");
    }

    for x in lst {
        println!("{x}");
    }
}

Nachher

Durch die Verwendung von Referenzen, wie unten gezeigt, können Sie jedoch die Notwendigkeit beseitigen, clone() zu verwenden.

fn main() {
    let lst = vec![1, 2, 3];
    for x in &lst { // Eliminate compile errors with a reference
        println!("{x}");
    }

    for x in lst {
        println!("{x}");
    }
}
Durch das Entfernen der clone()-Verwendung wird der Speicherverbrauch um bis zu 50 % reduziert.

Wirksamkeit(Reales Beispiel aus einem Pull Request)

Im folgenden Beispiel konnten wir durch das Ersetzen der unnötigen Verwendung von clone(), to_string() und to_owned(),

den Speicherverbrauch erheblich reduzieren.

Iterator anstelle von Vec verwenden

Vec hält alle Elemente im Speicher, daher verbraucht es viel Speicher proportional zur Anzahl der Elemente. Wenn die Verarbeitung eines Elements nach dem anderen ausreicht, verbraucht die Verwendung eines Iterators stattdessen viel weniger Speicher.

Vorher

Die folgende Funktion return_lines() liest zum Beispiel eine Datei von etwa 1 GB und gibt einen Vec zurück:

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)
    }
}

Nachher

Stattdessen sollten Sie wie folgt einen Iterator-Trait zurückgeben:

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)
    }
}
Oder wenn der Typ je nachdem, welcher Zweig genommen wird, unterschiedlich ist, können Sie wie folgt einen Box<dyn Iterator<Item = T>> zurückgeben:
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)
    }
}
Der Speicherverbrauch sinkt erheblich von 1 GB auf nur 3 MB.

Wirksamkeit(Reales Beispiel aus einem Pull Request)

Das folgende Beispiel verwendet die oben beschriebene Methode:

Beim Test mit einer 1,7 GB großen JSON-Datei verringerte sich der Speicher um 75 %.

Das compact_str-Crate beim Umgang mit kurzen Strings verwenden

Beim Umgang mit einer großen Anzahl kurzer Strings von weniger als 24 Byte kann das compact_str-Crate verwendet werden, um den Speicherverbrauch zu reduzieren.

Vorher

Im Beispiel unten enthält der Vec 10 Millionen Strings.

fn main() {
    let v: Vec<String> = vec![String::from("ABCDEFGHIJKLMNOPQRSTUV"); 10000000];
    // do some kind of processing
}

Nachher

Es ist besser, sie durch einen CompactString zu ersetzen:

use compact_str::CompactString;

fn main() {
    let v: Vec<CompactString> = vec![CompactString::from("ABCDEFGHIJKLMNOPQRSTUV"); 10000000];
    // do some kind of processing
}
Dadurch wird der Speicherverbrauch um etwa 50 % reduziert.

Wirksamkeit(Reales Beispiel aus einem Pull Request)

Im folgenden Beispiel werden kurze Strings mit CompactString behandelt:

Dies ergab eine Reduzierung des Speicherverbrauchs um etwa 20 %.

Unnötige Felder in langlebigen Strukturen löschen

Strukturen, die während des Prozessstarts weiterhin im Speicher gehalten werden, können den gesamten Speicherverbrauch beeinflussen. In Hayabusa werden insbesondere die folgenden Strukturen (Stand Version 2.2.2) in großer Zahl gehalten.

Das Entfernen von Feldern, die mit den obigen Strukturen verbunden sind, hatte einen gewissen Effekt auf die Reduzierung des gesamten Speicherverbrauchs.

Vorher

Das Feld DetectInfo war zum Beispiel bis Version 1.8.1 wie folgt:

#[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,
}

Nachher

Durch das Löschen des Feldes record_information wie folgt

#[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,
}
wurde eine Reduzierung des Speicherverbrauchs von mehreren Byte pro Erkennungsergebnis-Datensatz erreicht.

Wirksamkeit(Reales Beispiel aus einem Pull Request)

Im folgenden Beispiel konnten wir beim Test gegen Daten, bei denen die Anzahl der Erkennungsergebnis-Datensätze etwa 1,5 Millionen betrug,

eine Reduzierung des Speicherverbrauchs um etwa 300 MB erreichen.

Benchmarking

Die Statistikfunktion des Speicher-Allocators verwenden.

Einige Speicher-Allocatoren führen ihre eigenen Speicherverbrauchsstatistiken. In mimalloc kann zum Beispiel die Funktion mi_stats_print_out() aufgerufen werden, um den Speicherverbrauch zu erhalten.

So erhalten Sie Statistiken

Voraussetzungen: Sie müssen mimalloc verwenden, wie im Abschnitt Den Speicher-Allocator ändern erklärt.

  1. Fügen Sie im dependencies-Abschnitt von Cargo.toml das libmimalloc-sys-Crate hinzu:
    [dependencies]
    libmimalloc-sys = { version = "*",  features = ["extended"] }
    
  2. Wann immer Sie die Speicherverbrauchsstatistiken ausgeben möchten, schreiben Sie den folgenden Code und rufen Sie innerhalb eines unsafe-Blocks mi_stats_print_out() auf. Die Speicherverbrauchsstatistiken werden auf die Standardausgabe ausgegeben.
    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());
      }
    }
    
  3. Der Wert peak/reserved oben links ist der maximale Speicherverbrauch.

    mimalloc_stats_print_out

Beispiel

Die obige Implementierung wurde im Folgenden angewendet:

Wenn Sie in Hayabusa die Option --debug hinzufügen, werden am Ende Speicherverbrauchsstatistiken ausgegeben.

Den Performance-Counter von Windows verwenden

Verschiedene Ressourcennutzungen können aus Statistiken überprüft werden, die auf der Betriebssystemseite erhalten werden können. In diesem Fall sind die folgenden zwei Punkte zu beachten.

  • Einfluss durch Antivirensoftware (Windows Defender)
  • Nur der erste Durchlauf wird vom Scan beeinflusst und ist langsamer, daher eignen sich Ergebnisse vom zweiten und folgenden Durchläufen nach dem Build für den Vergleich. (Oder Sie können Ihr Antivirenprogramm deaktivieren, um genauere Ergebnisse zu erhalten.)
  • Einfluss durch Datei-Caching
  • Die Ergebnisse vom zweiten und folgenden Mal nach dem Betriebssystemstart sind schneller als beim ersten Mal, weil evtx und andere Datei-IOs aus dem Datei-Cache im Speicher gelesen werden, daher sind die Ergebnisse vom ersten Mal nach dem Booten des Betriebssystems idealer für die Durchführung von Benchmarks.

So erhalten Sie ihn

Voraussetzungen:Das folgende Verfahren ist nur für Umgebungen gültig, in denen PowerShell 7 bereits auf Windows installiert ist.

  1. Starten Sie das Betriebssystem neu
  2. Führen Sie den Get-Counter-Befehl von PowerShell 7 aus, der den Performance-Counter kontinuierlich jede Sekunde in eine CSV-Datei aufzeichnet. (Wenn Sie andere Ressourcen als die unten aufgeführten messen möchten, ist dieser Artikel eine gute Referenz.)
    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
    
  3. Führen Sie den Prozess aus, den Sie messen möchten.

Beispiel

Das Folgende enthält ein Beispielverfahren zum Messen der Performance mit Hayabusa.

heaptrack verwenden

heaptrack ist ein ausgefeilter Speicher-Profiler, der für Linux und macOS verfügbar ist. Durch die Verwendung von heaptrack können Sie Engpässe gründlich untersuchen.

So erhalten Sie ihn

Voraussetzungen: Unten ist das Verfahren für Ubuntu 22.04. Sie können heaptrack nicht auf Windows verwenden.

  1. Installieren Sie heaptrack mit den folgenden zwei Befehlen.
    sudo apt install heaptrack
    sudo apt install heaptrack-gui
    
  2. Entfernen Sie den folgenden mimalloc-Code aus Hayabusa. (Sie können den Speicher-Profiler von heaptrack nicht mit mimalloc verwenden.
  3. https://github.com/Yamato-Security/hayabusa/blob/v2.2.2/src/main.rs#L32-L33
  4. https://github.com/Yamato-Security/hayabusa/blob/v2.2.2/src/main.rs#L59-L60
  5. https://github.com/Yamato-Security/hayabusa/blob/v2.2.2/src/main.rs#L632-L634

  6. Löschen Sie den [profile.release]-Abschnitt in der Datei Cargo.toml von Hayabusa und ändern Sie ihn wie folgt:

    [profile.release]
    debug = true
    

  7. Erstellen Sie einen Release-Build: cargo build --release

  8. Führen Sie heaptrack hayabusa csv-timeline -d sample -o out.csv aus

Wenn Hayabusa nun mit der Ausführung fertig ist, werden die Ergebnisse von heaptrack automatisch in einer GUI-Anwendung geöffnet.

Beispiele

Ein Beispiel für die Ergebnisse von heaptrack wird unten gezeigt. Die Tabs Flame Graph und Top-Down ermöglichen es Ihnen, Funktionen mit hohem Speicherverbrauch visuell zu überprüfen.

heaptrack01

heaptrack02

Referenzen

Beiträge

Dieses Dokument basiert auf Erkenntnissen aus tatsächlichen Verbesserungsfällen in Hayabusa. Wenn Sie Fehler oder Techniken finden, die die Performance verbessern können, senden Sie uns bitte ein Issue oder einen Pull Request.