perf(semseg): in-process numpy mask fast path (response_mask_format) - #2729
perf(semseg): in-process numpy mask fast path (response_mask_format)#2729theo-roboflow wants to merge 1 commit into
Conversation
2a8944a to
eec8095
Compare
517fca9 to
773d1b2
Compare
eec8095 to
d0cafe1
Compare
773d1b2 to
f6bcbf1
Compare
d0cafe1 to
8bb7c04
Compare
|
Memory impact review (item 4 of the reviewer checklist — analysis verified against the code, plus measurements) What changes hands, per frame at 16.13 MP:
Measured (x86,
The numpy fast path has the lowest peak (the encode/decode intermediates are gone) and, like the others, retains nothing across iterations. On-device (Jetson AGX Orin 64 GB, container-level telemetry, same 16.1 MP stream + yolo26n-sem TRT): the image carrying both PRs (native overlay on 1.3.7) held 3.69–3.72 GB flat across ~16 h of continuous streaming, vs a 3.3–3.9 GB envelope for the baseline-image windows earlier the same week — within noise, no drift. A fresh controlled A/B runs together with the queued percentile re-measurement. 🤖 Generated with Claude Code |
f6bcbf1 to
1cf15e5
Compare
8bb7c04 to
7d6ece0
Compare
|
On-device percentile tables (completes item 3 of the reviewer checklist — measured 2026-07-31 on the target device) Setup: Jetson AGX Orin 64 GB (JetPack 6.2), 5320×3032 (16.13 MP) RTSP source at ~7 fps (on-device simulator replaying real Basler area-scan footage), model End-to-end wall per frame (baseline converged at 1,020 frames; PR at 3,520):
(Earlier plain-fps runs with the profiler off sustained 4.48–4.88; the tail speedup is slightly below the mean speedup because the surviving stages — GPU post-processing and RLE — carry proportionally more of their own variance once the PNG round-trip is gone.) Per stage, ms/frame (mean | p50 / p95 / p99):
¹ per-call percentiles (two mask encodes per frame); the mean is the per-frame total. ² the PR path's Inside Memory (same hour, fresh containers, identical model/stream): inference-container steady state 3244–3344 MB (baseline window) vs 3133–3205 MB (PR window) — the PR image sits slightly lower while sustaining 4.4× the frame rate, consistent with the analysis in the memory-impact comment above (encode/decode intermediates gone). Raw profiler log blocks and the exact measurement runbook are archived with the branch ( 🤖 Generated with Claude Code |
The semantic segmentation adapter PNG-encodes + base64s two full-resolution masks only for the in-process workflows block to immediately base64-decode + PNG-decode them. At 16.1MP on a Jetson AGX Orin that round-trip measured ~512ms/frame (PIL.save 382ms + cv2.imdecode 121ms + base64) while the model forward was 2.8ms. SemanticSegmentationInferenceRequest gains response_mask_format: 'base64_png' (default, unchanged wire behavior) | 'numpy'. With 'numpy', the model returns the raw uint8 arrays in the mask fields and the workflow block consumes them directly - no PNG anywhere on the in-process path. Implemented for both the inference-models adapter and the legacy ONNX semantic segmentation base. 'numpy' is an in-process contract and every serialization boundary keeps producing base64 PNG strings: - the HTTP layer (process_inference_request) and the enterprise parallel postprocess task coerce 'numpy' back to 'base64_png', since both serialize via python-mode model_dump + orjson/json where field serializers do not run; - a when_used='json' field serializer on the prediction entity lazily encodes ndarray masks for any json-mode serialization (model_dump_json / FastAPI response_model), keeping the documented string schema (json_schema_extra pins type: string). Active learning only registers on the HTTP path (active_learning_eligible), which is coerced above, so it never sees ndarray masks. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1cf15e5 to
2adf96b
Compare
7d6ece0 to
8867b03
Compare
|
Orin-native Nsight Systems traces (profiling-skill follow-up — same snippets, now captured on the target device) Both profiling snippets (
¹ the dual-mask design deliberately pays two cheap C-order Artifacts: 🤖 Generated with Claude Code |
Motivation
Stacked on #2728. In-process, the semantic segmentation model PNG-encodes + base64s two full-resolution masks only for the workflows block to immediately base64-decode + PNG-decode them. At 16.1 MP on a Jetson AGX Orin that round-trip measured ~512 ms/frame (
PIL.save382 ms +cv2.imdecode121 ms + base64/framing) while the model forward was 2.8 ms. Base64 itself and the GPU→CPU copy are nearly free — it is PNG compression and decompression that costs.What changed
SemanticSegmentationInferenceRequestgainsresponse_mask_format: "base64_png" (default) | "numpy", following the existingresponse_mask_formatprecedent on the instance segmentation request. With"numpy", the model puts the raw uint8 arrays in the mask fields and the workflow block (v1 + v2, which now request it inrun_locally) consumes them directly — no PNG anywhere on the in-process path. Implemented for both the inference-models adapter and the legacy ONNX semantic segmentation base.Wire-contract safety
"numpy"is an in-process contract; every serialization boundary still produces base64 PNG strings:process_inference_request) and the enterprise parallel postprocess task coerce"numpy"→"base64_png"before inference. Both paths serialize via python-modemodel_dump+ orjson/stdlib-json, where pydantic field serializers do not run — without the coercion an opted-in HTTP caller would have received nested int arrays (orjsonOPT_SERIALIZE_NUMPY). Found by adversarial review, fixed at the boundary.json_schema_extrapinstype: string) and awhen_used="json"field serializer lazily encodes ndarray values to base64 PNG for any json-mode serialization (model_dump_json, FastAPIresponse_model), so even a"numpy"response that reaches JSON is encoded correctly.active_learning_eligible), which is coerced above — it never sees ndarray masks. Workflow blocks callinfer_from_request_syncwithout that flag.run_remotelyis untouched: the SDK client doesn't send the field, servers default to base64.Trade-off note: the mask fields' python type widens from
strtoAny(schema unchanged). In-process consumers that python-dump a"numpy"response and hand it to a serializer that can't handle ndarrays would need the json-mode dump — only relevant to callers who explicitly opt in.Measured (on-device, 16.1 MP, Jetson AGX Orin, 600+ frames converged)
Validated via a byte-identical
custom_modulesshim in roboflow-edge (branchclaude/semseg-perf-fixes-4aa779) before porting here. Full stack (this PR + #2728):png_b64_encodedisappears entirely from the per-stage profile;sv_convertdrops from 500 ms to 140 ms (remaining: RLE encode 40 ms + one F-order conversion + per-class ops).Correctness
test_v1.py/test_v2.py).model_dump_jsonproduces base64 PNG that decodes back pixel-identical; string masks serialize unchanged (test_semantic_segmentation_masks.py).test_inference_models_adapters.py).Review round (applied)
An adversarial multi-agent review pass was run over both PRs before marking ready; fixes applied here:
numpy→base64_pngboundary coercion now lives in one owned helper,ensure_wire_safe_mask_format(entities/requests), called by bothprocess_inference_requestand the enterprise parallelpostprocesstask, and it logs a warning when it downgrades an explicit request (matching thebase.pydropped-confidenceprecedent). Unit tests cover coercion, default, and non-semseg no-op.when_used="json"field serializer covers json-mode dumps only — the python-dump + orjson wire boundaries do NOT run it, which is exactly why the boundary coercion is load-bearing. (Without it,orjson'sOPT_SERIALIZE_NUMPYwould ship a leaked mask as a giant nested int array with a 200.)Reproduction scripts + percentile table (reviewer-requested round)
The benchmark and parity scripts behind the numbers in this stack are committed in the base PR (#2728) under
development/benchmarks/semantic_segmentation/and run unchanged on this branch, where thepr-numpymode (this PR's fast path) activates via feature detection:benchmark_postprocessing.py— per-frame latency of the model-side mask encode and the block's_convert_to_sv_detections, with mean/p50/p95/p99 per stage, inbaseline/pr-base64(perf(semseg): present_class_ids hint + single F-order conversion in workflow post-processing #2728 only) /pr-numpy(this PR) modes; synthetic 16.1 MP input generator with full metadata in the report,--label-map/--confidence-mapfor real frames,--memoryfor per-mode subprocess memory measurement.check_output_parity.py— byte-identical output check (RLEcounts/size, xyxy, class ids/names, float32-exact confidences,confidence_mask) against the pre-PR reference across transport × hint combinations — includingnumpy-transport vsbase64-transport equality this PR guarantees — on a full 16.13 MP map, non-zero exit on mismatch. Latest run at this head: ALL PARITY CHECKS PASSED.x86 percentile table (isolated post-processing path)
Input: synthetic 5320×3032 (16.13 MP) uint8 label map, 4 foreground classes (44.9% fg pixels), PNG+base64 payloads 227 KiB (seg) / 125 KiB (conf), seed 42. Host: macOS arm64 (Apple silicon, 18 cores), Python 3.11.14, numpy 2.3.5, OpenCV 4.10.0, Pillow 12.3.0, torch 2.13.0 (CPU). 3 warmup + 100 measured iterations per mode, tree = this head:
Reading notes:
pr-numpy'smodel_sideis purely thepresent_class_idsbincount, which on this CPU-only host runs on CPU (~25 ms) but on the CUDA/TRT device runs where the tensor lives.Open questions for maintainers
"numpy"be in the public schema at all? Today it's a public enum value that HTTP silently (but now loudly-logged) coerces. Alternatives: model it as an internal field excluded from the schema (thestream_pipeline_context_idpattern), or return 422 over HTTP. Current draft keeps it public + coerce + log; happy to switch.response_mask_formatcollides with the instance-seg field of the same name but a disjoint domain (polygon|rle), whichinference_sdk.InferenceConfigurationalso types with the instance-seg vocabulary. Also note: an out-of-domain value in a semseg body was previously ignored (pydantic default) and now 422s. Rename (mask_transport?mask_encoding?) or keep?Anytyping trade-off. The mask fields' python type widensstr→Any(wire schema pinned to string viajson_schema_extra), which drops construction-time validation; the lazy encoder also usesnp.asarray(..., uint8)(silent wrap) where the eagerimg_to_b64_strraises on non-uint8. Afield_validatoraccepting onlystr | uint8 ndarraywould restore fail-fast — worth it?img_to_b64_str+ the entity serializer). A shareduint8_mask_to_base64_pnghelper would prevent byte-level drift between eager and lazy encodings.TINY_CACHE=Falseinteraction. With that non-default setting, the model-monitoring cache write runsjsonable_encoder(json mode) per frame, which re-triggers the full-resolution PNG encode the fast path removes. DefaultTINY_CACHE=Trueis unaffected. Document, or strip mask fields from semseg cache payloads?TestClientroute test, and nothing pins thatresponse_mask_format="numpy"survives therequest.dict()→postprocess(**kwargs)chain end-to-end (if a link dropped it, the block would silently fall back to base64 with byte-identical outputs — only the perf win disappears). Both are straightforward follow-ups if wanted before merge.Native on-device validation (no shim)
To rule out shim artifacts, both PR commits were cherry-picked onto the
v1.3.7tag (branchtheo/semseg-prs-on-1.3.7— a clean pick, confirming the touched files are identical between 1.3.7 and current main) and the 9 changed files were overlaid onto the device's 1.3.7 image directly, no monkey-patching involved. Same Jetson AGX Orin, same 16.1 MP source, and the same yolo26n model version as the baseline row, on TRT from the cached engine (warmup_backends_resolved→backend: trt; theexecution_environmentevent confirms the overlaid adapter loaded the model):sv_convertpng_b64_encode2,240 frames converged; the per-class-ops residual inside
sv_convertmatches the shim run to 0.1 ms (65.8 ms both). Also reproduced on a second model version (yolo26n-v4: 214.8 ms/frame).🤖 Generated with Claude Code