From 0165c581984b448233ceeba7f40e350c4ee27b9c Mon Sep 17 00:00:00 2001 From: raster Date: Sun, 24 May 2026 22:53:59 +0530 Subject: [PATCH] =?UTF-8?q?release:=20v10.14.0=20=E2=80=94=20JP3D=20parall?= =?UTF-8?q?el=20per-slice=20encode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first JP3D encoder speedup of the v10 arc. JP3DSliceStackCodec's per-slice 2D encode loop was sequential since shipping; v10.14.0 splits it into sequential slices 0-1 (commits the M6/M7 Z-delta tile-mode decision) + parallel slices 2..N via TaskGroup. JP3D encode wins (M2 release, --jp3d bench, in-process, 7 runs / 2 warmups, median): Fixture vx enc ser enc par Δ ms ratio ---------------------------------------------------------------- mr_3d_small 262K 11.84 7.58 −4.27 1.56x ct_3d_small 1.05M 33.92 26.17 −7.75 1.30x us_3d_small 1.84M 58.58 40.73 −17.85 1.44x mr_3d_mid 2.10M 71.59 65.68 −5.91 1.09x ct_3d_mid 8.39M 295.58 246.98 −48.60 1.20x ct_3d_large 16.78M 615.94 539.95 −75.99 1.14x All 6/6 fixtures clear the 3 ms acceptance threshold. Smallest fixture shows the largest relative speedup (1.56× on mr_3d_small) because the per-slice fixed cost amortises better; largest fixture wins 76 ms absolute on the radiologist-relevant 16M-voxel CT. Sub-linear vs N=8 cores because: - Z-delta probe is serialized - Slices 0-1 are sequential - Memory contention on the 16M-voxel CT writes 33 MB of output Architectural change: - Per-slice body extracted into encodeOneSlice(...) instance method so sequential + parallel paths share the same logic. - From slice 2 onward neither tile-mode flag mutates, so the parallel closure captures the committed flags by value. - Z-delta correctness preserved: residual depends on raw INPUT bytes, not on the encoded codestream of the previous slice. Combined with v10.11-v10.13 decode wins: round-trip on the 16M-voxel CT is ~3.5× faster vs v10.10.0. Validation: - swift test --filter JP3D regression 519/519 PASS (codestream bytes-identical to v10.13.0; parallel encode = serial encode under the same Z-delta gating) - Mandatory commit gate (release mode) 7/7 PASS Codestream bytes byte-identical to v10.13.0; decoder unchanged. MINOR per RELEASING.md — internal refactor only, same encoder API and output bytes. Opt-out via J2K_JP3D_PARALLEL_ENCODE=0. Co-Authored-By: Claude Opus 4.7 (1M context) --- ...arm64-v10_23-parallel-encode-20260524.json | 487 ++++++++++++++++++ ...h-arm64-v10_23-serial-encode-20260524.json | 487 ++++++++++++++++++ README.md | 8 +- RELEASE_NOTES_v10.14.0.md | 157 ++++++ Sources/J2K3D/JP3DSliceStackCodec.swift | 186 +++++-- 5 files changed, 1270 insertions(+), 55 deletions(-) create mode 100644 Documentation/Benchmarks/data/jp3d-bench-arm64-v10_23-parallel-encode-20260524.json create mode 100644 Documentation/Benchmarks/data/jp3d-bench-arm64-v10_23-serial-encode-20260524.json create mode 100644 RELEASE_NOTES_v10.14.0.md diff --git a/Documentation/Benchmarks/data/jp3d-bench-arm64-v10_23-parallel-encode-20260524.json b/Documentation/Benchmarks/data/jp3d-bench-arm64-v10_23-parallel-encode-20260524.json new file mode 100644 index 0000000..ae10b95 --- /dev/null +++ b/Documentation/Benchmarks/data/jp3d-bench-arm64-v10_23-parallel-encode-20260524.json @@ -0,0 +1,487 @@ +{ + "decode" : { + "ct_3d_large" : { + "depth" : 64, + "fixture" : "ct_3d_large", + "height" : 512, + "label" : "CT 512×512×64", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 658.72316599999999, + "median" : 650.45399999999995, + "min" : 641.165708, + "samples" : [ + 658.72316599999999, + 644.11629200000004, + 641.165708, + 650.45399999999995, + 649.86445800000001, + 656.66558399999997, + 651.07100000000003 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 245.42349999999999, + "median" : 237.47304199999999, + "min" : 225.86025000000001, + "samples" : [ + 228.84033400000001, + 227.42083299999999, + 225.86025000000001, + 245.36808300000001, + 245.42349999999999, + 241.42666700000001, + 237.47304199999999 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 172.415042, + "median" : 168.79833400000001, + "min" : 159.81254200000001, + "samples" : [ + 164.712875, + 170.95012500000001, + 161.66833299999999, + 168.79833400000001, + 171.16995800000001, + 172.415042, + 159.81254200000001 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_mid" : { + "depth" : 32, + "fixture" : "ct_3d_mid", + "height" : 512, + "label" : "CT 512×512×32", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 325.26887499999998, + "median" : 320.743875, + "min" : 317.13287500000001, + "samples" : [ + 325.26887499999998, + 319.147041, + 321.51358299999998, + 317.13287500000001, + 323.958958, + 320.743875, + 320.70325000000003 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 115.354333, + "median" : 113.149833, + "min" : 109.93508300000001, + "samples" : [ + 113.05820799999999, + 115.354333, + 109.93508300000001, + 113.188834, + 112.93425000000001, + 113.149833, + 113.405292 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 79.673874999999995, + "median" : 77.288708, + "min" : 74.691165999999996, + "samples" : [ + 76.197625000000002, + 75.391166999999996, + 78.730416000000005, + 79.673874999999995, + 74.691165999999996, + 78.584541999999999, + 77.288708 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_small" : { + "depth" : 16, + "fixture" : "ct_3d_small", + "height" : 256, + "label" : "CT 256×256×16", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 42.211540999999997, + "median" : 39.987541, + "min" : 39.632083999999999, + "samples" : [ + 39.987541, + 39.880583999999999, + 42.211540999999997, + 39.632083999999999, + 40.796999999999997, + 40.649583, + 39.960957999999998 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 14.614125, + "median" : 14.38725, + "min" : 14.202500000000001, + "samples" : [ + 14.597167000000001, + 14.614125, + 14.38725, + 14.35375, + 14.202500000000001, + 14.519666000000001, + 14.384667 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 9.6501249999999992, + "median" : 9.326041, + "min" : 9.1549160000000001, + "samples" : [ + 9.6501249999999992, + 9.3424580000000006, + 9.5827080000000002, + 9.2874580000000009, + 9.1549160000000001, + 9.326041, + 9.2784999999999993 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_mid" : { + "depth" : 32, + "fixture" : "mr_3d_mid", + "height" : 256, + "label" : "MR 256×256×32", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 82.29325, + "median" : 79.948625000000007, + "min" : 79.607917, + "samples" : [ + 79.880916999999997, + 79.723042000000007, + 79.948625000000007, + 79.607917, + 81.889042000000003, + 82.29325, + 81.971041 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 29.967040999999998, + "median" : 29.504542000000001, + "min" : 27.929957999999999, + "samples" : [ + 29.001957999999998, + 27.929957999999999, + 29.556459, + 29.504542000000001, + 28.809083000000001, + 29.967040999999998, + 29.555166 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 19.524999999999999, + "median" : 19.218667, + "min" : 18.666041, + "samples" : [ + 19.524750000000001, + 18.666041, + 19.214666999999999, + 19.524999999999999, + 19.329792000000001, + 18.860125, + 19.218667 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_small" : { + "depth" : 16, + "fixture" : "mr_3d_small", + "height" : 128, + "label" : "MR 128×128×16", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 15.405167, + "median" : 14.42825, + "min" : 14.123333000000001, + "samples" : [ + 14.123333000000001, + 14.42825, + 14.400290999999999, + 14.189541, + 14.493917, + 15.405167, + 14.994458 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 7.6132499999999999, + "median" : 7.2455410000000002, + "min" : 7.0307909999999998, + "samples" : [ + 7.5888340000000003, + 7.6132499999999999, + 7.2455410000000002, + 7.0480830000000001, + 7.3259999999999996, + 7.201708, + 7.0307909999999998 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 4.4592080000000003, + "median" : 4.2787499999999996, + "min" : 3.980667, + "samples" : [ + 4.2592499999999998, + 4.4592080000000003, + 4.2787499999999996, + 4.2997920000000001, + 3.980667, + 4.0906669999999998, + 4.3074170000000001 + ] + } + }, + "source" : "synthetic", + "width" : 128 + }, + "us_3d_small" : { + "depth" : 24, + "fixture" : "us_3d_small", + "height" : 240, + "label" : "US 320×240×24", + "modality" : "US", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 59.673583000000001, + "median" : 57.787084, + "min" : 55.551250000000003, + "samples" : [ + 55.551250000000003, + 56.834000000000003, + 59.673583000000001, + 58.707166000000001, + 57.655040999999997, + 57.834000000000003, + 57.787084 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 26.239000000000001, + "median" : 25.308499999999999, + "min" : 24.847207999999998, + "samples" : [ + 24.847207999999998, + 26.239000000000001, + 25.406416, + 26.181125000000002, + 25.190708999999998, + 25.308499999999999, + 24.967040999999998 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 13.621541000000001, + "median" : 13.180917000000001, + "min" : 11.25625, + "samples" : [ + 11.25625, + 12.632875, + 13.180917000000001, + 13.3285, + 13.396208, + 13.621541000000001, + 13.165417 + ] + } + }, + "source" : "synthetic", + "width" : 320 + } + }, + "encode" : { + "ct_3d_large" : { + "depth" : 64, + "fixture" : "ct_3d_large", + "height" : 512, + "label" : "CT 512×512×64", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 592.09066700000005, + "median" : 539.95037500000001, + "min" : 493.45779199999998, + "samples" : [ + 580.93579099999999, + 539.95037500000001, + 550.22470799999996, + 493.45779199999998, + 501.37841600000002, + 511.60683299999999, + 592.09066700000005 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_mid" : { + "depth" : 32, + "fixture" : "ct_3d_mid", + "height" : 512, + "label" : "CT 512×512×32", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 283.78691700000002, + "median" : 246.983542, + "min" : 220.77433300000001, + "samples" : [ + 220.77433300000001, + 244.171334, + 246.983542, + 246.6465, + 271.41095899999999, + 252.63499999999999, + 283.78691700000002 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_small" : { + "depth" : 16, + "fixture" : "ct_3d_small", + "height" : 256, + "label" : "CT 256×256×16", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 32.365000000000002, + "median" : 26.170417, + "min" : 24.796208, + "samples" : [ + 31.49175, + 26.095458000000001, + 27.471333000000001, + 32.365000000000002, + 26.170417, + 25.760999999999999, + 24.796208 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_mid" : { + "depth" : 32, + "fixture" : "mr_3d_mid", + "height" : 256, + "label" : "MR 256×256×32", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 74.475125000000006, + "median" : 65.678250000000006, + "min" : 50.984917000000003, + "samples" : [ + 74.475125000000006, + 50.984917000000003, + 57.731625000000001, + 65.678250000000006, + 59.741917000000001, + 65.775166999999996, + 65.862875000000003 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_small" : { + "depth" : 16, + "fixture" : "mr_3d_small", + "height" : 128, + "label" : "MR 128×128×16", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 9.0700830000000003, + "median" : 7.5780839999999996, + "min" : 7.1217090000000001, + "samples" : [ + 9.0700830000000003, + 8.4596250000000008, + 8.1082920000000005, + 7.5780839999999996, + 7.3155840000000003, + 7.1217090000000001, + 7.1858750000000002 + ] + } + }, + "source" : "synthetic", + "width" : 128 + }, + "us_3d_small" : { + "depth" : 24, + "fixture" : "us_3d_small", + "height" : 240, + "label" : "US 320×240×24", + "modality" : "US", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 48.250208000000001, + "median" : 40.730499999999999, + "min" : 38.542665999999997, + "samples" : [ + 38.542665999999997, + 38.663207999999997, + 39.643374999999999, + 40.730499999999999, + 41.188291999999997, + 48.250208000000001, + 42.455416 + ] + } + }, + "source" : "synthetic", + "width" : 320 + } + }, + "host" : { + "brand" : "Apple M2", + "machine" : "arm64", + "model" : "Mac14,2", + "ncpu" : 8, + "release" : "15.7.5", + "system" : "Darwin" + }, + "j2k_version" : "J2KSwift version 10.9.2", + "lane" : "inproc-jp3d", + "runs" : 7, + "schema" : "warm-inproc-jp3d-v1", + "warmups" : 2 +} \ No newline at end of file diff --git a/Documentation/Benchmarks/data/jp3d-bench-arm64-v10_23-serial-encode-20260524.json b/Documentation/Benchmarks/data/jp3d-bench-arm64-v10_23-serial-encode-20260524.json new file mode 100644 index 0000000..a647a22 --- /dev/null +++ b/Documentation/Benchmarks/data/jp3d-bench-arm64-v10_23-serial-encode-20260524.json @@ -0,0 +1,487 @@ +{ + "decode" : { + "ct_3d_large" : { + "depth" : 64, + "fixture" : "ct_3d_large", + "height" : 512, + "label" : "CT 512×512×64", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 712.98287500000004, + "median" : 685.65258300000005, + "min" : 677.26462500000002, + "samples" : [ + 712.98287500000004, + 690.66133300000001, + 684.30329200000006, + 677.26462500000002, + 681.26549999999997, + 685.71141699999998, + 685.65258300000005 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 238.358125, + "median" : 232.25120799999999, + "min" : 227.02879200000001, + "samples" : [ + 234.00120799999999, + 228.29320799999999, + 229.708125, + 227.02879200000001, + 232.33237500000001, + 238.358125, + 232.25120799999999 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 174.18712500000001, + "median" : 168.12629200000001, + "min" : 164.000125, + "samples" : [ + 164.836083, + 164.000125, + 164.787542, + 168.12629200000001, + 170.076292, + 170.03912500000001, + 174.18712500000001 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_mid" : { + "depth" : 32, + "fixture" : "ct_3d_mid", + "height" : 512, + "label" : "CT 512×512×32", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 370.90020800000002, + "median" : 353.62758300000002, + "min" : 342.423833, + "samples" : [ + 370.90020800000002, + 353.62758300000002, + 359.46829200000002, + 357.29229199999997, + 350.15724999999998, + 342.423833, + 343.01308299999999 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 117.67525000000001, + "median" : 117.07908399999999, + "min" : 114.029583, + "samples" : [ + 117.65175000000001, + 116.504042, + 114.407, + 117.14700000000001, + 117.67525000000001, + 114.029583, + 117.07908399999999 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 81.545292000000003, + "median" : 79.293791999999996, + "min" : 77.202957999999995, + "samples" : [ + 77.502083999999996, + 78.089042000000006, + 79.293791999999996, + 77.202957999999995, + 80.320125000000004, + 80.030749999999998, + 81.545292000000003 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_small" : { + "depth" : 16, + "fixture" : "ct_3d_small", + "height" : 256, + "label" : "CT 256×256×16", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 42.965291999999998, + "median" : 42.713166999999999, + "min" : 42.513916999999999, + "samples" : [ + 42.844749999999998, + 42.752875000000003, + 42.965291999999998, + 42.688291, + 42.587707999999999, + 42.713166999999999, + 42.513916999999999 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 15.841374999999999, + "median" : 14.958375, + "min" : 14.484624999999999, + "samples" : [ + 14.860167000000001, + 15.413292, + 14.484624999999999, + 14.958375, + 15.841374999999999, + 14.841291, + 15.006167 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 10.180958, + "median" : 9.6911249999999995, + "min" : 9.6467500000000008, + "samples" : [ + 9.846584, + 10.180958, + 10.076000000000001, + 9.6467500000000008, + 9.6672089999999997, + 9.6911249999999995, + 9.6769580000000008 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_mid" : { + "depth" : 32, + "fixture" : "mr_3d_mid", + "height" : 256, + "label" : "MR 256×256×32", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 87.779792, + "median" : 86.839292, + "min" : 85.538916999999998, + "samples" : [ + 87.761792, + 87.779792, + 87.566625000000002, + 86.180542000000003, + 85.538916999999998, + 86.747667000000007, + 86.839292 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 29.867208000000002, + "median" : 29.388209, + "min" : 29.044665999999999, + "samples" : [ + 29.25375, + 29.140584, + 29.388209, + 29.867208000000002, + 29.044665999999999, + 29.486499999999999, + 29.522583000000001 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 21.405457999999999, + "median" : 20.056332999999999, + "min" : 18.982167, + "samples" : [ + 18.982167, + 20.337917000000001, + 21.405457999999999, + 20.429082999999999, + 20.050666, + 19.709917000000001, + 20.056332999999999 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_small" : { + "depth" : 16, + "fixture" : "mr_3d_small", + "height" : 128, + "label" : "MR 128×128×16", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 15.869707999999999, + "median" : 14.859583000000001, + "min" : 14.596333, + "samples" : [ + 14.596333, + 14.901667, + 15.582875, + 14.859583000000001, + 15.869707999999999, + 14.732542, + 14.664291 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 7.6044580000000002, + "median" : 7.1486660000000004, + "min" : 7.0098330000000004, + "samples" : [ + 7.6044580000000002, + 7.3851250000000004, + 7.1717909999999998, + 7.1486660000000004, + 7.0098330000000004, + 7.0331250000000001, + 7.0098750000000001 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 4.2843749999999998, + "median" : 3.9526669999999999, + "min" : 3.89825, + "samples" : [ + 4.2752920000000003, + 3.9431660000000002, + 4.0380000000000003, + 4.2843749999999998, + 3.9526669999999999, + 3.9075839999999999, + 3.89825 + ] + } + }, + "source" : "synthetic", + "width" : 128 + }, + "us_3d_small" : { + "depth" : 24, + "fixture" : "us_3d_small", + "height" : 240, + "label" : "US 320×240×24", + "modality" : "US", + "results" : { + "J2KSwift+jp3d+full" : { + "max" : 62.451542000000003, + "median" : 61.584665999999999, + "min" : 59.496709000000003, + "samples" : [ + 62.243541999999998, + 61.584665999999999, + 61.596499999999999, + 62.451542000000003, + 61.116624999999999, + 60.262459, + 59.496709000000003 + ] + }, + "J2KSwift+jp3d+res1" : { + "max" : 25.815458, + "median" : 25.383125, + "min" : 24.767541000000001, + "samples" : [ + 25.383125, + 25.432749999999999, + 25.139292000000001, + 24.767541000000001, + 25.815458, + 25.459292000000001, + 25.011375000000001 + ] + }, + "J2KSwift+jp3d+roiq" : { + "max" : 12.803042, + "median" : 12.555667, + "min" : 12.06475, + "samples" : [ + 12.2585, + 12.749166000000001, + 12.555667, + 12.06475, + 12.372249999999999, + 12.688583, + 12.803042 + ] + } + }, + "source" : "synthetic", + "width" : 320 + } + }, + "encode" : { + "ct_3d_large" : { + "depth" : 64, + "fixture" : "ct_3d_large", + "height" : 512, + "label" : "CT 512×512×64", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 619.98333400000001, + "median" : 615.94150000000002, + "min" : 605.93041700000003, + "samples" : [ + 605.93041700000003, + 616.07266700000002, + 608.93279199999995, + 615.94150000000002, + 616.05091700000003, + 615.29150000000004, + 619.98333400000001 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_mid" : { + "depth" : 32, + "fixture" : "ct_3d_mid", + "height" : 512, + "label" : "CT 512×512×32", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 314.50120900000002, + "median" : 295.57858399999998, + "min" : 293.369125, + "samples" : [ + 295.57858399999998, + 295.66458399999999, + 293.369125, + 294.17700000000002, + 295.06637499999999, + 314.50120900000002, + 308.54341699999998 + ] + } + }, + "source" : "synthetic", + "width" : 512 + }, + "ct_3d_small" : { + "depth" : 16, + "fixture" : "ct_3d_small", + "height" : 256, + "label" : "CT 256×256×16", + "modality" : "CT", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 34.120375000000003, + "median" : 33.918917, + "min" : 33.436999999999998, + "samples" : [ + 34.067667, + 33.918917, + 34.094040999999997, + 33.740917000000003, + 33.436999999999998, + 33.624000000000002, + 34.120375000000003 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_mid" : { + "depth" : 32, + "fixture" : "mr_3d_mid", + "height" : 256, + "label" : "MR 256×256×32", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 72.091458000000003, + "median" : 71.588083999999995, + "min" : 67.738124999999997, + "samples" : [ + 67.738124999999997, + 69.070082999999997, + 70.128332999999998, + 71.588083999999995, + 72.091458000000003, + 71.956000000000003, + 71.708292 + ] + } + }, + "source" : "synthetic", + "width" : 256 + }, + "mr_3d_small" : { + "depth" : 16, + "fixture" : "mr_3d_small", + "height" : 128, + "label" : "MR 128×128×16", + "modality" : "MR", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 12.358000000000001, + "median" : 11.843500000000001, + "min" : 11.740500000000001, + "samples" : [ + 11.843500000000001, + 11.762917, + 11.907500000000001, + 11.837415999999999, + 12.071458, + 12.358000000000001, + 11.740500000000001 + ] + } + }, + "source" : "synthetic", + "width" : 128 + }, + "us_3d_small" : { + "depth" : 24, + "fixture" : "us_3d_small", + "height" : 240, + "label" : "US 320×240×24", + "modality" : "US", + "results" : { + "J2KSwift+jp3d+inproc" : { + "max" : 59.521124999999998, + "median" : 58.576374999999999, + "min" : 56.707042000000001, + "samples" : [ + 57.689875000000001, + 56.707042000000001, + 58.415916000000003, + 58.824959, + 59.521124999999998, + 59.397041999999999, + 58.576374999999999 + ] + } + }, + "source" : "synthetic", + "width" : 320 + } + }, + "host" : { + "brand" : "Apple M2", + "machine" : "arm64", + "model" : "Mac14,2", + "ncpu" : 8, + "release" : "15.7.5", + "system" : "Darwin" + }, + "j2k_version" : "J2KSwift version 10.9.2", + "lane" : "inproc-jp3d", + "runs" : 7, + "schema" : "warm-inproc-jp3d-v1", + "warmups" : 2 +} \ No newline at end of file diff --git a/README.md b/README.md index 86b8cae..8393d34 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,15 @@ A pure Swift 6.2 implementation of JPEG 2000 (ISO/IEC 15444) encoding and decoding with strict concurrency support. -**Current Version**: 10.13.0 -**Status**: Apple Silicon-first JPEG 2000 / HTJ2K (Part-15) implementation. v10.13.0 closes the last open cell of the JP3D **{K, ROI}** decode matrix — `JP3DROIDecoder(cfg).decode(data, region:)` with `cfg.resolutionLevel > 0` now returns a downsampled sub-volume (`ceil(roi.w / 2^K) × ceil(roi.h / 2^K) × roi.d`) instead of throwing. v10.12.0 had shipped the K=0+ROI and K>0+nil lanes but left this combination as a known limitation. Decoder-only; codestream bytes byte-identical to v10.12.0. -**Previous Release**: 10.12.0 (JP3D batched bridge — partial-resolution + ROI lanes) +**Current Version**: 10.14.0 +**Status**: Apple Silicon-first JPEG 2000 / HTJ2K (Part-15) implementation. v10.14.0 ships the first **JP3D encoder** speedup of the v10 arc — `JP3DSliceStackCodec.encode` parallelises the per-slice 2D encode tail (slices 2..N) via TaskGroup; slices 0-1 stay sequential to commit the M6/M7 Z-delta tile-mode decision. M2 release JP3D encode wins **−4 to −76 ms** (1.09–1.56× faster) across every fixture; all 6/6 fixtures clear the 3 ms acceptance threshold. Encoder-only; codestream bytes byte-identical to v10.13.0. +**Previous Release**: 10.13.0 (JP3D K>0+ROI composition closes the {K, ROI} decode matrix) **Release process**: see [RELEASING.md](RELEASING.md). Every release MUST update this README (Current Version line + new Release Status paragraph) — see the Release artefacts checklist for the full requirements. ## 📦 Release Status +**v10.14.0** is the first **JP3D encoder** speedup of the v10 arc. The per-slice 2D encode loop in `JP3DSliceStackCodec.encode` was sequential since shipping — each Z-slice's 2D J2K encode ran one after another even though encodes are independent across slices (Z-delta residual depends on the previous slice's INPUT bytes, not its codestream). v10.14.0 splits the loop: sequential slices 0-1 commit the M6/M7 Z-delta tile-mode decision (`tileSignedOnlyActive` / `tileTryBothActive`), and slices 2..N run in parallel via `withThrowingTaskGroup`. The per-slice body is extracted into `encodeOneSlice(...)` so both phases share the same logic (raw encode + optional signed encode + best-of-two pick). M2 release JP3D encode A/B (`J2KBenchMac --jp3d`, in-process, 7 runs / 2 warmups, median): mr_3d_small (262K vox) 11.84→7.58 ms (**1.56×**), ct_3d_small (1.05M) 33.92→26.17 ms (**1.30×**), us_3d_small (1.84M) 58.58→40.73 ms (**1.44×**), mr_3d_mid (2.10M) 71.59→65.68 ms (**1.09×**), ct_3d_mid (8.39M) 295.58→246.98 ms (**1.20×**), ct_3d_large (16.78M) 615.94→539.95 ms (**1.14×** = **−76 ms absolute on the radiologist-relevant 16M-voxel CT**). All 6/6 fixtures clear the 3 ms acceptance threshold; smallest fixture shows the largest relative speedup (1.56× — per-slice fixed cost amortises better). Sub-linear vs N=8 M2 cores because Z-delta probe is serialized + slices 0-1 sequential + memory contention on the 33 MB output. Codestream bytes byte-identical to v10.13.0 (round-trip 519/519 JP3D tests PASS). Combined with the v10.11–v10.13 decode wins: round-trip on 16M-voxel CT is ~3.5× faster vs v10.10.0. Opt-out via `J2K_JP3D_PARALLEL_ENCODE=0`. Mandatory commit gate 7/7 PASS. See [RELEASE_NOTES_v10.14.0.md](RELEASE_NOTES_v10.14.0.md) and the bench JSONs at [`Documentation/Benchmarks/data/jp3d-bench-arm64-v10_23-{parallel,serial}-encode-20260524.json`](Documentation/Benchmarks/data/). + **v10.13.0** closes the last open cell of the JP3D **{K, ROI}** decode matrix. v10.11.0 shipped the K=0+nil (full-volume) batched bridge; v10.12.0 added K=0+ROI and K>0+nil; v10.13.0 ships K>0+ROI. Both the per-tile throw in `JP3DSliceStackCodec.decode` and the upstream throw in `JP3DROIDecoder.decode` (both fail-loud guards introduced in v10.18 as Phase 5 wiring tripwires) are removed. **`JP3DROIDecoder(cfg).decode(data, region:)`** with `cfg.resolutionLevel > 0` now returns a sub-volume sized `ceil(region.width / 2^K) × ceil(region.height / 2^K) × region.depth`, with voxels bit-identical to `JP3DDecoder(cfg).decode(data)` cropped to the downsampled-mapped region. `JP3DBridgeOptions.regionOfInterest` is standardised on **full-image coordinates** (matching the 2D codec's `decodeRegion` / `decodePartial` convention); the bridge maps the region onto the reduced grid for the crop step via `region / 2^(N − partialResolutionLevel)` (mirrors `decodePartial`'s `J2KAdvancedDecoding.swift:528-543` logic). `JP3DROIDecoder` is now K-aware end-to-end: the output `roiBuffers` is sized for the downsampled output dims when K>0, and the per-tile composite uses downsampled stride for both src (the slice-stack returns downsampled per-slice buffers) and dst (the roiBuffers). Z stays full — JP3D's K is a 2D-only halving; the Z-delta residual chain is per-slice. **`V10_18_TrueSelectiveParityTests.testCombinedResolutionLevelAndROI`** is the new K+ROI parity oracle (was the v10.18 fail-loud tripwire, inverted to a success-branch check); **9/9 PASS**. `V10_21_BatchedBridgeOptionsParityTests` updated to use full-coords ROI for the K=4+ROI composition test — **7/7 PASS**. `V10_20_BatchedBridgeParityTests` 5/5, `V10_20_BatchedInverseInt32ParityTests` 12/12, `V10_20_JP3DBridgeParityTests` 5/5 — all unchanged. Full `swift test --filter JP3D` regression sweep **519/519 PASS**, mandatory commit gate 7/7 PASS. Codestream bytes byte-identical to v10.12.0; encoder unchanged. The full {K, ROI} matrix — (K=0, no ROI), (K=0, ROI), (K>0, no ROI), (K>0, ROI) — is now batched. See [RELEASE_NOTES_v10.13.0.md](RELEASE_NOTES_v10.13.0.md). **v10.12.0** extends v10.11.0's JP3D batched bridge SPI to the **partial-resolution and ROI lanes** — closes the explicit "Partial-resolution + ROI paths not yet batched" known limitation. A new public `JP3DBridgeOptions(partialResolutionLevel:, regionOfInterest:)` struct + `J2KDecoder._jp3dDecodeToCoefficients(_:options:)` overload thread the options into the bridge SPI; the batched orchestrator truncates the chain to `effectiveLevels = N − K` for partial-res (output dims = `levelSizes[K]`), runs the full chain for ROI and crops the per-slice output via `cropImage`, and composes both (`K>0 + ROI` inside the reduced grid). The eligibility gate now requires uniform options across the batch (which JP3D's per-volume options trivially satisfy). `JP3DSliceStackCodec` removes the per-slice fallback on both branches and now builds a `JP3DBridgeOptions` from `K` + `regionOfInterest`, decodes all slices to coefficients in parallel via the new overload, then runs ONE batched iDWT + finalize. **M2 release, J2KBenchMac --jp3d, in-process, 7 timed runs / 2 warmups, median**: `full` lane ct_3d_large **−82.89 ms** (1.13×), `res1` lane ct_3d_large **−25.82 ms** (1.12×), `ROIq` lane ct_3d_large **−47.53 ms** (1.33×); ct_3d_mid full **−38.85 / res1 −10.44 / ROIq −21.18**. Per acceptance discipline (≥3 ms wins): full 4/6, res1 2/6, ROIq 3/6 fixtures clear; smaller fixtures wash on the lighter lanes (reduced workload leaves less to amortise). K=0 thumbnail (no iDWT chain) falls back to per-slice serial. The only `K > 0 AND ROI` composition still throws (the 2D codec doesn't compose those — separate arc). Opt-out via `J2K_JP3D_BATCHED_BRIDGE=0` (introduced v10.11.0; still works for all three lanes). **`V10_21_BatchedBridgeOptionsParityTests` 7/7 PASS** (K=0/2/4/N, ROI 128² and 64², ROI+K composition), `V10_20_BatchedBridgeParityTests` 5/5 PASS, `V10_20_BatchedInverseInt32ParityTests` 12/12 PASS, `V10_20_JP3DBridgeParityTests` 5/5 PASS, full `swift test --filter JP3D` regression sweep **519/519 PASS**, mandatory commit gate 7/7 PASS. Codestream bytes byte-identical to v10.11.0; encoder unchanged. See [RELEASE_NOTES_v10.12.0.md](RELEASE_NOTES_v10.12.0.md) and the bench JSONs at [`Documentation/Benchmarks/data/jp3d-bench-arm64-v10_21-{batched,serial}-20260524.json`](Documentation/Benchmarks/data/). diff --git a/RELEASE_NOTES_v10.14.0.md b/RELEASE_NOTES_v10.14.0.md new file mode 100644 index 0000000..cd2863f --- /dev/null +++ b/RELEASE_NOTES_v10.14.0.md @@ -0,0 +1,157 @@ +# J2KSwift v10.14.0 + +**JP3D parallel per-slice encode.** The encoder side gets its first +optimisation in the v10 JP3D arc. The per-slice 2D encode loop in +`JP3DSliceStackCodec` was sequential since shipping — each Z-slice's +2D J2K encode ran one after another even though encodes are +independent across slices. v10.14.0 splits the loop into a sequential +probe (slices 0-1, commits the tile-mode decision) and a parallel +tail (slices 2..N via TaskGroup). M2 release JP3D encode wins +**−4 ms to −76 ms (1.09–1.56× faster)** across every fixture in the +JP3D bench corpus, with the largest absolute win (−76 ms) on the +16M-voxel CT volume. + +Encoder-only release; **codestream bytes byte-identical to v10.13.0**. +Decoder unchanged. MINOR per RELEASING.md — no public API removed +or signature-changed; the parallelism is a pure-internal refactor +that produces identical output bytes under the same Z-delta gating. + +## Summary + +`JP3DSliceStackCodec.encode(...)` previously ran every slice's 2D +encode sequentially: + +``` +for z in 0.. 0 { - if let candidate = computeResidualCandidate( - currentZ: z, previousZ: z - 1, in: tile) { - let signedImage = makeSignedImage( - from: candidate.perComponent, in: tile) - let signedCS = try await signedEncoder.encode(signedImage) - slicePayloads.append((Self.sliceFlagIsResidual, signedCS)) - continue - } - // Residual overflowed — must fall through to raw for - // this slice. The next slice still tries the residual - // path; tileSignedOnlyActive is preserved. - } - - // Per-slice gating when try-both is active: - // 1) Allocation-free probe (`probeResidualLooksGood`) - // keeps natural-medical slices that won't benefit - // out of the heavyweight path. - // 2) Only when tier 1 passes does - // `computeResidualCandidate` allocate the residual - // buffer — skipping tier 1 would burn ~1.5 ms per - // slice on natural MR even when residual is rejected. - var candidate: ResidualCandidate? = nil - if tileTryBothActive && z > 0 - && probeResidualLooksGood(currentZ: z, previousZ: z - 1, in: tile) { - candidate = computeResidualCandidate( - currentZ: z, previousZ: z - 1, in: tile) - } - - let rawImage = makeUnsignedImage(forSlice: z, in: tile) - let rawCS = try await unsignedEncoder.encode(rawImage) - - var bestFlags: UInt8 = 0 - var bestCS = rawCS - var measuredSavings: Double = 0.0 - - if let candidate { - let signedImage = makeSignedImage(from: candidate.perComponent, in: tile) - let signedCS = try await signedEncoder.encode(signedImage) - if signedCS.count < bestCS.count { - measuredSavings = Double(rawCS.count - signedCS.count) / Double(rawCS.count) - bestCS = signedCS - bestFlags |= Self.sliceFlagIsResidual - } + // v10.23-research — sequential probe for slices 0..1 to commit + // the tile-mode decision (`tileTryBothActive` / + // `tileSignedOnlyActive` from the empirical first-slice + // savings measurement). Slices 2..N have no further mode + // changes to make, so they encode in parallel via TaskGroup. + // The per-slice 2D J2K encode is by far the dominant cost + // (~8-9 ms/slice on M2 release for 512² CT slices), so the + // parallelism wins ~Nx-cores speedup on the slice-tail. + // + // Z-delta correctness: the residual `slice_z - slice_{z-1}` + // depends only on the raw input bytes (`TileVoxels`), NOT on + // the encoded codestream of slice z-1. Slices can encode in + // parallel without breaking the residual chain. + // + // Off-switch: `J2K_JP3D_PARALLEL_ENCODE=0` forces the legacy + // serial loop for A/B benching. + let parallelOptedOut: Bool = { + if let v = ProcessInfo.processInfo + .environment["J2K_JP3D_PARALLEL_ENCODE"] { + return v == "0" || v.lowercased() == "false" || v.lowercased() == "no" } + return false + }() - slicePayloads.append((bestFlags, bestCS)) + let sequentialUpTo = parallelOptedOut ? tile.depth : min(2, tile.depth) + for z in 0..= signedOnlyThreshold { + if savings >= signedOnlyThreshold { tileSignedOnlyActive = true - } else if measuredSavings < firstSliceSavingsThreshold { + } else if savings < firstSliceSavingsThreshold { tileTryBothActive = false } } } + if !parallelOptedOut && tile.depth > 2 { + let parallelStart = 2 + let parallelCount = tile.depth - parallelStart + // Snapshot the committed tile-mode state; from slice 2 + // onward neither flag mutates (line above), so capturing + // by value is safe. + let signedOnly = tileSignedOnlyActive + let tryBoth = tileTryBothActive + let raw = unsignedEncoder + let signed = signedEncoder + let codec = self + var parallelOut: [(flags: UInt8, data: Data)?] = + Array(repeating: nil, count: parallelCount) + try await withThrowingTaskGroup( + of: (Int, UInt8, Data).self + ) { group in + for z in parallelStart.. (flags: UInt8, data: Data, measuredSavings: Double) { + // Fast path: tile committed to signed-only after seeing + // huge savings on slice 1. Encode just the residual, + // falling back to raw only when the bit-depth signed + // range overflows on this particular slice. + if tileSignedOnlyActive && z > 0 { + if let candidate = computeResidualCandidate( + currentZ: z, previousZ: z - 1, in: tile) { + let signedImage = makeSignedImage( + from: candidate.perComponent, in: tile) + let signedCS = try await signedEncoder.encode(signedImage) + return (Self.sliceFlagIsResidual, signedCS, 0.0) + } + // Residual overflowed — fall through to raw for this slice. + } + + // Per-slice gating when try-both is active. + var candidate: ResidualCandidate? = nil + if tileTryBothActive && z > 0 + && probeResidualLooksGood(currentZ: z, previousZ: z - 1, in: tile) { + candidate = computeResidualCandidate( + currentZ: z, previousZ: z - 1, in: tile) + } + + let rawImage = makeUnsignedImage(forSlice: z, in: tile) + let rawCS = try await unsignedEncoder.encode(rawImage) + + var bestFlags: UInt8 = 0 + var bestCS = rawCS + var measuredSavings: Double = 0.0 + + if let candidate { + let signedImage = makeSignedImage(from: candidate.perComponent, in: tile) + let signedCS = try await signedEncoder.encode(signedImage) + if signedCS.count < bestCS.count { + measuredSavings = Double(rawCS.count - signedCS.count) / Double(rawCS.count) + bestCS = signedCS + bestFlags |= Self.sliceFlagIsResidual + } + } + + return (bestFlags, bestCS, measuredSavings) + } + private func makeUnsignedImage(forSlice z: Int, in tile: TileVoxels) -> J2KImage { let bytesPerSample = max(1, (tile.bitDepth + 7) / 8) let voxelsPerSlice = tile.width * tile.height