🧊

パフォーマンスにシビアなRustのプロジェクトでは、`String`に注意する

個人的なOSS活動として、今年はずっとOXCのことをやっておりまして。

https://github.com/oxc-project/oxc

で、このプロジェクトはとってもパフォーマンスにシビア(㍉秒とかそういう)で、PRごとにCodspeedというベンチマークCIがパフォーマンスの変化を教えてくれるようになってる。

で、今回出したとあるPRがどうにもパフォーマンスを低下させてるらしく、原因がわからず困った・・・という話。

問題のコード

これが、パフォーマンス低下に遭遇したoxlintに対するPRの再現コードのイメージ。

fn run(node: &AstNode, ctx: &Context) {
    // 前提となる材料たち
    let settings = &ctx.settings();
    let target_name = settings.get_name("foo");

    // 対象のノード以外はEarly return
    let Some(target_node) = check_target_node(node, ctx) else { return };

    // 処理の本編
    if target_node.name == target_name {
        // ...
    }
}

Linterなので、特定のASTノードに対してのみ、処理を行いたいというイメージ。

run(node, ctx)は、ソースコード中のすべてのASTノードに対して呼び出される。

で、このコードのパフォーマンスが悪くてなんで・・・?って。

この疑似コードでいうcheck_target_node()は、親ノードを遡ったりしてたので、最初はそのループの効率が悪いのか?とか調査してた。

問題だった部分

記事タイトルからお察しの通り、このsettings.get_name()は、&strではなくStringを返す処理だった。

fn run(node: &AstNode, ctx: &Context) {
    let settings = &ctx.settings();
    let target_name = settings.get_name("foo"); // 👈 これ

    let Some(target_node) = check_target_node(node, ctx) else { return };

    if target_node.name == target_name {
        // ...
    }
}

なので、数多のASTノードに対して、使いもしないのにStringを生成していて、それがパフォーマンス影響になってた。

というわけで、本当に必要なときだけ生成するようにしたら、CIがパスするようになった。

fn run(node: &AstNode, ctx: &Context) {
    let Some(target_node) = check_target_node(node, ctx) else { return };

    // 本当に必要なケースでだけ
    let settings = &ctx.settings();
    let target_name = settings.get_name("foo");

    if target_node.name == target_name {
        // ...
    }
}

String&strの違いはわかってたとはいえ、ただ生成するだけでこんなに影響あるんや・・・ってのを実感した1日であった。