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

sqler: オーバーヘッドの実際の所在

パート1で公平なメソドロジーを確立し、実際のボトルネックを特定した: バルクインサートが1.9x、エクスポートが2.8x、FTS再構築が4.65x、FTSランク付きがスケールで悪化。この記事ではそれらのコストを根本原因まで追跡する。

3つについては答えが同じだった: Pydantic。

5部構成のパート2。パート1: メソドロジー | パート3: 修正 | パート4: スコアカード | パート5: カラムナルベースライン


スケール横断検証: 数値は安定している

何も最適化する前に、v1.2公平スイートを4つのスケール(50K、100K、500K、1M行)で実行し、比率が安定しているかを確認した — 特定のデータサイズのアーティファクトではないか。両ストレージモードで1,563回の計測。

比率は安定していた。バルクインサートは50Kから1Mまで1.87〜1.92xだった; スケールでスパイクもせず、改善もしなかった。エクスポートは全テストサイズで2.73〜2.86xだった。動いた唯一のカテゴリはFTS再構築で、SQLiteの絶対的な処理が固定Pythonオーバーヘッドに対して成長するにつれ、50Kの4.65xから1Mの3.78xに改善した。

これが重要な理由: コストが比例的であることを意味する; 行ごとの固定オーバーヘッドであり、アルゴリズム的な爆発ではない。2.8xのエクスポートギャップを引き起こしていたものは、テーブルサイズに関係なく行ごとに一定の追加処理を行っていた。そのパターンはオブジェクト構築を指し示す。


Pydanticハイドレーションパイプライン

sqlerがSQLiteから読み込む全行はこのパイプラインを通過する:

行ごとのコスト内訳

行ごとに3ステップ:

  1. json.loads(data) → Pythonのdict(約200ns)。両アームがこれを支払う; SQLiteからJSONを読み込むコストだ。
  2. model_validate(dict) → Pydanticモデル(約1,100ns)。型変換、デフォルト注入、ネストされたモデル構築、バリデーター実行、フィールドエイリアス解決。sqlerが生データを型付きオブジェクトに変換するのはここだ。
  3. _model_to_dict(model) → dictに戻す(約500ns)。フィールドごとのgetattr(model, field)、加えてPydanticの型変換を元に戻すための_serialize_value()(datetimeオブジェクトをISO文字列に戻す)。

ステップ2と3は読み取り専用パスでお互いを相殺する。PydanticがISO文字列をdatetimeオブジェクトにパースし、_serialize_valueがそれを右からISO文字列に変換し直す。このラウンドトリップは生のdictをそのまま渡した場合と同一の出力を生成する — つまり行ごとに1,600nsのオブジェクト構築と分解が、生のdictがすでに持っていなかったものを何も生み出さないまま発生している。出力フォーマットがJSON(またはJSONから派生したもの)であり、入力がdataカラム(これ自体がJSON)であるパスでは、フルハイドレーションパイプラインは純粋なオーバーヘッドだ。

これが2.8xのエクスポートギャップを説明する。エクスポートパスはクエリセットを反復し、行ごとにフルORMパイプラインを実行していた: クエリ → フェッチ → JSONをモデルにデシリアライズ → 出力のために再シリアライズ。ベースラインは生JSON文字列を読み込んで直接書き出していた。


エクスポート修正: Pydanticを完全にバイパス

修正は直接的だった: データがJSONとして書かれJSONとして読まれるなら、その間のPythonオブジェクトをスキップする。フォーマットが何を必要とするかに応じた3段階:

JSONLファクストパスinclude_id=False、全フィールド): ゼロパース。生のdataカラム文字列を直接ファイルに書き出す。dataカラムはすでに有効なJSON; パースする理由がない。

IDまたはフィールドフィルタ付きJSON/JSONL: _idを注入またはフィールドをフィルタするための1回のjson.loads()、その後json.dumps()。Pydanticを完全にスキップ。

CSV: フィールド抽出のためのjson.loads()、その後CSVライター。フィールドごとの抽出にパースが必要だが、モデル構築はスキップ。

エクスポート前後の比率

1M行(ディスクモード)での結果:

フォーマット変わったこと
CSV2.86x1.34xモデルハイドレーションをスキップ; json.loads() + フィールド抽出のみ
JSON2.85x0.97xモデルハイドレーションをスキップ; 生dictパススルー
JSONL2.85x1.06xモデルハイドレーションをスキップ; 最小限のパース
JSONL(IDなし)2.85x0.98xゼロパースファストパス; 生文字列

JSONエクスポートは今や生sqlite3とパリティに達した。JSONLはスケールでパリティに収束する。CSVは不可逆な1.34xのギャップを保持する; dictからのフィールドごとの抽出には両アームが異なる方法で支払うコストがある(sqlerはjson.loads() + dict内包表記; ベースラインはカラム値を直接読む)。

スケール横断データが比率の安定を確認する:

フォーマット50K100K500K1M
CSV1.37x1.34x1.40x1.34x
JSON0.88x0.98x0.97x0.97x
JSONL1.03x1.10x1.06x1.06x
JSONL IDなし1.21x1.09x0.99x0.96x

JSONL-no-idはディスクで1Mにおいて0.96xに達する。オーバーヘッドは本当になくなった。

Pydanticをスキップするのが安全なのはいつか?

バリデーションのバイパスは全ての条件が成立する場合に安全だ: 書き込みパスが信頼できる(sqlerがmodel.save()またはbulk_upsert()を通じて書き込んだデータ)、外部ミューテーションがない、書き込みと読み込みの間でスキーマドリフトがない、出力がJSONまたはJSONから派生したもの、そしてどのバリデーターも副作用を持たない。

複数のライターがデータベースに触れるとき、スキーマが進化するとき(新しいデフォルト付きフィールド、古い行がそれを持っていない)、または下流コードがPydantic強制だけが保証する型を前提とするときは危険だ。

エクスポート関数は終端操作だ; オブジェクトではなくファイルを生成する。それがバイパスの安全な候補にする。


残りのオーバーヘッドの源

エクスポート修正後、残るギャップは:

50Kでのコスト影響フォーマット
query._build_query() SQL構築約2〜5ms全て
カーサーラッピング / アダプターレイヤー約2〜5ms全て
_serialize_value(for_csv=True) フィールドごと約100msCSVのみ
json.loads() + _id注入約50msCSV、JSON、JSONL
フィールドごとのdict内包表記約50msCSVのみ

CSVのオーバーヘッドは構造的だ。JSONドキュメントを表形式の行に変換するにはフィールドごとの抽出が必要で; CSVエクスポートの意味を変えずに省略できるショートカットはない。1.34xはORMのコストではなく、フォーマット変換そのもののコストだ。


Pydanticの代替

ハイドレーションが読み取りパスの支配的コストと特定された後、代替案を評価した:

代替案ハイドレーション高速化トレードオフ
msgspec Structs約8x(生変換)異なるAPI; 移行コスト
model_construct()約3x(バリデーションをスキップ)バリデーターなし、型変換なし
cattrs / attrs約3〜5x異なるモデリングパラダイム
TypedDict + 手動約2xPydanticエコシステムを失う
生dict(ハイドレーションなし)約5.5x型安全性なし

model_construct()の行き止まり

Pydanticのmodel_construct()はバリデーションをスキップする — 信頼できるデータへの明白なショートカットに聞こえる。実際には、sqlerのユースケースではmodel_validate()より遅かった; model_construct()は型変換を行わないため、datetimeオブジェクトを期待する下流コードが文字列を受け取ってしまう。それを回避するにはPython側の修正が十分に必要で、節約分が消えてしまう。また安全性も低い: バリデーションなしはスキーマドリフトへの保護もないことを意味する。

msgspecの問い

msgspecが最も有望な代替案だった: 8倍速いバリデーション、完全な型安全性、メモリ効率の良いStructs。しかし4つのブロッカーが立ちはだかった:

  1. PrivateAttr(ハードブロッカー): sqlerは_id_snapshotをPydanticのPrivateAttrとして保存する。msgspec Structsには等価なプライベート属性メカニズムがない。
  2. フィールドバリデーター(ミディアム): msgspecは__post_init__のみをサポート; フィールドごとのバリデーターもmode='before'前処理もない。
  3. model_fieldsイントロスペクション(ミディアム): sqler全体の約20の呼び出しサイトで使用されている。cls.model_fieldsからmsgspec.structs.fields(cls)への書き換えが全て必要。
  4. 計算フィールド: msgspecには@computed_fieldがない。

フルマイグレーションは全ユーザーのモデルを壊す。しかし並列モデルベース(SQLerLiteModelへのオプトイン代替としてSQLerMsgspecModel)は、_idをデフォルト付きの宣言されたStructフィールドにすることで4つのブロッカー全てを回避できる。

そのプロトタイプはパート3で取り上げる。


これまでの状況

エクスポート修正とスケール横断検証の後:

カテゴリ比率ステータス
クエリ0.95–0.98xアクション不要*
JSON操作0.98–0.99xアクション不要*
集計0.94–0.99xアクション不要*
バックアップ/リストア1.00–1.02xアクション不要
エクスポートCSV1.34x修正済み(旧2.86x); 不可逆な残余
エクスポートJSON0.97x修正済み(旧2.85x); パリティ
エクスポートJSONL1.06x修正済み(旧2.85x); パリティ近傍
バルクインサート1.87–1.92x要対処
any_where1.47–1.51x要対処
FTS再構築3.78–4.65x要調査
FTSランク付きスケールで1.50x要調査

*1.0未満の比率はパート1のrow factoryアーティファクトとメモリモード計測を反映している。パート4はrow factoryアーティファクトを考慮した補正済みディスクモード数値を1.03〜1.15xとして報告している。

1.15xを超える項目が4つ残る。パート3ではPydanticを完全に回避する新しいオプトインモデルバックエンドを含む5つのマイルストーンで全4件を修正する。