Goによるde(v)軽フル連続ベンチマーク



次の記事では、Goアプリケーションのベンチマークの重要性について説明しません.
重要性を理解していても、新しいアプリケーション機能を開発するとき、または既存のものを改善するとき、何度もベンチマークテストは無視されます.これは多くの理由の結果でありえます、しかし、主にそれは意味のあるベンチマークを実行するのに必要な面倒の結果です.
ここでは,機能開発ライフサイクルにおける簡単で低摩擦ステップをベンチマーキングする方法を述べた.

何が必要


まず、アプリケーション機能をベンチマークするときの基本的な要件について説明します.
  • ベンチマーク結果は、我々に何か意味がある
  • を話しなければなりません
  • ベンチマーク結果は信頼性があり再現性がある
  • であるべきである
  • ベンチマークテストは、互いから分離されなければなりません
    Go Testingパッケージで利用可能なベンチマークツールは、上記の要件を満たすために有用で受け入れられている方法ですが、最善のワークフローを得るためには、追加のniceを含める必要があります.
  • ベンチマークテストは、(非常に)
  • を書くのが簡単でなければなりません
  • ベンチマークスイートはできるだけ速く走らなければならないので、それはCI
  • に統合されることができます
  • ベンチマーク結果は、Actionable
  • でなければなりません

    簡単、右?


    一見して、これらの要件の全ては既存のツールで覆われるべきです.
    Dave CheneyのGreat blog postからの例を見て、Fib(int)関数のベンチマークを書くのは非常に簡単で、このテストと別のコードの間の重複コードは無視できます.
    func BenchmarkFib10(b *testing.B) {
        // run the Fib function b.N times
        for n := 0; n < b.N; n++ {
            Fib(10)
        }
    }
    

    現実世界ではない


    この記事の目的のために、より挑戦的なユースケースを見てみましょう.今回はSQLデータベースのSELECT文を実行する関数をベンチマークします.
    このフローの決定的な性質は、実際のテストを実行する前にセットアップステップを実行する必要があります.
    そのようなテストはどうでしょうか?おそらくこれに似ているでしょう.
    func Benchmark_dao_FindByName(b *testing.B) {
        ctx, ctxCancel := context.WithCancel(context.Background())
        defer ctxCancel()
    
        // when working with multiple DB types, or randomized ports, 
        // this can involve some logic, for example, 
        // resolving the connection info from env var / config.
        dbDriver := resolveDatabaseDriver()
        dbUrl := resolveDatabaseURL()
    
        db, err := sql.Open(driver, dbUrl)
        require.NoError(b, err)
    
        dao := domain.NewDAO(db)
    
        // populate the db with enough entries for the test to be meaningful
        populateDatabaseWithTestData(b, dao)
    
        // ignore setup time from benchmark
        b.ResetTimer()
    
        // run the FindByName function b.N times
        for n := 0; n < b.N; n++ {
            _, err = dao.FindByName(ctx, "last_inserted_name")
            require.NoError(b, err)
        }
    }
    
    Yikesは、最も単純な例でさえ、すべてのセットアップコードを見ます.今、実際のアプリケーションCodeBaseの各ベンチマークテストのために書いて想像!
    ここで注意しなければならないことは、必要とされるテストタイマーをリセットしたり、必要なループ内でテストを実行することを忘れてしまった場合(はい、間違いが発生した場合)は、明確な指示なしでテストが無効になることです.

    だから、何をすることができますか?


    JFrogにおいて、我々はプロセスができるだけ苦痛のないものであることを望みます、それで、我々は生産性と開発時間にあまりに影響を及ぼすことなく、devチームに左の責任を移すことができます.この考え方の例は、エンドツーエンドスイートまたはQAパイプラインの一部ではなく、アプリケーションテストスイートの一部としてベンチマークテストを記述することです.
    したがって、上記の例を見て、我々は新しいベンチマークテストを書く経験が我々の開発者にとって苦痛であることを知っている.セットアップコードをできるだけ少なくする方法を見てみましょう.

    1 .アプリケーションのセットアップとベンチマークの状態


    上の例のセットアップロジックのほとんどはラップユーティリティに延期できます.
    DB接続情報の解決、アプリケーションオブジェクトの作成、テストで使用できるようにする
    type BenchSpec struct {
        Name string
        // Runs b.N times, after the benchmark timer is reset.
        // The provided context.Context and container.AppContainer 
        // are closed during b.Cleanup().
        Test func(t testing.TB, ctx context.Context, app myapp.Application)
    }
    
    func runBenchmark(b *testing.B, spec BenchSpec) {
        b.Helper()
    
        ctx, ctxCancel := context.WithCancel(context.Background())
        b.Cleanup(ctxCancel)
    
        // when working with multiple DB types, or randomized ports, 
        // this can involve some logic, for example, 
        // resolving the connection info from env var / config.
        dbDriver := resolveDatabaseDriver()
        dbUrl := resolveDatabaseURL()
    
        db, err := sql.Open(driver, dbUrl)
        require.NoError(b, err)
    
        dao := domain.NewDAO(db)
        // did you try https://github.com/google/wire yet? ;)
        app := myapp.NewApplication(dao)
    
        // populate the db with enough entries for the test to be meaningful
        populateDatabaseWithTestData(b, app)
    
        b.Run(spec.Name, func(b *testing.B) {
            for n := 0; n < b.N; n++ {
                spec.Test(b, ctx, app)
            }
        }
    }
    
    func Benchmark_dao_FindByName(b *testing.B) {
        runBenchmark(b, BenchSpec{
            Name: "case #1",
            Test: func(t testing.TB, ctx context.Context, app myapp.Application) {
                _, err = app.Dao.FindByName(ctx, "last_inserted_name")
                require.NoError(t, err)
            }
        })
    }
    
    さて、実際のテストコードは些細なことがわかります.ベンチマークの方法をrunBenchmarkヘルパー関数に渡す必要があります.この関数は、私たちのすべての重いリフトを扱います.
    また、私たちは間違いを犯すことから私たちを続けます、BenchSpec.Testの引数242479142が完全なt物へのアクセスを与える代わりにtesting.TBまですり減らされることに注意してください.
    ラッパーユーティリティはもちろん、複数のテスト仕様を一度に受け入れるために拡張できます.そのため、単一のデータベース/アプリケーション初期化に対して複数のテストを実行できます.

    2 .中古人口DB Docker画像の使用


    各テストのデータベースを占有するにはあまりにも多くの時間がかかります.事前に設定されたdocker imageを使用すると、私たちが待機することなく、よく知られているDB状態に対してテストを行うことができます.
    付加的なボーナスとして、DBコンテナはほとんどの場合初期化動作を必要としないので、それは我々がずっと速く始まるイメージを使うことができることを意味します.

    操作性


    今我々は我々の新しい機能のベンチマークを追加する簡単な方法を持って、我々は何を行うのですか?出力は以下のようになります:
    % go test -run="^$" -bench="." ./... -count=5 -benchmem
    
    PASS
    Benchmark_dao_FindByName   242    4331132 ns/op     231763 B/op      4447 allocs/op
    
    ok      jfrog.com/omerk/benchleft       3.084s
    
    単に出力を見て、パフォーマンスが十分であるかどうか決定するのは良いです、しかし、我々は我々のベンチマークスイートからより多くのactionableな結果を得ることができますか?
    我々のアプリケーションへの変更に取り組んでいるとき、我々は我々が何かを壊さないことを確認するために我々のCIに頼ります.なぜ我々はアプリケーションのパフォーマンスを低下させないことを確認するために同じフローを使用しないでください?
    それで、CGIの一部としてベンチマークスイートを走らせましょう.しかし、私たちの働くブランチの結果を得ることは十分ではありません、また、これらの結果を安定した状態に比較する必要があります.そのためには、作業ブランチとリリースブランチまたはGitリポジトリのメインブランチのベンチマークスイートを実行できます.
    benchstatのようなツールを使用することによって、2つの別々のベンチマーク実行の結果を比較することができます.このような比較結果の出力は以下のようになります.
    % benchstat "stable.txt" "head.txt"
    
    name                                                                   old time/op    new time/op    delta
    pkg:jfrog.com/omerk/benchleft goos:darwin goarch:amd64 _dao_FindByName 4.04ms ± 8%    5.43ms ±40%  +34.61%  (p=0.008 n=5+5)
    
    name                                                                   old time/op    new time/op    delta
    pkg:jfrog.com/omerk/benchleft goos:darwin goarch:amd64 _dao_FindByName 10.9kB ± 0%    10.9kB ± 0%   +0.19%  (p=0.032 n=5+5)
    
    name                                                                   old time/op    new time/op    delta
    pkg:jfrog.com/omerk/benchleft goos:darwin goarch:amd64 _dao_FindByName 8.60k ± 0%     8.60k ± 0%   +0.01%  (p=0.016 n=4+5)
    
    上で見ることができるように、私たちは、時間、メモリと操作あたりの配分の数のためにパフォーマンス差の明確な理解を得ます.
    安定したベンチマーク結果をバケットまたはアーティファクトリポジトリ(JFrog Artifactoryなど)に格納することは一つのオプションですが、パフォーマンスの分散を最小限に保つためには、可能であれば現在のCIエージェント上の両方のブランチにスイートを実行することを好みます.
    以下に、簡単なbashスクリプトの例を示します.
    #!/usr/bin/env bash
    set -e -o pipefail
    
    BENCH_OUTPUT_DIR="${BENCH_OUTPUT_DIR:-out}"
    DEGRADATION_THRESHOLD="${DEGRADATION_THRESHOLD:-5.00}"
    MAX_DEGRADATION=0
    THRESHOLD_REACHED=0
    
    function doBench() {
       local outFile="$1"
       go test -run="^$" -bench="." ./... -count=5 -benchmem | tee "${outFile}"
    }
    
    function calcBench() {
       local metricName="$1"
       MAX_DEGRADATION=$(cat "${BENCH_OUTPUT_DIR}/result.txt" | grep -A 2 "${metricName}" | head -n 3 | tail -n 1 | awk 'match($0,/(\+[0-9]+\.[0-9]+%)/) {print substr($0,RSTART,RLENGTH)}' | tr -d "+%")
       if [[ "${MAX_DEGRADATION}" == "" ]]; then
           echo "Benchmark ${metricName} - no degradation"
           return
       fi
       if [[ "${THRESHOLD_REACHED}" == "0" ]]; then
           THRESHOLD_REACHED=$(echo "${MAX_DEGRADATION} > ${DEGRADATION_THRESHOLD}" | bc -l)
       fi
       echo "Benchmark ${metricName} degradation: ${MAX_DEGRADATION}% | threshold: ${DEGRADATION_THRESHOLD}%"
    }
    
    mkdir -p "${BENCH_OUTPUT_DIR}"
    
    git checkout stablebranch && git pull
    doBench "${BENCH_OUTPUT_DIR}/stable.txt" || {git checkout - && exit 1; }
    
    git checkout -
    doBench "${BENCH_OUTPUT_DIR}/head.txt"
    
    benchstat -sort delta "${BENCH_OUTPUT_DIR}/stable.txt" "${BENCH_OUTPUT_DIR}/head.txt" | tee "${BENCH_OUTPUT_DIR}/result.txt"
    
    calcBench "time/op"
    calcBench "alloc/op"
    calcBench "allocs/op"
    
    exit "${THRESHOLD_REACHED}"
    
    *testing.Bは、完全なベンチマークスイートを実行し、結果を出力します.doBench()は、比較結果出力を受け取り、我々の指定された劣化しきい値が達したなら計算します.calcBench()benchstatフラグと共に使用されるので、我々は我々の計算に最高のデルタを取る.
    スクリプトは「stablebranch」に対するベンチマークを実行して、それから我々の働く枝に対して、2つの結果を比較して、しきい値に対して計算します.
    十分な性能劣化の場合には、出口コードを使用してCIパイプラインを失敗させることができます.

    更なる考察


    あなたのCIパイプライン(JFrog Pipelinesなど)が生産性と観測性を高めるためのツールであることを覚えておくことが重要です.ベンチマークステップを追加することは非常に役立つことができますが、完全に開発者の責任を軽減しません.
    上記の例に従って、劣化計算を自動化するときに新しく追加されたベンチマークテストは考慮されません.この場合,良好なベース値を決定するためには開発者の裁量が必要である.
    また、変化が予想されて、許容できるパフォーマンス低下を引き起こす場合も必然的にあります.CIパイプラインはこれらのケースをサポートします.
    最後に、データベース依存のベンチマークについては、テストをあなたのスナップショットに格納されている特定のデータから分離しておくのが最善でしょう-そのようなテストはデータの量に基づいているべきであり、内容ではありません.これにより、以前に書かれたテストを中断する心配をすることなくデータベーススナップショットを更新できます.

    結論


    この方法論を利用して、ベンチマークテストを書くことの認知負荷を減らすことができます.また、時間がかかるのを減らすことができます.そして、それは最終的に私たちが非常に少し加えられた努力で我々の特徴開発ライフサイクルの一部としてそれらを含めるのを許します.その上で、CIパイプラインの一部としてActionableなベンチマーク結果を持つことで、我々は開発ライフサイクルにおいて問題を解決することができます.
    機能デリバリーの完全な所有権と責任を取ることは容易になります.そして、我々の開発者が検証の代わりに開発に集中させます.
    高い標準を維持しながらワークフローを改善する気になるチームのメンバーと高性能製品に取り組んで興味がありますか?Come join us at JFrog R&D!