本稿の執筆にあたっては、内容整理および文章推敲のため 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 出力問題における測定は、アルゴリズム差だけでなく、多くの外部要因に左右されるため、単純な比較では誤った結論に到達してしまう。
本章で複雑な測定設計を採用する理由は:
- I/O と計算が分離できない → 出力先(画面 / ファイル / NullStream)で時間が変わる
- JIT コンパイルが予測不可能 → warmup の回数・タイミングで最適化度合いが変わる
- ヒープサイズで挙動が激変 → GC が頻発すると測定結果が不安定になる
- 戦略の実行順序がバイアスをもたらす → 1番目と10番目の測定値が異なる
- メモリ消費とアロケーションパターンが異なる → ピークと常用値を区別する必要がある
以下では、これらの課題をどう対処するかを、設計の選択と根拠とともに説明する。
理論から実験設計へ:支配パラメータの検証
前章(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_sum と export_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/cause や gc/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 つの原則に基づいている:
- I/O 分離:アルゴリズム差を純粋に比較するため、Compute 系を主軸に測定
- バイアス対策:JIT、ヒープ、戦略順序などの外部要因を統制
- 特性抽出:メモリはメモリ、時間は時間で独立測定し、各戦略の本質的な特性を明確化
これにより、理論モデル(第 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 章の理論モデルを対応させ、以下について考察する。
- Big-O が同じ($O(N \cdot A + V)$)でも、実装・データ密度により大幅に異なる理由
- 各戦略の支配パラメータと実測の一致
- メモリ特性の理論と実装のずれ
- 実務における戦略選択の指針
理論モデルの再確認
前章の実測結果と対応させるため、理論モデルを簡潔に再掲:
| 戦略 | $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)$ 下界)に起因する。
本実験を通じて示されたことは:
-
戦略選択は本質的にはコスト配分の意思決定
- preload:DB 全件ロード(初期 I/O コスト)、アプリ側で高速参照(レイテンシ優位)
- window 系:ストリーミング(ストレージ効率)、ただしアプリ側オーバーヘッド
- multiset:DB 側で相関サブクエリ実行・集約、DB CPU・内部 materialization が支配的
-
実測が理論を支持
- 支配パラメータ($V$、$\bar{a}$、$N$)の違いが、実装レベルで正確に観測される
- Sparse 条件での劇的な改善(window 系 1/5.6)は、$\bar{a}$ 依存の直接的な証拠
-
実用性には「リソース制約」と「データ特性」が不可分
- 一般的な SaaS(ヒープ 512MB - 2GB、属性数 15-30)では、データの疎密度に応じて使い分け
- 属性数が急増する可能性があれば、設計段階で window 系を採用
この視点を持つことで、CSV 出力に限らず、多くの「行 → 列変換」問題、データ集約と出力フォーマットの不整合問題を冷静に設計できるようになるだろう。
付記:測定と推定の限界
本章で示した理論推定値(特にメモリ必要量の線形推定、DB 負荷の比較)は、Baseline 実測から外挿したものであり、以下の制限を持つ:
- 実装に依存:preload の実装方法(オブジェクト構造、キャッシング)により大きく変動
- DB エンジン依存:PostgreSQL での相関サブクエリ最適化は他 DB と異なる可能性
- データ特性に依存:属性値の文字列長、NULL 分布などが影響
- ヒープ設定に依存:GC 戦略、ヒープ分割比により結果が変わる
したがって、本稿の指針は「方向性の参考」と位置づけ、本番環境では独自測定を推奨する。
謝辞
本稿の執筆にあたり、内容整理・文章推敲・測定設計の検討において AI ツールの支援を活用しました。あくまで補助として用いており、記述内容の正確性と結論の責任は筆者にあります。誤りや改善点の指摘は歓迎します。
また、検証用の実装は以下のリポジトリに公開しています(ベンチマーク用データ生成・測定スクリプトを含みます)。再現や追加検証に役立てば幸いです。
疲れた!!!
良いお年を!!!