本記事は生成AIと共同で執筆しています。事実関係は可能な範囲で公式ドキュメント等と照合していますが、誤りが含まれている可能性があります。重要な判断を行う前にご自身でも一次情報をご確認ください。

Oxigraph は Rust で書かれた RDF(Resource Description Framework)ストアです。単一バイナリで動き、SPARQL(SPARQL Protocol and RDF Query Language)1.1 の Query と Update に対応しています。手元に Turtle 形式の RDF ファイルが 13 本、合計 4GB ほどあったので、これをコンテナに入れて実際にどう動くのかを測りました。日本語の人文系データセット群で、由来の異なる資料が別々のファイルに分かれています。以下ではデータの中身には立ち入らず、規模と挙動だけを扱います。

途中で「1〜2GB 程度のデータでも速いのか」「Virtuoso と比べてメモリ効率はどうなのか」という疑問が出たので、そちらも実測しています。結果として、集約クエリでは Virtuoso が 50〜130 倍速く、一方でメモリ効率についての当初の見立ては崩れました。誤った見立てをどこで作ってしまったかも含めて書きます。

コンテナで起動する

公式イメージは Docker Hub ではなく GitHub Container Registry で配布されています。

docker run -d --name oxigraph --restart unless-stopped -p 127.0.0.1:7878:7878 -v oxigraph-data:/data ghcr.io/oxigraph/oxigraph:latest serve --location /data --bind 0.0.0.0:7878 --cors

引数で注意が要るのは 2 か所です。

コンテナ内で渡す --bind0.0.0.0 にします。既定値が localhost:7878 なので、そのままではコンテナの外から届きません。一方でホスト側の公開は 127.0.0.1:7878:7878 と書いて、ループバックに絞っています。Oxigraph には認証機構がなく、-p 7878:7878 にすると同一ネットワーク上の誰でも SPARQL Update でデータを書き換えられる状態になるためです。

起動すると、ブラウザから使える SPARQL エディタが同じポートで開きます。

Oxigraph の Web UI のスクリーンショット。上部にエンドポイント http://localhost:7878/sparql の入力欄があり、その下のエディタに、すべての名前付きグラフを対象にトリプル数とグラフ数を数える SPARQL クエリ(SELECT (COUNT(*) AS ?triples) (COUNT(DISTINCT ?g) AS ?graphs) WHERE { GRAPH ?g { ?s ?p ?o } })が入力されている。下半分が結果テーブルで、「1 result in 10.167 seconds」と表示され、triples 列に 30408603、graphs 列に 13 が入っている。全走査をともなう集約クエリに 10 秒かかっていることが読み取れる。

エンドポイントは次のとおりです。

パス用途
GET /Web の SPARQL エディタ
POST /querySPARQL Query
POST /updateSPARQL Update
POST /sparqlQuery と Update の両方を受ける(上の 2 つの和集合)
GET / PUT / POST / DELETE /storeGraph Store Protocol(RDF の取得と投入)

データを入れる経路は 2 つある

最初は HTTP の SPARQL Update で入れようとしたのですが、この経路は 1 つのリクエストの本文として更新文を送る形なので、GB 単位の Turtle をそのまま流すには無理があります。Oxigraph には load サブコマンドがあり、サーバを介さずストアのファイルを直接書きます。

図の見出しは「RDF ファイルをストアに入れる経路は 2 つあり、大きいファイルでは使い分けが要る」。上段は経路A「HTTP の SPARQL Update 経由」で、Turtle ファイル → 稼働中のサーバ(POST /update) → ストア と矢印でつながり、右に「サーバは止めなくてよい」「GB 単位は 1 リクエストに載らない」と注記がある。下段は経路B「ストアのファイルを直接書く(oxigraph load)」で、Turtle ファイル → load コマンド(サーバを介さない) → ストア とつながり、右に「実測 58.5万トリプル/秒」「996MB を 34 秒」とある。下段の load コマンドから下向きの矢印が伸び、「書き込みロックは 1 プロセスしか持てない。サーバを停止してから load し、終わったら再開する」という枠に接続していて、左側に✗印と「同時には実行できない」と書かれている。

書き込みロックは 1 プロセスしか持てないので、load を使うときはサーバを止める必要があります。

docker stop oxigraph
docker run --rm -v oxigraph-data:/data -v ~/lod:/src:ro ghcr.io/oxigraph/oxigraph:latest load --location /data --file /src/ds1.ttl --graph https://example.org/lod/ds1/
docker start oxigraph

--graph で名前付きグラフを指定できます。データセットごとに別のグラフに入れておくと、後から出所別に絞り込めます。

大きいファイルには 2 つのオプションが要ります。

--non-atomic は、ロードの途中でディスクに書き出していく指定です。既定の atomic は「ファイル全体がストアに入るか、まったく入らないかのどちらか」というトランザクション上の性質で、公式ドキュメントが --non-atomic の利点として挙げているのは、RocksDB が途中で compact・compress できることによるディスク使用量の低減です。代償として CPU 使用量と write amplification が増えるとされています。

ところが手元では所要時間が逆でした。996MB のファイルで atomic が 47 秒、--non-atomic が 29 秒です。必要メモリの閾値は両者とも同じで、差が出ませんでした(後述)。

--lenient は RDF の検証を緩める指定です。これが必要になった経緯は次の節に書きます。

投入した結果は次のようになりました。13 ファイル、合計 30,408,603 トリプルです。所要時間はいずれもメモリ上限なし・--non-atomic での値です。

ファイルサイズ    トリプル数      投入時間
   996MB      20,109,540       34秒
   2.9GB       8,882,723       26秒
   133MB         442,846      3.7秒
    22MB         343,720
    18MB         197,137
    11MB         152,501
   5.7MB          88,881
    35MB          64,597
   1.4MB          33,181
   2.3MB          25,700
   1.3MB          25,325
   912KB          21,292
   892KB          21,160
            ──────────────
            30,408,603

速度は最大で毎秒 58.5 万トリプルでした。「34 秒」も「58.5 万トリプル/秒」もローダ自身が出力した値で、秒は切り捨てられています(実際は 34.4 秒ほど)。

ストア全体は 10.2GB になりました。元の Turtle が 4GB ほどなので 2.5 倍です。これは optimize をかける前の値で、後述するように optimize を実行するとさらに倍近くまで増えます。

なお 2.9GB のファイルは行数が 1,668 万行あるのに対してトリプル数が 888 万で、半分ほどしかありません。これは記事本文が複数行にわたる長いリテラルとして入っているためで、欠落ではありませんでした。ファイル末尾のトリプルがストアに存在することを ASK で確認しています。行数とトリプル数を突き合わせて「入りきっていないのでは」と疑ったときは、末尾の 1 件を直接引くのが確実でした。

厳格なパーサに 2 本が弾かれた

13 本のうち 2 本が、既定の設定ではロードに失敗しました。どちらも IRI(Internationalized Resource Identifier)の構文エラーです。

Error while loading file /src/A.ttl: Parser error at line 44 between columns 25 and 109: Invalid IRI code point '#'
Error while loading file /src/B.ttl: Parser error between line 669 column 24 and line 670 column 2: Invalid IRI code point '
'

1 本目は、1 つの IRI の中にフラグメント記号 # が 2 回現れているものでした。画像ビューアの URL がそのまま値として入っており、…#?#&… のようにフラグメントが二重になっています。同じ形が 1 万件強ありました。

2 本目は、IRI の途中で改行が入って > が次の行に来ているものが 1 件だけありました。生成時に折り返しが混入したように見えます。

いずれも --lenient を付ければ投入できます。ただし --lenient は検証を飛ばすだけなので、構文として不正な値がそのままストアに入ります。厳格なパーサを持つ他の実装では、同じファイルがまるごとロード失敗になる可能性があります。

この 2 件は、それまで別のツールでは問題なく扱えていたものでした。Oxigraph に入れて初めて表面化した形です。RDF の検証を通したいだけであれば、ストアに入れる前にパーサだけ通すという使い方もできそうです。

名前付きグラフに分けると横断できる

データセットごとに別の名前付きグラフへ入れておくと、共通の語彙をキーに横断できます。今回のデータでは、日付が http://datetime.hutime.org/date/YYYY-MM-DD という形の IRI で表現されていました。HuTime(時間情報学の研究基盤として時間軸上の位置に恒久的な識別子を与えているプロジェクト)が公開しているもので、「1915 年 4 月 3 日」のような日付そのものが 1 つの IRI になります。同じ日付 IRI を持つデータセットが何個あるかを数えてみます。

PREFIX dcterms: <http://purl.org/dc/terms/>
SELECT ?date (COUNT(DISTINCT ?g) AS ?datasets)
WHERE {
  GRAPH ?g { ?s dcterms:date ?date }
  FILTER(STRSTARTS(STR(?date), "http://datetime.hutime.org/date/"))
}
GROUP BY ?date HAVING(COUNT(DISTINCT ?g) >= 4)
ORDER BY DESC(?datasets) ?date

結果は、多いもので 13 グラフ中 9 つが同じ日付 IRI を共有している、というものでした(該当した日付そのものは伏せます)。8 つ以上が重なる日は 16 日、7 つ以上なら 94 日あります。それぞれ別々に作られたファイルを 1 つのストアに置いただけで、こうした照合ができます。日付の表現が IRI で揃っていたことが効いています。

逆に言えば、揃っていない語彙は横断できません。今回のデータでも、日付以外の項目(地名や人物)は表記が統一されておらず、同じ手では突き合わせられませんでした。

どれだけメモリが要るか

コンテナのメモリ上限を変えて、同じクエリを流しました。データは 3,040 万トリプル、ストアは 10.2GB です。

クエリ上限なし1GB 制限2GB 制限
主語を 1 つ指定して述語と目的語を引く0.002s0.002s0.003s
ラベルの完全一致検索(全 13 グラフが対象)0.003s0.003s0.002s
型指定スキャン LIMIT 10000.003s0.002s0.003s
2 ホップ結合 LIMIT 1000.003s0.003s0.003s
全件 COUNT(3,040 万件を数える)8.74s7.86s7.83s
述語別 GROUP BY(1 グラフ 2,010 万件を集約)5.82s4.95s4.93s

1GB 上限でも劣化しませんでした。実際のメモリ使用量は 94MB で、上限の 9% しか使っていません。Oxigraph は RocksDB(LSM ツリー、Log-Structured Merge-tree 方式のキーバリューストア)の上に載っていて、データをメモリに載せることを前提にしていないためのようです。

集約クエリで上限なしのほうがかえって遅く出ていますが、この差は測定のばらつきの範囲と見ています。上限による違いを示すものではありません。

ただしこの 94MB という数字は、実際の消費より小さく出ている可能性があります。コンテナのメモリ使用量は cgroup(Linux がプロセス群の資源を制限・計上する仕組み)を通じて測られますが、回収可能なページキャッシュの多くはこの計上から除かれるためです。1GB でも速度が落ちなかったのは、測ったクエリが触るページ数が少なかったからで、全走査を連発する用途なら事情は変わる可能性があります。

投入時は事情が違いました。996MB の Turtle ファイルを空のストアに入れる実験です。

メモリ上限--non-atomic既定(atomic)
1GBOOM Kill(2 秒で終了。以下 OOM は Out Of Memory)OOM Kill(2 秒で終了)
2GB成功 29 秒成功 47 秒
3GB成功 30 秒
4GB成功 32 秒成功 52 秒
6GB成功 39 秒

1GB では入りません。2GB あれば通り、それ以上増やしても速くはなりませんでした。むしろ 2GB の 29 秒に対して 6GB では 39 秒と 3 割ほど伸びています。メモリに余裕があるぶん RocksDB が書き出しを遅らせているのではないかと考えていますが、確認は取れていません。

ただし 2GB は境界に近いようです。この表を書いた後、同じマシンで測り直したところ、2GB は 3 回とも成功した一方、別の機会には同じ条件で OOM Kill されました。他のコンテナが動いているかどうかで結果が変わったように見えます。余裕を見るなら 2.5GB 以上を確保したほうがよさそうです。

つまり「検索は 1GB でも足りるが、投入には 2GB 要る」という形で、必要な容量が非対称でした。運用に載せるときは投入時のほうを基準に見積もることになります。

同じデータを Virtuoso にも入れる

ここまでの結果を見て、Virtuoso と比べたらどうなのかが気になりました。Virtuoso はバッファプールを設定で確保する設計で、Oxigraph とは考え方が逆です。当初は「Oxigraph のほうがメモリ効率が桁違いに高いはず」と考えていたのですが、実測すると違いました。

比較は 996MB のファイル(20,109,540 トリプル)を両者に入れて行いました。トリプル数と集約結果は完全に一致しています。

ここから先の Oxigraph 側の数値は、13 本すべてが入った 10.2GB のストアではなく、この 1 本だけを空のストアに入れ直したものに対する値です。加えて optimize(RocksDB のファイルを再編成して読み取りを速くするサブコマンド)を実行しています。前節までの数値と直接は比べられません。

この optimize には副作用がありました。実行するとストアが 1,734,056,163 バイト(1.6GB)から 3,154,916,869 バイト(2.9GB)へ、1.8 倍に増えます。30 秒待っても縮まないので、圧縮の完了待ちではなく確定値のようです。読み取りを速くする代わりにディスクを使う、という挙動だと思われます。

docker run -d --name virtuoso --memory=4g -p 127.0.0.1:8890:8890 -p 127.0.0.1:1111:1111 -v virt-data:/database -v ~/lod:/staging:ro -e DBA_PASSWORD=... -e VIRT_Parameters_NumberOfBuffers=340000 -e VIRT_Parameters_MaxDirtyBuffers=250000 -e VIRT_Parameters_DirsAllowed=". , /database, /staging" openlink/virtuoso-opensource-7:latest

投入はバルクローダを使います。ld_dir で対象を登録し、rdf_loader_run で実行します。

docker exec virtuoso isql 1111 dba dba exec="ld_dir('/staging','big.ttl','https://example.org/lod/big/');"
docker exec virtuoso isql 1111 dba dba exec="rdf_loader_run();"
docker exec virtuoso isql 1111 dba dba exec="checkpoint;"

44 秒で完了し、checkpoint に 2 秒かかりました。投入中の CPU 使用率は 274% で、複数コアが動いていました。

公式の手順では、この後に checkpoint_interval(N);scheduler_interval(M); を実行して元に戻すよう指示されています。バルクローダが checkpoint と scheduler を一時的に無効化するためです。今回は使い捨ての検証環境なので省きましたが、運用に載せるなら必要な手順です。

Virtuoso にも Web の SPARQL エディタが付いています。

Virtuoso の SPARQL Query Editor のスクリーンショット。上から Default Data Set Name (Graph IRI) の入力欄、Query Text の大きなテキストエリア(トリプル数と述語の種類数を数える SPARQL クエリ SELECT (COUNT(*) AS ?triples) (COUNT(DISTINCT ?p) AS ?predicates) WHERE { ?s ?p ?o } が入力されている)、Results Format のプルダウン(HTML が選択されている)、Execute Query と Reset のボタン、Execution timeout の入力欄、Strict checking of void variables などのオプションのチェックボックスが並んでいる。

検索性能は、クエリの形で差の出方が変わる

3 回実行した最良値です。

図の見出しは「同じ 2,010 万トリプルでも、クエリの形によって差の出方が変わる」。4 種類のクエリの応答時間を Oxigraph と Virtuoso で比較した横棒グラフで、横軸は 1ms から 10秒 までの対数目盛り。「主語や型で絞る検索(型指定スキャン LIMIT 1000)」は Oxigraph 0.003 秒、Virtuoso 0.004 秒でほぼ同じ長さ。「2 ホップの結合(LIMIT 100)」は Oxigraph 0.003 秒、Virtuoso 0.002 秒でこれもほぼ同じ。「全件 COUNT(20M 行を数える)」は Oxigraph 4.835 秒に対し Virtuoso 0.085 秒で、バーの長さが大きく異なる。「述語別 GROUP BY(20M 行を集約)」は Oxigraph 5.374 秒に対し Virtuoso 0.042 秒。凡例は Oxigraph が塗りつぶしのバー、Virtuoso が斜線のバー。

クエリOxigraphVirtuoso
型指定スキャン LIMIT 10000.003s0.004s
2 ホップ結合 LIMIT 1000.003s0.002s
全件 COUNT(20M)4.835s0.085s
述語別 GROUP BY(20M 集約)5.374s0.042s

絞り込みのあるクエリは互角でした。差が出たのは集約で、COUNT が 57 倍、GROUP BY が 128 倍の開きになりました。

Oxigraph 側で集約クエリを流している間の CPU 使用率を測ると 87〜92% で、16 コアのうち 1 コア分でした。これは仕様どおりで、Oxigraph のアーキテクチャ文書は「各クエリの評価は、単純さのため現時点ではシングルスレッドである」と明記しています。CLI の README にも「SPARQL のクエリ評価はまだ最適化されていない」とあります。

対する Virtuoso は、イメージに同梱されている virtuoso.ini.sample の既定が ThreadsPerQuery = 4 で、1 つのクエリを複数スレッドで実行する設定になっています。Virtuoso 側は 42 ミリ秒で終わってしまうため CPU 使用率は同じ方法では測れませんでしたが、少なくとも投入時には 274% を観測しました。カラムストアで述語の列が高圧縮になることも効いていそうです。

集約結果は両者で完全に一致していたので、少なくとも今回のクエリについては、Virtuoso が統計値で近似を返しているわけではないことを確認できました。

メモリの見立ては実測で覆った

当初は「Virtuoso はバッファプールを固定確保するので、この規模でも数 GB は要る」と考えていました。実際には違いました。

コンテナ上限NumberOfBuffers実メモリ使用量(RSS, Resident Set Size)述語別 GROUP BY
1GB85,000(プール 664MB)389MB0.043s
2GB170,000(1.3GB)389MB0.042s
4GB340,000(2.6GB)437MB0.045s

1GB 上限でも動き、集約性能も落ちませんでした。

これは公式ドキュメントの説明とは食い違います。Virtuoso のドキュメントは「各バッファは 8K ページをキャッシュし、およそ 8,700 バイトのメモリを占める」「物理メモリを超える設定は大きな悪影響がある」と書いており、遅延確保のような挙動には触れていません。それでも実測の RSS は設定したプール容量よりはるかに小さく、触れていないぶんは実メモリを消費していないように見えました。仮想メモリ上は確保しつつ、触っていないページが RSS に計上されていないだけ、という可能性もあり、今回の観測だけでは区別できません。

絶対値では Oxigraph のほうが小さく済んでいます。この比較用ストアでの Oxigraph は 55MB、13 本すべてを入れたストアでも 94MB でした。Virtuoso の 389MB に対して 4〜7 倍の差です。

ただし、どちらも 1GB に収まります。当初は「Virtuoso はバッファプールを固定確保するので数 GB は要る、だからメモリ効率で Oxigraph を選ぶ理由がある」と考えていたのですが、崩れたのはこの後半です。Oxigraph が省メモリであること自体は覆っていません。覆ったのは、その差がこの規模で選定の判断材料になる、という部分でした。

設定が効いていないことに気づくのが遅れた

この結論に至る前に、いったん「Virtuoso は 1GB でも 2GB でも OOM Kill される」という誤った測定結果を出しています。原因は環境変数の扱いでした。

最初にこのコンテナを作ったときは、8GB のメモリを想定して NumberOfBuffers=680000 を渡していました。その後メモリ上限を 1GB や 2GB に下げて測り直したのですが、このとき渡した小さい値が反映されていませんでした。

図の見出しは「設定が効いていないまま一貫した結果が出るので、設定のほうを疑いにくい」。Virtuoso コンテナで環境変数が初回起動時にしか反映されない様子を示した図。上段は「1 回目の起動(ボリュームが空)」で、環境変数 NumberOfBuffers=680000 から矢印が伸びて「virtuoso.ini を生成、680000 が書き込まれる」という枠につながり、✓印と「設定が反映される」の注記がある。下段は「2 回目以降の起動(ボリュームに ini が残っている)」で、環境変数 NumberOfBuffers=85000 から矢印が伸びて「既存の ini をそのまま使う、680000 のまま」という枠につながり、✗印と「環境変数は無視される」の注記がある。最下段の枠に「結果:メモリ上限 1GB のつもりで測っていたが、実際には 5.3GB 分のバッファを要求していた。コンテナは起動直後に OOM Kill され、『Virtuoso は小さいメモリでは動かない』という誤った結論が出た」と書かれている。

openlink/virtuoso-opensource-7 イメージは virtuoso.ini をデータボリュームに書き出します。そして VIRT_Parameters_* 環境変数が反映されるのは、この ini が生成される初回起動のときだけでした。2 回目以降にメモリ上限だけを変えて再起動しても、ini の中身は初回の値のままです。

イメージの entrypoint スクリプトを読むと、環境変数を ini に流し込む処理が if [ ! -f virtuoso.ini ] のブロックの内側にしかなく、既存の ini に再適用する分岐が存在しないことが確認できます。同じ構造は DBA_PASSWORD にもあるので、2 回目以降の起動ではパスワードも変わりません。

そのため、--memory=1gVIRT_Parameters_NumberOfBuffers=85000 を同時に渡したつもりが、実際には 680,000 バッファ(約 5.3GB)を要求し続けていて、起動直後に OOM Kill されていました。

紛らわしいのは、この状態でも「それらしい結果」が出てしまうことです。1GB でも 2GB でも落ちるので、「Virtuoso は小さいメモリでは動かない」という一貫した結論に見えました。設定が反映されているかを別途確認しなければ、そのまま信じていたところです。

確認方法は単純で、ボリューム内の ini を直接読みます。

docker exec virtuoso grep -E '^(NumberOfBuffers|MaxDirtyBuffers)' /database/virtuoso.ini
docker exec virtuoso isql 1111 dba dba exec="status();" | grep -E 'RSS|buffers'

設定値を変えて測り直すときは、ini を直接書き換えてから起動しました。

docker run --rm -v virt-data:/database alpine sed -i 's/^NumberOfBuffers.*/NumberOfBuffers          = 85000/' /database/virtuoso.ini

コンテナの設定を環境変数で渡す形式は手軽ですが、状態を持つボリュームと組み合わさると、渡したつもりの値が使われないことがあります。パラメータを変えて比較する種類の計測では、渡した値ではなく実際に使われている値を毎回確認したほうがよさそうです。

投入とディスク

Virtuoso 側も、同じ 996MB のファイルを空のデータベースに入れる実験をメモリ上限別に行いました。NumberOfBuffers は上限に合わせて設定しています。

メモリ上限NumberOfBuffers(プール)結果
2GB170,000(1.3GB)OOM Kill
2GB340,000(2.6GB)OOM Kill
3GB170,000(1.3GB)成功 29〜45 秒
3GB255,000(2.0GB)OOM Kill
4GB340,000(2.6GB)成功 31〜42 秒

ここで分かるのは、必要なメモリが上限そのものではなく、バッファプールの設定に従って動くということです。同じ 3GB でも、プールを 1.3GB に絞れば入り、2.0GB にすると落ちます。投入にはプールに加えて 1.5GB ほどの余裕が要るように見えました。

なお最初にこの実験をしたとき、私は上限だけを変えてプールを 2.6GB に固定したまま測り、「Virtuoso は投入に 3GB 要る」と結論しかけました。前の節で自分が書いたのと同じ失敗です。設定が効いているかを毎回確認する、というのは思っているより難しいことのようです。

クエリを投げるだけなら 1GB で足りるのに、投入にはプール+1.5GB が要るという形で、Oxigraph と同じく非対称でした。両者を並べると次のようになります。

OxigraphVirtuoso
投入時間(996MB)34 秒44 秒
投入に必要なメモリ2〜2.5GB(1GB は OOM)バッファプール+1.5GB ほど
クエリに必要なメモリ1GB で十分1GB で十分
ディスク使用量1.6GB(optimize 後は 2.9GB)1.3GB
起動からクエリを受け付けるまで即時数秒

投入速度は Oxigraph がやや速く、ディスクは Virtuoso が半分以下でした。投入時に必要なメモリはどちらも元ファイルの 2〜3 倍で、Virtuoso のほうが 1GB 多く要ります。

どちらを選ぶか

今回測った範囲では、判断の軸はメモリではありませんでした。

集約や分析のクエリを投げるなら Virtuoso が向いていそうです。50〜130 倍の差があるので、たとえば「年ごとの件数」のような集計画面を作る場合、Oxigraph では毎回数秒待つことになります。

一方、絞り込み検索と API 提供が中心なら、性能の差はほとんどありません。表の上 2 行は 1 ミリ秒前後の違いで、これは curl での測り方を考えれば誤差の範囲です。つまりデータが示しているのは「集約は Virtuoso が圧倒的、それ以外は引き分け」であって、二者択一のトレードオフではありません。

そうなると Oxigraph を選ぶ理由は性能ではなく、運用の手数になります。単一バイナリで起動が即時、既定値のまま docker run 一発で立ち上がりました。Virtuoso は搭載メモリに合わせて NumberOfBuffers を決める手順が要り、今回の構成では ini の永続化と環境変数の関係も把握しておく必要がありました。

なお今回測っていない軸が多く残っています。同時アクセス、大きな結果集合の返却、OPTIONALUNION を含む複雑なクエリ、更新の負荷はいずれも未測定です。Virtuoso にある全文検索(bif:contains)、RDFS(RDF Schema)や OWL(Web Ontology Language)の推論、クラスタ構成、SQL 統合といった機能も比較していません。選定時にはこちらのほうが効いてくる場面もあるはずです。

サーバレス環境で動かせるか

Cloudflare Workers のようなサーバレス環境に SPARQL エンドポイントを置けるかも調べました。調査した限りでは、次のような状況のようです。

Oxigraph の JavaScript 版(npm の oxigraph)は WebAssembly ビルドで、インメモリストア専用です。RocksDB が使えないので、今回のような永続ストアは載りません。加えて Workers にはisolate あたりメモリ 128MB、バンドルは gzip 後 3MB(Free)/ 10MB(Paid)という制約があります。数万トリプル規模の語彙やシソーラスなら成立しそうですが、2,000 万トリプルは難しそうです。

現実的な選択肢は次の 4 つに見えます。

方式どういう仕組みか
Cloudflare Containers今回のイメージを動かす。10ms 単位の課金で scale to zero。ただし linux/amd64 要件とインスタンスタイプの上限あり
AWS Lambda + Oxigraphuedayou/oxigraph-sparql-api-serverless。ビルド時に Turtle から RocksDB ストアを作り、それを ZIP パッケージ(provided.al2 カスタムランタイム)に同梱する読み取り専用の構成
Deno Deploynishad/serverless-sparql-endpoint。Turtle ファイルに対するインメモリ Oxigraph
Linked Data Fragmentsサーバは 1 パターンぶんの断片を返すだけにして、Comunica がクライアント側で SPARQL を解決する

Cloudflare に載せるなら Containers ということになりそうです。Workers ではなく Containers であれば Docker イメージを動かせて、リクエストがない間は課金されません。

ただし制約が 2 つあります。1 つはイメージが linux/amd64 で動く必要があること(Oxigraph の公式イメージは amd64 と arm64 の両方が配布されているので、この点は問題ありません)。もう 1 つは、メモリとディスクがインスタンスタイプで決まることです。最小の lite は 256MiB・ディスク 2GB、basic が 1GiB・4GB で、上は standard-4 の 12GiB・20GB まで。今回の実測(投入に 2GB のメモリ、ストア 10.2GB)を当てはめると litebasic では投入できず、standard-3(8GiB・16GB)相当が要ることになります。

コールドスタート時のコンテナ起動と、ディスクの永続性の扱いは未検証です。

Linked Data Fragments は発想が逆で、サーバは 1 つのトリプルパターンぶんの断片を返すだけにして、結合はクライアントが行います。サーバ側の負荷は小さくなるかわり、今回測ったような集約クエリは相当遅くなるはずです。なお、標準的な Triple Pattern Fragments サーバは任意のパターンに動的にマッチングして断片を作るので、静的ファイルを置くだけで済むわけではありません。ファイルを置くだけで成立するのは、Comunica が RDF ダンプを直接読む経路を使う場合です。いずれにせよ公開用のカタログには向いても、分析用途には向かないと思われます。

なお Cloudflare の Durable Objects は SQLite が1 オブジェクトあたり最大 10GB(Workers Paid の場合。Free は 1GB でアカウント合計 5GB)なので容量としては足りますが、これをバックエンドにする既製の SPARQL エンジンは見当たりませんでした。

計測環境

機材        Apple M4 Max / メモリ 128GB / macOS 26.5.2
Docker      29.6.2(Docker Desktop の VM に 16 コア・23.4GiB 割り当て)
Oxigraph    0.5.9   ghcr.io/oxigraph/oxigraph:latest
Virtuoso    07.20.3243 (7.2.18-dev, Column Store, aarch64)
            openlink/virtuoso-opensource-7:latest

応答時間はいずれも curltime_total を 3 回測った最良値です。ローカルのループバック接続なので、ネットワーク遅延はほぼ含まれていません。同一マシン上で 1 つずつ順に測っており、両者を同時に動かした状態での比較ではありません。

再現性について 2 点、断っておきます。1 つは、このマシンでは他にも多数のコンテナが動いており、投入の所要時間は測るたびに揺れました。同じ条件の Virtuoso への投入が 29 秒だったり 45 秒だったりします。桁は変わりませんが、秒単位の値をそのまま受け取らないでください。もう 1 つは、メモリ上限が境界に近いときに、同じ条件でも成功したり OOM Kill されたりしたことです。上の表の閾値は、余裕を見て一段上を取るための目安として読んでください。

公開前に、記事の主張は独立した複数のエージェントで検証しました。その過程で、11 グラフの時点で測った値を 13 グラフの結果として書いていた誤り、optimize の効果を逆に書いていた誤り、--non-atomic の説明が公式ドキュメントと食い違っていた誤りが見つかり、いずれも修正しています。