本文へ
Archival Packager

Archival Packager 技術資料

規模と性能の実測(2026-09-12)

このアプリがどの規模まで実用に耐えるかを初めて測った記録。 「数千ファイルで重くなった」という報告が過去に 1 度あり、そのときは UI 側だけ 直してパイプライン本体は測っていなかった。実際の移管は数万ファイルになることが あるので、どこで破綻するかを先に知っておくために測った。

再現に使った道具は scripts/benchmark.py。回帰の見張りは tests/test_scaling.py(既定では走らない)。

uv run python scripts/benchmark.py --suite quick
uv run python scripts/benchmark.py --case files:50000 --format json
ARCHIVAL_PACKAGER_SCALING=1 uv run pytest tests/test_scaling.py

結論を先に

測定環境

   
機種 Apple M4 Max(16 コア)/ メモリ 128 GiB
OS macOS 26.6.2(ビルド 25G83)、arm64
Python 3.12.13(.venv、flet[desktop] 同梱版に合わせた版)
ディスク 内蔵 SSD・APFS(空き 1.3 TiB)。作業用データも同じボリュームの一時領域
同梱ツール siegfried(binaries/macos/sf)、ClamAV 定義 DB 取得済み

注意: 計測中、同じ機械で別の作業が動いていた。隣り合う点どうしで ±30% 程度のばらつきがあるのはそのため(10,000→20,000 の伸びが 2.76 倍、 5,000→10,000 が 1.80 倍と揺れているのが典型)。傾向を読むには十分だが、 1 点の絶対値を仕様として扱わないこと。

ファイル件数を変えたときの実測値

小さいテキスト(1 件あたり約 1 KiB)を、1 ディレクトリ 100 件ずつに分けて配置。 ファイル名には日本語・空白・長い名前(90 文字)・記号(()&[])・濁点を含む語を 混ぜてある(実際の移管データに入るため)。SIP は BagIt bag として作り、 続けて同じ SIP から AIP を作った。ウイルス検査と PII 走査は無効。

件数 入力 SIP 生成 AIP 生成 合計 ピークメモリ SIP 出力 AIP 出力 うち METS
100 0.1 MiB 0.17 秒 0.12 秒 0.28 秒 38.0 MiB 0.2 MiB 0.8 MiB 0.6 MiB
1,000 0.9 MiB 0.89 秒 0.95 秒 1.84 秒 75.0 MiB 1.9 MiB 7.7 MiB 6.0 MiB
2,000 1.9 MiB 1.98 秒 1.94 秒 3.92 秒 113.1 MiB 3.7 MiB 15.5 MiB 11.9 MiB
5,000 4.8 MiB 5.22 秒 5.19 秒 10.40 秒 229.3 MiB 9.4 MiB 38.9 MiB 29.8 MiB
10,000 9.6 MiB 9.37 秒 8.79 秒 18.16 秒 420.9 MiB 18.9 MiB 77.8 MiB 59.7 MiB
20,000 19.6 MiB 25.89 秒 23.24 秒 49.13 秒 771.4 MiB 38.4 MiB 156.1 MiB 119.5 MiB
50,000 49.8 MiB 58.21 秒 57.90 秒 116.11 秒 1,869.0 MiB 96.7 MiB 391.2 MiB 298.9 MiB

ピークメモリは resource.getrusage(RUSAGE_SELF).ru_maxrss。 1 規模 = 1 子プロセスで測っているので、前の規模の値が混ざることはない。

出力が入力より大きい。 50,000 件では入力 49.8 MiB に対して AIP が 391.2 MiB。 約 8 倍になる。増えた分のほとんどは METS(298.9 MiB)で、これは 1 件あたり 6.1 KiB が、どの規模でもそのまま掛かる(100 件でも 50,000 件でも 1 件あたり 6.1 KiB)。原本が小さい資料群ほど、この比率は悪くなる。

計算量の性質

件数を 1,000 から 50,000 へ 50 倍にしたときの伸びを、工程ごとに見る。 指数が 1.0 なら比例、2.0 なら二乗。

工程 1,000 件 50,000 件 倍率 指数
走査 0.032 秒 2.056 秒 64.3 1.06
フォーマット識別(siegfried) 0.190 秒 11.770 秒 62.0 1.05
ハッシュ(SHA-256) 0.057 秒 5.917 秒 103.8 1.19
スプレッドシート・DFXML 生成 0.014 秒 0.798 秒 57.0 1.03
ペイロード複写 0.377 秒 24.512 秒 65.0 1.07
権限正規化 0.029 秒 1.954 秒 67.4 1.08
BagIt 化(SIP・AIP 合計) 0.380 秒 23.703 秒 62.4 1.06
SIP 読み取り 0.053 秒 2.833 秒 53.5 1.02
完全性確認(再ハッシュ) 0.081 秒 5.199 秒 64.2 1.06
METS 生成 0.092 秒 4.511 秒 49.0 1.00

線形を超えて増えている箇所は無かった。 最も大きいハッシュの 1.19 乗も、 個々の点のばらつき(前述の ±30%)に収まる範囲で、アルゴリズムの問題ではない。 指数が 1 をわずかに上回るのは、ファイル数が増えるとディレクトリのメタデータが 大きくなり、ファイルシステム側の 1 件あたりのコストが上がるため。

つまり「10 倍のデータに 10 倍の時間」は成り立つ。 数万件で詰まるとしたら、 時間ではなくメモリのほうである。

工程ごとの内訳(50,000 件)

工程 秒 全体に占める割合
ペイロード複写(SIP) 24.512 21%
BagIt 化(SIP + AIP) 23.703 20%
AIP 組み立て(複写・METS 書き出し込み) 44.741 — ※
フォーマット識別(siegfried) 11.770 10%
ハッシュ(SHA-256) 5.917 5%
完全性確認(再ハッシュ) 5.199 4%
METS 生成 4.511 4%
SIP 読み取り 2.833 2%
走査 2.056 2%
権限正規化 1.954 2%
スプレッドシート・DFXML 生成 0.798 1%

※ AIP 組み立てには BagIt 化が内側に含まれるので、足すと二重になる。

支配しているのはファイルの複写とハッシュ、つまりディスク I/O である。 METS も CSV も、全体から見れば誤差の範囲。したがって速くしたければ、 まず I/O の回数を減らすことになる。

ペイロードは端から端まで 6 回読み直されている(SIP で ①ハッシュ計算 ②複写のための読み ③BagIt マニフェストの計算、AIP で ④完全性確認の再ハッシュ ⑤複写のための読み ⑥BagIt マニフェストの計算)。書き込みは複写の 2 回。 1 GiB の資料群なら、読みだけで 6 GiB の I/O になる。 SIP 段で計算済みのハッシュを持っているのに再計算している箇所があるので、 削る余地はここにある(ただし完全性確認の再ハッシュは意図的な二度手間で、 これを省くと「照合した」という記録だけが残る。削ってはいけない)。

メモリの伸び方 — ここが本当の限界

ピークメモリは件数に比例して伸びる(指数 1.0)。問題は傾きで、 1 ファイルあたり 37.5 KiB。1 KiB のテキスト 1 件を処理するのに、 その 37 倍の常駐メモリを使っている。

どの工程で積み上がるかを、50,000 件のときの「その段を抜けた時点での最高水位」で 追うとこうなる。

通過した段 到達したピーク 直前からの増分 1 件あたり
開始時 33.5 MiB — —
走査を終えた時点 97.7 MiB +64.2 MiB 1.3 KiB
フォーマット識別を終えた時点 255.2 MiB +157.5 MiB 3.2 KiB
書類生成(DFXML 込み)を終えた時点 383.4 MiB +128.2 MiB 2.6 KiB
SIP 組み立てを終えた時点(SIP 完了) 419.7 MiB +36.3 MiB 0.7 KiB
完全性確認を終えた時点 419.7 MiB ±0 —
METS 生成を終えた時点 1,869.0 MiB +1,449.3 MiB 29.7 KiB
AIP 完了 1,869.0 MiB ±0 —

METS 生成だけでピークの 78% を作っている。

原因は、mets.build_mets が AIP 全体の XML を lxml の木として一度に組み、 そのうえで etree.tostring でバイト列にしてから書き出すこと。1 ファイルにつき PREMIS の object 1 つと event 3 つ(取り込み・完全性確認・フォーマット識別。 ウイルス検査を実施していれば 4 つ)、fileSec の項目 1 つ、 structMap の div 1 つを作る。 細かい要素が 1 件あたり数十個できるので、木そのものが大きい。 そのうえ、書き出す直前は木とバイト列が同時にメモリに載る。

なお、この 29.7 KiB は lxml が C 側で確保するため tracemalloc には映らない。 ru_maxrss でしか見えない(scripts/benchmark.py の冒頭に書いた理由)。

直すのは別の担当だが、直す先の見当だけ書いておく。 METS は etree.xmlfile による逐次書き出しに替えられる構造をしている(amdSec → fileSec → structMap の順で、後ろのセクションが前のセクションを参照しない)。 ただし fileID と admID を 3 セクションで一致させる必要があるので、 ID の割り当てだけ先に済ませてから流す形になる。

ウイルス検査の有無

ClamAV は 1 ファイルあたりのコストが大きいので、別に測った。

件数 検査なし 検査あり 増分 子プロセスのピーク
1,000 0.89 秒 12.83 秒 +11.94 秒 1,383.6 MiB
10,000 9.37 秒 66.44 秒 +57.07 秒 1,404.5 MiB

50,000 件での予測は 7 + 50,000 × 0.005 ≒ 257 秒(4 分強)。 検査なしの SIP 生成が 58 秒なので、ウイルス検査を入れると 5 倍以上になる。

PII 走査の有無

件数 走査なし 走査あり PII 走査の段だけ 1 件あたり
1,000 0.89 秒 1.23 秒 0.191 秒 0.19 ミリ秒
10,000 9.37 秒 11.39 秒 2.006 秒 0.20 ミリ秒

小さいテキストに対しては安い。ただしこれは PDF を含まない数字である。 document_text.scannable は PDF を pypdf で開いてテキストを取り出すので、 PDF が多い資料群では桁が変わるはず。未計測(下の「測っていないこと」を参照)。

単一ファイルが大きいとき

入力 SIP 生成 AIP 生成 ピークメモリ 実効速度(SIP)
100 MiB × 1 本 0.19 秒 0.14 秒 38.8 MiB 約 526 MiB/秒
1 GiB × 1 本 1.25 秒 1.20 秒 38.9 MiB 約 819 MiB/秒

ファイルサイズはメモリに効かない。 10 倍にしてもピークは 38.8 → 38.9 MiB。 checksums.sha256_of が 1 MiB ずつのチャンク読みでストリーム計算しているため。 BagIt 化も同様で、1 GiB の内訳はほぼ全部がハッシュとコピーの I/O。

つまり「重い」の正体はファイル数であって、総バイト数ではない。 同じ 1 GiB でも、1 本なら 2.5 秒で終わってメモリは 39 MiB のまま、 50,000 本に分かれていれば 2 分以上かかってメモリを 1.8 GiB 使う。

階層が深いとき

2,000 件を、1 階層に置いた場合と 12 階層の奥に置いた場合の比較。

配置 SIP 生成 AIP 生成 ピークメモリ 警告
深さ 1 2.02 秒 2.05 秒 113.8 MiB 0 件
深さ 12 2.46 秒 2.65 秒 133.8 MiB 714 件

深さそのものの影響は 2 割ほど(相対パスが長くなり、文字列と XML が太る)。 むしろ効くのは警告が 714 件出たことで、これは sip_builder.check_path_lengths が Windows の MAX_PATH(260 文字)超えを 拾ったもの。長い名前を深い階層に置くと、警告が 1 ファイル 1 行で積み上がる。

この 714 件という数字は打ち切りを入れる前のもの。 いまは種類ごとに 100 件で打ち切り、超えた分は (他 N 件) の 1 行にまとめる(後述)。 同じ入力をいま測れば、警告は 100 件 + 1 行になる。

実用の上限の目安

ピークメモリが 1 件あたり 37.5 KiB で比例するので、ここから逆算できる。

件数 所要時間(検査なし) ピークメモリ METS の大きさ 判断
〜1,000 2 秒 75 MiB 6 MiB 何も気にしなくてよい
〜10,000 20 秒 420 MiB 60 MiB 快適に使える範囲
〜50,000 2 分 1.8 GiB 300 MiB 待てば通る。メモリ 8 GiB 以上を推奨
〜100,000 4 分(外挿) 3.6 GiB(外挿) 600 MiB(外挿) メモリ 8 GiB の機械の実用上限
200,000 以上 8 分以上(外挿) 7.2 GiB 以上(外挿) 1.2 GiB 以上(外挿) 厳しい。分割して移管すべき

具体的な線引き:

測っていないこと

数字を使う人が誤解しないよう、測っていない範囲をはっきりさせておく。

回帰を防ぐ仕掛け

tests/test_scaling.py に、2,000 件で時間とメモリが一定に収まることを 確かめるテストを置いた。既定では skip する(filterwarnings = error の 設定があるため、未登録のマーカーではなく環境変数で切り替えている)。

ARCHIVAL_PACKAGER_SCALING=1 uv run pytest tests/test_scaling.py -v

上限は実測(2,000 件で SIP 1.98 秒 / AIP 1.94 秒 / 113.1 MiB)に対して 時間は約 10 倍、メモリは約 3 倍の余裕を取ってある。時間の余裕を大きく取るのは、 CI の共有ランナーや HDD の機械では簡単に数倍になるため。

時間の絶対値より効くのは、同じファイルに入れた「件数を 4 倍にしたときの 伸び」の検査のほう。 500 件と 2,000 件を比べて、時間とメモリの伸びが 6 倍を超えたら失敗する。線形なら 4 倍、二乗なら 16 倍になるので、 機械の速さに関係なく計算量の劣化だけを捕まえられる。

METS 生成のメモリを削った(2026-09-12)

上の実測で「ピークの 78% は METS 生成」と分かったので、その段だけを直した。 出力は 1 バイトも変えていない。(上の表は改修前の値。歴史として残す。)

何を変えたか

core/mets.py の build_mets が、AIP 全体の XML を lxml の木として一度に 組み、etree.tostring でバイト列にしてから返していた。書き出す直前は 木とバイト列が同時にメモリに載る。

これを、セクションを 1 つずつ組んでは直列化し、書き出して捨てる形に変えた。 metsHdr → dmdSec → amdSec → fileSec → structMap の順に後方参照が無いので、 この順に流せる(前の担当者の所見のとおり)。3 セクションで一致させる必要のある fileID・admID は持ち回らず、ファイルの並び順から同じ式で導き直している。

fileSec と structMap はセクションが 1 つなのに中身が件数に比例するので、 親要素(fileGrp / div)を開いたまま子要素を 512 件ずつ流す。

etree.xmlfile(lxml の逐次書き出し)は使わなかった。 出力が変わるため。 親で宣言済みの名前空間を要素ごとに ns0: として再宣言し、インデントの深さも 引き継がない。METS は AIP に収められ BagIt manifest のハッシュ対象になる 来歴記録なので、見た目だけの差も出せない。代わりに、セクションを mets:mets の 入れ物に入れて直列化し、入れ物の開始タグ・終了タグの分を切り落として流している。 桁揃えも名前空間も lxml が決めるので、1 本の木にしたときと結果が一致する。

出力が変わっていないことの確かめ方

旧実装を mets._build_mets_in_memory として残し(参照専用・本番経路では呼ばない)、 tests/test_mets_streaming.py が新旧のバイト列の完全一致と C14N 正規化後の一致を、11 通りの入力で確かめる(原本のみ/派生物あり/ 提出書類あり/日本語・記号を含むファイル名/イベント複数/記述メタデータあり/ ハッシュ・フォーマット不明/深い階層/区切りの境目ちょうど/0 件/200 件)。

実規模でも 1 度確かめた。20,000 件(METS 113.5 MiB、118,989,294 バイト)で、 旧実装・新実装・write_mets でのファイル直書きの 3 経路が完全に一致した。 下の 10,000 件のパイプライン実測でも、mets_bytes と aip_output_bytes が 改修前後で 1 バイト違わない(62,605,993 / 85,138,332)。

実測(パイプライン全体・10,000 件)

scripts/benchmark.py --case files:10000 を、改修前後で 1 回ずつ。改修前は build_mets を旧実装に差し替えて走らせた(benchmark.py は書き換えていない)。

  改修前 改修後
METS 生成の段の増分 288.3 MiB 42.7 MiB
1 ファイルあたり 29.52 KiB 4.37 KiB
パイプライン全体のピーク 418.2 MiB 167.1 MiB
METS 生成の所要時間 0.997 秒 0.973 秒
METS の大きさ 62,605,993 バイト 62,605,993 バイト

METS 生成の段は 6.75 分の 1、全体のピークは 2.50 分の 1 になった。 時間は変わっていない。

実測(METS 生成の段だけ・最大 50,000 件)

パイプラインを通すと他の段のメモリが混ざるので、build_mets の呼び出しだけを 同じ流儀(ru_maxrss、1 計測 = 1 子プロセス)で測った。入力は scripts/benchmark.py と同じ名前の癖・1 ディレクトリ 100 件で、各ファイルに イベントを 3 つ持たせた合成データ。

件数 旧(木を丸ごと) 新(build_mets) 新(write_mets) METS の大きさ
2,000 64.1 MiB 15.3 MiB 2.4 MiB 11.3 MiB
5,000 156.8 MiB 33.6 MiB 2.8 MiB 28.3 MiB
10,000 311.3 MiB 63.0 MiB 4.6 MiB 56.7 MiB
20,000 618.3 MiB 122.5 MiB 7.3 MiB 113.5 MiB
50,000 1,539.1 MiB 301.6 MiB 15.9 MiB 283.8 MiB

1 ファイルあたりでは 31.5 KiB → 6.2 KiB(write_mets なら 0.33 KiB)。 50,000 件で 5.10 分の 1。所要時間は 3.97 秒 → 4.26 秒(+7%)。

この合成データでの旧実装の値(50,000 件で 1,539.1 MiB)は、上のパイプライン 実測の +1,449.3 MiB と 6% 差で一致する。合成データが実際の負荷と同じ形だと 見てよい。

残っているもの — 戻り値そのもの

build_mets は METS をバイト列で返す。50,000 件ならその戻り値が 283.8 MiB あり、改修後に残っている 301.6 MiB のほぼ全部がこれである。 セクションを流す仕組みでは、ここから先は削れない。

削るには、呼び出し元がファイルへ直接流す必要がある。そのための mets.write_mets(open(path, "wb"), ...) を用意してあり、同じバイト列を出すことは テストで固定してある。core/aip_pipeline.py が

mets_xml = mets.build_mets(...)          # …そして _build が write_bytes する

をやめて write_mets でファイルへ書けば、この段のメモリは 50,000 件で 15.9 MiB(96 分の 1)になる。今回は aip_pipeline.py を 他の担当者が編集中のため触っていない。次に触る人への申し送り。

気づいたこと(対応済み)

SIPResult.warnings が件数で打ち切られていない件。上の「階層が深いとき」で 2,000 件から警告が 714 件出ているとおり、未識別ファイルが数万件ある資料群では 警告も数万行になる。メモリ量そのものより、UI がそれを全部受け取って描く ほうが効く。→ 打ち切りを入れた(この文書の末尾を参照)。

申し送りを実施し、通しで測り直した(2026-09-12)

上の申し送り(build_mets をやめて write_mets で直接ファイルへ流す)を core/aip_pipeline.py に入れた。METS のバイト列を戻り値として持つのをやめ、 書き出し先が決まる _build の中で流す形にした。

通しでの実測(同じ機械・同じ合成入力)

件数 ピークメモリ(前) ピークメモリ(後) 合計時間(前) 合計時間(後)
10,000 420.9 MiB 128.9 MiB 18.16 s 19.25 s
50,000 1,869.0 MiB 433.4 MiB 116.11 s 109.25 s

50,000 件で 4.31 分の 1。1 ファイルあたりの常駐は 37.5 KiB → 8.9 KiB。

時間はほぼ変わらない(50,000 件でむしろ 6% 速い)。バイト列を 1 度作って 書き直す手間が減ったぶんで、逐次書き出しの上乗せ(METS 生成の段だけで見ると +7%)と 相殺している。

METS の段は工程内訳から消えた。 計測用のフックが build_mets を包んでいたが、 その関数を通らなくなったため。内訳を取り直すなら write_mets を包む必要がある (scripts/benchmark.py の _instrument)。

実用の上限の見直し

上の「実用の上限の目安」は改修前の数字なので、メモリの側は次のように読み替える。

件数 改修前 改修後
50,000 1.8 GiB 0.43 GiB
100,000 3.6 GiB(外挿) 0.87 GiB(外挿)
200,000 7.2 GiB(外挿) 1.7 GiB(外挿)

メモリが上限を決める要因ではなくなった。 20 万件でも 2 GiB 弱で、 8 GiB の機械なら余裕がある。次に効くのは時間のほう(20 万件で外挿 7 分強、 ウイルス検査を入れると 20 分超)。

METS を分割しなかった理由

この改修の前は「50,000 件で METS の生成が +1,449 MiB」だったため、 METS を mets:mptr で分割する案を検討した。メモリを理由にする分割は、 この改修で不要になった(分割しても各文書の生成で同じことをすれば同じ山ができる)。

残る論点は「298.9 MiB の単一 XML を下流のシステムが読めるか」だが、 こちらの METS を実際に読む下流は、まだ 1 つも確認できていない (docs/interoperability.md の「6. この検証の限界」を参照)。 確認できていない相手のために構造を複雑にしない、という判断で単一ファイルのままとした。

将来分割するとしたら、容量ではなく記述の都合(多巻物、fonds → series のような 階層)を理由にすべきで、そのときは objects/ 直下の単位で分けるのが自然である。

警告の件数を打ち切った(2026-09-12)

上の申し送りを core/sip_pipeline.py に入れた。SIPResult.warnings は 種類ごとに 100 件で打ち切り、あふれた分は種類ごとに 1 行 (未識別: (他 12,340 件))にまとめる。件数そのものは失われない。

全体で 1 本ではなく、種類ごとに数える。 全体で 100 件にすると、 未識別が 30,000 件ある転送では、たった 1 行のウイルス検出が枠から押し出される。 いちばん重要な行が最初に消えることになる。種類ごとに分ければそれが起きない。

上限 100 件と言い回しは core/report.py の _LIST_LIMIT に合わせてある。 画面と report.txt が違うことを言うと、どちらが本当か分からなくなるため。

正確な件数は report.txt を見ること。 こちらは警告の一覧ではなく ファイルの一覧から数えているので、打ち切りの影響を受けない。