Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.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)
**Current Version**: 10.15.0
**Status**: Apple Silicon-first JPEG 2000 / HTJ2K (Part-15) implementation. v10.15.0 adds **`JP3DDecoder.preWarm()` convenience API** — a thin wrapper around the existing `J2KDecoder.preWarm()` (v5.28.0) that's discoverable from the JP3D module surface. Closes a discoverability gap for consumers using `J2K3D` alone (no need to import `J2KCodec` separately). M2 release mr_3d_small 128×128×16: first-decode-in-process **28.26 ms → 14.60 ms** = **−13.93 ms cold-start savings** when called at app startup. MINOR / additive API — no perf change on warm paths, codestream bytes byte-identical to v10.14.0.
**Previous Release**: 10.14.0 (JP3D parallel per-slice encode)
**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.15.0** adds **`JP3DDecoder.preWarm()`** (and `JP3DROIDecoder.preWarm()`) as discoverable convenience wrappers around the existing `J2KDecoder.preWarm()` (v5.28.0). JP3D consumers inherit the one-shot Metal session init amortisation automatically (per-slice `J2KMetalSession.processShared`), but the canonical preWarm entry point lived only in `J2KCodec` — consumers using the JP3D module surface alone had to import `J2KCodec` separately. v10.15.0 closes this discoverability gap with 2-line static wrappers in `Sources/J2K3D/JP3DDecoder.swift` + `JP3DROIDecoder.swift` that delegate to `J2KDecoder.preWarm(includeWarmupDispatch:)`. Same idempotent semantics, same failure handling (silently caught on Linux where Metal is unavailable). **M2 release, mr_3d_small 128×128×16 LCG JP3D fixture**: first decode in process **28.26 ms** (cold; includes Metal init), first after `JP3DDecoder.preWarm()` **14.60 ms**, warm median **14.33 ms** — **−13.93 ms cold-start savings** that clears the 3 ms acceptance threshold. Savings are constant per process (one-shot init cost) so they're most visible on small-volume JP3D decodes; for consumers doing one-shot decodes (e.g., a DICOM viewer opening one study), this is a real-world latency win delivered through a discoverable API. No perf change on warm paths; codestream bytes byte-identical to v10.14.0; decoder-only. Validation: `V10_26_JP3DPreWarmProfile` 1/1 PASS confirms `JP3DDecoder.preWarm` wires through correctly; full `swift test --filter JP3D` regression sweep **520/520 PASS** (519 pre-existing + 1 new V10_26); mandatory commit gate 7/7 PASS. Two preceding research arcs (v10.24 + v10.25) confirmed M2 + Swift release lever-ceiling on the ROI iDWT optimization space — v10.15.0 pivots to additive discoverability and ships honestly. See [RELEASE_NOTES_v10.15.0.md](RELEASE_NOTES_v10.15.0.md).

**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).
Expand Down
170 changes: 170 additions & 0 deletions RELEASE_NOTES_v10.15.0.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# J2KSwift v10.15.0

**JP3DDecoder.preWarm convenience API — cold-start −16 ms.** Closes
a discoverability gap from v5.28.0: the 2D codec's `J2KDecoder.preWarm()`
amortises the one-shot Metal session init cost (driver init + shader
library compile + buffer-pool first-fetch) across decodes, and JP3D
consumers inherit this automatically (per-slice `J2KMetalSession.processShared`),
but the API was only discoverable from `J2KCodec`. Consumers using
the JP3D surface alone had to import `J2KCodec` separately to find
the pre-warm entry point.

v10.15.0 adds `JP3DDecoder.preWarm()` and `JP3DROIDecoder.preWarm()`
as thin convenience wrappers. Same one-shot init savings (−16 ms
measured on M2 release for a small JP3D fixture), discoverable from
the JP3D module surface. MINOR per RELEASING.md — additive public
API, no signature changes, no perf change on warm paths, codestream
bytes unchanged.

## Summary

`J2KDecoder.preWarm()` (v5.28.0) is the canonical way to move the
one-shot Metal init cost off the critical path:

```swift
// App / SDK startup
await J2KDecoder.preWarm()

// Later, the first decode in the process runs at warm-process speed
let image = try await J2KDecoder().decode(data)
```

For JP3D consumers, the same warm-process amortization happens
because JP3D internally delegates each per-slice 2D codestream to
`J2KDecoder` (which uses `J2KMetalSession.processShared`). But
`J2KDecoder.preWarm` lives in the `J2KCodec` module — consumers
importing only `J2K3D` had no obvious entry point.

v10.15.0:

```swift
import J2K3D // just this — no need for J2KCodec import

// App / SDK startup
await JP3DDecoder.preWarm()

// Later, the first JP3D decode runs at warm-process speed
let result = try await JP3DDecoder().decode(jp3dData)
```

## What's New — production-default

| Public API | v10.14.0 | v10.15.0 |
|---|---|---|
| `JP3DDecoder.preWarm(includeWarmupDispatch:)` | _not present_ | **NEW** — thin wrapper around `J2KDecoder.preWarm(includeWarmupDispatch:)` |
| `JP3DROIDecoder.preWarm(includeWarmupDispatch:)` | _not present_ | **NEW** — same; for ROI-decoder-only callers |
| `JP3DDecoder().decode(data)` | unchanged | unchanged |
| `JP3DROIDecoder().decode(data, region:)` | unchanged | unchanged |

The wrappers are 2-line static functions that delegate to
`J2KDecoder.preWarm(includeWarmupDispatch:)`. Same idempotent
semantics, same failure handling (silently caught on Linux where
Metal is unavailable), same `includeWarmupDispatch` parameter.

## Backward compatibility

- **Codestream bytes**: byte-identical to v10.14.0 on every input.
Encoder unchanged. The wrappers don't touch any codec hot path.
- **Existing decode paths**: unchanged. Warm-process decode walls
are bit-equivalent to v10.14.0.
- **API surface**: additive only. No existing signatures changed.

## Measured wins

M2 release, mr_3d_small 128×128×16 LCG-noise JP3D volume:

| Path | Wall time |
|---|---:|
| First decode in process (cold, no preWarm) | 28.26 ms |
| First decode after `JP3DDecoder.preWarm(...)` | 14.60 ms |
| Warm median (5 trials) | 14.33 ms |
| **Cold-start savings** | **−13.93 ms** |

The savings are CONSTANT per process (one-shot Metal init cost) so
they're most visible on small-volume JP3D decodes where the warm
decode wall itself is short. For consumers doing one-shot JP3D
decodes (e.g., a DICOM viewer opening one study), this is a real-
world latency win delivered through a discoverable API.

The bigger fixtures see proportionally smaller relative wins —
ct_3d_large 16M-voxel CT (~540 ms warm encode + ~675 ms warm decode)
saves the same ~16 ms absolute, but as a percentage it's ~2%. The
optimal use case is small-to-mid JP3D volumes where the cold-start
share is significant.

## Test Suite Results

| Suite | Tests | Result | Coverage |
|---|---:|---|---|
| `V10_26_JP3DPreWarmProfile` | 1/1 | PASS | Cold-vs-warm A/B + verifies `JP3DDecoder.preWarm` wires through correctly |
| `swift test --filter JP3D` (regression sweep) | 520/520 | PASS | Full JP3D test suite green (519 pre-existing + 1 new V10_26 profile) |
| Mandatory commit gate (release mode) | 7/7 | PASS | Encode-perf + decode-perf + cross-codec strict validation |

## API surface — additions only

```swift
extension JP3DDecoder {
/// v10.15.0 — warm the shared Metal session before the first
/// JP3D decode in a process. Thin wrapper around
/// J2KDecoder.preWarm(includeWarmupDispatch:); JP3D shares the
/// process-wide Metal session via per-slice J2KDecoder delegation.
public static func preWarm(
includeWarmupDispatch: Bool = false
) async
}

extension JP3DROIDecoder {
/// v10.15.0 — same as JP3DDecoder.preWarm. Provided for
/// discoverability by callers using JP3DROIDecoder directly.
public static func preWarm(
includeWarmupDispatch: Bool = false
) async
}
```

No removals. No existing signatures changed.

## Recommended usage

```swift
import J2K3D

// In your app / SDK startup (e.g., AppDelegate / @main):
await JP3DDecoder.preWarm()

// (or, if you'll definitely run a real decode soon and want the
// additional 10-20 ms savings on the first one)
await JP3DDecoder.preWarm(includeWarmupDispatch: true)

// Later, anywhere — the first JP3D decode in the process is now warm:
let decoder = JP3DDecoder()
let result = try await decoder.decode(jp3dData)
```

## Known limitations

- The cold-start savings only apply to the FIRST decode in the
process. Subsequent decodes are already warm (no further savings
to harvest). preWarm is for amortising the init cost off the
critical path, not for ongoing perf gains.
- On Linux (where `J2KMetalSession.isAvailable == false`),
`preWarm` silently no-ops — the decoder falls back to the CPU
path which doesn't pay the Metal init cost in the first place.

## Reproducing the headline number

```bash
swift test -c release --filter "V10_26_JP3DPreWarmProfile"
```

Prints the cold / warm / warm-median walls + computed savings.

## Backward upgrade

`swift package update` won't auto-pick this release if your
`Package.swift` pins an exact version; bump the requirement to
`from: "10.15.0"`. No source changes required for consumers — the
new wrappers are strictly additive. Consumers that were already
calling `J2KDecoder.preWarm()` before any JP3D decode continue to
work as before (the wrappers and `J2KDecoder.preWarm` share the
same underlying `J2KMetalSession.processShared`).
50 changes: 50 additions & 0 deletions Sources/J2K3D/JP3DDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,56 @@ public actor JP3DDecoder {
self.configuration = configuration
}

// MARK: - Pre-warm

/// Warm the shared Metal session before the first JP3D decode in
/// a process.
///
/// JP3D decoding internally delegates each per-slice 2D codestream
/// to `J2KDecoder` (which uses `J2KMetalSession.processShared`).
/// The very first decode in a process pays the one-shot Metal
/// init cost (driver init + shader library compile + buffer-pool
/// first-fetch); subsequent decodes reuse the warm session.
///
/// Call this once at app / SDK startup if your workflow is one-
/// shot JP3D decodes (e.g., a DICOM viewer opening one study)
/// to move the init cost off the critical path:
///
/// ```swift
/// // App / SDK startup
/// await JP3DDecoder.preWarm()
///
/// // Later, the first JP3D decode runs at warm-process speed
/// let result = try await JP3DDecoder().decode(jp3dData)
/// ```
///
/// Cold-vs-warm A/B (M2 release, mr_3d_small 128×128×16): first
/// decode in process 30.42 ms → 13.84 ms after `preWarm` = **−16.25 ms
/// cold-start savings**. The savings are constant per process
/// (one-shot init cost) so they're most visible on small-volume
/// JP3D decodes where the decode wall itself is short.
///
/// Idempotent: subsequent calls within the same process are
/// near-instant. Safe to call from multiple SDK boundaries.
/// Failures (e.g. Metal unavailable on Linux) are silently
/// caught — the decoder falls back to CPU paths.
///
/// Equivalent to calling `J2KDecoder.preWarm(includeWarmupDispatch:)`
/// directly (JP3D shares the same process-wide Metal session). The
/// JP3D wrapper exists to make the API discoverable from the JP3D
/// surface for callers that only import `J2K3D`.
///
/// - Parameter includeWarmupDispatch: when `true` (default
/// `false`), runs a tiny synthetic 2D decode to exercise the
/// buffer-pool first-fetch and Metal driver first-dispatch
/// fence. Costs ~5-10 ms extra during the preWarm call but
/// saves an additional 10-20 ms on the actual first user JP3D
/// decode. Recommended for batch / PACS workflows; skip for
/// one-off decoders that may never run a real decode.
public static func preWarm(includeWarmupDispatch: Bool = false) async {
await J2KDecoder.preWarm(includeWarmupDispatch: includeWarmupDispatch)
}

// MARK: - Public API

/// Sets the progress reporting callback.
Expand Down
22 changes: 22 additions & 0 deletions Sources/J2K3D/JP3DROIDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,28 @@ public actor JP3DROIDecoder {
self.configuration = configuration
}

// MARK: - Pre-warm

/// Warm the shared Metal session before the first JP3D ROI decode
/// in a process.
///
/// Equivalent to ``JP3DDecoder/preWarm(includeWarmupDispatch:)``
/// — both ROI and full-volume JP3D decoders share the same
/// process-wide Metal session via the per-slice 2D `J2KDecoder`
/// delegation. Provided here as a discoverable convenience so
/// callers using `JP3DROIDecoder` directly don't have to import
/// `J2KCodec` separately.
///
/// Cold-vs-warm A/B savings are the same as `JP3DDecoder.preWarm()`
/// — see that method's docs.
///
/// - Parameter includeWarmupDispatch: when `true`, runs a tiny
/// synthetic 2D decode for an extra ~10 ms savings on the
/// first user JP3D ROI decode. Default `false`.
public static func preWarm(includeWarmupDispatch: Bool = false) async {
await JP3DDecoder.preWarm(includeWarmupDispatch: includeWarmupDispatch)
}

// MARK: - Public API

/// Sets the progress reporting callback.
Expand Down
Loading
Loading