「Python で製造業の実用に耐えるか」を公開値の引用ではなく、この PC での実測で答えるための記録。 測定機 = 開発機(Windows 11, Python 3.11)。再現コマンドを各節に記載。 数字はすべて中央値 (p50) ベース。異常に良い結果は内訳を疑う方針([[feedback_benchmark_honest_disclosure]])に従い、 何を測っていないかも明記する。
本記録で最も製造業にとって重要な発見は、個々の数字ではなくこれである。
同一セッション中に、同じコードを同じマシンで 3 回測った(4 MP / 200 blob):
| 測定 | セッション経過 | 手書き cv2 | fslib industrial |
ObjectSet(numpy) |
|---|---|---|---|---|
| 1 回目 | 開始 ~10 分(冷えた状態) | 10.8 ms | 16.9 ms | 250 ms |
| 2 回目 | ~70 分後 | 17.9 ms | 24.6 ms | 318 ms |
| 3 回目 | ~90 分後(CPU 負荷 9%・アイドル) | 18.3 ms | 26.6 ms | 266 ms |
ネイティブ経路が 1.7 倍劣化した。 その間:
解釈(確度: 中): 最も整合する説明は 1.5 時間の連続負荷による熱 / 電力制限。 1 回目だけが冷えた状態で、以降は温まった状態である。 短く・メモリ帯域律速なネイティブ経路が比例的に強く効き、長い numpy 経路は相対的に鈍い、という現れ方とも整合する。 (CPU 温度は WMI から読めず 直接確認はできていない。ゆえに確度は中。)
冷えたマシンで測ったサイクルタイムは、契約に書けない。
fslib)まで開いた。
p50 の 1.7 倍ぶれに対し、max は 4 倍ぶれる。| 構成 | p50 | 対 現状 | 何を変えたか |
|---|---|---|---|
現状(fscript.py の L1 + AST インタプリタ) |
1448 ms | 1.0x | — |
| L1 を直接 Python から呼ぶ(言語層を外す) | 1382 ms | 1.05x | 言語 VM を外しただけ |
| ObjectSet(ラベル画像 + ID 列)に置換 | 250 ms | 5.8x | オブジェクトモデル |
| 手書き numpy/scipy の床 | 244 ms | 5.9x | (参考: これ以上速くならない numpy の下限) |
| ネイティブカーネル(OpenCV)+ ObjectSet | 10.8 ms | 134x | 画素カーネルの実装 |
この 134 倍のどこにも「Python をやめる」は入っていない。 言語も駆動も Python のまま 10.8 ms に到達する。
内訳:
再現: py -3.11 tools/bench_realtime.py --cycles 30 --json out/bench_realtime_scale.json
| 構成 | raw numpy | ObjectSet | 現 L1 | fscript VM | 言語層の税 |
|---|---|---|---|---|---|
| 512x512 / 25 blob | 14.0 | 13.1 | 12.9 | 12.7 | 測定限界以下 |
| 1024x1024 / 50 blob | 58.1 | 58.0 | 97.6 | 98.0 | +0.4% |
| 2048x2048 / 50 blob | 243 | 257 | 394 | 409 | +3.8% |
| 2048x2048 / 200 blob | 244 | 250 | 1382 | 1448 | +4.7% |
(単位 ms, p50。「言語層の税」= fscript VM ÷ 現 L1)
★この列の正しい読み方(自己訂正): 512x512 では fscript VM の方が速い(12.7 vs 12.9 ms)。
つまりこの差は測定ノイズと実装差の中に埋もれており、言語層のコストは分離できていない。
正しい主張は「+0.4〜4.7% のコスト」ではなく 「画素処理が支配的な負荷では、言語層のコストは -2%〜+5% の
範囲に埋もれて解像できない」。言語層の素のコストを分離した測定は §3(0.67 us/statement)であり、
そちらを設計判断の根拠に使うこと。
(なお 2 経路は完全に同一アルゴリズムではない — fscript 側は select_obj を index 反復し、
Rows := Rows + [...] が毎回リストを複製する。差分には実装差も含まれる。)
読み取れること
fscript の AST インタプリタは、画素処理が支配的な検査負荷ではコストとして検出できない。
言語を Python VM で実行することはサイクルタイム上の問題にならない。
制御フローが 1 サイクルあたり数十〜数百 statement である限り成立する(§3 の境界表)。再現: py -3.11 tools/bench_realtime.py --configs 2048x2048x200 --modes native_cv2 --cycles 5000
| 2048x2048 / 200 blob, native_cv2 | p50 | p99 | p99.9 | max | max/p50 |
|---|---|---|---|---|---|
| gc 有効 | 10.97 | 12.71 | 13.51 | 16.73 | 1.52x |
| gc 無効 + freeze | 10.94 | 12.47 | 14.11 | 19.72 | 1.80x |
4 MP の検査を 5000 サイクル連続して、最悪 16.7 ms、p99.9 で 13.5 ms。 駆動は Python。 製造業が要求するのは平均ではなく最悪値なので、この形で提示できることが重要 (「p50 が 11 ms」ではなく「5000 回中の最大が 16.7 ms」)。 なお gc を止めた方が max が悪い(19.7 vs 16.7)。GC 抑止は無条件の改善ではない。
gc.freeze() + gc.disable() の有無で比較した結果、有意差なし。測定中の GC 収集回数は全条件で 0。
| 例(2048x2048/200 blob, ObjectSet) | p50 | p99 | max |
|---|---|---|---|
| gc 有効 | 249.9 | 267.0 | 267.0 |
| gc 無効 + freeze | 257.9 | 264.6 | 264.6 |
上の結論は「ネイティブ経路 = 確保が少ない」で得たものなので、最も確保が重い経路で反証を試みた。
l1_builtins は 1 サイクルあたり全面 bool マスクを blob 数だけ作る(1024x1024 x 50 blob = 約 50 MB/サイクル)。
600 サイクル = 約 30 GB の確保・解放。
再現: py -3.11 tools/bench_realtime.py --configs 1024x1024x50 --modes l1_builtins --cycles 600
| p50 | p99 | p99.9 | max | GC 収集回数 | max/p50 | |
|---|---|---|---|---|---|---|
| gc 有効 | 96.32 | 102.13 | 102.59 | 103.18 | 0 | 1.07x |
| gc 無効 + freeze | 96.39 | 101.47 | 103.63 | 103.83 | 0 | 1.08x |
反証は失敗し、結論はむしろ強化された。 30 GB の確保・解放を経ても循環 GC は一度も起動していない。 理由は明快で、numpy 配列は参照カウントで即時解放され、循環参照に入らないため循環コレクタの対象にならない。 巨大配列の解放は refcount で決定的に起きる。
この結論が成り立たなくなる条件(★外部検証のデータで定量化できた):
外部の独立検証は、同じ CPython で「gen2 GC が p50 105.9 ms のスパイクを打つ」という 一見正反対の結果を出している。両方とも正しい。 分岐しているのは Python ではなく 「常駐させている Python オブジェクトの数」である:
| 常駐 live オブジェクト数 | gen2 collection のポーズ |
|---|---|
| 3 万 | 0.69 ms |
| 30 万 | 8.47 ms |
| 300 万 | 232 ms |
| (本測定の検査ループ) | そもそも発火せず(収集 0 回) |
本測定で GC が一度も走らなかったのは、この負荷が Python オブジェクトをほとんど常駐させていないため
(numpy 配列は refcount で即解放され、循環コレクタの管理対象にならない)。
逆に、24 時間分のトレンドデータやレシピを dict / list で常駐させれば、簡単に 100 万個を超えて
100 ms 級のスパイクに変わる。
→ 設計指標として使える形: 10 ms サイクルで GC を 1 ms 以内に収めたいなら、 常駐 Python オブジェクトを概ね 5 万個以下に保つ。 これは「Python だから危ない」ではなく 「Python オブジェクトを何個常駐させる設計にしたか」の問題。 → 要件 R4 に反映(cycle 中は Python オブジェクトを作らない + 常駐数に上限を設けて監視する)。
その他、成り立たなくなる条件:
timeBeginPeriod(1) を保持しないと 2500 サイクル中 10 回が 1 ms 超・max 8.9 ms。
さらに Windows 11 ではウィンドウが最小化・完全に隠れると timeBeginPeriod の保証が失われる
→ HMI と制御ループを同一プロセスに置いてはいけない(Runtime を headless にする設計上の根拠)。正直な限界: これは短時間測定であり、 (a) 連続運転でのメモリ断片化、(b) OS スケジューリング / ウイルス対策スキャン / Windows Update、 (c) カメラ取得と同時実行したときの競合 — は測っていない。 「CPython の GC は本質的にジッタを生む」という一般論はこの負荷では再現しなかったというだけで、 24/7 運転の証拠にはならない。長時間試験は Runtime 実装後の課題(§6)。
再現: 本書 §2 のスクリプト(scipy.ndimage vs cv2 4.11.0、同一入力、p50)
| 処理 | 2048x2048 での p50 | 倍率 |
|---|---|---|
gaussian — scipy.ndimage float64 |
54.8 ms | 1.0x |
gaussian — scipy.ndimage float32 |
46.6 ms | 1.2x |
gaussian — cv2.GaussianBlur float32 |
5.9 ms | 9.3x |
gaussian — cv2.GaussianBlur uint8 |
1.1 ms | 49x |
label — scipy.ndimage.label |
7.5 ms | 1.0x |
label + 面積 + 重心 — scipy(sum_labels+center_of_mass) |
195 ms | 1.0x |
label + 面積 + 重心 — cv2.connectedComponentsWithStats |
9.2 ms | 21x |
上表への正当な反証があった —
「cv2 は既定で全コア(24 スレッド)を使い、scipy.ndimage は単スレッド。倍率の相当部分は SIMD ではなくスレッド数だ」。
指摘のとおりなので、cv2 を 1 スレッドに固定して測り直した:
| 処理(2048x2048) | scipy(常に単スレッド) | cv2 = 24 スレッド | 倍率 | cv2 = 1 スレッド | 倍率 |
|---|---|---|---|---|---|
| gaussian | 95.5 / 103.5 ms | 4.22 ms | 22.6x | 7.21 ms | 14.4x |
| label + 面積 + 重心 | 157.4 / 157.6 ms | 13.63 ms | 11.5x | 20.15 ms | 7.8x |
結論(訂正版): 反証は部分的に正しい。スレッド数は確かに差の一部を占める(22.6→14.4、11.5→7.8)。 しかし単スレッド同士でも 7.8〜14.4 倍の差が残る。これは SIMD・アルゴリズム・dtype による本物の差であり、 「ネイティブカーネルに落とす価値がある」という結論自体は単スレッド条件でも成立する。 実際、パイプライン全体の単スレッド実測(§5.5)でも 1 コアで 25.9 ms(studio 経路 240 ms に対し約 9 倍)である。
★測定環境の汚染について(正直に): この再測定時、同一マシンで調査用のエージェントが並列に走っており、 scipy gaussian の絶対値が 54.8 ms → 95.5 ms に悪化している。倍率は絶対値より頑健だが、 本記録の絶対値は負荷のある環境で取られたものを含む。クリーンな環境での再測定を要する (§6 の未測定項目に追加)。
読み取れること
_norm01)。
カメラが出す uint8 のまま扱えば同じ gaussian が 14〜23 倍速い(スレッド条件による)。connectedComponentsWithStats は面積と重心を 1 パスで返す。scipy 経路の「label してから特徴量を別に計算する」
構造そのものが 21 倍の差を生んでいる。これは API 設計の差であり、言語の差ではない。再現: 20000 反復 x 3 statement の制御ループを fscript と CPython で比較
| 時間 | 1 statement あたり | スループット | |
|---|---|---|---|
fscript AST インタプリタ |
40.3 ms | 0.67 us | 1.49 M stmt/s |
| CPython(等価な Python コード) | 0.44 ms | 0.007 us | 136 M stmt/s |
言語層は CPython の約 91 倍遅い。 ただし絶対値で評価すること:
for で画素を舐めた瞬間に破綻する
(0.67 us/stmt x 4M 画素 = 45 分)。→ 言語仕様として画素ループを書けなくする(op に落とす)ことが要件。0.67 us/stmt から逆算すると:
| サイクル予算 | 言語層が 10% を占める statement 数 | 言語層が 50% を占める statement 数 |
|---|---|---|
| 10 ms | 約 1,500 | 約 7,500 |
| 50 ms | 約 7,500 | 約 37,000 |
| 100 ms | 約 15,000 | 約 75,000 |
→ 1 サイクル 1,500 statement までなら、10 ms サイクルでも言語層は 10% 未満。
検査スクリプトの実態(数十〜数百 statement)から見て桁で余裕がある。
逆に「500 個の測定領域を per-object で舐める」ような制御重視スクリプトは 1,500 に近づくので、
per-object 反復は言語の for ではなく ObjectSet のベクトル化 op に落とす設計を要件に含める
(これは性能だけでなく §0 の 5.8x とも同じ結論)。
bytecode VM 化はこの数字を数倍改善しうるが、サイクルタイム上の動機は無い。 VM 化の動機は速度ではなく step/breakpoint/ソース span/ウォッチの実装しやすさである(正直に区別する)。
再現: py -3.11 -X importtime -c "import api" / subprocess 3 回の最小値
| 時間 | |
|---|---|
| Python 起動のみ | 15 ms |
| + numpy | 80 ms |
| + numpy, scipy.ndimage | 152 ms |
+ fullseye api(654 op レジストリ) |
1817 ms |
import fscript 単体 |
82 ms |
import api の内訳: torch が 800 ms(ops.py → backends_kornia → kornia → torch が eager import)。
含意
fslib.py PoC)| 起動 | |
|---|---|
Runtime プロファイル import fslib(numpy + scipy) |
147 ms |
Runtime プロファイル import fslib, cv2 |
160 ms |
Studio プロファイル import api(650 op レジストリ) |
1663 ms |
10.4 倍の改善。160 ms なら、ウォッチドッグによるプロセス再起動が 0.2 秒で完了する
(= 障害復旧が実運用に耐える)。これは「Runtime を Studio と分ける」判断の直接の裏付けであり、
fslib.py が 650 op レジストリを import しない設計にした理由でもある。
| パッケージ | インストール実測 |
|---|---|
| numpy | 31.7 MB |
| scipy | 157.0 MB |
| cv2 (opencv-python) | 137.6 MB |
| 小計(産業ランタイム最小) | 326 MB |
| Python stdlib + exe | 49 MB |
| Runtime 合計(目安) | ~375 MB |
| PySide6(Studio のみ) | 634 MB |
| torch(Studio/研究のみ) | 4487 MB |
含意: 顧客 PC に Python をインストールさせない「閉じた Runtime イメージ」は ~400 MB で構成できる。 商用ビジョンランタイムと同程度で、配布サイズは差別化要因にならない。 torch と PySide6 を Runtime に混ぜないことがサイズ要件の全て。
fslib.py PoC — プロファイル切替の実測再現: docs/FSCRIPT_DECISION.md §1.7 のスクリプト(gauss → threshold → connection → select_shape → region_features)
| 構成 | studio / reference(numpy) |
industrial(cv2) |
倍率 |
|---|---|---|---|
| 1024x1024 / 50 blob | 58.6 ms | 5.42 ms | 10.8x |
| 2048x2048 / 200 blob | 240.2 ms | 16.94 ms | 14.2x |
同じスクリプト・同じ API・同じ結果(差分テストで一致を保証)で、profile を切り替えただけ。
★PoC 構築中に見つかった設計要件: 最初の版は 4 MP で 41.7 ms だった。
原因は API の形状が連結成分パスを 3 回走らせていたこと
(connection で 1 回、select_shape の測定で 2 回目、最終取得で 3 回目)。
→ ObjectSet が測定済み特徴量を id 索引で保持して運ぶことを要件に加え、41.7 → 16.9 ms。
オブジェクトモデルは「形」だけでなく「測定結果の持ち回り」まで含めて設計する必要がある。
正直な残差: 手書き cv2 の 10.8 ms に対し PoC は 16.9 ms。
差の原因は特定済み(threshold が u8 上で numpy 比較 2 回 + AND、connection で uint8 変換のコピー)。
アーキテクチャの限界ではなく実装の詰めしろ。
「産業 PC(低クロック・少コア)では数倍悪化しうる」という限界を、測定可能な形に変換した。
fslib の industrial プロファイルで 4 MP / 200 blob を、OpenCV のスレッド数を絞って実測
(測定機 = 24 コア):
| スレッド数 | p50 | p99 | max | 対 24 スレッド |
|---|---|---|---|---|
| 24(既定 = 全コア) | 16.66 ms | 17.45 | 19.08 | 1.00x |
| 4 | 18.53 ms | 19.58 | 19.69 | 1.11x |
| 2 | 21.62 ms | 22.29 | 22.49 | 1.30x |
| 1 | 25.92 ms | 27.83 | 27.88 | 1.56x |
読み取れること(重要)
domain / reduce_domain による ROI 限定)、(b) パス数を減らす、(c) dtype を小さくする。
→ HALCON の「Image は domain を内包する」モデルを型に入れる判断は、意味論だけでなく性能上も正しい。再現: py -3.11 tools/bench_soak.py --minutes 25 --bucket 400 --json out/bench_soak_25min.json
条件: fslib industrial プロファイル、2048x2048 / 200 blob、温まった状態から開始
| 指標 | 結果 |
|---|---|
| 総サイクル | 52,800(25.0 分) |
| p50 のドリフト | 前半 25.98 ms → 後半 26.26 ms = 実質ゼロ(全体でも 26.47 → 23.89 = −9.8%) |
| RSS のドリフト | 98.9 → 100.0 MB = +1.6 MB / 52,800 サイクル ≒ 30 バイト/サイクル |
| GC 収集回数 | 52,800 サイクルで 7 回 |
| p50 | 中央 26.07 ms(範囲 22.02〜33.82) |
| p99 | 中央 55.65 ms(範囲 40.09〜87.89)= p50 の 2.1 倍 |
| max | バケット max の中央 77.28 ms、全体最大 118.72 ms = p50 の 4.6 倍 |
p50 26 ms に対し、52,800 回中の最悪は 118.7 ms(4.6 倍)。
製造業に提示できるのは p50 ではなく最悪値なので、現状の実力は「4 MP 検査 ≒ 最悪 120 ms」であり、 「26 ms」ではない。裾を詰めることが、平均を速くすることより優先度が高い。
裾の原因は未診断(候補: OS スケジューリング / 他プロセス / アロケータ / cv2 スレッドプール / ページフォールト /
timeBeginPeriod 未設定)。次に測るべきはここであり、本記録では原因を断定しない。
なお §1 の 5000 サイクル(native_cv2、max 16.7 ms)と本測定(52,800 サイクル、max 118.7 ms)の差は、
(a) 冷 vs 温、(b) 手書き cv2 vs fslib(numpy 比較 + uint8 変換が入る)、(c) 試行回数 10 倍
の 3 つが混ざっている。裾は試行回数を増やすほど悪化して見えるという当然の性質も含む。
この記録は設計判断に必要な最小限しか測っていない。以下は未測定であり、主張に使ってはいけない:
timeBeginPeriod 未設定)。
次に測るべき最優先項目。製造業に出せるのは p50 でなく最悪値なので、ここが実力を決める測定の過程で、fscript.py に 黙って誤った値を返す欠陥が 5 件見つかった。
いずれも「言語が自前の型モデルを持たず numpy/Python の意味論を継承している」ことが根因で、
実装言語を変えても残る。回帰テスト = tests/test_fscript.py。
★I-2(2026-08-15)で 5 件すべて封鎖済 = fscript を fslib の型モデルへ載せ替え(全スイート 4372→4377 passed)。
| # | 欠陥 | 症状 | 状態 |
|---|---|---|---|
| 1 | * を行途中でもコメント扱い |
A := I * 2 が A := I になりエラーも出ない |
修正済(行頭のみコメント) |
| 2 | _norm01 が画像の最大値で正規化 |
隅の明るい画素 1 個で同じ部品の area が 256 → 1 に変わる | 修正済(I-2) = FImage が値域を運ぶ |
| 3 | 数値 Tuple の + が連結 |
[1,2,3]+[10,20,30] = 連結。HALCON は要素和 [11,22,33] |
修正済(I-2) = + 要素ごと・連結は [t1,t2] |
| 4 | iconic が条件式で暗黙に真偽化 | if (Region) が .any() として通る |
修正済(I-2) = iconic は真偽値なし→エラー |
| 5 | 比較が配列を返し条件で潰れる | if (Image = 0) が「1 画素でも 0 なら真」 |
修正済(I-2) = iconic の比較はエラー |
さらに value_kind が画素の中身から型を推測していた(相異なる値が 2 個以下なら Region)欠陥も
I-2 で型ベースに修正(2 値のグレースケール画像を Region と誤判定しない)。→ 型は運ぶもので推測するものではない。
欠陥 2 が最も重かった: 実ラインの正反射・ホットピクセルで判定が黙って反転していた。 これは性能問題ではなく信頼性問題であり、ネイティブ化より優先度が高いと判断し I-2 で最初に閉じた。
正本の設計判断は docs/FSCRIPT_LANGUAGE.md へ反映する。
背景: 決定案 R4 は timeBeginPeriod(1)(Windows タイマ分解能)を裾の最大レバーと位置づけた
(参照測定で max 8.905 → 0.340 ms = 26x)。そこで tools/bench_soak.py に
timer_resolution(1) コンテキスト(--timer-resolution/--no-timer-resolution、既定 ON)を追加し、
実検査パイプラインで A/B した(2026-08-15、この PC、cv2 studio→industrial 相当、GC 発火 0)。
| 構成 | タイマ | p50 | p99 | p99.9 | max | サイクル |
|---|---|---|---|---|---|---|
| 1024²×50 | OFF | 5.50 | 6.21 | 6.50 | 6.65 | 4,000 |
| 1024²×50 | ON | 5.46 | 6.09 | 6.30 | 6.46 | 4,000 |
| 2048²×200 | OFF | 17.07 | 18.31 | 18.77 | 18.81 | 4,800 |
| 2048²×200 | ON | 16.98 | 18.35 | 18.62 | 18.67 | 4,800 |
正直な結論(過大主張しない):
★この節は当初「原因を特定」「唯一の確実策」「DEFINITIVE」と書いたが、自分の敵対検証(4 レンズ WF)で **複数の overclaim を摘発し、以下に格下げ・訂正した([[feedback_benchmark_honest_disclosure]] / [[feedback_no_false_reporting]])。 データ(下表)は実測だが、そこから引ける結論は当初主張より弱い**。
tools/bench_soak.py --diagnose(op 別内訳 + ページフォールト)で 4 条件を測定(4MP×200・GC 実質ゼロ、各 N=1):
| 条件 | p50 | max(最遅 cycle) | jitter | 備考 |
|---|---|---|---|---|
| クリーン(アイドル機・54,000 cycle/15分) | 17.0 | 21.0 | 1.24x | p50 ドリフト +0.4%・リークなし・GC 3 回。計装なしでも max ~18.9ms(独立確認) |
| 18 burner(6 コア空き) | 18.0 | 21.7 | 1.20x | コアに余裕があれば裾は不変 |
| 24 burner(全コア飽和) | — | ~73 | — | cv2 系が膨張: gauss 0.9→~25ms / connection 12→40-63ms |
24 burner + cv2-threads=1 |
~32 | ~57 | 1.8x | 単スレッド化で裾は縮むが p50 上昇 |
★所見(honest・確度を明記):
cv2.setNumThreads 抑制: 飽和下で弱く noisy(N=1)。優先度(SetPriorityClass HIGH)は本環境で未適用=未検証。timer は §9 の通り実検査で効かず(ただし飽和条件では未試験)。