0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScript マイクロベンチマークで自分を騙さないために — ts-bench の設計

0
Posted at

「Date.now で十分」の 5 つの嘘

benchmark.js は事実上メンテされていない。hyperfine は CLI ツールには最高だが JavaScript の関数は扱えない。結局、みんな Date.now() のループをスクラッチファイルに貼り付ける。

const start = Date.now()
for (let i = 0; i < 1_000_000; i++) { myFunction() }
const elapsed = Date.now() - start
console.log(`${elapsed}ms total`)

このコードには 5 つの問題がある:

  1. Date.now() はミリ秒精度。1 ミリ秒以下で終わる処理ではクロックの精度を測っている
  2. ウォームアップがない。V8 のベースラインコンパイラで動く最初の数千回は定常状態を反映しない
  3. サンプルが 1 つ。「50ms」が「50ms ± 40」なのか区別できない
  4. 戻り値を消費していない。V8 がループ本体を丸ごと削除する可能性がある
  5. ベースラインがない。前回のコミットで遅くなったのか分からない

ts-bench はこれらすべてに対処する。

📦 GitHub: https://github.com/sen-ltd/ts-bench

Screenshot

ナノ秒精度と dead-code 防止

export class Runner {
  private sink: unknown = undefined

  constructor(private readonly now: NowFn) {}

  private runBucket(fn: BenchFn, iterations: number): number {
    const start = this.now()
    for (let i = 0; i < iterations; i++) {
      this.sink = fn()
    }
    const end = this.now()
    if (this.sink === Symbol.for('ts-bench/unreachable')) {
      throw new Error('unreachable')
    }
    return Number(end - start)
  }
}

2 つのポイント:

  • クロックを注入 — 本番では process.hrtime.bigint()、テストでは事前にプログラムした bigint 列を返すフェイク
  • sink が dead-code 防止 — 戻り値をインスタンスフィールドに保存し、到達不可能な if で参照する。V8 はフィールドが未読だと証明できないのでループ本体を削除できない

フェイククロックによる決定論的テスト

ウォームアップが確実に捨てられることを証明するテスト:

it('discards warmup buckets so their timings do not influence stats', () => {
  const clock = fakeClock([
    0n, 10_000n,       // warmup 1 (破棄)
    10_000n, 20_000n,  // warmup 2 (破棄)
    20_000n, 20_100n,  // sample 1: 100ns / 1 iter
    20_100n, 20_200n,  // sample 2: 100ns / 1 iter
  ])
  const runner = new Runner(clock)
  const result = runner.run('const', () => 0, {
    ...defaultRunOptions,
    iterations: 1,
    warmup: 2,
    samples: 2,
    autoCalibrate: false,
  })
  expect(result.stats.meanNs).toBe(100)
  expect(result.stats.stddevNs).toBe(0)
})

ウォームアップバケットは 10,000ns(本番サンプルの 100 倍遅い)だが、報告される平均は 100ns。ウォームアップがサンプルプールに混入していればテストが大きく失敗する。

標準偏差はノイズへの最初の防壁

export function computeStats(samplesNs: readonly number[]): Stats {
  const n = samplesNs.length
  let sum = 0
  let min = Number.POSITIVE_INFINITY
  let max = Number.NEGATIVE_INFINITY
  for (const s of samplesNs) {
    sum += s
    if (s < min) min = s
    if (s > max) max = s
  }
  const mean = sum / n

  let variance = 0
  if (n > 1) {
    let sqSum = 0
    for (const s of samplesNs) {
      const d = s - mean
      sqSum += d * d
    }
    variance = sqSum / (n - 1) // ベッセルの補正
  }
  const stddev = Math.sqrt(variance)
  const rsd = mean > 0 ? stddev / mean : 0
  const opsPerSec = mean > 0 ? 1e9 / mean : 0

  return { samples: n, meanNs: mean, minNs: min, maxNs: max,
           stddevNs: stddev, rsd, opsPerSec }
}

ベッセルの補正(n-1)を使う理由は、サンプルから母分散を推定しているため。サンプル数が少ないときに差が出る。

human フォーマッタは rsd > 10% のベンチを黄色で表示する:

sum_formula: 0.8 ns ± 0.6 ns, 1.18 Gops/s, 73.52%

RSD 73% は「V8 がベンチ本体を定数畳み込みで消した結果、空ループのスケジューラノイズを測定している」というシステムからのメッセージ。ランナーが問題を表面化するのが重要で、自信満々に「1.18 Gops/s」と報告して PR に貼らせるのは最悪。

自動キャリブレーション

export function estimateIterations(
  probeIters: number,
  probeNs: number,
  targetNs: number
): number {
  if (probeIters <= 0 || probeNs <= 0 || targetNs <= 0) return 1
  const perIter = probeNs / probeIters
  const est = Math.round(targetNs / perIter)
  if (!Number.isFinite(est) || est < 1) return 1
  if (est > 1_000_000_000) return 1_000_000_000
  return est
}

--auto-calibrate では、まず 1,000 回のプローブを実行して 1 反復あたりのナノ秒を推定し、各バケットが約 100ms になるイテレーション数を自動選択する。速いベンチ → 大きなバケット、遅いベンチ → 小さなバケット、実行時間は同程度。

ベースライン比較: CI 向け

export function compareToBaseline(
  current: readonly BenchResult[],
  baseline: BaselineFile,
  thresholdPct = 5
): ComparisonResult {
  const baseByName = new Map(baseline.entries.map((e) => [e.name, e]))
  const rows: ComparisonRow[] = []
  const regressions: string[] = []

  for (const r of current) {
    const b = baseByName.get(r.name)
    if (!b) {
      rows.push({ name: r.name, status: 'new',
                  currentMeanNs: r.stats.meanNs, baselineMeanNs: null,
                  deltaPct: null })
      continue
    }
    const delta = ((r.stats.meanNs - b.meanNs) / b.meanNs) * 100
    let status: ComparisonStatus
    if (delta > thresholdPct) {
      status = 'slower'
      regressions.push(r.name)
    } else if (delta < -thresholdPct) {
      status = 'faster'
    } else {
      status = 'same'
    }
    rows.push({ name: r.name, status,
                currentMeanNs: r.stats.meanNs,
                baselineMeanNs: b.meanNs, deltaPct: delta })
  }

  return { rows, regressions }
}

閾値はデフォルト 5%。ゼロ許容だと実行ごとの分散でほぼ毎回「リグレッション」になる。5% は健全なベンチのノイズフロア(rsd < 2%)より広く、実際のアルゴリズムのリグレッションは捕捉できる。

--fail-on-regression で exit 1。GitHub Actions の if: github.event_name == 'pull_request' に入れればパフォーマンスゲートになる。

このツールの限界

  • JIT に勝てない — V8 の最適化判定はインライン化、型フィードバック、脱最適化の履歴に依存する。ウォームアップ、戻り値消費、rsd チェックが精一杯
  • シングルプロセス — 先に走ったベンチのインラインキャッシュが後のベンチに影響しうる
  • マクロベンチマークではない — 「10k 同時リクエストでの HTTP サーバ性能」は wrkk6 の領域
  • 統計的検定はない — 平均が 1% 以内なら「same」と報告して人間に委ねる

63 アサーション: stats、runner、ベースラインラウンドトリップ、比較ロジック、3 フォーマッタ、引数パース。タイミングテストはフェイククロックでマイクロ秒単位で完了。

試してみる

cat > bench.ts << 'EOT'
export function bench_sum_loop() {
  let s = 0
  for (let i = 0; i < 1000; i++) s += i
  return s
}
export function bench_sum_reduce() {
  return Array.from({ length: 1000 }, (_, i) => i)
    .reduce((a, b) => a + b, 0)
}
EOT

docker run --rm -v "$PWD":/work ts-bench /work/bench.ts --auto-calibrate

ソース・テスト・Dockerfile: https://github.com/sen-ltd/ts-bench


SEN 合同会社100 超ポートフォリオシリーズ #166。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?