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

sqler: おそらく十分なベンチマーク

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-1PRAGMAの不一致sqlerは32MBキャッシュ、WALモード、synchronous=OFF。ベースラインは2MBキャッシュ、ロールバックジャーナル、synchronous=FULL
H-2SQLの不一致sqlerはjson_each(data, '$.path')(直接)を使用。ベースラインはjson_each(json_extract(data, '$.path'))(冗長なラップ)を使用。
H-3異なるinsert APIsqlerは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-2sqlerのみrow factoryを使用sqlerはsqlite3.Row構築オーバーヘッドを支払う; ベースラインは安価なタプルを返す。
M-3クエリごとのloggerオーバーヘッドsqlerは全SQL実行でtime.perf_counter()を2回 + query_logger.log()を呼び出す。
M-4アーム間でGCをリセットしない蓄積されたヒープ状態で逐次計測。
M-5WAL vs ロールバックsqlerはWALモード(リーダーがライターをブロックしない)を使用。ベースラインはデフォルトのロールバックジャーナルを使用。
M-6非対称な接続処理sqlerは事前に接続をオープン; ベースラインはsqlite3.connect() + close()をタイム計測ウィンドウ内で実行。
M-7DDLがタイミングウィンドウ内最初のdb.query("bench")がタイム計測ウィンドウ内でCREATE TABLE IF NOT EXISTSを実行。
M-8PRAGMA復元のオーバーヘッドsqlerのSQLerDB.on_disk(rst)は8回のPRAGMAラウンドトリップを実行。ベースラインはゼロ。
M-9ハイライトのベースラインなしsqlerはsearch_with_highlightsを計測したがsqlite3の対応物なし。
M-10bool(first()) vs SELECT 1sqlerの.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_memsqler_disksqlite_memsqlite_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つについては答えが同じものだった。