LESSON 30分

「メトリクスが”何が遅いか”を教えてくれるなら、プロファイリングは”なぜ遅いか”を教えてくれる」と佐藤CTOは言った。「フレームグラフ1枚で、何日もかかるデバッグが数分で終わることもある。」

1. プロファイリングの種類

種類対象ツール例用途
CPU プロファイリングCPU 実行時間perf, V8 Profilerホットパスの特定
メモリプロファイリングヒープ使用量heapdump, Chrome DevToolsメモリリーク検出
I/O プロファイリングディスク/ネットワークstrace, tcpdumpI/O ボトルネック
ロックプロファイリング同期待ち時間async_hooks競合の検出

サンプリング vs インストルメンテーション

方式仕組みオーバーヘッド精度
サンプリング定期的にスタックトレースを記録低(1-5%)統計的近似
インストルメンテーション関数の入出口にフックを挿入高(10-50%)正確

2. Node.js CPU プロファイリング

// Node.js 組み込みの V8 Profiler を使った CPU プロファイリング
import { Session } from 'node:inspector/promises';
import { writeFileSync } from 'node:fs';

class CpuProfiler {
  private session: Session;

  constructor() {
    this.session = new Session();
  }

  async start(): Promise<void> {
    this.session.connect();
    await this.session.post('Profiler.enable');
    await this.session.post('Profiler.start');
    console.log('CPU profiling started');
  }

  async stop(outputPath: string): Promise<void> {
    const { profile } = await this.session.post('Profiler.stop') as any;
    writeFileSync(outputPath, JSON.stringify(profile));
    await this.session.post('Profiler.disable');
    this.session.disconnect();
    console.log(`CPU profile saved to ${outputPath}`);
  }
}

// Express ミドルウェアとしてのプロファイリング
import { Request, Response, NextFunction } from 'express';

function profilingMiddleware() {
  return async (req: Request, res: Response, next: NextFunction) => {
    if (req.headers['x-enable-profiling'] !== 'true') {
      return next();
    }

    const session = new Session();
    session.connect();
    await session.post('Profiler.enable');
    await session.post('Profiler.start');

    const originalEnd = res.end;
    res.end = (async function (this: Response, ...args: any[]) {
      const { profile } = await session.post('Profiler.stop') as any;
      session.disconnect();

      // プロファイル結果をレスポンスヘッダーに添付
      const profileId = `profile-${Date.now()}`;
      // 実際にはオブジェクトストレージに保存
      console.log(`Profile ${profileId}: ${JSON.stringify(profile).length} bytes`);
      this.setHeader('X-Profile-Id', profileId);

      return originalEnd.apply(this, args);
    }) as any;

    next();
  };
}

3. フレームグラフの読み方

フレームグラフは、CPU 使用時間をスタックトレースごとに可視化したもの。

graph TD
    subgraph Main["main() ← ルート関数"]
        subgraph PR["processRequest()"]
            V["validate()"]
            subgraph S["serialize()"]
                J["JSON()"]
                CO["compress()"]
            end
        end
        subgraph HDB["handleDB()"]
            Q["query()"]
            subgraph T["transform()"]
                M["map()"]
            end
        end
    end

    classDef root fill:#fee2e2,stroke:#ef4444
    classDef mid fill:#fef3c7,stroke:#f59e0b
    classDef leaf fill:#dbeafe,stroke:#3b82f6
    class Main root
    class PR,HDB mid
    class V,S,Q,T,J,CO,M leaf

フレームグラフの解析ルール

  1. 幅が広い = CPU 時間を多く消費(最適化候補)
  2. 高さが深い = コールスタックが深い(不要な再帰の可能性)
  3. プラトー(平坦な頂上) = リーフ関数で時間を消費
  4. 左右の並びに意味はない(アルファベット順が一般的)
// Node.js で 0x を使ったフレームグラフ生成
// npm install -g 0x
// 0x -- node dist/server.js

// プログラム的にフレームグラフデータを生成
import { createWriteStream } from 'node:fs';

interface StackFrame {
  functionName: string;
  lineNumber: number;
  columnNumber: number;
  scriptId: string;
  url: string;
  children: StackFrame[];
  selfTime: number;  // この関数自体の実行時間(μs)
  totalTime: number; // 子を含む合計時間(μs)
}

function generateFlameGraphData(profile: any): string[] {
  const stacks: string[] = [];

  function traverse(node: any, stack: string[]): void {
    const name = node.callFrame?.functionName || '(anonymous)';
    const currentStack = [...stack, name];

    if (node.hitCount > 0) {
      stacks.push(`${currentStack.join(';')} ${node.hitCount}`);
    }

    for (const child of node.children || []) {
      traverse(child, currentStack);
    }
  }

  traverse(profile.head, []);
  return stacks;
}

// 出力例(folded stack format):
// main;processRequest;validate 15
// main;processRequest;serialize;JSON 42
// main;processRequest;serialize;compress 28
// main;handleDB;query 65
// main;handleDB;transform;map 33

4. メモリプロファイリング

// ヒープスナップショットの取得と分析
import { Session } from 'node:inspector/promises';

class MemoryProfiler {
  private session: Session;

  constructor() {
    this.session = new Session();
  }

  async takeHeapSnapshot(outputPath: string): Promise<void> {
    this.session.connect();

    const chunks: string[] = [];
    this.session.on('HeapProfiler.addHeapSnapshotChunk', (m: any) => {
      chunks.push(m.params.chunk);
    });

    await this.session.post('HeapProfiler.takeHeapSnapshot');
    this.session.disconnect();

    const { writeFileSync } = await import('node:fs');
    writeFileSync(outputPath, chunks.join(''));
    console.log(`Heap snapshot saved: ${outputPath}`);
  }

  // メモリリーク検出: 2つのスナップショットを比較
  async detectLeaks(): Promise<void> {
    const before = process.memoryUsage();

    // テスト対象の処理を実行...
    // await runTestScenario();

    // GC を強制実行 (--expose-gc フラグが必要)
    if (global.gc) {
      global.gc();
    }

    const after = process.memoryUsage();

    const heapGrowth = after.heapUsed - before.heapUsed;
    const rssGrowth = after.rss - before.rss;

    console.log(`Heap growth: ${(heapGrowth / 1024 / 1024).toFixed(2)} MB`);
    console.log(`RSS growth: ${(rssGrowth / 1024 / 1024).toFixed(2)} MB`);

    if (heapGrowth > 10 * 1024 * 1024) {
      console.warn('Potential memory leak detected!');
    }
  }
}

// メモリリークのよくあるパターン
class LeakExamples {
  // パターン1: クロージャによる参照保持
  private handlers: Function[] = [];

  addHandler(data: Buffer): void {
    // data が handlers 配列経由でずっと参照される
    this.handlers.push(() => {
      console.log(data.length);
    });
  }

  // パターン2: イベントリスナーの未解除
  private emitter = new (require('events').EventEmitter)();

  startListening(): void {
    // removeListener しないとリーク
    this.emitter.on('data', (chunk: Buffer) => {
      this.processChunk(chunk);
    });
  }

  private processChunk(chunk: Buffer): void {
    // ...
  }

  // パターン3: Map/Set の無制限成長
  private cache = new Map<string, object>();

  cacheResult(key: string, value: object): void {
    // サイズ制限がないため無限に成長する
    this.cache.set(key, value);
    // 修正: LRU キャッシュを使う
  }
}

5. ブラウザプロファイリング

// Performance API を使ったブラウザ側の計測
class BrowserPerformanceProfiler {
  // User Timing API でカスタム計測
  static measureAsync<T>(name: string, fn: () => Promise<T>): Promise<T> {
    performance.mark(`${name}-start`);
    return fn().then((result) => {
      performance.mark(`${name}-end`);
      performance.measure(name, `${name}-start`, `${name}-end`);
      return result;
    });
  }

  // Long Task API で重い処理を検出
  static observeLongTasks(): void {
    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries()) {
        console.warn(`Long task detected: ${entry.duration.toFixed(1)}ms`, {
          startTime: entry.startTime,
          name: entry.name,
          // attribution で原因を特定
          attribution: (entry as any).attribution,
        });
      }
    });

    observer.observe({ type: 'longtask', buffered: true });
  }

  // LayoutShift の監視
  static observeLayoutShifts(): void {
    let clsScore = 0;

    const observer = new PerformanceObserver((list) => {
      for (const entry of list.getEntries() as any[]) {
        if (!entry.hadRecentInput) {
          clsScore += entry.value;
          console.log(`Layout shift: ${entry.value.toFixed(4)}, CLS total: ${clsScore.toFixed(4)}`);
        }
      }
    });

    observer.observe({ type: 'layout-shift', buffered: true });
  }

  // リソースタイミングの収集
  static getResourceTimings(): Record<string, number[]> {
    const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
    const byType: Record<string, number[]> = {};

    for (const entry of entries) {
      const ext = entry.name.split('.').pop() || 'other';
      if (!byType[ext]) byType[ext] = [];
      byType[ext].push(entry.duration);
    }

    return byType;
  }
}
コラム: Continuous Profiling とは

本番環境で常時低オーバーヘッドのプロファイリングを行う手法。

  • Datadog Continuous Profiler: 1% のサンプリングレートで常時稼働
  • Pyroscope: オープンソースの Continuous Profiling プラットフォーム
  • Google Cloud Profiler: GCP 環境での常時プロファイリング

利点:

  • 本番環境でしか再現しないボトルネックを発見
  • デプロイ前後の性能変化を可視化
  • 特定のリクエストパターンに紐づく CPU/メモリ消費を分析

まとめ

トピック要点
プロファイリング種類CPU/メモリ/I-O/ロック、目的に応じて使い分け
サンプリング方式低オーバーヘッドで本番環境にも適用可能
フレームグラフ幅=CPU時間、高さ=コールスタック深度
メモリプロファイリングヒープスナップショット比較でリーク検出
ブラウザプロファイリングPerformance API/Long Task/LayoutShift

チェックリスト

  • サンプリングとインストルメンテーションの違いを説明できる
  • フレームグラフの読み方を理解した
  • Node.js の CPU/メモリプロファイリングを実行できる
  • メモリリークの一般的なパターンを知っている
  • ブラウザの Performance API を活用できる

次のステップへ

プロファイリングでボトルネックを特定できるようになった。次は アムダールの法則とスケーラビリティ を学び、性能改善の理論的限界を理解しよう。

推定読了時間: 30分