記事をどれだけ読んだかを示します

sqler: 5つの修正、5日間

パート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行での結果(ディスクモード)

スケールsqlersqlite比率
50K1,236ms1,185ms1.04x
100K2,376ms2,320ms1.02x
500K14,242ms13,313ms1.07x
1M32,266ms31,410ms1.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行での結果(ディスクモード)

スケールsqlersqlite比率
50K294ms289ms1.02x
100K567ms550ms1.03x
500K2,968ms2,824ms1.05x
1M6,476ms6,219ms1.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 (メモリ)比率
1K7.4ms5.9ms1.25x
5K30.4ms30.3ms1.00x
10K59.6ms61.6ms0.97x
25K144.8ms155.8ms0.93x
50K299.2ms315.5ms0.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回では比べ物にならない。

スケール横断確認(ディスクモード)

スケールsqlersqlite比率
50K351ms367ms0.96x
100K637ms699ms0.91x
500K3,212ms3,535ms0.91x
1M7,006ms7,858ms0.89x

1M行でsqlerはsqlite3のexecutemany()より11%速い。教訓は汎化できる: マルチ行INSERTにバッチする任意のORMは、十分なスケールでexecutemany()に勝つ。コストがN回の文パースから1回にシフトするからだ。

公正性についての注記

比較は公正だが戦略が異なる: sqlerはチャンク化マルチ行INSERTを使い、ベースラインはexecutemany()を使う。どちらも正当なバルクインサートアプローチ。executemanyは生のsqlite3を使う開発者の大半が使うものなので、自然なベースラインとなる。完全に等価なテストにするなら両アームが同じ戦略を使うべきだが、それでは面白いものは何も測れない。


M-4: FTSランク検索(1.50x → 1.00x)

問題

FTSランク検索だけがスケールとともに悪化する唯一のシナリオだった:

スケール比率
50K0.95x
100K0.70x(ノイズ: 50ms以下の値)
500K1.52x
1M1.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行での結果(ディスクモード)

スケールsqlersqlite比率
50K30.5ms31.1ms0.98x
100K67.8ms67.5ms1.01x
500K479.1ms479.8ms1.00x
1M1,036.8ms1,033.3ms1.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=Truefrom __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.6ms29.7ms5.1x
model_dump()256ms189ms1.4x
queryset.all()(メモリ)473.7ms324.4ms1.46x
queryset.all()(ディスク)454.8ms311.1ms1.46x

I/Oからハイドレーションを分離

エンドツーエンドのクエリセット時間にはSQLのI/OとPythonのハイドレーションの両方が含まれる。as_dicts()(ハイドレーションなし)をベースラインとして使う:

LiteMsgspec
queryset.all()(メモリ)473.7ms324.4ms
as_dicts()(メモリ、ハイドレーションなし)170.9ms170.9ms
ハイドレーションのみ(差分)~303ms~153ms

SQLとJSONパースはモデルバックエンドに関係なく171ms。ハイドレーションコストが303msから~153msに低下。ORMのウォールタイムへの寄与が2倍削減された。

スケールで比率が安定

スケール純粋validateエンドツーエンドall()ハイドレーションのみ
50K4.5x1.41x1.83x
100K4.2x1.45x1.95x
250K4.3x1.36x1.74x
500K4.2x1.47x2.13x

純粋ハイドレーション: 4.2〜4.5xで安定、サイズによる劣化なし。エンドツーエンド: 1.36〜1.47xで安定、SQLのI/Oに上限を制約される。

教訓

リフレクションするものは全てキャッシュすること。msgspecのstructs.fields()は呼び出し側からは安く見えるが、呼び出しごとに実際の作業をする。50K回の呼び出しで、キャッシュなしのフィールドリフレクションがmodel_dump()を支配し、「速い」バックエンドがdataclassより16倍遅くなった。修正は些細(モジュールレベルのdictキャッシュ)だったが、プロファイリングなしには見えなかった。


まとめ: 修正前後

マイルストーン対象修正前修正後根本原因
M-1FTS再構築4.65x1.03x異なる操作を比較するベンチマークのバグ
M-2any_where1.50x1.04xjson_each()内の冗長なjson_extract()
M-3バルクインサート1.92x0.89x行ごとSQL → チャンク化マルチ行INSERT
M-4FTSランク1.50x1.00x2クエリパターン → シングルJOIN
M-5ハイドレーション1.0x (lite)liteの5.1xmsgspec Structバックエンド(オプトイン)

5つの「コードバグ」のうち2つは実際にはベンチマークのバグだった(M-1、M-4のトゥームストーン問題)。修正により数値は短期的に悪化したが、信頼できるデータが得られた。このシリーズ全体のパターンはそれだ: 正しい答えは滅多に都合のいいものではない。

パート4で最終的なスケール横断検証を実施する。1,725件の計測が全ての結果が1M行でも保持されることを確認する。