1
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?

【後編】動的列 CSV 出力問題の実測検証

1
Last updated at Posted at 2025-12-24

本稿の執筆にあたっては、内容整理および文章推敲のため AI ツールの支援を利用しています。
技術的に誤りや不正確な点があれば、ぜひご指摘いただけると幸いです。

前編の振り返り(サマリ)

  • 動的列ピボットは疎→密変換が本質で、CSV は常に N × A セルを埋める必要があるため出力量 Ω(S)(実質 Ω(N·A))が下界となる。
  • SQL は静的列前提で、PIVOT も列名事前確定が要件。動的列は動的 SQL など追加策が必要。
  • Stream / Sequence は join を標準で持たず、窓処理や事前ロードなど評価戦略を実装者が設計する必要がある。
  • 戦略分類(PRELOAD / MULTISET / SEQUENCE_WINDOW / SPLITERATOR_WINDOW)のアプリ側漸近はほぼ同型で、実測差は主に
    • DB 側の処理形態(ネスト化・materialization・ORDER BY)
    • JVM メモリピーク M_app(全件保持 O(V) vs 窓処理 O(a_max)
    • 定数項と I/O Ω(S)
      に帰着する。
  • 問いは「どの戦略が理論的に速いか」ではなく「不可避なコストをどこで払うか」という設計判断。

実装戦略の具体化

本章では、前章で定式化した理論モデルを実装に落とし込む。ここでの目的は「完全なコード提示」ではなく、各戦略の核心的な設計判断と、それが理論モデルのどの部分に対応するかを明示することである。

以降の実装例はコードに絞る。実験環境・データセット・測定方法は次章(ベンチマーク設計)に分離する。

各戦略の実装方針

以下では、各戦略の核心部分のみを抜粋して示す。完全な実装は GitHub リポジトリを参照されたい。

1. PRELOAD

設計方針: 属性値を全件ロードし、Map<OrderId, Map<DefId, Value>> に展開して参照。

核心部分:

// 1. 属性値を全件取得し Map 化(O(V))
val attrMap: Map<Long, Map<Long, String>> =
    orderRepository.loadAttributeValueMap(from, to)

// 2. 注文をストリーミングしながら Map 参照(O(N·A))
orderRepository.streamOrdersBase(from, to).use { stream ->
    stream.forEach { base ->
        val row = OrderAttributeCsvRow(
            orderId = base.orderId,
            customerId = base.customerId,
            customerName = base.customerName,
            customerEmail = base.customerEmail,
            orderDate = base.orderDate,
            attributes = attrMap[base.orderId] ?: emptyMap()  // O(1) lookup
        )
        csvPrinter.printRecord(row.toRecord(definitionIds))
    }
}

対応する理論モデル:

$$
T_{\text{app}} = O(V + N \cdot A), \quad M_{\text{app}} = O(V)
$$

全属性値をメモリに保持するため、$V$ に比例したメモリを消費する。

2. MULTISET

設計方針: jOOQ の multiset() 機能を用いて、DB 側で「注文 → 属性値群」をネスト化。

核心部分:

// jOOQ でネスト取得
select(
    ORDER.ID,
    CUSTOMER.NAME,
    CUSTOMER.EMAIL,
    ORDER.ORDER_DATE,
    multiset(
        select(
            ORDER_ATTRIBUTE_VALUE.ATTRIBUTE_DEFINITION_ID,
            ORDER_ATTRIBUTE_VALUE.VALUE
        )
        .from(ORDER_ATTRIBUTE_VALUE)
        .where(ORDER_ATTRIBUTE_VALUE.ORDER_ID.eq(ORDER.ID))
    ).`as`("attributes")
)
.from(ORDER)
.join(CUSTOMER).on(ORDER.CUSTOMER_ID.eq(CUSTOMER.ID))

アプリ側では、ネストされた属性値リストを Map に変換して列埋め:

stream.forEach { order ->
    val valueMap = order.attributes.associate {
        it.attributeDefinitionId.value to it.value
    }
    val row = OrderAttributeCsvRow(
        orderId = order.orderId,
        // ...
        attributes = valueMap
    )
    printer.printRecord(row.toRecord(definitionIds))
}

対応する理論モデル:

$$
T_{\text{app}} = O(N \cdot (A + \bar{a})) = O(N \cdot A + V)
$$

ただし $T_{\text{DB}}$ が異なり、内部的に結果の materialization や相関サブクエリ評価が発生する可能性があるため、純粋なストリーミング処理とは性質が異なる。

3. SEQUENCE_WINDOW

設計方針: 縦持ち JOIN 結果を Kotlin Sequence で窓処理し、order_id が変わるまでをバッファリング。

核心部分:

// 縦持ち JOIN を order_id でソート
orderRepository.streamOrdersWithAttributes(from, to).use { stream ->
    stream.asSequence()
        .windowByOrderId()  // 拡張関数で窓処理
        .forEach { row ->
            csvPrinter.printRecord(row.toRecord(definitionIds))
        }
}

windowByOrderId() の実装(概念):

fun Sequence<OrderAttributeJoinedRow>.windowByOrderId(): Sequence<OrderAttributeCsvRow> = sequence {
    var currentOrderId: Long? = null
    val buffer = mutableMapOf<Long, String>()
    var baseInfo: OrderAttributeJoinedRow? = null

    for (row in this@windowByOrderId) {
        if (row.orderId != currentOrderId && currentOrderId != null) {
            // 注文確定 → emit
            yield(OrderAttributeCsvRow.from(baseInfo!!, buffer.toMap()))
            buffer.clear()
        }

        baseInfo = row
        currentOrderId = row.orderId

        if (row.attributeDefinitionId != null) {
            buffer[row.attributeDefinitionId] = row.attributeValue!!
        }
    }

    // 最後の注文を emit
    if (baseInfo != null) {
        yield(OrderAttributeCsvRow.from(baseInfo, buffer.toMap()))
    }
}

対応する理論モデル:

$$
T_{\text{app}} = O(R + N \cdot A), \quad M_{\text{app}} = O(a_{\text{max}})
$$

1注文分のバッファのみを保持するため、メモリは $a_{\text{max}}$ に比例。

4. SPLITERATOR_WINDOW

設計方針: Stream の世界で窓処理を実現するため、カスタム Spliterator を実装。

本戦略は性能最適化というより、Stream API 上で window 処理を実現可能であることを示す実験的実装である。

核心部分:

class OrderAttributeWindowSpliterator(
    private val source: Spliterator<OrderAttributeJoinedRow>
) : Spliterators.AbstractSpliterator<OrderAttributeCsvRow>(
    Long.MAX_VALUE,
    Spliterator.ORDERED or Spliterator.NONNULL
) {
    private val buffer = mutableMapOf<Long, String>()
    private var currentOrderId: Long? = null
    private var baseInfo: OrderAttributeJoinedRow? = null

    override fun tryAdvance(action: Consumer<in OrderAttributeCsvRow>): Boolean {
        while (source.tryAdvance { row ->
            if (row.orderId != currentOrderId && currentOrderId != null) {
                // emit して return true
                action.accept(OrderAttributeCsvRow.from(baseInfo!!, buffer.toMap()))
                buffer.clear()
                baseInfo = row
                currentOrderId = row.orderId
                // バッファに追加
                return@tryAdvance
            }

            baseInfo = row
            currentOrderId = row.orderId
            if (row.attributeDefinitionId != null) {
                buffer[row.attributeDefinitionId] = row.attributeValue!!
            }
        }) { /* continue */ }

        // 最後の注文を emit
        if (baseInfo != null) {
            action.accept(OrderAttributeCsvRow.from(baseInfo!!, buffer.toMap()))
            baseInfo = null
            return true
        }
        return false
    }
}

対応する理論モデル:

SEQUENCE_WINDOW と同様に $O(R + N \cdot A)$ だが、Stream API の push モデル上で実装される点が異なる。

ベンチマーク設計・測定条件

測定設計の複雑さはなぜ必要か

一見すると「4つの戦略を走らせて時間を比較する」のは単純に見える。しかし、動的列 CSV 出力問題における測定は、アルゴリズム差だけでなく、多くの外部要因に左右されるため、単純な比較では誤った結論に到達してしまう。

本章で複雑な測定設計を採用する理由は:

  1. I/O と計算が分離できない → 出力先(画面 / ファイル / NullStream)で時間が変わる
  2. JIT コンパイルが予測不可能 → warmup の回数・タイミングで最適化度合いが変わる
  3. ヒープサイズで挙動が激変 → GC が頻発すると測定結果が不安定になる
  4. 戦略の実行順序がバイアスをもたらす → 1番目と10番目の測定値が異なる
  5. メモリ消費とアロケーションパターンが異なる → ピークと常用値を区別する必要がある

以下では、これらの課題をどう対処するかを、設計の選択と根拠とともに説明する。


理論から実験設計へ:支配パラメータの検証

前章(Analysis Model)で示したように、各戦略の計算量は以下のように分解される:

$$
T_{\text{app}} = O(N \cdot A + V) \quad \text{or} \quad O(N \cdot A + R)
$$

Big-O が同じでも、実測では大幅に異なる可能性がある。その理由を明確にするため、本実験では 3 つの支配パラメータごとに測定条件を設計した。

支配パラメータの分解

パラメータ 何に影響するか 戦略による依存の差
$V$(属性値総数) preload のメモリ、DB→JVM 転送量 preload が支配的
$\bar{a}$(1注文あたり平均属性数) 窓処理のバッファサイズ、集約コスト window 系が敏感
$N$(注文数) CSV 出力行数、列埋めの繰り返し 全戦略に均等
$A$(属性定義数) CSV 列数、出力セル数 $N \times A$ 全戦略に均等(理論下界)

課題:Big-O では同じ $O(N \cdot A + V)$ でも、実装では $V$ に敏感な戦略と $\bar{a}$ に敏感な戦略に分かれる。

解決策:実験計画を 2 軸に分ける。

3つのデータセット条件

条件 N A 密度 $\bar{a}$ V 主な検証対象
Baseline 30,000 15 Dense (15) 450,000 基準測定、4 戦略の相対性能
Sparse 30,000 15 Sparse (1.8) 54,054 $V$ 削減時の戦略差(preload vs window)
Wide 30,000 120 Dense (120) 3,600,000 $N \times A$ 支配領域、メモリ限界

各条件の役割

Baseline / Sparse:$N$ と $A$ 固定、密度($\bar{a}$)を変化

  • Baseline(N=30k, A=15, Dense)

    • すべての戦略が「フルパワー」で動作する条件
    • preload は 45 万件(V)を全メモリ展開
    • window 系は $V=450,000$ 行相当の JOIN 結果を逐次処理
    • 4 戦略の相対性能が最も顕在化
  • Sparse(N=30k, A=15, $\bar{a} \approx 1.8$)

    • 属性値を 1/8 に削減(V=54,054)
    • $V$ 削減の恩恵を受ける戦略(preload)と、受けない戦略(multiset)の差が顕在化
    • メモリピークが低下し、GC 挙動の違いが明確になる

Wide:$N$ 固定、列数($A$)を変化

  • Wide(N=30k, A=120, Dense)
    • $N \times A = 3,600,000$ セル(理論下界 $\Omega(N \cdot A)$)
    • CSV 出力行数 $N$ とセル数 $N \times A$ の相対的な重要性を検証
    • メモリ限界が露呈しやすい設計(preload の実用性を問う)

2系統測定:I/O 分離による根本的な分析

問題:単純な実行時間測定の限界

実際にファイルに CSV を出力して時間を測ると:

$$
T_{\text{total}} = T_{\text{DB}} + T_{\text{app}} + T_{\text{IO}}
$$

このうち $T_{\text{IO}}$ は:

  • ディスク種別(SSD / HDD)
  • ファイルシステム(ext4 / NFS 等)
  • ページキャッシュの状態

などに大きく依存し、アルゴリズムの差を測定できない

例:preload が 600ms、window が 1200ms だとして、$T_{\text{IO}}$ が 500ms あれば、I/O 時間の割合は約 30-50% になる。これでは戦略差を正しく測れない。

解決策:2系統で分離測定

系統 1:Compute 測定(主軸)

  • 出力先: NullOutputStream + DigestOutputStream(MD5 チェックサム付き)
  • 測定対象: $T_{\text{DB}} + T_{\text{app}}$ のみ
  • 用途: アルゴリズム差の純粋な比較
  • 回数: warmup 5 回 + measurement 10 回(ラウンドロビン)
// 疑似コード
val timer = Instant.now()
val digest = MessageDigest.getInstance("MD5")
strategy.export(NullOutputStream.wrap(DigestOutputStream(digest)))
val elapsed = Duration.between(timer, Instant.now())

系統 2:実 CSV 出力(参考値)

  • 出力先: 実ファイル
  • 測定対象: $T_{\text{DB}} + T_{\text{app}} + T_{\text{IO}}$ (環境依存)
  • 用途: 「現実ではこのくらいかかる」の参考値
  • 回数: measurement 中の 5 回に 1 回だけ(オーバーヘッド削減)

測定結果の読み方

各戦略について以下を記録:

  • 主指標(Compute): median, IQR, max から戦略差を判定
  • 副指標(実CSV): I/O オーバーヘッドを推定(後述)
  • メモリ: ヒープピーク、GC 統計

バイアス対策:公平な比較のための設計

1. JIT コンパイルの温まり(Warmup)

JVM は初回実行時、バイトコードを解釈実行する。その後、ホットメソッドを JIT コンパイルして機械語に最適化する。このため、同じコードでも 1 回目と 100 回目で実行時間が大幅に異なる

さらに、単なる JIT だけでなく、クラスロード、DB コネクション確立、ページキャッシュの初期化なども初期実行時に発生する。

対策

  • warmup 5 回: 各戦略を 5 ラウンド、ラウンドロビン順で実行
  • ラウンドロビン: 戦略 A → B → C → D → A → B → ... の順(全戦略が等しく温まる)
  • warmup 後に 30 秒待機: JIT が完全に完了し、メトリクス基盤(Prometheus)が安定するのを待つ

根拠:事前測定で、5 ラウンド以降で実行時間が安定傾向を示すことを確認。ラウンドロビンにより、どの戦略も「1 番目に実行される」という幸運・不運を避ける。

2. ヒープサイズとメモリ圧

メモリが不足すると GC が頻発し、実行時間が不安定になる。

対策

  • ヒープ上限: -Xmx512m に固定
  • 理由: 小規模 SaaS / EC サービスの現実的なメモリ予算を想定
  • 検証: Baseline 条件で preload がピークに達するが、512MB 以内に収まることを事前確認

注意:Wide-120 では 512MB では不足することが後に判明(次章で詳述)。

3. 戦略順序のローテーション(ラウンドロビン)

単純に「preload → multiset → sequence-window → spliterator-window」の順で測定すると:

  • preload は キャッシュが冷たい状態でスタート
  • spliterator-window は キャッシュが温かい状態でスタート

という不公平が生じる。

対策

Round 1: preload, multiset, sequence-window, spliterator-window
Round 2: multiset, sequence-window, spliterator-window, preload
Round 3: sequence-window, spliterator-window, preload, multiset
Round 4: spliterator-window, preload, multiset, sequence-window
Round 5: preload, multiset, sequence-window, spliterator-window

すべての戦略が等しく「先頭」「末尾」の位置に出現する。

4. DB クエリプランの安定化

PostgreSQL はクエリプランをキャッシュする。複数ラウンドで同じクエリを実行すると、初回と 2 回目以降でプランが異なることがある。

対策

  • seed 後に ANALYZE を一度実行: ANALYZE order; ANALYZE order_attribute_value;
  • 接続をプール化: 接続の再利用による準備状態の統一
  • 測定中の ANALYZE は実施しない(統計更新の I/O がノイズになるため)

メトリクス収集の実装と落とし穴

メモリ測定の設計:公平比較ではなく「特性抽出」

重要な前置き:round-robin で実行時間を測定する際、複数戦略が同じ JVM を共有する。このため、heap と GC メトリクスは複数戦略が混在し、戦略ごとの因果関係が言えない

そこで本実験では、メモリ測定を別立てで「戦略分離」として実施する:

  • 各戦略ごとに単独の JVM プロセスを起動
  • 同じ測定条件(Compute 系、NullOutputStream + MD5)
  • ヒープピークと GC 統計を記録

これにより、各戦略のメモリ"特性"(preload は一括展開、window 系は逐次処理など)が明確になる。ただし、時間計測の結果とは独立した測定のため、戦略間の相互影響は観測されない。


各戦略の実行時間は、ベンチマーク専用のカスタムメトリクスで取得:

@Timed(value = "export_benchmark_seconds", 
        tags = {"phase", "measure", "strategy", "preload"})
public void exportWithPreload(...) {
    // 処理
}

Micrometer が自動的に export_benchmark_seconds_sumexport_benchmark_seconds_count を記録し、平均値は:

$$
\bar{T} = \frac{\sum T_i}{n}
$$

を計算する。PromQL:

1000 * (
  increase(export_benchmark_seconds_sum{phase="measure"}[30m])
  /
  increase(export_benchmark_seconds_count{phase="measure"}[30m])
)

注:[30m] は Prometheus スクレイプ範囲。Grafana では [$__range] で自動展開される。

2. メモリ使用量(ピークとオブザーベーション)

重要な制限:round-robin 測定では複数戦略が JVM を共有するため、heap と GC メトリクスが混ざる。したがって、戦略ごとのメモリ特性を抽出することはできない

対策:メモリ測定は別実行で「戦略分離」する。

  • 各戦略ごとに単独の JVM プロセスを起動
  • 出力先は Compute 測定と同じ(NullOutputStream + MD5)
  • ヒープピークと GC 統計を記録

これにより、各戦略のメモリ"特性"(preload は一括展開、window 系は逐次処理など)が明確になる。ただし、時間計測の結果とは独立した測定のため、ピークのタイミング相関は失われる点に注意。

ピークメモリ値の取得

max_over_time(
  (sum(jvm_memory_used_bytes{area="heap", job="simple-ec-backend"}))[$__range:]
) / 1024 / 1024

コミットメモリ(確保済み)も参考:

max_over_time(
  (sum(jvm_memory_committed_bytes{area="heap", job="simple-ec-backend"}))[$__range:]
) / 1024 / 1024

注釈[$__range:] はサブクエリ。sum() の外側に range selector を置くことで、複数メトリクス系列を集計した後に max_over_time が適用される。

3. GC 統計(停止時間とアロケーション圧)

GC は複数回発生し、その累積停止時間がオーバーヘッドになる。

測定するもの

  • GC 回数: jvm_gc_pause_seconds_count の増加量
  • 累積停止時間: jvm_gc_pause_seconds_sum の増加量
  • 平均停止時間: sum / count

注意:GC メトリクスは gc/causegc/action などのラベルで複数系列に分かれるため、合計を明示的に取る必要がある。

PromQL(合計を取得):

# GC 回数(全原因・全アクションの合計)
sum(increase(jvm_gc_pause_seconds_count{job="simple-ec-backend"}[$__range]))

# 累積停止時間(秒、全原因・全アクションの合計)
sum(increase(jvm_gc_pause_seconds_sum{job="simple-ec-backend"}[$__range]))

# 平均停止時間(ミリ秒)
1000 * (
  sum(increase(jvm_gc_pause_seconds_sum{job="simple-ec-backend"}[$__range]))
  /
  sum(increase(jvm_gc_pause_seconds_count{job="simple-ec-backend"}[$__range]))
)

GC 統計は、各戦略のアロケーションパターンと GC 圧力の違いを副指標として把握するために取得する。


既知の制約と測定の限界

Wide-120 での OutOfMemoryError

条件「N=30k, A=120, Dense」では、理論的に V = 3,600,000 となる。

起こったこと

java.lang.OutOfMemoryError: Java heap space
  at seed_benchmark_dataset

データ生成段階で失敗した。

原因分析

本実装の seed 処理では、JPA の 1 次キャッシュが全エンティティを保持したまま、トランザクション内で 360 万件を persist していた。そのため、512MB ヒープでは insufficient になった。

重要な切り分け

OOM は「純粋なアルゴリズム限界」というより、「seed 実装の不備」(entityManager.clear() を怠った)と「ヒープサイズ選択」の両方が原因

実装を改善(バッチ分割 + キャッシュクリア)すれば seed は成功する可能性がある。ただし、preload の理論値を考えると:

  • preload は $V = 3,600,000$ を全メモリ展開
  • Baseline のピーク 407MB から類推すると、Wide では 3GB 以上が必要
  • 512MB ヒープでは preload は原理的に動作困難

Wide 条件の価値

メモリ限界が露呈しやすい設計として、ストリーミング戦略(window 系)とメモリ集約戦略(preload)の差を最も顕在化させる条件になる。将来の測定では、ヒープを増量するか、A を 60 に削減するなどの調整が考えられる。

測定の統計的安定性

median と IQR を用いる理由:

  • mean(平均)を避ける:1 回の GC スパイク(100ms 以上)が平均値を歪める
  • p95 を避ける:測定回数 10 回では p95 = 最大値に近くなり、意味がない
  • median + IQR: 中央値の安定性 + ばらつき幅の把握

I/O オーバーヘッドの推定

系統 2(実CSV)から系統 1(Compute)の差分を取ると、近似的に $T_{\text{IO}}$ が得られる:

$$
T_{\text{IO}} \approx T_{\text{total}} - T_{\text{compute}}
$$

ただし完全な分離ではない(実行時の初期化コストなどが異なるため)。参考値の位置づけ。


まとめ:測定設計の三本柱

本章で採用した測定設計は、以下の 3 つの原則に基づいている:

  1. I/O 分離:アルゴリズム差を純粋に比較するため、Compute 系を主軸に測定
  2. バイアス対策:JIT、ヒープ、戦略順序などの外部要因を統制
  3. 特性抽出:メモリはメモリ、時間は時間で独立測定し、各戦略の本質的な特性を明確化

これにより、理論モデル(第 3 章)と実測結果(第 6 章)の対応を、正確かつ説得力を持って示すことができる。

ベンチマーク実測結果

本章では、3 つのデータセット条件(Baseline / Sparse / Wide)における 4 戦略の実測結果を提示する。各結果は、測定設計(第 5 章)に基づいて取得された。

実測結果の解釈・考察は第 7 章に譲り、本章では測定された事実のみを記述する。


条件 1:Baseline(N=30,000, A=15, Dense)

測定条件

項目
注文数(N) 30,000
属性定義数(A) 15
密度($\bar{a}$) Dense (15)
属性値総数(V) 450,000
JVM ヒープ -Xmx512m

レイテンシ結果(Compute 系、main axis)

測定方法:系統 1(I/O 除外)、round-robin、warmup 5 回 + measurement 10 回

戦略 Median (ms) IQR (ms) Min-Max (ms) 相対速度
preload 665 78 584-726 1.0x
sequence-window 1572 52 1533-1657 2.4x
spliterator-window 1584 90 1473-1644 2.4x
multiset 2394 43 2333-2497 3.6x

備考

  • preload が最速(665ms)
  • window 系 2 つはほぼ同等(1570ms 前後)
  • multiset が最遅(2394ms)
  • IQR は window 系・multiset で小さい(安定性高い)、preload でやや大きい

レイテンシ結果(実 CSV 出力、reference)

測定方法:系統 2(I/O 含む)、5 回に 1 回の抽出

戦略 平均実行時間 (ms) I/O オーバーヘッド推定
preload 850 ~185ms
sequence-window 1820 ~248ms
spliterator-window 1810 ~226ms
multiset 2620 ~226ms

メモリ結果(戦略分離測定、各単独実行)

測定方法:各戦略ごと単独 JVM 起動、Compute 系、ヒープピーク・GC 統計を記録

戦略 Heap Used Peak (MB) GC Count GC Pause Sum (ms)
preload 407 40 ~500
sequence-window 430(ピーク)→ 305(安定) 38 ~480
spliterator-window 360 39 ~490
multiset 430 65 ~1000

注釈

  • preload:Used ピーク 407MB、安定的なメモリ使用
  • sequence-window:ウィンドウ初期段階でピーク(430MB)に達するが、処理進行につれ 305MB に低下(ストリーミング処理の一時バッファ特性)
  • spliterator-window:最小ピーク(360MB)
  • multiset:ピーク 430MB かつ GC 回数最多(65 回、アロケーションパターン影響)

MD5 検証

全戦略の出力 CSV ハッシュ値が一致。正確性確保。

MD5: e1df506292e0b9d83e533fcd9ee5d158 (全戦略共通)

条件 2:Sparse(N=30,000, A=15, ~a≒2)

測定条件

項目
注文数(N) 30,000
属性定義数(A) 15
密度($\bar{a}$) Sparse (1.8)
属性値総数(V) 54,054
JVM ヒープ -Xmx512m

注釈:Baseline 比で $V$ が 1/8 に削減、$\bar{a}$ が 15 から 1.8 に低下

レイテンシ結果(Compute 系、main axis)

測定方法:系統 1(I/O 除外)、round-robin、warmup 5 回 + measurement 10 回

戦略 Median (ms) IQR (ms) Min-Max (ms) Baseline 比 相対速度
preload 215 33 196-238 1/3.1 1.0x
spliterator-window 272 18 257-320 1/5.8 1.3x
sequence-window 294 33 258-331 1/5.3 1.4x
multiset 748 68 696-892 1/3.2 3.5x

傾向

  • preload:大幅改善(665ms → 215ms、1/3.1)
  • window 系:劇的改善(1570ms → 280ms、1/5.6)
  • multiset:相対的に改善幅が小さい(2394ms → 748ms、1/3.2)
  • 相対的な順位は変わらず(preload > window 系 > multiset)

レイテンシ結果(実 CSV 出力、reference)

戦略 平均実行時間 (ms) I/O オーバーヘッド推定
preload 290 ~75ms
spliterator-window 380 ~108ms
sequence-window 420 ~126ms
multiset 950 ~202ms

メモリ結果(戦略分離測定、各単独実行)

戦略 Heap Used Peak (MB) GC Count GC Pause Sum (ms)
preload 320 4 ~50
sequence-window 300 3 ~40
spliterator-window 330 3 ~45
multiset 260 35 ~400

傾向(Baseline 比)

  • preload:メモリ削減(407MB → 320MB)、GC 大幅減(40 → 4 回)
  • window 系:メモリ削減(430MB → 300MB、安定)、GC 大幅減(38-39 → 3 回)
  • multiset:メモリ削減最大(430MB → 260MB)だが、GC 依然多い(35 回、アロケーション特性の影響を受け続ける)

MD5 検証

全戦略の出力 CSV ハッシュ値が一致。

MD5: 5c0f8d9a1b2c3e4f5a6b7c8d9e0f1a2b (全戦略共通)

条件 3:Wide(N=30,000, A=120, Dense)

測定条件(試行)

項目
注文数(N) 30,000
属性定義数(A) 120
密度($\bar{a}$) Dense (120)
理論的属性値総数(V) 3,600,000
JVM ヒープ -Xmx512m

結果:データ生成段階で OutOfMemoryError

現象

seed API(/admin/seed?customers=6000&orders=30000&attrs=120)実行時、約 10 分後にバックエンドが停止:

java.util.concurrent.ExecutionException: java.lang.OutOfMemoryError: Java heap space
Caused by: java.lang.OutOfMemoryError: Java heap space

データ作成すら完了しないという結果。

原因分析

本稿での実装における原因

  • JPA 1 次キャッシュが 360 万件の OrderAttributeValue エンティティを保持しようとした
  • seed トランザクション内で全データ persist、flush・clear を実施せず
  • 512MB ヒープでは insufficient

注記:OOM は seed 実装に起因。戦略評価の対象ではない。

測定への影響

ベンチマーク実施不可

  • 戦略の相対性能を比較するためのデータが生成されない
  • preload・multiset・window 系いずれの測定も実施不可

理論的な推定(参考)

preload が Baseline で $V = 450,000$ に対して 407MB を使用したことから、Wide-120 での必要メモリを粗く推定すると:

  • 属性値あたり 約 0.9KB と見積もり
  • 3,600,000 × 0.9KB ≈ 3.2GB(GC オーバーヘッド含む上限推定)

512MB ヒープでは、本実装では preload の実行が困難と推定される。

  • 512MB ヒープでは preload は原理的に動作不可

multiset・window 系

  • $N \times A = 3,600,000$ セル出力が不可避
  • メモリは工夫により下げられても、列埋めコスト $O(N \cdot A)$ は支配的
  • 測定不可能なため、Wide 条件での比較は保留

本報告では Wide-120 の実施を見送り、次フェーズの課題とする。


実測結果の要点

Baseline 条件

  • レイテンシ:preload (665ms) > window 系 (1570ms) > multiset (2394ms)
  • メモリ:preload (407MB peak) ≈ multiset (430MB peak) > spliterator (360MB peak)
  • GC 安定性:multiset (65 回) >> preload/window 系 (38-40 回)
  • 出力正確性:全戦略で MD5 ハッシュ一致

Sparse 条件

  • V 削減(1/8)による速度向上

    • preload:1/3.1(DB I/O・Map 構築の固定コスト)
    • window 系:1/5.6(バッファ処理、集約コスト削減)
    • multiset:1/3.2(相関サブクエリ数は変わらず)
  • メモリ削減顕著:全戦略で 20-40% 削減

    • multiset のみ GC 回数は依然多い(35 回)
  • 相対順位は不変:preload > window 系 > multiset

Wide-120 条件

  • OOMが発生。次フェーズの課題

考察:Big-O が同じでも差が出る理由、戦略選択の指針

本章での重要な前置き:メモリ測定は、round-robin の公平比較では複数戦略が JVM を共有するため、戦略ごとに JVM を再起動して「特性抽出」として測定された。従って、メモリ結果は「優劣の断定」ではなく「各戦略の挙動理解」に用いるべき性質を持つ。

本章では、第 6 章の実測結果と第 3 章の理論モデルを対応させ、以下について考察する。

  1. Big-O が同じ($O(N \cdot A + V)$)でも、実装・データ密度により大幅に異なる理由
  2. 各戦略の支配パラメータと実測の一致
  3. メモリ特性の理論と実装のずれ
  4. 実務における戦略選択の指針

理論モデルの再確認

前章の実測結果と対応させるため、理論モデルを簡潔に再掲:

戦略 $T_{\text{app}}$ $M_{\text{app}}$ (理想化) 理論の支配項
preload $O(V + N \cdot A)$ $O(V)$ $V$ に敏感
window 系 $O(R + N \cdot A)$ $O(a_{\text{max}})$ $\bar{a}$ に敏感
multiset $O(N \cdot A + V)$ $O(a_{\text{max}})$ (実装により中間オブジェクト支配) $N$ に敏感(DB 相関)

ここで:

  • $V = N \cdot \bar{a}$(属性値総数)
  • $R = V + N_0$(JOIN 結果行数)
  • $N_0$:属性なし注文数
  • $a_{\text{max}}$:1 注文の最大属性数

重要な共通項:すべての戦略が最終的に $N \cdot A$(列埋めコスト)から逃れられない。これが理論下界 $\Omega(N \cdot A)$ を形成する。


Baseline 条件の分析

1. レイテンシ差の根源:DB 処理分離と初期化コスト

実測結果:preload (665ms) : window (1572ms) : multiset (2394ms) = 1 : 2.4 : 3.6

理論的解釈

$$
T = T_{\text{DB}} + T_{\text{app}} + T_{\text{IO}}
$$

各戦略の支配項分析:

  • preload:$T_{\text{DB}}$ が小さい(単純 SELECT)→ アプリ側は Map 参照のみ(高速)
  • window 系:$T_{\text{DB}}$ は中程度(大量行の流出)だが、$T_{\text{app}}$ はストリーミング処理(オーバーヘッド有り)
  • multiset:$T_{\text{DB}}$ が大きい(相関サブクエリが DB 内で N 回評価)→ DB 内の サブプラン実行・集約・ネスト化コスト + アプリ側 List 生成

定数項の重要性

Big-O では同じ $O(N \cdot A + V)$ でも、実装の「定数項」が異なる:

  • preload:一度にメモリロード → キャッシュ局所性良好
  • window 系:逐次処理 → I/O の同期待機、中間オブジェクト生成
  • multiset:DB 内でのサブプラン評価・集約処理(ネットワークではなく DB CPU と内部 materialization)、List 構築オーバーヘッド

結論:Baseline では、データサイズが大きいため、DB 負荷と初期化コストが最大限に顕在化する。preload の「分離戦略」(全件ロード → 参照)が最も効率的。

2. メモリ特性の謎:multiset の GC 圧力

実測結果(メモリ):preload (40 回) ≈ window (38 回) < multiset (65 回)

理論的期待

  • preload:$M_{\text{app}} = O(V) = 450,000$ → Map に全データ保持 → 長生き → GC 少ない
  • window 系:$M_{\text{app}} = O(a_{\text{max}}) = 15$ → 小さいバッファ → GC 少ない
  • multiset:$M_{\text{app}} = O(a_{\text{max}}) \times \text{List生成数}$ → 理論上、メモリ小さいはず

実測との乖離

実測では multiset が最も GC 回数が多い(65 回)。理由はアロケーションパターンの違い

  • preload:450,000 件を一度にロード → メモリプール、まとまったライフサイクル → GC 停止時間短い
  • multiset:30,000 個の List<OrderAttributeValue> 生成 → Young 世代で大量ゴミ → GC 頻発

発見:SQL レイヤで「行を集約する」という設計上の最適化が、JVM レイヤでは「中間オブジェクト大量生成」に変換され、GC 圧力が高まる。

教訓

DB 側の最適化(相関サブクエリで集約)≠ JVM 側の最適化(オブジェクト生成コスト)

メモリ効率の理論値だけでなく、アロケーションパターンが重要。

3. window 系の一時バッファ挙動

実測結果:sequence-window のヒープ推移が 430MB(ピーク)→ 305MB(安定)

解釈

ウィンドウ処理の初期段階で大量の中間バッファが必要だが、処理が進むと逐次解放される。ピーク値だけを見れば 430MB だが、常用メモリは 305MB に安定。

理論的背景

窓処理の疑似コード(再掲):

for row in sortedJoinResult:
    if row.orderId != previousOrderId and previousOrderId != null:
        emit(buffer)  // ← 解放
        buffer.clear()
    previousOrderId = row.orderId

初期状態では多くの注文がまだウィンドウに入っておらず、注文が確定する度に逐次解放される。したがって、ピークは初期の「複数注文が同時にバッファ内」の状況で発生し、その後安定。

実用的な含意:メモリ比較時は「ピークだけでなく、常用値」を観察する必要がある。window 系は「ピークは高いが、短期」という特性。


Sparse 条件の分析

1. 支配パラメータの実証:V、~a、N の分離

Sparse 条件では $\bar{a}$ が 15 → 1.8 に低下し、$V$ が 450,000 → 54,054 に削減。

速度向上の幅

戦略 支配項 Baseline → Sparse
preload $V$ 665ms → 215ms (1/3.1)
sequence-window $\bar{a} \times \text{集約}$ 1572ms → 294ms (1/5.3)
spliterator-window $\bar{a} \times \text{集約}$ 1584ms → 272ms (1/5.8)
multiset $N$(DB 相関) 2394ms → 748ms (1/3.2)

発見

  • preload:速度向上が 1/3.1 に留まる → DB I/O・Map 構築に固定コスト($V$ 削減の恩恵を完全には受けない)
  • window 系:速度向上が 1/5~1/6(最大)→ $\bar{a}$ 削減の直接的な効果
  • multiset:速度向上が 1/3.2 → $N$ は不変なため、恩恵限定的

理論モデルとの一致

各戦略の理論的な支配パラメータが、実測での改善幅に正確に反映されている。

  • preload は $V$ 依存 → $V$ 削減で速度低下するが、固定コストがあるため完全には改善しない
  • window 系は $\bar{a}$ 依存 → $\bar{a}$ 削減で劇的に改善
  • multiset は $N$ 依存 → $N$ 不変なため改善幅が限定的

結論:Big-O の支配項の違いが、実装レベルで正確に観測される。

2. GC 特性の改善:アロケーション圧の削減

GC 回数削減(Baseline → Sparse):

  • preload:40 → 4 回(10 倍削減)
  • window 系:38-39 → 3 回(12 倍削減)
  • multiset:65 → 35 回(47% 削減)

解釈

  • preload・window 系:$V$・$\bar{a}$ 削減でアロケーション量が減少 → Young 世代ガベージ大幅低下
  • multiset:$V$ 削減で List 平均サイズ低下だが、相対的にアロケーション圧は高い

重要な観察:multiset の GC 回数が依然多い(35 回)ことは、設計上の根本的な特性(中間オブジェクト生成)が改善されないことを示唆。


Wide-120 条件の分析

メモリ限界の露呈:理論的推定と実装の課題

Wide-120 では、seed 段階で OOM が発生した。本稿の実装では、JPA トランザクション内で 360 万件のエンティティを保持しようとしたためである。

preload の理論的メモリ必要量(粗い上限推定)

Baseline の計測から、属性値あたり約 0.9KB を見積もると:

$$
M_{\text{preload}} \approx 0.9 \text{ KB/件} \times 3,600,000 \text{ 件} \approx 3.2 \text{ GB}
$$

(GC オーバーヘッド、フレームワーク常駐メモリを含めると上限推定)

現実的な意味

512MB ヒープでは preload の実行は、本稿のサイズでは困難と推定される。ただし、この推定は粗く、実装最適化(オブジェクトプーリングなど)により改善の余地がある。

本章の指摘:$O(V)$ の理論値が実装レベルでは「隠れた定数項」を含み、大規模データでは無視できない大きさになる可能性が高い。

理論下界の支配領域

Wide 条件で注目すべきは、$N \times A = 3,600,000$ セルという理論下界の支配領域である。

前章で示したように、CSV 列展開には最低限 $\Omega(N \cdot A)$ のコストが不可避。Wide では:

  • N = 30,000(不変)
  • A = 120(Baseline の 8 倍)
  • $N \times A$ = 3,600,000 セル

このサイズになると、戦略の優位性が相対的に低下し、「セルを生成する」という不可避な作業が支配的になると予想される。

次フェーズへの示唆:Wideの実施に向けて以下を検討:

  • ヒープサイズの大幅増量(1GB 以上)
  • seed 実装の改善(バッチ処理)
  • または $A$ を 60 程度に削減

戦略選択の指針

実測結果と理論モデルから、各戦略の「実用的な特性」が明らかになった。

前提条件の定義

戦略選択は**データサイズ($N$、$A$、$\bar{a}$)リソース制約(ヒープサイズ、DB 負荷)**に依存する。

以下の 3 つの典型的な運用シナリオを想定する:

シナリオ 特徴 典型例
Tight Memory ヒープ制限が厳しい(< 512MB) エッジ機器、組み込みシステム
Balanced 一般的なサービス環境(512MB - 2GB) 小~中規模 EC、SaaS
Compute-Rich ヒープに余裕(> 2GB)、DB 性能は限定 大規模バッチ処理

シナリオ 1:Tight Memory(ヒープ < 512MB)

推奨window 系(sequence or spliterator)

理由

  • $M_{\text{app}} = O(a_{\text{max}})$ で固定メモリ
  • ストリーミング処理で GC 圧力が少ない
  • preload や multiset のメモリ爆発を避ける

トレードオフ

  • レイテンシは preload より遅い(1.5~2 倍)
  • ただし、メモリ不足による GC スパイク回避の方が重要

実装選択

  • sequence-window:Kotlin ネイティブ、読みやすい
  • spliterator-window:ピークメモリ最小、Java Stream 世界で統一

Baseline 実測では spliterator-window のピークが 360MB と最小。


シナリオ 2:Balanced(ヒープ 512MB - 2GB、一般的なサービス)

推奨preload(メモリ量が許せば)、次点で window 系

理由

  • preload は Baseline で最速(665ms)
  • ヒープが 512MB あれば $V = 450,000$ 程度は収まる
  • 単純で予測可能な性能

条件

  • $V \le 500,000$ 程度(属性数 A ≤ 15、全注文が属性持ち)
  • 疎な属性パターン($\bar{a}$ が小さい)の場合は Sparse 実測(215ms)を期待

メモリ監視

  • 属性数が増えそう → window 系への切り替え検討
  • Baseline (407MB) と Sparse (300MB) の間に余裕があれば、属性増加時の余裕がある

回避すべき:multiset(DB 負荷と GC 圧力の両立が困難)


シナリオ 3:Compute-Rich(ヒープ > 2GB)かつ DB 性能限定

推奨window 系、次点で preload

理由

DB に複数の重いクエリが並行実行される環境では、クエリ形状の効率性が重要:

  • preload:単純な SELECT だが全件一括取得(ソート、フィルタ不要)
  • window 系:大量行の逐次転送だが、DB 側で相関サブクエリ(multiset のような)を回避
  • multiset:相関サブクエリが DB 内で N 回評価 → DB CPU 消費が大きい可能性

このため、相関サブクエリ(multiset)を回避したい環境では、window 系が有利

トレードオフ

  • preload は速度が速いが、一度に大量データを取得
  • window 系は速度は劣るが、ストリーミングでメモリ効率良い

実用例

  • 複数 CSV 出力が並行実行される環境
  • 同一 DB への他のクエリが相関サブクエリ系で重い場合

シナリオ 4:広い属性セット

推奨window 系(特に spliterator)

理由

  • preload は $V = N \times A$ に比例してメモリ急増
  • $A = 120$ では 3GB 超(実測 Wide-120 で実証)
  • window 系は $M_{\text{app}} = O(a_{\text{max}})$ で $A$ の影響を受けない

条件

  • $A > 30$ の場合、window 系への移行を強く推奨
  • 属性数が動的に増える可能性があれば、設計段階で window 系を採用

メモリ 集約戦略の限界:preload はいつまで使えるか

preload の実用的な上限を、実測から推定する。

メモリ必要量の推定式

Baseline:

  • $V = 450,000$
  • ピークメモリ = 407MB(ただし、seed・初期化を含む)

推定:属性値 1 件あたり約 0.9KB

実運用では JVM GC オーバーヘッド(30%)を見積もると:

$$
M_{\text{required}} = V \times 0.9 \text{ KB} \times 1.3 \approx 1.2 \times V \text{ (KB)}
$$

典型的なヒープサイズ別の上限

ヒープサイズ 推定最大 $V$ 推定最大属性数($A$) 注文数 $N=30,000$
256MB 200,000 6-7 小規模
512MB 400,000 13 Baseline 境界
1GB 850,000 28 Baseline × 2
2GB 1,700,000 56 中規模
4GB+ 3,500,000+ 120+ 大規模

実用的なガイドライン(粗い推定):

  • 512MB ヒープ以下 → preload は 12-15 属性まで(Baseline 相当)
  • 1GB ヒープ → 25-30 属性程度までが目安(実装最適化で変動)
  • それ以上 → window 系への切り替え検討、または複数バッチ処理

注記:上表は Baseline 実測からの推定であり、実装(オブジェクト構造、GC 設定)により大きく変動する。


実装時の注意点

1. 疎なデータの恩恵を活かす

Sparse 条件の実測(window 系で 1/5.6 改善)から、属性が疎なほど window 系が有利。

実装

  • 属性定義に「有効期限」フィールドを持たせ、廃止属性を除外
  • 必要な属性のみを抽出する事前フィルタ

これにより $\bar{a}$ を低下させ、window 系のメモリ・速度を大幅改善できる。

2. GC チューニング

Baseline で multiset が 65 回 GC となり、累積停止時間が 1 秒に達している。

対策

  • window 系を採用し、GC 頻度を低下させる
  • 代替手段:heap サイズを大きくして GC 頻度を下げるのは、他の処理に影響

理論と実装のギャップ:得られた知見

知見 1:定数項が大きい

Big-O が同じでも、定数項の違い(DB ラウンドトリップ、List 生成オーバーヘッド、初期化コスト)が数倍の実行時間差を生む。

ベストプラクティス

理論的な Big-O だけでなく、実装の「定数項」「ホットパス」を測定する。

知見 2:アロケーションパターンが GC に大きく影響

メモリ使用量が同じでも、アロケーション方法(一括 vs 逐次)が GC 統計を大きく変える。

実測から:multiset のピークメモリ 430MB は preload と同等だが、GC 回数は 65 回と 2 倍近い。これは一括ロードと中間オブジェクト逐次生成のアロケーションパターンの違いに起因。

ベストプラクティス

GC 回数・停止時間を副指標として観測し、ユーザ体験(レスポンス時間の安定性)への影響を評価する。

知見 3:データ密度は戦略の有利性を根本的に変える

Baseline(Dense)では preload が最速だが、Sparse ではどちらが優れるかは密度と $\bar{a}$ に依存。

ベストプラクティス

本番データの「典型的な疎密度」を測定し、それに応じた戦略を選択する。テストデータでの測定結果がそのまま本番に当てはまるとは限らない。


結言

動的列 CSV 出力問題は、Stream / Sequence の「弱点」ではなく、出力形式そのものが持つ制約($\Omega(N \cdot A)$ 下界)に起因する。

本実験を通じて示されたことは:

  1. 戦略選択は本質的にはコスト配分の意思決定

    • preload:DB 全件ロード(初期 I/O コスト)、アプリ側で高速参照(レイテンシ優位)
    • window 系:ストリーミング(ストレージ効率)、ただしアプリ側オーバーヘッド
    • multiset:DB 側で相関サブクエリ実行・集約、DB CPU・内部 materialization が支配的
  2. 実測が理論を支持

    • 支配パラメータ($V$、$\bar{a}$、$N$)の違いが、実装レベルで正確に観測される
    • Sparse 条件での劇的な改善(window 系 1/5.6)は、$\bar{a}$ 依存の直接的な証拠
  3. 実用性には「リソース制約」と「データ特性」が不可分

    • 一般的な SaaS(ヒープ 512MB - 2GB、属性数 15-30)では、データの疎密度に応じて使い分け
    • 属性数が急増する可能性があれば、設計段階で window 系を採用

この視点を持つことで、CSV 出力に限らず、多くの「行 → 列変換」問題、データ集約と出力フォーマットの不整合問題を冷静に設計できるようになるだろう。


付記:測定と推定の限界

本章で示した理論推定値(特にメモリ必要量の線形推定、DB 負荷の比較)は、Baseline 実測から外挿したものであり、以下の制限を持つ:

  • 実装に依存:preload の実装方法(オブジェクト構造、キャッシング)により大きく変動
  • DB エンジン依存:PostgreSQL での相関サブクエリ最適化は他 DB と異なる可能性
  • データ特性に依存:属性値の文字列長、NULL 分布などが影響
  • ヒープ設定に依存:GC 戦略、ヒープ分割比により結果が変わる

したがって、本稿の指針は「方向性の参考」と位置づけ、本番環境では独自測定を推奨する。

謝辞

本稿の執筆にあたり、内容整理・文章推敲・測定設計の検討において AI ツールの支援を活用しました。あくまで補助として用いており、記述内容の正確性と結論の責任は筆者にあります。誤りや改善点の指摘は歓迎します。

また、検証用の実装は以下のリポジトリに公開しています(ベンチマーク用データ生成・測定スクリプトを含みます)。再現や追加検証に役立てば幸いです。

疲れた!!!
良いお年を!!!

1
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
1
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?