sqlerはSQLite上に構築したドキュメント指向のJSONストアだ。Pydanticモデルを投入し、json_extractクエリで取り出す。ORMベンチマークが答えるべき問いはひとつ: 抽象化レイヤーの実際のコストは何か?
最初のステップは、その問い自体を公平なものにすることだ。
これはsqlerのベンチマーク旅の5部構成シリーズのパート1だ。パート2ではオーバーヘッドの実際の所在を掘り下げる。パート3では5つのターゲット修正を取り上げる。パート4は最終スコアカード。パート5ではドキュメントストレージというアーキテクチャのコストを計測する。
v1.1スイート: 22シナリオ、22の問題
最初のベンチマークスイートは5カテゴリ(インサート、クエリ、JSON操作、全文検索、運用タスク)にわたる22シナリオを計測した。数値は良好だった。多くの項目でsqlerは生のsqlite3の1〜2倍以内、場合によっては速かった。
それは赤信号であるべきだった。全呼び出しをPythonでラップするORMが、その下のCコードより速いはずがない。結果が良すぎるとき、何か驚くべきものを作ったか、バイアスのかかったベンチマークを作ったかのどちらかだ。後者の可能性が高い。
敵対的監査を実施した。仮想の敵対的レビュアーが公開の場でメソドロジーを解体しようとしている、という想定で、全ての計測について「もし相手のアームを勝たせたいなら、何を変えるか?」と問うやり方だ。
監査で18の公平性問題が見つかった(後続ラウンドで合計23に増加; 全カウントはパート3を参照)。8件はHIGH深刻度: sqlerが実際より劇的に良く見えるバイアス。10件はMEDIUM: 結果をsqler有利に傾ける微妙な非対称性。sqlerに不利なバイアスはひとつもなかった; スイート全体が称賛マシンだった。
18の公平性問題
HIGH深刻度(8件)
これらはカテゴリ全体を無効にするレベルのものだ。
| # | 問題 | 何が不公平だったか |
|---|---|---|
| H-1 | PRAGMAの不一致 | sqlerは32MBキャッシュ、WALモード、synchronous=OFF。ベースラインは2MBキャッシュ、ロールバックジャーナル、synchronous=FULL。 |
| H-2 | SQLの不一致 | sqlerはjson_each(data, '$.path')(直接)を使用。ベースラインはjson_each(json_extract(data, '$.path'))(冗長なラップ)を使用。 |
| H-3 | 異なるinsert API | sqlerはbulk_upsert()で行ごとのexecute()を使用。ベースラインはexecutemany()(Cレベルバッチ)を使用。 |
| H-4 | コミットセマンティクス | sqlerのinsert_document()は行ごとに自動コミット(Nコミット)。ベースラインは1回だけコミット。 |
| H-5 | デシリアライゼーションの欠如 | sqlerの.all()はパース済みdictを返す。ベースラインのfetchall()は生のsqlite3.Rowタプルを返す; json.loads()なし。 |
| H-6 | コールドキャッシュバイパス | コールドキャッシュ計測にウォームアップなし、GCアイソレーションなし、ループ内でデコレートされた関数を再定義。 |
| H-7 | エクスポートのスキップ | ベースラインはdataカラムから生JSON文字列を書き出し。sqlerは行ごとにjson.loads() → dict → json.dumps()を実行。 |
| H-8 | 入力のミューテーション | bulk_upsert()はdocsをインプレースでミューテート(doc["_id"]を設定)し、後続イテレーションでINSERTがUPDATEに変わる。 |
H-1だけで2〜3倍の差を説明できる。一方のアームにWALモードと16倍大きいページキャッシュを与えながら「同程度のパフォーマンス」と主張するのは、コードではなく設定を計測している。これはORMベンチマークで最も一般的なバイアスであり、修正も最も簡単だ。
H-5が最も巧妙だ。ベースラインがjson.loads()を呼ばないなら、等価な処理をしていない; 全く別のデータ型を返している。一方でデシリアライズ済みdictを返し、もう一方で生タプルを返すクエリベンチマークは、リンゴと果物の概念を比較しているようなものだ。
MEDIUM深刻度(10件)
| # | 問題 | 何が不公平だったか |
|---|---|---|
| M-1 | 順序バイアス | sqlerは全22シナリオで常に最初に計測。CPUターボブースト、アロケーターの状態、キャッシュウォーミング全てがアーム1を有利に。 |
| M-2 | sqlerのみrow factoryを使用 | sqlerはsqlite3.Row構築オーバーヘッドを支払う; ベースラインは安価なタプルを返す。 |
| M-3 | クエリごとのloggerオーバーヘッド | sqlerは全SQL実行でtime.perf_counter()を2回 + query_logger.log()を呼び出す。 |
| M-4 | アーム間でGCをリセットしない | 蓄積されたヒープ状態で逐次計測。 |
| M-5 | WAL vs ロールバック | sqlerはWALモード(リーダーがライターをブロックしない)を使用。ベースラインはデフォルトのロールバックジャーナルを使用。 |
| M-6 | 非対称な接続処理 | sqlerは事前に接続をオープン; ベースラインはsqlite3.connect() + close()をタイム計測ウィンドウ内で実行。 |
| M-7 | DDLがタイミングウィンドウ内 | 最初のdb.query("bench")がタイム計測ウィンドウ内でCREATE TABLE IF NOT EXISTSを実行。 |
| M-8 | PRAGMA復元のオーバーヘッド | sqlerのSQLerDB.on_disk(rst)は8回のPRAGMAラウンドトリップを実行。ベースラインはゼロ。 |
| M-9 | ハイライトのベースラインなし | sqlerはsearch_with_highlightsを計測したがsqlite3の対応物なし。 |
| M-10 | bool(first()) vs SELECT 1 | sqlerの.first()はフルドキュメント + json.loads()をフェッチ。ベースラインは定数1を選択。 |
これらのひとつひとつは小さな親指の重さ程度; 合わされば一方のアームを体系的に称賛するベンチマークになる。バイアスは意図的ではない — 「相手のアームを勝たせるには何を変えるか?」と自問せずにベンチマークを書くと起こることだ。
v1.2書き直し: 全ての数値を悪化させる
修正は単純だったが痛みを伴った: 全てをマッチさせる。監査が見つけた全ての非対称性を同じ方向で解決; ベースラインにsqlerが得ていた全てのアドバンテージを与える。
PRAGMAのマッチング
両アームが今やストレージモードごとに同一の設定を受け取る:
メモリモード(両アーム):
PRAGMA foreign_keys = ON;
PRAGMA synchronous = OFF;
PRAGMA journal_mode = MEMORY;
PRAGMA temp_store = MEMORY;
PRAGMA cache_size = -32000;
PRAGMA locking_mode = EXCLUSIVE;
ディスクモード(両アーム):
PRAGMA foreign_keys = ON;
PRAGMA busy_timeout = 5000;
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA cache_size = -64000;
PRAGMA wal_autocheckpoint = 1000;
PRAGMA mmap_size = 268435456;
PRAGMA temp_store = MEMORY;
sqlerが内部でPRAGMAを設定するなら、ベースラインも同じものを受け取る。例外なし。
SQLのマッチング
両アームが同一のSQLパターンを実行する。sqlerがjson_each(data, '$.tags')を生成する場所では、ベースラインもjson_each(data, '$.tags')を使い、json_each(json_extract(data, '$.tags'))は使わない。v1.1での冗長なjson_extractラップはベースラインに計測可能なオーバーヘッドを加えていたが、sqlerはそれを支払っていなかった。
シリアライゼーションのマッチング
両アームがデシリアライズ済みデータを返す。ベースラインはsqlerと同様に全行でjson.loads()を呼び出す; パース済みdictと生タプルの比較はなくなった。
アーム交互実行
実行順序はシナリオごとにハッシュ関数で決定論的に入れ替わる; CPUターボブースト、キャッシュウォーミング、アロケーターの状態による「sqlerが常に先」バイアスはなくなった。
GCアイソレーション
gc.collect()がアーム間で実行される; 蓄積されたヒープ状態のクロスコンタミネーションはなくなった。
両ストレージモード
全シナリオがメモリとディスクの両方で実行される。v1.2スイートは4アームマトリクスを使用: sqler_mem、sqler_disk、sqlite_mem、sqlite_disk。良く見えるモードのチェリーピッキングはない。
公平な数値が示したもの
20イテレーション、3ウォームアップ、GCを無効にしたtime.perf_counter()。1M行、メモリモードでのスケール横断比率:
| カテゴリ | v1.2(公平)比率 | トレンド |
|---|---|---|
| クエリ(フィルタ、レンジ、複合) | 0.95–0.97x | 安定したパリティ |
| JSON操作(contains、isin) | 0.99x | パリティ |
| 集計(sum、avg、min、max) | 0.94–0.95x | パリティ* |
| バックアップ/リストア | 1.00–1.02x | 透過的 |
| バルクインサート | 1.87–1.92x | 安定した約1.9x |
| any_where(配列サブクエリ) | 1.47–1.51x | 安定した約1.5x |
| エクスポート(CSV/JSONL) | 2.73–2.85x | 安定した約2.8x |
| FTS再構築 | 3.78–4.65x | スケール依存 |
| FTSランク付き検索 | 0.70–1.52x | スケールで悪化 |
*集計とクエリのアスタリスク: ベースラインは文字列キーアクセス(row["data"])でsqlite3.Rowを使用し、sqlerは整数インデックスアクセス(row[0])を使用する。両方が同一のSQLを実行しており; 3〜6%のギャップはrow factoryのアーティファクトで、全クエリおよび集計シナリオで一貫している。sqlerのクエリレイヤーが生sqlite3より本当に速いと主張するのではなく、文書化する。
クエリレイヤーはメモリモードでは実質的に無料だ; パート4はrow factoryアーティファクトを考慮した後の補正済みディスクモード数値を1.03〜1.15xとして報告している。実際のコストはバルクインサート(1.9x)、配列サブクエリ(1.5x)、エクスポート(2.8x)、FTS操作(3.78〜4.65x)だ。これらはツールを称賛する代わりに実際のボトルネックを指し示す公平な数値だ。

メソドロジーチェックリスト
ORMベンチマーク(または任意のペアA/B比較)を書く誰ものために、私たちのベンチマークで18の問題を発見した公平性チェックリストを示す:
設定のパリティ:
- 両アームが同一のデータベース設定を使用する(PRAGMA、キャッシュサイズ、ジャーナルモード)
- 両アームが同一のウォームアップ処理を受ける
操作のパリティ:
- 両アームが等価なSQLを実行する
- 両アームが同一のシリアライゼーション処理を行う
- 両アームが同一のコミットセマンティクスを使用する
- 両アームが同一の結果型を返す
計測のパリティ:
- 両アームが同一のタイマーを使用する
- 計測中は両アームでGCを無効にする
- アームの実行順序を交互にする
- 接続セットアップはタイム計測ウィンドウの内側か外側かで両アームを統一する
最も有用な問い: 「もし相手のアームを勝たせたいなら、何を変えるか?」 その答えが非対称性を明らかにするなら、1回の計測を行う前に修正せよ。
自分で実行する
uv sync --all-groups
uv run python -m benchmarks run --scale medium --storage both
uv run --group benchmarks python -m benchmarks plot
環境: Python 3.12 | SQLite 3.50.4 | Linux x86_64
スイートはマッチングされたPRAGMA、アーム交互実行、GCアイソレーションを自動的に適用する。
次は何か
公平な数値は4つの実際のボトルネックを示した: バルクインサートが1.9x、エクスポートが2.8x、FTS再構築が4.65x、FTSランク付きがスケールで悪化。パート2ではそれらのコストを根本原因まで追跡する — そして3つについては答えが同じものだった。
