パート1で公正な計測手法を確立した。パート2でオーバーヘッドの原因をPydanticに特定し、エクスポートを修正した(2.8x→パリティ)。この記事では残ったギャップへの5つのピンポイント修正を解説する。
パート3/5。パート1: 方法論 | パート2: オーバーヘッド | パート4: スコアカード | パート5: カラムナーベースライン
各修正は同じループで進める: 計測 → 非対称性を見つける → 修正 → 再計測。非対称性がコードにあった場合もあれば、ベンチマーク自体にあった場合もある。

M-1: FTS再構築(4.65x → 1.03x)
バグの正体
4.65xはコードの問題ではなく、ベンチマークのバグだった。sqlerのfts.rebuild()は全体の再投入を行う: FTS行を全削除してから、ソーステーブルのJSONデータからINSERT...SELECT。一方、sqlite3ベースラインはFTS5の組み込み操作INSERT INTO fts(fts) VALUES('rebuild')を呼んでいた。これはセグメントマージ操作で、内部インデックス構造を再編するだけでソースデータを再読み込みしない。
本質的に異なる操作を比較していた。一方は行数にスケール(O(n))、もう一方はセグメント数にスケール(ほぼ定数)。「本を書き直す」と「目次を並べ替える」を比較するようなものだ。
修正
ベースラインが同じ作業をするよう修正: sqlerの実装に合わせてJSONからDELETE + INSERT…Selectを実行。
1M行での結果(ディスクモード)
| スケール | sqler | sqlite | 比率 |
|---|---|---|---|
| 50K | 1,236ms | 1,185ms | 1.04x |
| 100K | 2,376ms | 2,320ms | 1.02x |
| 500K | 14,242ms | 13,313ms | 1.07x |
| 1M | 32,266ms | 31,410ms | 1.03x |
4.65xは完全にベンチマークが異なる操作を比較していたせいだった。操作を揃えると、オーバーヘッドは3〜7%: sqlerのアダプターレイヤーが生SQLの上に積むコスト。ベンチマーク比率がデータセットサイズで変動するなら、2つのアームが同じようにスケールしているか確認すること。スケールが違えば、どのサイズで比較しても意味がない。
おまけ: 3つの追加公正性問題
M-1の修正中に、FTSベンチマークでさらに3つの非対称性が発覚した:
- トークナイザーの不一致: sqlerは
porter unicode61を使用、ベースラインはunicode61のみ。異なるトークナイザーは異なるインデックス構造を生成し、異なるドキュメントセットにマッチする。 - 検索作業の非対称性: ベースラインは生のFTSタプルを返し、sqlerはFTSクエリ → 2回目のSQLクエリ →
json.loads()→model_validate()→ SearchResult構築を行っていた。 - 結果件数の検証なし: 2つのアームが異なる件数のドキュメントにマッチしていても検出できない状態だった。
3つともv1.3で修正した。プロジェクト全体で発見された公正性問題の累計: 23件。
M-2: any_where配列サブクエリ(1.50x → 1.04x)
根本原因
sqlerが配列サブクエリに冗長なSQLを生成していた。any_whereパスがカラム値を先に抽出してからjson_eachに渡していた:
-- sqlerが生成していた(冗長)
json_each(json_extract(data, '$.events'))
-- 生成すべきだった(直接)
json_each(data, '$.events')
json_extractの呼び出しは不要で、json_eachはパス引数を直接受け取れる。1M行では、行ごとの余分な関数呼び出しが約3秒の純粋な無駄を生んでいた。
1M行での結果(ディスクモード)
| スケール | sqler | sqlite | 比率 |
|---|---|---|---|
| 50K | 294ms | 289ms | 1.02x |
| 100K | 567ms | 550ms | 1.03x |
| 500K | 2,968ms | 2,824ms | 1.05x |
| 1M | 6,476ms | 6,219ms | 1.04x |
1.50xから1.04xへ。残り4%はsqlerのアダプターオーバーヘッド。冗長なSQLがギャップの全てだった。
M-3: バルクインサート(1.92x → 0.89x)
オーバーヘッドの内訳
| 原因 | ギャップの割合 | 修正可能? |
|---|---|---|
行ごとのcursor() + execute() | ~45% | 可: チャンクにまとめる |
行ごとのdict内包表記(_idフィルタ) | ~25% | 可: リスト内包表記でまとめる |
行ごとのint(lastrowid) + append() | ~15% | 可: IDレンジで計算 |
行ごとのjson.dumps() | ~15% | 不可: 両アーム共通コスト |
sqlerが1行につき1つのSQL文を送っていた。ベースラインはexecutemany()を使っており、Cレベルでループする。修正: チャンク化したマルチ行INSERT。
修正
ドキュメントをinsert(_idなし)とupdate(_idあり)に分割し、各バッチにマルチ行SQLを構築。1チャンク999行のINSERT INTO t (data) VALUES (json(?)), (json(?)), ...を1回。1チャンク499行のINSERT ... ON CONFLICT(_id) DO UPDATEを1回(行ごとに2パラメータ、sqliteの999パラメータ制限を遵守)。
50K行ではSQL呼び出し50回、以前の50,000回から大幅削減。文ごとのパーサーオーバーヘッドは小さい(~1〜2µs)が、50K行では積み重なる。
逆転ポイント

| 行数 | sqler (メモリ) | sqlite (メモリ) | 比率 |
|---|---|---|---|
| 1K | 7.4ms | 5.9ms | 1.25x |
| 5K | 30.4ms | 30.3ms | 1.00x |
| 10K | 59.6ms | 61.6ms | 0.97x |
| 25K | 144.8ms | 155.8ms | 0.93x |
| 50K | 299.2ms | 315.5ms | 0.95x |
5K行でパリティ、10K行でexecutemany()より速くなる。
ORMがCレベルのexecutemany()に勝てる理由は何か。SQLiteのパーサーが各アプローチで何をするかにある。executemany()はN個の個別INSERT INTO t (data) VALUES (json(?))文を送る: N回のパース、N回のコンパイル、N回の実行。マルチ行INSERTは最大999行を1文で送る: パース1回、コンパイル1回、実行1回。パーサーの節約はスケールで複利になる。1M行で50回のSQL呼び出しと1,000,000回では比べ物にならない。
スケール横断確認(ディスクモード)
| スケール | sqler | sqlite | 比率 |
|---|---|---|---|
| 50K | 351ms | 367ms | 0.96x |
| 100K | 637ms | 699ms | 0.91x |
| 500K | 3,212ms | 3,535ms | 0.91x |
| 1M | 7,006ms | 7,858ms | 0.89x |
1M行でsqlerはsqlite3のexecutemany()より11%速い。教訓は汎化できる: マルチ行INSERTにバッチする任意のORMは、十分なスケールでexecutemany()に勝つ。コストがN回の文パースから1回にシフトするからだ。
公正性についての注記
比較は公正だが戦略が異なる: sqlerはチャンク化マルチ行INSERTを使い、ベースラインはexecutemany()を使う。どちらも正当なバルクインサートアプローチ。executemanyは生のsqlite3を使う開発者の大半が使うものなので、自然なベースラインとなる。完全に等価なテストにするなら両アームが同じ戦略を使うべきだが、それでは面白いものは何も測れない。
M-4: FTSランク検索(1.50x → 1.00x)
問題
FTSランク検索だけがスケールとともに悪化する唯一のシナリオだった:
| スケール | 比率 |
|---|---|
| 50K | 0.95x |
| 100K | 0.70x(ノイズ: 50ms以下の値) |
| 500K | 1.52x |
| 1M | 1.50x |
データセットサイズで大きくなる比率は、定数オーバーヘッドではなくアルゴリズムの違いのシグネチャだ。sqlerの実装で何かがベースラインより悪くスケールしていた。
根本原因: 2クエリパターン
search_ranked()が2つの個別SQLクエリを使っていた:
-- クエリ1: FTS検索でrowid + スコア取得
SELECT rowid, bm25(fts_table) as score
FROM fts_table WHERE MATCH ? ORDER BY score LIMIT ?;
-- クエリ2: IDリストでドキュメント取得
SELECT _id, data FROM source_table WHERE _id IN (?, ?, ...);
さらにPython側のオーバーヘッド: from_ids() → find_documents() → _batch_resolve() → 行ごとのmodel_validate() → スコアをドキュメントに戻すためのdictルックアップ + ソート。
修正: JOIN一本
SELECT t._id, t.data, bm25(fts_table) as score
FROM fts_table f
JOIN source_table t ON t._id = f.rowid
WHERE fts_table MATCH ?
ORDER BY score
LIMIT ? OFFSET ?;
2回目のクエリ、from_ids()パス、_batch_resolve()呼び出し、Python側のソートが全て不要になる。SQLが順序付けを担い、JOINがドキュメント取得を担う。ベースラインも公正性のために同じシングルJOINアプローチに更新した。
1M行での結果(ディスクモード)
| スケール | sqler | sqlite | 比率 |
|---|---|---|---|
| 50K | 30.5ms | 31.1ms | 0.98x |
| 100K | 67.8ms | 67.5ms | 1.01x |
| 500K | 479.1ms | 479.8ms | 1.00x |
| 1M | 1,036.8ms | 1,033.3ms | 1.00x |
500Kと1Mで完全パリティ。以前スケールとともに悪化していた唯一のシナリオが完全にフラットになった。
もう一つの公正性修正(問題#23)
M-4後の最初の結果は25Kで0.73xを示していた。FTSランク検索でsqlerが生のsqlite3より「速い」ように見えた。これは怪しい。sqlerは行ごとにmodel_validate()を実行するのだから、本当に速いはずがない。
根本原因: ベースラインのcreate()メソッドがリビルドタイマー開始前にFTSテーブルを投入していた。これでFTS5のシャドウテーブルに余分なトゥームストーンが残る。23回のリビルドサイクルを経てこれが蓄積し、ベースラインが約15%遅くなっていた。create()が仮想テーブルを作るだけにすることで修正(sqlerの動作と一致)。
これは本当に微妙な落とし穴だ。FTS5トゥームストーンはDELETE + INSERTサイクルをまたいで蓄積する。新鮮なFTS5インデックスは23回のリビルドを経たものより約2倍速く検索できる。リビルド後の検索パフォーマンスを計測するベンチマークは、両アームのトゥームストーン履歴が同じかどうか確認しなければならない。
M-5: msgspecプロトタイプ(ハイドレーション5.1x)
パート2でmsgspecがPydanticの最有力代替候補と特定した。M-5でプロトタイプを構築: SQLerMsgspecModel、dataclassの代わりにmsgspec.Structを使った並列モデルベース。
設計判断
_idと_snapshotをデフォルト付きのStructフィールド(Optional[int] = None)として宣言し、プライベート属性にしない。これによりmodel_validate()が生のdictをそのままmsgspec.convert()に渡せる。dictフィルタリング不要、後処理不要。ベースStructにkw_only=True。from __future__ import annotationsは使わない(msgspecのアノテーション評価を壊す)。クラスレベル属性への型アノテーションなし(msgspecがStructフィールドとして扱う)。SQLerLiteModelとの完全なAPI互換性を維持。
2ラウンドの最適化
ラウンド1: 初期プロトタイプは純粋ハイドレーション2.1xを示した。悪くないが、理論的上限の8xには程遠い。プロファイリングでmsgspec.convert()前のdictフィルタリング({k:v if not k.startswith("_")})がハイドレーション時間の59%を占めていることが判明。完全に不要だった。_idは宣言済みフィールドで、strict=Falseが未知のキーを処理する。これを削除して4.3xにジャンプ。
ラウンド2: ラウンド1後、model_dump()がdataclassバージョンより16倍遅かった。根本原因: msgspec.structs.fields()が~70µs/呼び出しのキャッシュなしリフレクション。50K回の呼び出しで、フィールドイントロスペクションがmodel_dump()の89%を消費。修正: クラスごとのモジュールレベルキャッシュ。model_dump()が4,062msから189msに。liteより1.4x速くなった。
最終結果(50K行)
| シナリオ | Lite (dataclass) | Msgspec (Struct) | 高速化 |
|---|---|---|---|
純粋なmodel_validate() | 151.6ms | 29.7ms | 5.1x |
model_dump() | 256ms | 189ms | 1.4x |
queryset.all()(メモリ) | 473.7ms | 324.4ms | 1.46x |
queryset.all()(ディスク) | 454.8ms | 311.1ms | 1.46x |
I/Oからハイドレーションを分離
エンドツーエンドのクエリセット時間にはSQLのI/OとPythonのハイドレーションの両方が含まれる。as_dicts()(ハイドレーションなし)をベースラインとして使う:
| Lite | Msgspec | |
|---|---|---|
queryset.all()(メモリ) | 473.7ms | 324.4ms |
as_dicts()(メモリ、ハイドレーションなし) | 170.9ms | 170.9ms |
| ハイドレーションのみ(差分) | ~303ms | ~153ms |
SQLとJSONパースはモデルバックエンドに関係なく171ms。ハイドレーションコストが303msから~153msに低下。ORMのウォールタイムへの寄与が2倍削減された。
スケールで比率が安定
| スケール | 純粋validate | エンドツーエンドall() | ハイドレーションのみ |
|---|---|---|---|
| 50K | 4.5x | 1.41x | 1.83x |
| 100K | 4.2x | 1.45x | 1.95x |
| 250K | 4.3x | 1.36x | 1.74x |
| 500K | 4.2x | 1.47x | 2.13x |
純粋ハイドレーション: 4.2〜4.5xで安定、サイズによる劣化なし。エンドツーエンド: 1.36〜1.47xで安定、SQLのI/Oに上限を制約される。
教訓
リフレクションするものは全てキャッシュすること。msgspecのstructs.fields()は呼び出し側からは安く見えるが、呼び出しごとに実際の作業をする。50K回の呼び出しで、キャッシュなしのフィールドリフレクションがmodel_dump()を支配し、「速い」バックエンドがdataclassより16倍遅くなった。修正は些細(モジュールレベルのdictキャッシュ)だったが、プロファイリングなしには見えなかった。
まとめ: 修正前後
| マイルストーン | 対象 | 修正前 | 修正後 | 根本原因 |
|---|---|---|---|---|
| M-1 | FTS再構築 | 4.65x | 1.03x | 異なる操作を比較するベンチマークのバグ |
| M-2 | any_where | 1.50x | 1.04x | json_each()内の冗長なjson_extract() |
| M-3 | バルクインサート | 1.92x | 0.89x | 行ごとSQL → チャンク化マルチ行INSERT |
| M-4 | FTSランク | 1.50x | 1.00x | 2クエリパターン → シングルJOIN |
| M-5 | ハイドレーション | 1.0x (lite) | liteの5.1x | msgspec Structバックエンド(オプトイン) |
5つの「コードバグ」のうち2つは実際にはベンチマークのバグだった(M-1、M-4のトゥームストーン問題)。修正により数値は短期的に悪化したが、信頼できるデータが得られた。このシリーズ全体のパターンはそれだ: 正しい答えは滅多に都合のいいものではない。
パート4で最終的なスケール横断検証を実施する。1,725件の計測が全ての結果が1M行でも保持されることを確認する。
