コンテンツにスキップ

性能と配布サイズ

日本語analyzerを有効にすると、Go組み込みの準備は2.02msから335msへ伸びます。server SDKにはchild processの起動が加わり、Javaではclasspath binaryの展開も待ち時間に含まれます。以下は単一マシンの観測値です。

このページは、OpenSearch 3.8のREST API互換性修正と、2つの性能改善(単一document書き込み時のin-memory segment merge、typedなtop-K sort実行)を経た2026-09-16に再計測しました。再計測の過程で、Cluster.Clone()と通常のqueryがさらに2つの理由で遅くなっていることが分かりましたが、どちらも互換性修正に本質的なcostではなく、事故的な回帰でした。原因はHTTP route tableがNew()/Clone()のたびに再構築されていたことと、nested fieldを持たないmappingでもqueryのたびにnested文書除外用の余分な条件を通していたことです。両方ともこのsession内でprofilingして修正しました(下記参照)。Clone()は特に、これまで公開したどの数値よりも速くなっています。1つだけ未対応のcostが残っています。bool query使用時の_searchの遅延で、これはLucene準拠のscore合成へ書き換えたことに伴う本質的なtrade-offであり、bugではありません──こちらも下記で扱います。

前回計測以降に2つの変更がmergeされました。

  • in-memory segment merge(decision record): bleveのscorch indexはpathを持たない限りsegmentをmergeしないため、単一documentの書き込みはそれぞれ専用のsegmentを持ち続けていました。各segmentは約100document分のbuffer領域を確保します。indexは各segmentが保持するdocumentを追跡し、小さなsegment同士をre-indexしてmergeするようになりました。
  • typedでtop-K boundedなsort(decision record): 従来はsort specの解決とkey抽出を[]any経由で毎hit行い、size 10のpageしか要らない場合でも全hitをstable sortしていました。sort keyは書き込み時にtypedな形でcacheされ、要求されたpageだけをbounded heapで選択するようになりました。

この作業の直前のcommit(0631107)と現在のHEADを同じマシン上でbuildし、同一benchmarkを連続実行して計測しました。BenchmarkSingleDocWritesとBenchmarkSort100kはこの作業より前には存在しなかったため、below の「修正前」の値は同じworkloadを修正前のCluster APIで再現したものです。3〜5回実行の中央値。

Benchmark 修正前 修正後
単一document書き込み5,000件: 保持heap 2,113 MiB 26.1 MiB
同上: term query 2.44 ms 43.7 µs
同上: 書き込み1件あたり 1.83 ms 480 µs
単一document書き込み1,000件: 保持heap 421.5 MiB 12.0 MiB
同上: term query 301 µs 44.8 µs
10万documentをsortしてpage 10取得(double) 228.8 ms 73.5 ms
同上(date) 268.3 ms 55.4 ms
同上(keyword) 260.0 ms 70.9 ms
同上(2 key) 323.4 ms 79.1 ms

1,000件の単一document書き込みでは、書き込み1件あたりの時間はほぼ変わりません(どちらも450〜500µs程度)。このscaleではsegment未mergeの代償は主に保持heapとqueryのcostに現れます。書き込み件数が増えるほど差は広がり、5,000件では旧codeが2GiBのheapに対して増大するGC costも払っていたため、segment数をdocuments/1000 + 27に抑える新codeとの差がさらに開きます。

二つの回帰を修正し一つを保留

Section titled “二つの回帰を修正し一つを保留”

同じsessionでOpenSearch 3.8のresponse忠実度に関する大規模な修正(commit 71962c6)もmergeされました。実際のOpenSearch 3.8.0と2.19.1に対して477シナリオでosmemと比較し、差異のあるrequestは6,139件中3,263件から308件へ減っています。このページを再計測する中で、Cluster.Clone()と通常のqueryが前回計測より明らかに遅くなっていることが分かりました。このマシン上でcommit単位にbuild・計測を繰り返して二分探索したところ、原因は互いに無関係な2つの箇所に特定でき、どちらも互換性修正が本質的に必要としていたものではなく、ここで修正しました。

  • route tableがNew()/Clone()のたびに再構築されていた。 newHTTPHandlerはbuildRoutes()(150以上のroute、それぞれclosure)を呼び、routing trieを毎回ゼロから構築していました。これは最初のcommitからそうだったのですが、71962c6が各routeの構築コストを(parameter validation用の付帯情報を追加して)引き上げたことで、誰も気づかないまま影響が拡大しました。どのhandler closureもtrie nodeも特定のClusterに紐づいていません──全handlerは*httpHandlerを引数として受け取り、そこを経由してclusterに到達します。そのためroute tableとtrieは今ではprocessごとに一度だけ構築され、共有されます。decision record。
  • nested fieldのないmappingでも、queryのたびにroot文書だけに絞る条件が付いていた。 nested-as-child-documents(2026-09-14)が、nested子文書をhitに含めないための2つ目のquery句を無条件に追加していました。nested fieldを持たないmappingはnested子文書を絶対に持てないため、この句は常にno-opでしたが、それでもrequestをbleveのDocumentMatchPool事前確保の上限(1,000document)超えに押し上げ、確保costを倍増させていました。今はmappingにnested fieldがないときこの句を省略します。decision record。

どちらも修正前にpprof(該当benchmarkへの-cpuprofile/-memprofile)で確認し、修正後はgo test -race ./...も通ります。下表は前回公開値・このsessionで回帰した(未修正)状態・修正後の現在値の3点です。

Benchmark 前回公開値 回帰時 修正後(現在)
Cluster.Clone()単体(queryなし) 14.4 µs 60.1 µs 0.8 µs
Cluster.Clone() + _count query 315 µs 約407 µs 約175 µs
単純なterm query、1万document 37.7 µs 約72 µs 約45 µs
_search: bool query + sort + date_histogram、1万document 1.78 ms 4.5 ms 4.5 ms(下記参照)

Clone()単体はこのページで過去最速の計測値になりました。HTTP requestを一切受けずquery DSLの解析も行わないため、呼び出しごとのroute再構築を取り除いたことで、in-process copy-on-write forkそのものに近いcostまで下がっています。term queryは元のbaselineに近い値まで戻りました。残るわずかな差はどちらの修正にも関係がなく、今回はそれ以上追っていません。

bool queryを使う_searchは未修正で、これはbugではありません。 このqueryは71962c6が追加したもう1つの仕組み(internal/engine/query_exec.go)を経由します。bleve自身のcompound searcherはLucene本来のquery normやcoordination factorとは異なる方法でscoreを合成するため、osmemのbool/dis_max/boosting/function_scorequeryは今ではLucene準拠のscoreを自前で計算します。そのためには、合成する前に各句の全matchをmemory上に読み切る必要があり、単一句のqueryのようにstreamしたり早期終了したりできません。これはaccidentではなく正しさを優先した本質的なcostで、profilingでも裏付けられています──BenchmarkSearch10kのmemory profileでは、該当句の全match件数を渡されたsearch.NewDocumentMatchPoolが最大の割り当て元でした。修正するにはLucene通りにscoreを合成しつつ早期終了できるevaluatorへの作り直しが必要で、より大きな設計変更になるため、今回は手を付けていません。

前回計測と同じApple M3 / Go 1.27.0 / Python 3.14.7 / Node.js 26.8.1 / Java 25.0.2 / Docker 29.4.0(OrbStack)のマシンで2026-09-16に再計測しました。ただし今回は他の作業と同居しており、単発の平均値は前回よりnoiseが大きい点に注意してください。起動処理自体はこのsessionのengine変更の影響をほとんど受けません。New()/LoadSeed()はNew()を1回呼ぶだけなので、二つの回帰を修正し一つを保留のroute table修正で浮くのはせいぜい60µs程度で、この表の計測noiseに埋もれる差です(実際に変わった箇所は書き込みとsortの性能とその節を参照してください)。container系の行(Docker/Testcontainers/Devbox)とdownload sizeの節は、このsessionで変更していないinfrastructureを計測しているため再計測せず、2026-09-13/14の値のままです。

API経路 日本語 起動平均 範囲 計測区間
Go組み込み 無効 2.02ms 1.52〜4.82ms (n=10) osmem.New() + LoadSeed()。起動済みGo test processと実行ファイルlaunchは除外
Go組み込み 有効 335.1ms 313.3〜404.1ms (n=10) 同上。osmem/jaをimportして有効化
Python server SDK 無効 126.2ms 18.2〜549.9ms (n=5) OsmemServer.start()からchild起動・seed読込完了まで。Python runner起動済み
Python server SDK 有効 339.6ms 327.0〜358.3ms (n=5) 同上、日本語analyzer有効
Java server SDK 無効 462.0ms 371.0〜816.7ms (n=5) OsmemServer.start()でclasspath内binaryを展開し、child起動・seed読込完了まで
Java server SDK 有効 769.9ms 725.7〜837.0ms (n=5) 同上、日本語analyzer有効
Node.js server SDK 無効 22.7ms 10.9〜68.3ms (n=5) OsmemServer.start()からchild起動・seed読込完了まで。Node runner起動済み
Node.js server SDK 有効 326.5ms 320.3〜346.2ms (n=5) 同上、日本語analyzer有効
Docker上のOpenSearch — 6.07秒 5.99〜6.31秒 (n=5) warmなopensearchproject/opensearch:2.19.0、linux/arm64。PUT /benchmark成功まで。再計測なし(上記参照)
Testcontainers Go + OpenSearch — 6.48秒 5.77〜8.22秒 (n=5) Testcontainers-Go 0.44.0。毎回fresh container、image取得済み。data pathは1GiB tmpfs。初回Ryuk起動を含む。再計測なし
Devbox管理のOpenSearch — 7.94秒 6.85〜10.63秒 (n=5) Devbox 0.17.5 services up -b。process-composeからdocker run、image取得済み。再計測なし

Python・Java・Node.jsはlanguage runtimeが起動した後に計時しており、test runnerからSDKを呼ぶ条件です。Javaのclasspath展開、Python/Nodeの配置済み実行ファイルという条件も変わっていません。Go組み込みはHTTP listenerを起動せず、Go test process自体の起動も含みません。n=10の各試行は独立したsingle-shot processです。日本語analyzerの初期化がprocessごとに一度きりのcostだと分かったためです(詳細は後述)。container系はPUT /benchmark成功をready条件とし、seed済みosmemとは準備完了の境界が異なる参考値です。write latencyは対象外です。構築済みindexを使うtestを想定しているためです。

新しい知見: 日本語analyzerの初期化はprocessごとに一度きりのcostです。 osmem/jaをimportした同一process内でosmem.New()を繰り返し呼んでも、kuromoji/kagomeの辞書読込costがかかるのは最初の呼び出しだけで、それ以降は日本語無効時と同じ0.75〜1.0msでした。このprojectのREADMEが示す通り、osmem/jaをimportしてTestMainで1つのbase clusterを構築するGo test suiteなら、約320msのcostはtest binary実行ごとに1回だけで、testごとには発生しません。

日本語有効で構築済みのseedを先に用意し、cloneを作る呼び出しだけを計測しました。fork側を変更しても共有baseには影響しません。document/indexのwriteとcloneのcloseは計時していません。

API経路 clone生成平均 試行
Go組み込み Cluster.Clone() 0.8µs 5 batch × 5,000 clone。seed準備は計測外
Python server.clone() 215µs 起動済みseed serverから300回
Java server.clone() 507µs 起動済みseed serverから300回
Node.js server.clone() 1.77ms 起動済みseed serverから300回

二つの回帰を修正し一つを保留の修正後の値です。Go組み込みの行はこのページで過去最速になりました(前回公開値14.4µs、session中の回帰時60.1µs)。language SDKの行も同じin-process Clone()を経由しますが、localhostのHTTP requestとJSON decodeがcostを薄めるため改善幅は相対的に小さく(回帰時から5〜30%)、PythonとJavaは前回公開値(240µs、583µs)も下回った一方、Node.jsは改善はしたものの前回の1.63msは上回ったままです。closeや、その後にテストが行うindex/document変更は含めません。

経路 ready時のmemory query 条件
日本語analyzer使用後のosmem server RSS 161.8MiB — 同じseedで5 process。日本語match queryの後にRSS取得
Go in-process API 未計測 filter + sort + date histogram: 4.5ms。完全一致term: 約45µs。読み取りclone + count: 約175µs 1万document fixture。`go test -run ‘^$’ -bench ’Benchmark(CloneReadOnly
日本語無効のosmem HTTP query 未計測 中央値0.5ms、p95 0.6ms、p99 1.1ms 3-documentのseedに対するmatch_all、size 10のrequestを500回逐次実行。このdocument数ではloopback HTTPとcurlのprocess起動costが支配的で、engine自体の時間ではありません
Docker上のOpenSearch container RSS 942.2MiB 中央値4.45ms、p95 9.76ms、p99 19.02ms test index作成後にdocker stats。queryは別試行。再計測なし
Testcontainers Go + OpenSearch container 954.4MiB + runner RSS増分1.3MiB = 955.7MiB — Go runnerは起動前19.0MiB、ready時20.3MiB。data tmpfsは1GiB。Docker daemonとRyukは除外。再計測なし
Devbox管理のOpenSearch container 947.6MiB + process-compose/Docker CLI 61.0MiB = 1,008.6MiB — Docker daemonは除外。再計測なし

検索行ごとにindexとquery planは異なります。Go benchmarkは1万document、小さなseedを使うosmem HTTP確認、空indexを検索するOpenSearchであり、厳密なengine比較ではありません。Dockerはsingle node、heap 512MiB、security demo設定無効で測りました。hardware、heap、architecture、storage driver、image cache、readiness条件で結果は変わります。

Testcontainersはテスト側からcontainer runtimeを扱うwrapperで、検索engineではありません。JVMの大きく変動するbaselineをcontainer消費と誤認しないよう、Go版(testcontainers-go 0.44.0)で計測しました。OpenSearchはheap 512MiB、security無効で、/usr/share/opensearch/dataをUID/GID 1000の1GiB tmpfsにmountしています。image取得済みの状態からfresh containerを5回起動し、index準備まで平均6.476秒(5.774〜8.223秒)でした。初回trialはTestcontainersのRyuk helper起動を含みます。ready時のdocker statsはcontainer memory 954.4MiBを報告しました。Go runnerのRSSは起動前平均19.0MiB、ready時20.3MiBで、増分1.3MiBを加えた合計は955.7MiBです。Docker daemonとRyukはmemory値から除外しています。このtmpfs条件はDocker・Devboxの行とはstorage条件が異なります。class全体でcontainerを共有すれば、起動時間はそのclass内のtestで按分されます。ここは前回計測(2026-09-14)から変わっていないため、再計測していません。

リポジトリrootから再計測できます。

ターミナルウィンドウ
cd bench/testcontainers
go run .

計測用harnessはcontainer作成からPUT /benchmark成功までを計時し、その後docker inspectでtmpfs mountを検証します。既定ではfresh containerを5回起動し、引数でtrial数を指定できます。

Devboxはprocess-compose経由でserviceを管理できます。公式services guideにdevbox services upとbackground起動の説明があります。ここではdevbox services up -bで、同じcache済みOpenSearch imageをdocker run経由で起動しました。新規起動5回の平均はPUT /benchmark成功まで7.94秒。ready時のcontainer RSS平均947.6MiBに、常駐するprocess-composeとDocker CLIの61.0MiBを足して1,008.6MiBです。Docker daemonは除外しています。warm状態のno-op devbox run(156ms)とMaven/JDK closureの初回download(194.1MiB、展開後351.7MiB)は、server起動・server imageとは別の開発toolchain costです。ここも前回計測から変わっていないため、再計測していません。

リンク後のアプリケーションバイナリとコンテナのdownload size

Section titled “リンク後のアプリケーションバイナリとコンテナのdownload size”

ライブラリのサイズは、source codeやpackage/archiveの容量ではなく、Goアプリケーションをlinkした最終実行ファイルの増分で示します。2026-09-16に、Go 1.27.0、-trimpath、-ldflags=-buildid=を揃え、空のmain、osmem clusterを生成・終了するmain、さらに日本語analyzerをimportするmainの3種類をこのマシン上でビルドしました。日本語analyzerの値は空のbaselineからの合計増分に加え、osmem単体への追加分も示します。

Go実行ファイル サイズ
最小mainのbaseline 1,815,298 bytes (1.73 MiB)
osmemをlinkした実行ファイル 32,771,522 bytes (31.26 MiB)、増分+30,956,224 bytes (+29.52 MiB)
osmem + 日本語analyzer 45,622,018 bytes (43.51 MiB)、baseline比増分+43,806,720 bytes (+41.78 MiB)。このうちanalyzerの追加分は12,850,496 bytes (12.25 MiB)

osmem単体の増分は前回計測(23.85 MB / 22.75 MiB)から約6.8MB増えました。internal/engine/aggs_dates.goがGoのIANA timezone databaseを無条件に埋め込むtime/tzdataをblank importするようになったためです──windows-latestでのCI hangがplatform依存のzone dataに起因していたことへの修正です。これに加え、同じsession内のOpenSearch 3.8互換性修正やsegment merge/sortの作業がまとまった量のengine codeを追加したことも影響しています。日本語analyzer自体の追加分は前回とほぼ変わりません(12.25 MiB、前回12.26 MiB)。これはkagome/IPADIC辞書に依存する部分で、今回の変更では触れていません。

これは特定のtoolchainと最小プログラムにおけるlink結果で、packageサイズの一般的な保証値ではありません。Node.jsとPythonは別プロセスのosmem実行ファイルを使い、JavaはJVM artifactを使うため、静的にlinkしたアプリケーションバイナリ増分とは同じ条件で比較できません。

トップページでは3種類のサイズを10進MBの同じscaleで比較しています。Go appへのlink増分は43.8 MB (+41.78 MiB)、Devbox Maven/JDK環境の初回downloadは203.5 MB (194.1 MiB)、Docker Hub掲載の圧縮済みlinux/arm64 image sizeは739.3 MBです(Docker Hub)。それぞれlink済み実行ファイルの差分、一度だけ取得するtoolchain、圧縮されたserver image全体であり、対象範囲は異なります。DockerローカルのSizeは展開後の容量なのでdownload sizeの代用にはしません。layerがcache済みなら実際の転送量はこれより少なくなる場合があります。

TestcontainersとDevbox serviceも同じOpenSearch imageを使うため、別のserver binaryはlinkしません。Devboxのtoolchain downloadはserver imageとは別指標です。

repository rootで実行します。

ターミナルウィンドウ
go test -run '^$' -bench 'Benchmark(CloneReadOnly|Search10k|TermQuery10k|SingleDocWrites|Sort100k)$' -benchmem -count=3

CloneReadOnly/Search10k/TermQuery10kは1万document、Sort100kは10万documentのfixtureを、query計測の前にbenchmarkが構築します。SingleDocWritesは計測対象そのものとして1,000件または5,000件を1件ずつ書き込みます。サービス起動やclient transportを測るときは、compile、binary展開、image取得、seed loadingを計測時間に含めるか、事前に明記してください。このページのbefore/after表のように変更をbaselineと比較するときは、git worktree addで両方のcommitをbuildし、同じマシン上で同じ-benchcommandを連続実行してください。共有マシンや負荷のあるマシンでの単発計測は(API別の起動時間の通り)ばらつきが大きいため、日付の異なる表の値同士を比較するより、同一session・同一マシンでの比較の方が信頼できます。同じ手法を複数のcommitに繰り返し適用して、上記の2つの回帰を「ここ数日のどこか」ではなく特定のcommitまで絞り込みました。