diff --git a/.github/workflows/wai-pack.yml b/.github/workflows/wai-pack.yml index ca27f97a1..2af5fb42e 100644 --- a/.github/workflows/wai-pack.yml +++ b/.github/workflows/wai-pack.yml @@ -6,7 +6,11 @@ name: wai-pack # - legs: on each architecture of the fleet, clippy with float arithmetic denied (no # float decides anything in a package), the tests in release with a floor on how many # each target lists, and the corpus recomputed by the crate: the golden packages -# packed again byte for byte and verified, every mutation's reason. +# packed again byte for byte and verified, every mutation's reason. On the macOS leg, +# whose host has a GPU, the steps a GPU runs (packaging §11.6): wai-pack-gpu's kernels +# against their references and every edited kernel caught, and wai-pack's fabric +# differential (every golden packed on the GPU byte for byte, every verdict the CPU's), +# an absent adapter a failure there (WAI_PACK_GPU_REQUIRE), never a skip. # - independent: the corpus recomputed by its second implementation, written in Python # from the extension text and sharing no code with wai-pack, the golden packages # verified and re-derived from their sources among it (every refinement layer and @@ -32,6 +36,7 @@ on: pull_request: paths: - 'wai/wai-pack/**' + - 'wai/wai-pack-gpu/**' - 'wai/wai-rs/**' - 'wai/pack-conformance/**' - 'wai/claim-conformance/_blake3.py' @@ -129,9 +134,10 @@ jobs: echo "$target: $n tests (floor $floor)" [ "$n" -ge "$floor" ] || { echo "::error::$target lists $n tests, under its floor of $floor"; fail=1; } done <<'EOF' - --lib 34 + --lib 39 --test=cmaf 4 --test=conformance 1 + --test=fabric 3 --test=floor_only 2 --test=ladder 3 --test=pack_alloc 1 @@ -149,6 +155,36 @@ jobs: working-directory: wai/wai-pack run: cargo run --release --locked --bin wai-pack -- conformance check ../pack-conformance + # The steps a GPU runs (packaging §11.6), on the one leg whose host has a GPU. Each + # test prints the adapter it ran on; the step requires those lines, so a run that + # found no adapter, or filtered the tests away, fails. + - name: wai-pack-gpu, clippy and its kernels against their references (Metal) + if: matrix.os == 'macos' + shell: bash + working-directory: wai/wai-pack-gpu + env: + WAI_PACK_GPU_REQUIRE: '1' + run: | + set -euo pipefail + cargo clippy --locked --all-targets -- -D warnings + cargo test --release --locked -- --nocapture 2>&1 | tee "$RUNNER_TEMP/gpu.txt" + grep -q 'motion search, 54 frames, every vector the text' "$RUNNER_TEMP/gpu.txt" + grep -q 'every hash the reference crate' "$RUNNER_TEMP/gpu.txt" + [ "$(grep -c '^caught: ' "$RUNNER_TEMP/gpu.txt")" -ge 12 ] + - name: wai-pack's fabric differential on the GPU (Metal) + if: matrix.os == 'macos' + shell: bash + working-directory: wai/wai-pack + env: + WAI_PACK_GPU_REQUIRE: '1' + run: | + set -euo pipefail + cargo clippy --locked --all-targets --features gpu,fabric-meter -- -D warnings + cargo test --release --locked --features gpu --test fabric -- --nocapture 2>&1 | tee "$RUNNER_TEMP/fabric.txt" + grep -q 'the search on the GPU: ' "$RUNNER_TEMP/fabric.txt" + grep -q 'verdicts, each the CPU' "$RUNNER_TEMP/fabric.txt" + grep -q 'test result: ok. 7 passed' "$RUNNER_TEMP/fabric.txt" + independent: name: wai-pack · independent verifier (Python) needs: relevant diff --git a/docs/research/wai-cross-fabric.md b/docs/research/wai-cross-fabric.md new file mode 100644 index 000000000..8441a2fbb --- /dev/null +++ b/docs/research/wai-cross-fabric.md @@ -0,0 +1,430 @@ +# WAI across fabrics: research for the packer and the verifiers + +Research and measurements of 2026-10-22, for the change that lets each step of a WAI +pack or verify run on the fabric that does it with the least measured energy (CPU, GPU, +a hardware media engine, or a neural engine): [packaging](../../wai/extensions/packaging.md) +§11.6, [jwp-receipts](../../wai/extensions/jwp-receipts.md) §2.4.8 and +[energy-measurement](../../wai/extensions/energy-measurement.md) §6.3. + +**Method.** Every claim about a product, an API or a specification links the page it +was checked against, fetched on the date above. Where a vendor publishes nothing, the +entry says *unconfirmed*. Items marked **[measured]** were run on the fleet for this +document (commands in §9); items marked **[local]** were read from the macOS 27 SDK on a +fleet machine. Exact counts and digests are exact; times and energies are indicative, +from a shared host whose load is given beside each. + +## 1. The fleet, as probed + +Each Mac was queried read-only over SSH (`sw_vers`, `sysctl machdep.cpu.brand_string +hw.memsize`, `system_profiler SPDisplaysDataType`) **[measured]**: + +| Host | Chip | GPU cores | Memory | OS | AV1 decode engines | AV1 encode | +|---|---|---:|---:|---|---:|---| +| build host (`100.100.141.123`) | M3 Ultra | 80 | 256 GB | macOS 27.0.1 | 2 | none | +| `.3` (`10.10.10.3`) | M3 Ultra | 60 | 96 GB | macOS 27.0.1 | 2 | none | +| `.1` (`100.98.101.113`) | M4 Max | 40 | 128 GB | macOS 27.0.1 | 1 | none | +| prod (`100.64.224.0`) | M4 Max | 32 | 36 GB | macOS 27.0.1 | 1 | none | +| dev | M5 Max | | | macOS 27.0.1 | 1 | none | + +The engine counts are Apple's: M3 Ultra "Two video decode engines · Four video encode +engines · Four ProRes encode and decode engines · AV1 decode", M4 Max "Video decode +engine · Two video encode engines · Two ProRes encode and decode engines · AV1 decode" +(https://support.apple.com/en-us/122211), M5 Max the same as M4 Max +(https://www.apple.com/macbook-pro/specs/). Every Mac reports Metal 4. + +- **cbio-device** (Windows, x86_64), queried with `Get-CimInstance Win32_VideoController` + **[measured]**: an Intel Iris Xe (PCI `8086:9A49`, Tiger Lake) and an NVIDIA GeForce + RTX 2060 (PCI `10DE:1F15`, Turing), on an i7-1165G7. Turing has neither AV1 decode nor + AV1 encode in NVDEC/NVENC (§3.2); Tiger Lake decodes AV1, with a film-grain path that is + not bit-exact (§6). +- **jetson-hub** (Ubuntu, x86_64) was not probed: its SSH goes through Tailscale SSH, + which asked for an interactive browser check that this work did not perform. The + repository's own record (wai/extensions/determinism-tiers.md §5) lists a Linux x86_64 + host with a discrete GPU of PCI `10de:28e1` and an integrated `8086:a7a0` and another + with `1002:1586`; which hosts those are was not confirmed. + +## 2. The steps, and the fabrics that can run each + +| Step | CPU | GPU | Media engine | Neural engine | Placed on | +|---|---|---|---|---|---| +| `source-decode` (a coded source to frames, before a pack) | dav1d | | AV1 decode, M3 and later (VideoToolbox); NVDEC Ampere+; QSV Tiger Lake+; VCN 3+ | | either, on equal pictures (§6) | +| `source-read`, `segment` | yes | | | | CPU (sequential, cheap) | +| `base-search` (the integer motion search) | reference | exact integer kernel | none (no hardware runs WAI's search; VideoToolbox's motion estimation is not the encoder's search, §3.1) | unsound (FP16, §5) | GPU where it qualifies | +| `base-code`, `refine-code` (residuals, rANS) | reference | | none: no media engine codes WIV1 or WIR1 | | CPU (rANS is sequential) | +| `sse` | reference | exact (partial sums under 2^32) | | unsound | by measured cost | +| `object-hash` (BLAKE3) | reference | exact (chunks on the device, parents on the host) | | | by measured cost | +| `digest` (SHA-256) | yes | no: one message is one chain (FIPS 180-4 §6.2.2, https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf) | | | CPU | +| `floor-read` (AV1 and CMAF structure) | yes | | | | CPU | +| `floor-decode` | dav1d | | AV1 decode, as above | | informative only (§6.3) | +| `index`, `io` | yes | | | | CPU | + +What this decides: the GPU takes the one step that dominates a pack's work (the motion +search: at least 96.8% of phase 0's CPU time, §9.2), plus SSE and BLAKE3 where the +measured cost says so, which on the build host it does not (§9.3). No hardware encoder computes any WAI step. The media engine decodes AV1 sources and +floors. The neural engine computes nothing exact. + +## 3. Media engines + +### 3.1 Apple: VideoToolbox + +- **AV1 decode starts with M3 and A17 Pro.** "the media engine supports AV1 decoding, + enabling power-efficient playback of streaming services" + (https://www.apple.com/newsroom/2023/10/apple-unveils-m3-m3-pro-and-m3-max-the-most-advanced-chips-for-a-personal-computer/); + M1 and M2 spec pages list no AV1 (https://support.apple.com/kb/SP825, + https://support.apple.com/kb/SP869). +- **No Apple chip encodes AV1 in hardware**, through the 2026 launches: every spec page + and press release says "AV1 decode" only. M5 Pro and M5 Max: "Apple's latest Media + Engine with support for hardware-accelerated H.264 and HEVC, AV1 decode, and ProRes + encode" + (https://www.apple.com/newsroom/2026/03/apple-debuts-m5-pro-and-m5-max-to-supercharge-the-most-demanding-pro-workflows/); + M6 and M5 Ultra: "hardware-accelerated AV1 decode" + (https://www.apple.com/newsroom/2026/08/apple-introduces-m6-and-m5-ultra-for-a-big-leap-in-performance-and-ai-compute/). + VideoToolbox's compression headers name no AV1 encoder **[local]**, and FFmpeg's + VideoToolbox encoders are `h264_videotoolbox`, `hevc_videotoolbox` and + `prores_videotoolbox` only + (https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/videotoolboxenc.c). +- **API.** `kCMVideoCodecType_AV1 = 'av01'` **[local]**; + `kVTVideoDecoderSpecification_RequireHardwareAcceleratedVideoDecoder`: "If it cannot, it + returns an error and the session creation fails" + (https://developer.apple.com/documentation/videotoolbox/kvtvideodecoderspecification_requirehardwareacceleratedvideodecoder). + A session needs an `av1C` atom carrying the sequence header OBU + (https://developer.apple.com/forums/thread/739953). FFmpeg's `av1_videotoolbox` hwaccel + was added in commit `f9c5c53` on 2024-10-02 and first released in FFmpeg 8.0 + (https://github.com/FFmpeg/FFmpeg/commit/f9c5c5358cfef3847674c6f3b3ded9611ebc5647); it + requires hardware for AV1 (`videotoolbox.c`, + https://github.com/FFmpeg/FFmpeg/blob/master/libavcodec/videotoolbox.c). +- **What the media engine gives back [measured]** (FFmpeg 9.0.2, M3 Ultra, macOS 27.0.1): + - The big-buck-bunny ladder's four AV1 rungs are Main profile, **10-bit** (each rung's + `av1C`). VideoToolbox's hardware frames are P010; `hwdownload,format=nv12` is refused + ("Invalid output format nv12 for hwframe download"). + - A low-overhead OBU stream (`-f obu`) does not decode on it: the demuxer gives the + session no sequence header up front, and the filter graph fails. The ladder's CMAF + (with `av1C`) decodes. So the reference tool decodes floors from the package's CMAF + files. + - Its raw P010 frames equal, frame by frame (MD5 of each of 240 frames of the 1080p + rung), dav1d's planar 10-bit output repacked to P010; but FFmpeg's P010-to-8-bit + conversion of them does not equal dav1d's own 10-to-8-bit conversion. That + conversion rounds and dithers, so a comparison has to stay at the stream's bit depth + or convert exactly. + - Earlier runs of this document's test compared `-f framemd5` outputs whose stderr had + been piped into the hash; the "difference" they showed was FFmpeg's error text. Every + comparison in §9 hashes the output file, not the console. +- **Motion estimation in hardware.** `VTMotionEstimationSession` (macOS 26) returns 4×4 or + 16×16 vectors (https://developer.apple.com/documentation/videotoolbox/vtmotionestimationsessioncreate); + Apple documents neither the engine, the cost metric, the search window nor + determinism, so it cannot compute §5.5's normative search. + +### 3.2 NVIDIA, Intel, AMD, Vulkan Video + +- **NVIDIA** (https://developer.nvidia.com/video-encode-and-decode-gpu-support-matrix-new): + AV1 NVDEC on Ampere and later, AV1 NVENC on Ada Lovelace and Blackwell; Turing has + neither (so cbio-device's RTX 2060 has no AV1 engine). NVDEC "runs completely + independent of compute/graphics engine" + (https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/nvdec-video-decoder-api-prog-guide/index.html). +- **Intel** (https://raw.githubusercontent.com/intel/media-driver/master/README.md): AV1 + decode from Tiger Lake (Gen12) in the Full-Feature build (on Ubuntu, + `intel-media-va-driver-non-free`), AV1 encode from Arc Alchemist and Meteor Lake. +- **AMD** (https://raw.githubusercontent.com/torvalds/linux/master/drivers/gpu/drm/amd/amdgpu/nv.c, + `soc21.c`): AV1 decode on VCN 3 (RDNA2) except VCN 3.0.33, AV1 encode from VCN 4; AV1 + on instance 0 only of a dual-VCN part. +- **Vulkan Video**: `VK_KHR_video_decode_av1` in Vulkan 1.3.277 + (https://www.khronos.org/blog/khronos-releases-vulkan-video-av1-decode-extension-vulkan-sdk-now-supports-h.264-h.265-encode), + `VK_KHR_video_encode_av1` in 1.3.302 + (https://www.khronos.org/blog/khronos-announces-vulkan-video-encode-av1-encode-quantization-map-extensions); + MoltenVK lists no video extension + (https://raw.githubusercontent.com/KhronosGroup/MoltenVK/main/Docs/MoltenVK_Runtime_UserGuide.md). + +## 4. GPU compute + +- **WGSL integer semantics** (W3C WGSL, https://www.w3.org/TR/WGSL/): "i32 operations use + two's complement arithmetic and have defined overflow behavior. Wrapping occurs."; `>>` + on a signed value inserts copies of the sign bit (§8.10, Bit Expressions); integer + division by zero evaluates to `e1` (§8.8); an out-of-bounds access is a dynamic error + that the implementation "will not perform ... as written" (§6.5.7). So a WGSL kernel + that keeps every value inside its type, and every index inside its buffer, has one + result on every conformant device. The WAI kernels do: a block's cost is at most + `block² · 765`, refused on the host where that passes 2^32 − 1. +- **Metal** (MSL 4.1, https://developer.apple.com/metal/Metal-Shading-Language-Specification.pdf): + unsigned integers wrap modulo 2^n and "The result of signed integer overflow is + undefined"; atomics are relaxed, so only commutative integer reductions are order + free. The WAI kernels use no atomics: each block's minimum is a tree reduction over a + total order, so it does not depend on which thread visits which candidate. +- **CUDA** (PTX, https://docs.nvidia.com/cuda/parallel-thread-execution/index.html): `sad` + is `d = c + ((a` too. Big-buck-bunny-v2's run B (four + floor rungs, the ladder's own CMAF, the base coded once): pack digest `3912bd7c…b9b7f` + and receipt `e0256cd9…`, v2's. Its run C (floor-only): `f8871444…e032`, v2's. +- **The media engine decodes what dav1d decodes.** Each of the four rungs, all 14,315 + frames: the SHA-256 of every frame in P010 equal between VideoToolbox's hardware frames + and dav1d's output. And `wai-pack floor-decode` of the four-rung package: the same + digest from both decoders for each of its 600 pieces, in the stream's own 10-bit form, + the P010 words read back exactly. +- **A source decoded by the media engine is the same source.** The proxy decoded from the + 1080p rung by VideoToolbox, then by the same scaler: SHA-256 `8d236f5b…b56d`, phase 0's + `proxy.y4m` byte for byte, as dav1d's decode is. +- **Placement.** In a 12-piece pack, 24,915 of 24,930 motion-search calls ran on the GPU + before the map-wait fix. The other 15 were handed back to the CPU, with the same + vectors. +- **Instructions.** Phase 0's av1-360 pack retired 98.3 × 10¹² CPU instructions; the GPU + pack retired 2.05 × 10¹². + +### 9.2 Pack and verify, joules per fabric + +Run 2, the GPU otherwise idle (its other clients under 1% of GPU time in every pack). + +| Run | Wall | CPU (task) | GPU (rail) | Media | Total | Peak memory | CPU baseline | +|---|---:|---:|---:|---:|---:|---:|---| +| av1-360 pack, search on the GPU | 56 s | 573 J | 1,284 J | – | **1,857 J** | 0.95 GB | 20,299 J, 3,689 s, 3.04 GB (phase 0) | +| av1-360 pack, `auto` (search on the GPU; SSE and hashes on the CPU) | 56 s | 571 J | 1,283 J | – | **1,854 J** | 0.76 GB | as above | +| run B pack (4 floor rungs, CMAF floor), search on the GPU, `--jobs 12` | 71 s | 525 J | 1,239 J | – | **1,763 J** | 1.62 GB | 19,541 J, 980 s at `--jobs 6`, 0.17 GB (v2) | +| run C pack (floor-only), hashes on the GPU | 2.2 s | 4.4 J | 2.3 J | – | **6.7 J** | 0.65 GB | 3.7 J, 1.5 s, 0.04 GB (v2) | +| `wai-pack verify` av1-360, CPU | 32 s | 118 J | 0 | – | 118 J | 0.58 GB | 31–33 s (phase 0) | +| `wai-pack verify` av1-360, hashes on the GPU | 31 s | 120 J | – (GPU shared) | – | ≥ 120 J | 2.04 GB | | +| `wai-pack verify` run B, CPU | 34 s | 119 J | 0 | – | 119 J | 1.55 GB | 35 s, 121 J (v2) | +| `wai-pack verify` run B, hashes on the GPU | 32 s | 122 J | 0.2 J | – | 122 J | 3.44 GB | | + +What the table shows: +- Moving the motion search to the GPU cuts a pack's energy about elevenfold. The CPU work + left (residuals, rANS, digests, I/O: 116 s of CPU against phase 0's 3,635 s) shows that + the search was at least 96.8% of phase 0's CPU time. +- Moving the object hashes or SSE gains nothing: the floor-only pack, whose only movable + step is hashing, costs more on the GPU. +- `wai-pack verify` is dominated by the integer base's decode, which is sequential rANS, + so it stays on the CPU. + +### 9.3 The measured cost table (`wai-pack fabric calibrate`) + +Each backend, two call sizes, 6 s each, fitted to `µJ per call + nJ per unit`: + +| Step | CPU | GPU | Placement | +|---|---|---|---| +| `base-search` (unit: candidate-sample) | 0 + 20.2 nJ | 11,276 µJ + 1.47 nJ | GPU from about 600,000 units, a 32×24 frame at the policy's range; every BBB frame is 32 million | +| `sse` (unit: byte) | 0 + 0.85 nJ | 3,136 µJ + 0.76 nJ | CPU below about 35 MB a call; a BBB piece is 5 MB | +| `object-hash` (unit: byte) | 7 µJ + 1.33 nJ | 1,699 µJ + 1.43 nJ | CPU always: the GPU's BLAKE3 is no cheaper per byte either | + +The GPU's per-call cost includes its idle and static draw over the call, since the rail is +the whole GPU's. + +### 9.4 Decoding: CPU against the media engine + +Run 3 (VideoToolbox's XPC service counted; the GPU was shared in this run, which no +decode used). + +| Decode of the whole floor | dav1d: wall, CPU | VideoToolbox: wall, CPU (FFmpeg + its service), media engine | +|---|---|---| +| 1080p (`av1-1080`) | 23.1 s, 198.7 J | 22.0 s, 17.4 J, + unmetered | +| 720p | 12.4 s, 111.0 J | 17.3 s, 11.3 J, + unmetered | +| 540p | 7.7 s, 73.3 J | 14.1 s, 8.5 J, + unmetered | +| 360p | 5.1 s, 53.8 J | 11.3 s, 15.9 J, + unmetered | +| proxy source (1080p decoded and scaled to 256×144) | 22.7 s, 304.1 J | 25.6 s, 350.5 J, + unmetered | + +What the table shows: +- A floor decode on the media engine costs the CPU 8 to 11 times less at 540p to 1080p, + and 3.4 times less at 360p, where the per-frame download dominates. +- The engine's own draw is not visible to any unprivileged reader on macOS 27 (§7.1), so + these are lower bounds. +- For the proxy source, VideoToolbox saves nothing: the Lanczos scale and the P010 repack + on the CPU cost more than the decode saved. +- Placing `source-decode` on the media engine is therefore worth it only where the + scaling also moves. Moving it would change the source's bytes, unless the scaler is + exact. + +### 9.5 A metered pack, sealed + +- **Sealed.** The av1-360 pack with `--fabric gpu --meter fabric` was run when the GPU's + other clients had held under 0.5% of its time over the preceding 10 s. It sealed a + `ModelBased` figure of 2,248.4 J, whose measurement claim carries the fabric record: + - the CPU's share is 475.4 J (`task`) and the GPU's 1,773.0 J (`rail`, the pack its sole + user); + - the steps are `base-search`, `sse` and `object-hash` on the GPU and every other step + on the CPU, with no search call handed back to the CPU now that the map wait is fixed. +- **Verified.** `wai-pack verify` accepts the package, and `pack_verify.py` holds every + check of it (3622 of 3622), the claim among them. +- **Refused.** The same metered pack of run B, a minute later, found the GPU shared (39.9 s + of its 50.0 s) and was refused `pack-meter`, as was the calibration then. +- The GPU figure here (1,773 J) and run 2's (1,284 J) differ by the GPU's state in each + window. Both are indicative; the rule that the pack held the rail alone is what each + states exactly. + +### 9.6 Load, and what is indicative + +- Times and energies are indicative. The host's one-minute load average was 14 to 50 over + the runs in §9.2 and 25 to 90 in §9.4. Every run's load before and after is in its + report. +- The CPU figures are each process's own, by the kernel's attribution, so other load + changes them only through contention. +- The GPU figures in §9.2 are from runs that held the GPU alone. In run 3 another process + held about a third of the GPU's time; the GPU packs then had no GPU figure, and the + metered packs were refused `pack-meter` ("the GPU was shared: this process had 45.2 s + of its 65.0 s, under 99%"), as §7.1's rule requires. +- Instruction counts and digests are exact. + +## 10. Judged unsound + +- **Any WAI step on the neural engine** (§5). +- **A hardware encoder for any WAI step**, and NVENC in particular for the floors here: + floors are supplied, the packer refuses to encode one (`pack-floor-encode-unsupported`), + and no hardware encoder's bytes are reproducible (§6.2). cbio-device's RTX 2060 has no + AV1 encoder anyway; jetson-hub's GPU was not probed. +- **VideoToolbox's motion estimation in place of §5.5's search** (§3.1). +- **Dividing a shared GPU rail by GPU time** (§7.1). +- **Media-engine and neural-engine energy on macOS 27**: no live unprivileged counter + (§7.1); a step run there makes a metered span refuse rather than guess. +- **A floor verdict from a hardware decoder** (§6.3). +- **FFmpeg's P010-to-8-bit conversion in a comparison** (§3.1). diff --git a/wai/CHANGELOG.md b/wai/CHANGELOG.md index 8606f8934..ed3812b63 100644 --- a/wai/CHANGELOG.md +++ b/wai/CHANGELOG.md @@ -6,6 +6,44 @@ wire format may change and such changes are recorded here. ## v0.4.0 — current +### Packaging: each step on the fabric that does it with the least energy + +- **Steps and fabrics** ([packaging](extensions/packaging.md) §11.6). The computations of + a pack and a verify are named steps (`source-read`, `segment`, `base-search`, + `base-code`, `refine-code`, `sse`, `object-hash`, `digest`, `floor-read`, + `floor-decode`, `index`, `io`, and `source-decode` before a pack), and each may run on a + CPU, a GPU, a media engine or a neural engine. Every step is defined in integers (and + AV1 decoding by its decoding process), so where it runs changes no byte of a package + and no verdict: §11.2 holds across fabrics. A packager runs a step on a backend only + after it has given the reference's output on qualifying inputs; a verifier computes a + check on another fabric only where the package states the value it compares with, and + decides any mismatch again on the reference. No hardware encoder computes a WAI step; a + floor encoded on one is a §7.5 floor. A floor states no decoded-output digest, so a + hardware decode never decides a floor verdict. +- **The fabric record** ([jwp-receipts](extensions/jwp-receipts.md) §2.4.8, + [energy-measurement](extensions/energy-measurement.md) §6.3). A measurement claim may + carry `fabrics`: each step with every fabric that ran it and its backend, and the + figure divided into one share per fabric, attributed `task` (the operating system's + account of the work's own energy) or `rail` (the fabric's whole rail, the work its sole + user). A rail shared with other work is never divided by busy time. `fabrics` is the one + claim member that is absent rather than null, so every existing claim and package keeps + its bytes. New reasons: `measurement_fabric_unknown`, `measurement_fabric_order`, + `measurement_fabric_unmetered`, `measurement_fabric_unused`, `measurement_fabric_sum`. +- **wai-pack** places each call of a step on the fabric whose measured cost for its size + is least (`--fabric cpu|gpu|auto:`, `wai-pack fabric list|calibrate`), the + CPU on a tie, where unmeasured, and for every call another fabric does not take. With + feature `gpu`, the motion search, SSE and the object hashes run on a GPU through + **wai-pack-gpu**, exact integer WGSL kernels through wgpu (Metal, Vulkan, DX12). With + feature `fabric-meter` on macOS, `--meter fabric` meters a pack per fabric without + privileges and writes the claim's fabric record. `wai-pack floor-decode` decodes a + package's floors with libdav1d or the media engine and prints each piece's digest, for + comparing decoders. The Python verifiers stay on the CPU and share no code with wai-rs; + `claim_verify.py` and `pack_verify.py` read the fabric record. +- **Conformance.** Claim vectors of the fabric record appended after the existing ones, which a test + pins by digest. wai-pack-gpu's kernels are held to references transcribed from the text + and every edited kernel is caught; wai-pack's fabric differential packs every golden on + a GPU byte for byte and gives every package and staged mutation the CPU's verdict. + ### Packaging: a title's whole ladder in one package - **Several floor rungs per package** ([packaging](extensions/packaging.md) §7.1). A diff --git a/wai/claim-conformance/README.md b/wai/claim-conformance/README.md index 31933c47a..b7086d78c 100644 --- a/wai/claim-conformance/README.md +++ b/wai/claim-conformance/README.md @@ -136,6 +136,14 @@ the figure with its class. The vectors cover: - **The labelled delivery leaf:** an `energy` claim against an object whose leaf carries a class, agreeing, and disagreeing in figure, class or evidence; an `OnChipCounter` label with no filtering state reads as `Unknown`. +- **The fabric record (§2.4.8 rule 6, energy-measurement §6.3),** appended after every + earlier vector, which a test pins by digest: accepted, a figure divided between the CPU + (`task`) and a GPU (`rail`) over steps that ran on each; at step 2, a record written + `null` (a report without one has no member), a step with a member it does not define, + an empty backend and a share written as a string; at step 4, a fabric and an + attribution outside §6.3, a share divided by GPU time (which names no attribution), + steps out of order, a fabric twice, no step, a step on an unmetered fabric, a share on + a fabric no step ran on, and shares that do not sum to the figure. ## What a party observed of a stage set: `stage` and `delivery-gap` diff --git a/wai/claim-conformance/claim_verify.py b/wai/claim-conformance/claim_verify.py index 0e7093178..cf8fcf021 100644 --- a/wai/claim-conformance/claim_verify.py +++ b/wai/claim-conformance/claim_verify.py @@ -214,6 +214,13 @@ GAP_REASONS = ("delivery-timeout", "discretion", "not-received") # §2.4.11 BRACKET = ["idle", "busy", "window_us", "completions", "in_flight", "opening"] SUMMARY = ["n", "mean_uw", "sd_uw", "live_intervals", "longest_live_run"] BUILD = ["profile", "toolchain", "target"] +# §2.4.8's fabric record (energy-measurement §6.3): the one member of a claim that is absent +# rather than null, so a report sealed before it existed keeps its bytes. +FABRIC_RECORD = ["steps", "shares"] +FABRIC_STEP = ["step", "fabric", "backend"] +FABRIC_SHARE = ["fabric", "joules_micro", "attribution"] +FABRICS = ["cpu", "gpu", "media", "neural"] +ATTRIBUTIONS = ["rail", "task"] PREDICTION = ["predictor", "descriptor_hash", "predicted_joules_micro"] # energy-measurement §6.1: profile -> (subject_type, carries several figures, an Ed25519 # key signs the figure, names a group). @@ -393,7 +400,7 @@ def check_body(cls, b): else: raise Reject(2, "field_type") return - members(b, BODY[cls], 2) + members(b, BODY[cls] + (["fabrics"] if cls == "measurement" and "fabrics" in b else []), 2) if cls == "reconstruct-session": check_session_body(b) return @@ -459,6 +466,8 @@ def check_body(cls, b): need(bd["toolchain"] is None or isinstance(bd["toolchain"], str), 2) need(bd["target"] is None or is_token(bd["target"]), 2) need(b["power_reader"] is None or isinstance(b["power_reader"], str), 2) + if "fabrics" in b: + check_fabric_shape(b["fabrics"]) else: need(is_uri(b["vocabulary"]), 2) for k in ("op_requested", "op_committed", "op_delivered"): @@ -1380,6 +1389,46 @@ def acquisition(b): need(u is None and f is None and ref is None, 4, ev) +def check_fabric_shape(f): + """§2.4.8's fabric record at step 2: an object of `steps` and `shares`, every row of + one an object of its members before any row's values, the terms tokens, a backend a + non-empty string, a share's figure an integer. Never null.""" + need(isinstance(f, dict), 2) + members(f, FABRIC_RECORD, 2) + for key, cols in (("steps", FABRIC_STEP), ("shares", FABRIC_SHARE)): + rows = f[key] + need(isinstance(rows, list), 2) + for row in rows: + need(isinstance(row, dict), 2) + members(row, cols, 2) + for row in rows: + if key == "steps": + need(all(isinstance(row[k], str) for k in cols), 2) + need(is_token(row["step"]) and is_token(row["fabric"]) and row["backend"] != "", 2) + else: + need(isinstance(row["fabric"], str) and isinstance(row["attribution"], str), 2) + need(is_token(row["fabric"]) and is_token(row["attribution"]) and is_int(row["joules_micro"]), 2) + + +def fabric_rules(f, joules): + """§2.4.8 rule 6: the vocabularies of energy-measurement §6.3, the order (steps by + term then fabric, shares by fabric, each once, neither empty), every fabric a step + names metered, every share's fabric running a step, and the shares summing to the + figure.""" + st, sh = f["steps"], f["shares"] + need(all(s["fabric"] in FABRICS for s in st) and all(s["fabric"] in FABRICS and s["attribution"] in ATTRIBUTIONS for s in sh), + 4, "measurement_fabric_unknown") + keys = [(s["step"].encode(), FABRICS.index(s["fabric"])) for s in st] + ranks = [FABRICS.index(s["fabric"]) for s in sh] + need(st and sh and all(a < b for a, b in zip(keys, keys[1:])) and all(a < b for a, b in zip(ranks, ranks[1:])), + 4, "measurement_fabric_order") + metered = {s["fabric"] for s in sh} + ran = {s["fabric"] for s in st} + need(ran <= metered, 4, "measurement_fabric_unmetered") + need(metered <= ran, 4, "measurement_fabric_unused") + need(sum(s["joules_micro"] for s in sh) == joules, 4, "measurement_fabric_sum") + + def class_rules(c): """§3.1 step 4 — §2.4.5-§2.4.7 and §2.6.""" cls, b = c["class"], c["body"] @@ -1405,6 +1454,8 @@ def class_rules(c): 4, "measurement_no_power_reader") if b["bracket"] is not None: bracket_rules(b["bracket"], b["joules_micro"], b["energy_provenance"], b["energy_uncertainty"]) + if "fabrics" in b: + fabric_rules(b["fabrics"], b["joules_micro"]) elif cls == "operating-point": figure_rules(c) pts = [b[k] for k in ("op_requested", "op_committed", "op_delivered")] diff --git a/wai/claim-conformance/vectors.json b/wai/claim-conformance/vectors.json index 23f5a1233..cbde2b137 100644 --- a/wai/claim-conformance/vectors.json +++ b/wai/claim-conformance/vectors.json @@ -23534,6 +23534,164 @@ "step": 4, "reason": "session_switch_invalid" } + }, + { + "name": "measurement_fabrics_accept", + "about": "a ModelBased figure of 9,000 uJ divided by fabric: the motion search ran on the CPU and on a GPU, the base's coding on the CPU, the object hashes on the GPU; the CPU's share is the task's own and the GPU's its whole rail, the work its sole user, and the shares sum to the figure", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":{\"shares\":[{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":6500},{\"attribution\":\"rail\",\"fabric\":\"gpu\",\"joules_micro\":2500}],\"steps\":[{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-code\"},{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"object-hash\"}]},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"ade1d72401ae9890ddb985640c3939d4a7366a47ff8ec64dd9d3a1b197f160bdbf5e5d54a45d464b86d981762c8d8b70c189ba67d6f0fe0f0fa3498829949200\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "context": { + "figure": { + "claim": "{\"body\":{\"coverage\":\"Whole\",\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"grade\":\"derived\",\"joules_micro\":9000,\"method\":\"https://transaction.science/wai/extensions/energy-measurement\",\"oer_receipt\":null,\"work_unit\":\"subpixel\",\"work_units\":49152},\"class\":\"energy\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":0,\"sig\":\"81c6ff685f4bf15dc35e0ca55e9823d274185c5ac26299e3c803d90f51fc7b82f87151748d2ed4d755c04ece8a3b77b166f97ddf5e0810da11fd9d96663e1e06\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"5bb20a10b980be8c6d95915394f6e89cd49c16d0f5e94cd87c0ec6ce37bcfc5e\",\"subject_type\":\"object\"}" + } + }, + "expected": { + "outcome": "accept", + "link_hash": "18e3adfd42cfe7e95a5cab676508039a3228201cc9b8dfab627a7a13b3acec59" + } + }, + { + "name": "measurement_fabrics_null", + "about": "a fabric record written null: a report without one has no member, so it has one canonical form", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":null,\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"d08ee5e9f8b1725316150be5e8eb2c3aa44dd384abf55ce6cb23497cc8f24533a5a4c7b9b54b1df0d51de1308b1a931220f06f3679261c1595a73c19bddbbf05\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "field_type" + } + }, + { + "name": "measurement_fabric_step_unknown_member", + "about": "a step carrying its work count: a step is its term, fabric and backend", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":{\"shares\":[{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":6500},{\"attribution\":\"rail\",\"fabric\":\"gpu\",\"joules_micro\":2500}],\"steps\":[{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-code\",\"units\":48},{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"object-hash\"}]},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"bf3b79d56a0f330542dd44835a796c0c85de6d1ca93552c2a4602813fb0f3587628823095c8960a499c0c83378ad2656226153e79faa0a279014f27c1239350e\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "unknown_member" + } + }, + { + "name": "measurement_fabric_backend_empty", + "about": "a step whose backend is the empty string", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":{\"shares\":[{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":6500},{\"attribution\":\"rail\",\"fabric\":\"gpu\",\"joules_micro\":2500}],\"steps\":[{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-code\"},{\"backend\":\"\",\"fabric\":\"cpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"object-hash\"}]},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"8315dbdc55a7579bda8af4d6e6c74ecae852f1a1479d20b3b0062997be76032500cf3cbe78bd62fe901b5963177932a86afc74a9d921d956f681ef476705e60b\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "field_type" + } + }, + { + "name": "measurement_fabric_share_not_integer", + "about": "a share written as a string", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":{\"shares\":[{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":\"6500\"},{\"attribution\":\"rail\",\"fabric\":\"gpu\",\"joules_micro\":2500}],\"steps\":[{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-code\"},{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"object-hash\"}]},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"96617e85e614ab1af4b8973282d845bab99fcf5dc5a6c9842f588519b44da16ec805f9cb18c3e5c96634aed04389bde11cc8e961a51ad2e918b00d971183330e\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 2, + "reason": "field_type" + } + }, + { + "name": "measurement_fabric_unknown", + "about": "a share on a fabric energy-measurement 6.3 does not name", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":{\"shares\":[{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":6500},{\"attribution\":\"rail\",\"fabric\":\"fpga\",\"joules_micro\":2500}],\"steps\":[{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-code\"},{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"object-hash\"}]},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"4b6524608520fccdf643c5cb3dedd95d9a9458cfddf5db5a4aba9633914b21e1ddf6459ffac2430852dbe3dc7fec8fb6462a74315de2ff10401ac8cde1121a0c\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_fabric_unknown" + } + }, + { + "name": "measurement_fabric_attribution_unknown", + "about": "a share attributed by a method energy-measurement 6.3 does not name", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":{\"shares\":[{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":6500},{\"attribution\":\"estimate\",\"fabric\":\"gpu\",\"joules_micro\":2500}],\"steps\":[{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-code\"},{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"object-hash\"}]},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"b402e8284b7ddb0e8d9ce8a95efe11e7b149e77bbbbc952bfe0b1c4529df7e20a4a3ca99fba81950c12cf6aca951ea4612d31e7724d294ce43eea50a64a8d407\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_fabric_unknown" + } + }, + { + "name": "measurement_fabric_time_share", + "about": "a shared GPU rail divided by its users' GPU time: busy time does not apportion energy, so energy-measurement 6.3 names no such attribution", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":{\"shares\":[{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":6500},{\"attribution\":\"time-share\",\"fabric\":\"gpu\",\"joules_micro\":2500}],\"steps\":[{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-code\"},{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"object-hash\"}]},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"1c27953daed7fa881dee0608475b91b85a25dd63c84000e4042b7f99e56c43aa6aaa9deced4e8cc35e69d07288656146763fca5365c4eaab5f60cd4c91ba4d02\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_fabric_unknown" + } + }, + { + "name": "measurement_fabric_steps_out_of_order", + "about": "the motion search listed before the base's coding: steps are in byte order of their terms", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":{\"shares\":[{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":6500},{\"attribution\":\"rail\",\"fabric\":\"gpu\",\"joules_micro\":2500}],\"steps\":[{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-code\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"object-hash\"}]},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"d3d12b97d54fa252120ad54a1e1799c4648beedf078dc37223093ac6cd3d666e1ad61b399e1be0fe8d042697583fa3ad8085f1e0842fe88fe8e4b4abb623a505\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_fabric_order" + } + }, + { + "name": "measurement_fabric_share_twice", + "about": "two shares on the CPU: a fabric has one share", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":{\"shares\":[{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":6500},{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":2500}],\"steps\":[{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-code\"}]},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"fbec2c55c3d7a36f0d3f2093e25c31c5a55bd9e19b3c6f8879aa8dc266e1092fdecb2489f2bafb89fc5abbdf6adf643fc5d59d367fff38d2dd0bfdb4eab86008\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_fabric_order" + } + }, + { + "name": "measurement_fabric_no_step", + "about": "shares with no step: a record says where the work ran", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":{\"shares\":[{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":6500},{\"attribution\":\"rail\",\"fabric\":\"gpu\",\"joules_micro\":2500}],\"steps\":[]},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"b45a7c64df5a376493f0136630ef1080c55c7c966f02eb5410b390c09340812c862a90b7b12703fdb953e58ab665ca55341b1eeb87afc1d5bda68a65a6b9d80e\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_fabric_order" + } + }, + { + "name": "measurement_fabric_unmetered", + "about": "a step on the GPU with a share on the CPU alone: a fabric that ran work has a share", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":{\"shares\":[{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":9000}],\"steps\":[{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-code\"},{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"object-hash\"}]},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"0296dfdd393715cf081ae36dd7e1046345e41489c38a50e44c51ba7fac0f7456d03b5fd8508372517df2bfe583f813394f2f7fd8edaa9d6b68c3daf24a1c1001\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_fabric_unmetered" + } + }, + { + "name": "measurement_fabric_unused", + "about": "a share on the GPU while every step ran on the CPU", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":{\"shares\":[{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":6500},{\"attribution\":\"rail\",\"fabric\":\"gpu\",\"joules_micro\":2500}],\"steps\":[{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-code\"},{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-search\"}]},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"a538aac1de0c7d1c8b50b141ae9e4d8a272264451e15e4736e62126d33f50c3f69e577e33a1b39a07862412718cb7f5f1a009bc38ed96f3297540db4a8bd270e\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_fabric_unused" + } + }, + { + "name": "measurement_fabric_sum", + "about": "shares of 6,500 and 2,499 uJ under a figure of 9,000: the shares divide the figure", + "procedure": "claim", + "receipt": "{\"body\":{\"bracket\":null,\"build\":{\"profile\":\"release\",\"target\":\"x86_64-unknown-linux-gnu\",\"toolchain\":\"rustc 1.98.0 (example)\"},\"energy_calibration_ref\":null,\"energy_counter_filtering\":null,\"energy_provenance\":\"ModelBased\",\"energy_uncertainty\":null,\"fabrics\":{\"shares\":[{\"attribution\":\"task\",\"fabric\":\"cpu\",\"joules_micro\":6500},{\"attribution\":\"rail\",\"fabric\":\"gpu\",\"joules_micro\":2499}],\"steps\":[{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-code\"},{\"backend\":\"claim-conformance: a cpu backend\",\"fabric\":\"cpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"base-search\"},{\"backend\":\"claim-conformance: a gpu backend\",\"fabric\":\"gpu\",\"step\":\"object-hash\"}]},\"figure_index\":null,\"figure_profile\":\"moq-jwp:claim-link\",\"group\":null,\"joules_micro\":9000,\"power_reader\":\"reader:example\"},\"class\":\"measurement\",\"issued_at\":\"2026-09-27T12:00:00Z\",\"kind\":\"claim\",\"parent_receipt_hash\":null,\"seq\":1,\"sig\":\"7528210f3893e4066c79e478bd771fb9dff22ead676a08e86b76a89a0cb37b1fc041d1391250cdca436ad14f1e4a4efa22937c2c1a0f5eb480dea1f954cee201\",\"signer_id\":\"did:example:meter\",\"signer_pubkey\":\"a09aa5f47a6759802ff955f8dc2d2a14a5c99d23be97f864127ff9383455a4f0\",\"subject\":\"a2316cae02860ddb6643af9b2213dcc4768867fbcd46e32e17ad7aabccf8758e\",\"subject_type\":\"receipt\"}", + "expected": { + "outcome": "reject", + "step": 4, + "reason": "measurement_fabric_sum" + } } ] } diff --git a/wai/extensions/energy-measurement.md b/wai/extensions/energy-measurement.md index 4cbb0ae3b..1a11b7f46 100644 --- a/wai/extensions/energy-measurement.md +++ b/wai/extensions/energy-measurement.md @@ -1026,6 +1026,46 @@ and, under a hardware class, that the rail was live and the ratio is at least 1 (jwp-receipts §2.4.8). It does not check the values, which cannot be re-measured (§5). +### 6.3 A figure divided by fabric + +Work can run on more than one kind of processor, a **fabric**: `cpu`, `gpu`, +`media` (a hardware video engine) or `neural` (a neural-network engine). A +`measurement` report MAY carry a **fabric record** (jwp-receipts §2.4.8, +`fabrics`): the steps of the work, each with every fabric that ran it and the +backend that did, and the figure divided into one **share** per fabric that ran +a step. The figure is the sum of its shares, and its class (§2) is the class of +the whole: a share is no measurement of its own. + +Each share states how its energy was attributed to the work: + +- `task`: the operating system's account of the work's own energy on that + fabric, as it accounts a task's use of the processors (on the reference + hosts, the kernel's per-task CPU energy, `ri_energy_nj` of + `proc_pid_rusage`, read by the process for itself), which other work on the + host does not enter; +- `rail`: the fabric's whole rail over the span, in which the work was the + fabric's sole user, at least 99% of the busy time every user of the fabric + accrued over it; a share that is exactly 0 because the work spent no time on + the fabric is also `rail`. + +No attribution divides a rail that other work shared by its users' busy time. +Busy time does not apportion energy: a GPU kernel that keeps the GPU busy at a +low clock and one that keeps it busy at a high clock accrue the same time and +draw very differently, and a division by GPU time has been measured to charge +a decode three to fifteen times the GPU energy it drew alone +(`jouleclaw-energy`, `process_gpu`). Where a fabric that ran a step was shared +and its rail cannot be attributed, the span has no fabric record: the producer +meters it otherwise (§3's bracket, over the whole figure), or seals it +unmetered. So does a span that ran a step on a fabric with no live counter: on +the reference hosts (macOS 27 on arm64) the media engine and the neural engine +have none an unprivileged process can read. + +The record says where the work ran and what each fabric drew; it changes +nothing a figure's verifier checks of the figure itself (§6.2). Which fabric +ran a step changes no output of a step whose output its specification fixes +([packaging](packaging.md) §11, its steps and fabrics), so a record is a statement about energy, not +about what was produced. + ## 7. Worked example `wai.video.int_motion`, real-footage vector, 8 frames of 96×96 RGB, release diff --git a/wai/extensions/jwp-receipts.md b/wai/extensions/jwp-receipts.md index 5767db631..756c1af06 100644 --- a/wai/extensions/jwp-receipts.md +++ b/wai/extensions/jwp-receipts.md @@ -315,7 +315,11 @@ one such statement — a class-scoped `body` under a `class` discriminator — on one envelope. Required members. A claim has no optional members: a nullable member -is present and `null`, so one claim has one canonical form. +is present and `null`, so one claim has one canonical form. There is one +exception, a `measurement` claim's `fabrics` (§2.4.8): it is absent when the +claim does not divide its figure by fabric and is never `null`, so a claim +sealed before the member existed keeps its bytes and its link hash, and one +claim still has one canonical form. | field | type | meaning | |---|---|---| @@ -676,6 +680,7 @@ receipt's identity for every other profile. | `bracket` | object or `null` | For a figure obtained under energy-measurement §3: `{ "idle": summary, "busy": summary, "window_us": integer, "completions": integer, "in_flight": integer, "opening": "from-rest" or "running" }`, a summary being `{ "n": integer, "mean_uw": integer, "sd_uw": integer, "live_intervals": integer, "longest_live_run": integer }` as energy-measurement §6.2 defines them. `null` otherwise. | | `build` | object | `{ "profile": "release" or "debug", "toolchain": string or null, "target": term or null }`: the build of the code measured (energy-measurement §5, §8). `release` is an optimised build (for a Rust build, `opt-level` 2, 3, `s` or `z`, whatever its profile is named); any other build is `debug`. A finer vocabulary is a new registration. | | `power_reader` | string or `null` | The reader of the power interface (energy-measurement §8). Required under a hardware class (rule 4). | +| `fabrics` | object, or absent | Where the work the figure covers ran, and the figure divided by fabric (energy-measurement §6.3): `{ "steps": [step, ...], "shares": [share, ...] }`. A step is `{ "step": term, "fabric": term, "backend": string }`: a step of the work, in the vocabulary of the document that defines the figure's receipt (for `wai:video-package-receipt-id`, the steps of [packaging](packaging.md) §11), a fabric that ran it, and the implementation and device that did, a non-empty string. A share is `{ "fabric": term, "joules_micro": integer, "attribution": term }`. Absent, the claim does not divide its figure; never `null` (§2.4). | A report carries none of what follows from its values: neither the §4 ratio nor its verdict, which every reader derives (energy-measurement §6.2). A bracket @@ -721,6 +726,18 @@ Rules, checked at §3.1 step 4 in this order: **MUST** be the bracket's (`measurement_window_mismatch`), and the declared `relative_ppm` **MUST** be at least the least `p` the bracket supports (energy-measurement §6.2) (`measurement_declaration_below_bracket`). +6. Where `fabrics` is present, in this order: + - every fabric is `cpu`, `gpu`, `media` or `neural`, and every attribution `task` + or `rail` (energy-measurement §6.3) (`measurement_fabric_unknown`); + - there is at least one step and one share; the steps are in the order of their + `step`, in byte order, and then of their fabric, in the order just given, each pair + once; and the shares are in the order of their fabric, each fabric once + (`measurement_fabric_order`); + - every fabric a step names has a share (`measurement_fabric_unmetered`): a fabric + that ran work is metered; + - every share's fabric runs a step (`measurement_fabric_unused`); + - the shares sum to `joules_micro` (`measurement_fabric_sum`): the record divides + the figure, and the figure's class covers every share. **Against the figure** (§3.1 step 6). The context is the receipt the claim names, verified under its own profile's rules; for `moq-jwp:object`, that is @@ -1917,9 +1934,14 @@ order listed. 2. **Body.** A `body` that is not an object is `field_type`. Otherwise it is checked against the class's member table (for a lawful-basis body, the table of the record that `record`, read first, names; see - §2.4.6): a member absent is `missing_required_field`, an additional + §2.4.6): a member absent is `missing_required_field` (but a + `measurement` body's `fabrics`, which may be absent, §2.4), an additional member is `unknown_member`, and then, in the table's order, a member - that is not of the type the table gives it is `field_type`. That is + that is not of the type the table gives it is `field_type`; a `fabrics` + that is `null` is `field_type`, and within it, as in a bracket, a member + absent is `missing_required_field` and an additional one `unknown_member` + (`steps` before `shares`, each array's rows read for their members before + its rows' values). That is a wrong JSON type; an integer that is not one of §2.4.3; a byte string that is not lowercase hex, two digits a byte, or a hash that is not 64 of them; a value outside an enumeration this extension @@ -2061,6 +2083,11 @@ and never folds it in: confirmation changes the outcome of no step here. | 4 | `measurement_figure_mismatch` | `joules_micro` is not the figure the bracket gives | | 4 | `measurement_window_mismatch` | under a hardware class, the declared window is not the bracket's | | 4 | `measurement_declaration_below_bracket` | under a hardware class, `relative_ppm` is below the least the bracket supports | +| 4 | `measurement_fabric_unknown` | a fabric record names a fabric other than `cpu`, `gpu`, `media` and `neural`, or an attribution other than `task` and `rail` | +| 4 | `measurement_fabric_order` | a fabric record with no step or no share, its steps not in the order of their step and fabric, or its shares not in the order of their fabric, or a pair or a fabric twice | +| 4 | `measurement_fabric_unmetered` | a fabric record's step runs on a fabric the record gives no share | +| 4 | `measurement_fabric_unused` | a fabric record's share is on a fabric no step of it runs on | +| 4 | `measurement_fabric_sum` | a fabric record's shares do not sum to `joules_micro` | | 4 | `operating_point_empty_point` | a point is present with no member | | 4 | `operating_point_delivered_alone` | `op_delivered` is present without `op_committed` | | 4 | `operating_point_incomplete` | `op_requested` or `op_committed` is present without `op_delivered` | diff --git a/wai/extensions/packaging.md b/wai/extensions/packaging.md index 6705aa135..1a4b02359 100644 --- a/wai/extensions/packaging.md +++ b/wai/extensions/packaging.md @@ -7,10 +7,12 @@ > piece's ladder (§5), the stage index (§6), the floor, one or several, supplied as OBU > streams or as an existing ladder's CMAF files (§7), what each class is offered > over HTTP and MoQ and how its delivery is simulated (§8), the package receipt and live -> windows (§9), and the package, its verification and a re-pack from a parent (§11). +> windows (§9), the package, its verification and a re-pack from a parent (§11), and +> where each step of a pack or a verify may run (§11.6). > Reference impl: `wai-pack` — > `policy`, `capcheck`, `source`, `scenecut`, `classify`, `plan`, `encode`, `png`, -> `floor`, `cmaf`, `cmaf_read`, `catalog`, `files`, `pack`, `live`, `delivery`, `verify`; +> `floor`, `cmaf`, `cmaf_read`, `catalog`, `files`, `pack`, `live`, `delivery`, `verify`, +> `fabric`, `fabric_meter`, `floor_decode`, and `wai-pack-gpu` for the steps a GPU runs; > in `wai-rs`, the > encoder `int_video_enc`, the refinement encoder `staged::wir1`, the stage index > `stage_index`, the receipt `package` and the delivery simulator `stage_measure::sim`; @@ -40,6 +42,10 @@ where it names them. several. - **Timebase** of a component: `num / den` units per second. For video one unit is one frame; for audio, one sample. +- **Step**: one of the computations of a pack or a verify (§11.6), such as the base's + motion search or an object's content hash. +- **Fabric**: the kind of processor a step runs on: `cpu`, `gpu`, `media` (a hardware + video engine) or `neural` (a neural-network engine) (§11.6). ## 3. Policy @@ -1192,8 +1198,9 @@ are refused (`pack-id`). **11.2** *Re-pack equivalence.* Given the same source bytes, effective policy, supplied floor, signing key and packager identity, a conforming reference packager **MUST** -produce byte-identical package files on every machine, except the energy figure and -measurement claim of a metered package and floor objects §7.5 covers. A verifier +produce byte-identical package files on every machine and whichever fabric runs each +step (§11.6), except the energy figure and measurement claim of a metered package and +floor objects §7.5 covers. A verifier **MAY** pack again and compare listings. A packager whose identity differs is not held to this. A re-pack from a parent (§11.5) is held to it with the parent among its inputs; and where the parent was packed by a conforming packager from the same source under base @@ -1276,7 +1283,8 @@ failed check names is not applied: index no catalog can be written for (a start past `2^53 − 1`) is refused (`catalog-mismatch`). 7. A metered package's measurement claim (§9.4: `measurement-claim-missing`, - `measurement-claim-invalid`); an object file no entry names (`object-unlisted`); a + `measurement-claim-invalid`, a fabric record naming a step this extension does not + define, §11.6, among the latter); an object file no entry names (`object-unlisted`); a catalog file past the ones given (`catalog-unexpected`); any other file (`unexpected-file`). @@ -1286,7 +1294,9 @@ refinements of `wai.video.int_refine` over an `int_motion` base (§5.4); each pi ladder, a rule's ladder subset included (§5.7); and a supplied AV1 floor per floor rung, one or several (§7.1), in the OBU form (§7.3) or the CMAF form (§7.7); and floor-only components (§7.8). Its live -packer packs the same pieces in windows, without a floor (§9.2). It refuses by name, +packer packs the same pieces in windows, without a floor (§9.2). It runs `base-search`, +`sse` and `object-hash` on a GPU that qualifies, where it is built to and asked to, and +every other step on the CPU (§11.6). It refuses by name, before writing anything, what it does not pack yet: another medium (`pack-media-unsupported`), a floor to encode (`pack-floor-encode-unsupported`), and a rung of a capability it does not encode, or a refinement over a base it does not refine @@ -1347,6 +1357,69 @@ entries decide (`repack-parent-mismatch`). A re-pack holds no source of its pare bases; what binds a reused base to this source is the third check, and what binds it to the parent is the receipt. +**11.6** *Steps and fabrics.* The steps of a pack and a verify are named by these terms: + +| Step | What it computes | +|---|---| +| `source-decode` | a source's frames from a coded rendition, before a pack, by a producer's tool (§4.7 reads the result) | +| `source-read` | a source's frames or samples (§4.7) | +| `segment` | the pieces (§4.3) and their kinds (§4.6) | +| `base-search` | the integer base's motion search (§5.5) | +| `base-code` | the rest of the integer base (§5.5), its step (§5.6) and a still (§5.7) | +| `refine-code` | refinement layers, each applied by the reference sink (§5.4) | +| `sse` | an entry's `sse` (§6.6) | +| `object-hash` | an object's content hash (§5.3) | +| `digest` | the SHA-256 digests: decoded-output digests (§6.5), listings, file names | +| `floor-read` | a supplied floor's units, CMAF boxes and listing (§7.3, §7.7) | +| `floor-decode` | a floor's units decoded to pictures (item 4 below) | +| `index` | the stage indexes, catalogs, receipt and claim (§6, §8, §9) | +| `io` | reading and writing files | + +Every step is a function of its inputs, defined in integers (and `floor-decode` by the +AV1 decoding process), and none names a fabric: + +1. *Packages.* A packager **MAY** run a step on any fabric whose output is the step's + for every input. It **MUST NOT** run a step on a backend that has not given the + reference implementation's output on the qualifying inputs before it is used (the + reference packager's: the frame pairs of `wai_pack::fabric::qualifying_frames` for + the motion search, and seeded inputs for SSE and BLAKE3, on each device it opens), nor + at all on one that computes otherwise than the step's definition, such as in floating + point where the step is defined in integers, or with a learned approximation. A + backend that cannot take a call (a size, a lost device) leaves it to the CPU, with the + same output. So §11.2 holds across fabrics: the package files are the same bytes + whichever fabric ran each step, and only a metered package's measurement claim says + where they ran. +2. *Verdicts.* A verifier's verdict and its reason are functions of the package + (§11.3). A verifier **MAY** compute a check on another fabric where the package states + the value the check compares with, such as an object's content hash: a match shows + that the fabric computed the check, and a mismatch **MUST** be computed again on the + reference implementation before it is reported. Every other check runs on a backend + held to the reference's output as item 1 holds a packager's. A verdict that changes + with the fabric is the verifier's fault, never the package's. +3. *Hardware encoders.* A hardware encoder's output depends on the chip, its firmware + and driver, and its settings, and no rule fixes it, so no step of this extension is + computed by one. A floor a packager is given is an input, whatever made it (§7.3, + §7.7); a floor a packager encodes on such hardware is one §7.5 covers, whose + `derived_from.renderer` names the encoder, its version, and the device and driver that + ran it, and §11.2 does not hold its bytes. +4. *Decoding a floor.* The AV1 decoding process fixes every output sample of a stream + whose sequence header has `film_grain_params_present` 0, and of every stream before + film grain is applied (AV1 §7.2), so conformant decoders on any fabric give the same + pictures of a floor's units. A floor states no decoded-output digest (§7.4), so a + decode of it has no stated value to compare with: a verifier that decodes floors + **MUST NOT** refuse a package for what a decoder other than the reference gives, nor + read a hardware decoder's error or concealment as a verdict. A hardware decoder's + frames may be held in a wider format than the stream's bit depth (a 10-bit sample + shifted into the high bits of a 16-bit word); a reader of them recovers each sample + exactly or refuses the frame, and never rounds. (Informative: `wai-pack floor-decode` + reports each piece's digest from either decoder, for comparing them.) +5. *The fabric record.* A metered package's measurement claim **MAY** carry a fabric + record (jwp-receipts §2.4.8, energy-measurement §6.3): each step the metered span ran, + with every fabric that ran it and its backend, and the figure's share on each. Its + steps are this section's terms (§11.3 step 7). An unmetered package carries no claim, + so no record: which fabric ran a step is not part of the package, and two packages + that differ only in where their steps ran are the same files. + ## 12. Reasons | Reason | Where | diff --git a/wai/pack-conformance/pack_verify.py b/wai/pack-conformance/pack_verify.py index 50103e2aa..20ac55f7c 100644 --- a/wai/pack-conformance/pack_verify.py +++ b/wai/pack-conformance/pack_verify.py @@ -3458,9 +3458,18 @@ def measurement_claim_fault(b, j): return "another acquisition class or evidence than the receipt's" if c["signer_pubkey"] != j["signer_pubkey"]: return "signed by another key than the receipt's" + # A package's fabric record (jwp-receipts §2.4.8) names the steps of §11.6. + bad = [s["step"] for s in f.get("fabrics", {"steps": []})["steps"] if s["step"] not in PACK_STEPS] + if bad: + return f"a fabric record naming {bad[0]}, which is not a step of §11.6" return None +# §11.6: the steps of a pack or a verify, as a measurement claim's fabric record names them. +PACK_STEPS = ("source-decode", "source-read", "segment", "base-search", "base-code", "refine-code", "sse", + "object-hash", "digest", "floor-read", "floor-decode", "index", "io") + + def repack_fields(ix, k): """§11.5: piece k's base and refinement entries, every field but the entry's place in its index: the entry it refines counted from its piece's first, its flags but the duplicate diff --git a/wai/pack-conformance/package-mutations/expected.json b/wai/pack-conformance/package-mutations/expected.json index 6da029e10..ac38988bf 100644 --- a/wai/pack-conformance/package-mutations/expected.json +++ b/wai/pack-conformance/package-mutations/expected.json @@ -297,6 +297,16 @@ "reason": "measurement-claim-missing", "remove": [] }, + { + "name": "metered-fabrics", + "reason": "ok", + "remove": [] + }, + { + "name": "metered-fabrics-unknown-step", + "reason": "measurement-claim-invalid", + "remove": [] + }, { "name": "empty-directory", "reason": "unexpected-file", diff --git a/wai/pack-conformance/package-mutations/metered-fabrics-unknown-step/LISTING b/wai/pack-conformance/package-mutations/metered-fabrics-unknown-step/LISTING new file mode 100644 index 000000000..cc1ce69ce --- /dev/null +++ b/wai/pack-conformance/package-mutations/metered-fabrics-unknown-step/LISTING @@ -0,0 +1,36 @@ +6b414a7fc1d9ee44a40aa79b9c3338d04ec00dbfbf88d39f5965ce65a6804890 claims/01d891a6690096d3de7c8e7cec802cb95f82deb84b5d45994dd713e7576673f2.measurement.json +91b8aba2292191f2046eb841fe6fcb7b214826cecd9e06a3c193a68e7089e144 dash/classic.mpd +a3aa7658db9b85a9d9a1fed977825189f75c5adad0f5fdfcd70ebd604824c138 dash/mobile.mpd +3f520749a6d2f231cddfd17804ee3629439f3678515e424c44e36cff54d89de9 dash/tv.mpd +04c948608aaa7b5d78ad5355042a2ff65b0f75d9d4b1ad78f3ebffb886a74be6 floor/v/04c948608aaa7b5d78ad5355042a2ff65b0f75d9d4b1ad78f3ebffb886a74be6.m4s +06d5cd79ba3860cccdc252fe915de0902f941a3c6a000295714f7cab193aa6cf floor/v/06d5cd79ba3860cccdc252fe915de0902f941a3c6a000295714f7cab193aa6cf.m4s +2f93f0ad449f9cc881b7816c6ab33fd445037d6538b445bcc76431f9c9f91c5b floor/v/2f93f0ad449f9cc881b7816c6ab33fd445037d6538b445bcc76431f9c9f91c5b.m4s +420ae4c941bba6e12a6c7fdb7d01dc69170ecb4240dffd2e86808ddf87baa12a floor/v/420ae4c941bba6e12a6c7fdb7d01dc69170ecb4240dffd2e86808ddf87baa12a.m4s +99a24de04f7be9fc21097e69d2d61b222e458b1f93b8082026a9874aeb9f4c15 floor/v/99a24de04f7be9fc21097e69d2d61b222e458b1f93b8082026a9874aeb9f4c15.m4s +58c598174eed554cd0d1bc8abedca8c9d51d992c8e3b963bcb7574da4983ed9f floor/v/init.mp4 +289bfcb09eb25124ac19597bc624371c34bea00aa1011ced9372d96b5e14b2bd hls/classic/floor-v.m3u8 +3026d7f11005e34ff66298175777ee6806d649cf9d5c1467aaa4fb00ac5987e6 hls/classic/master.m3u8 +3026d7f11005e34ff66298175777ee6806d649cf9d5c1467aaa4fb00ac5987e6 hls/legacy/master.m3u8 +c5902f61ffaf77f940c4d3dc6e850340aaaeb3f433e4541f935c32443c50b341 hls/mobile/master.m3u8 +c4044eca76959c9f87230484a2b207fb12c0bcaedb8d921eab806014fca53cb8 hls/mobile/wai-v.m3u8 +60a6378f46afc9308dc4ac4d138602b16568577616ea0bf4526b142d86899151 hls/mobile/wai.m3u8 +c5902f61ffaf77f940c4d3dc6e850340aaaeb3f433e4541f935c32443c50b341 hls/tv/master.m3u8 +ff4edf2929e380d205c1c74156621185feec53d593ffa968b8dfaf0d05448bcd hls/tv/wai-v.m3u8 +5d42147b308330913c3d9c8a868ca83ce4d3f3e460da8d84c84014eb24aaf9d7 hls/tv/wai.m3u8 +0b65400d0bbde57ed6a2c19d90cb72ec753de0e76165e4f286acfd5a7bda14d2 index-v.wsix +115e8e1e7ee0765de9bd2f71576cae01ce6576a5ce2a24f3374879eb74d9a6af moq/catalog.json +07e98fb9ef9ff3fa6bf04f9078da2a3d2423e586feea1061256131a1d1fbb943 objects/24761a434c91c627f11abd6265371966b50573602a200dd7bb04e0d90889326d.wai +4d583d03292b77f9d46b02f2c80a774b1cda1b07df38243d18fe586b82eaca60 objects/35c7a45323b7afe979c123fa885f35cc55fabd2a1954759aebe4b95dbc76361f.wai +cfb71256ef44758ea279846a8938823994d321abce83ef73afd2ed269a8e0259 objects/618e0a89be8c7e556b380326b219c2f6fbfc5a0cb97754e871c1c23a99bdb481.wai +f3eedf9eb2870c0160801397412e7a4c3aca4a6e16f1a69a276e1356d38c9220 objects/61c7c3b7ccab07ed01df5b0d7ca0240598b914dbf2cabe545aa001e8de12d365.wai +9fdf29f5c9536d1f7701c791328d656ef725ba9b8cb3760bdb00f5a503c69338 objects/761c4649427c51796a2b699568775765b209ee80d2f4fc1cf040838c6724aa2d.wai +a3135205d3139b39409d4e4e2aafed2c042797d293a9f9fc8664467165336e25 objects/7cc2b824ad6105abfccae27093190311b6a2348842e571684b021a213341b94a.wai +3c66c0b25b759cd1fe6245d282ba928441b873d62e4ac62c95260e45a80fff77 objects/8f748dc64787b5c5ece081270c8559277f926322dd6ddc345306b8231f2d1752.wai +b2a40a9a8cec3dcaf6c92a07d6765203c412c57ec879b7e33a52eabb2e74609d objects/c2ce1907255a041c388afd06ccd007436368e8d20467fdec2a1eebbb84b03bf2.wai +4a2370c37281e64f30cb66a4bd7125854d6280855e90f00229b177bd70af3d8c package.receipt.json +517630d9bee1f5559baff3bb3e642f6aa7b5276226f6e3bdba4f3f9724794859 plan-v.txt +a497fdf29528c4744ce8548e3b430d6311f15c10ca2a4cb548fb9958d3d98f07 policy.json +d667cfa0c1e4e52f254f3a89a80cb0d3b6be0d833c40c8b44f60be33765859b0 policy.toml +59b343802800c815390b840da3ee6981367b495a30d8a1952f6484d04271caf9 stages/legacy.json +b7514cb869338126e4785943a4624e9dbfa44464fa450c55341b7931bd9c19f9 stages/mobile.json +33898452413a8fe209fb6b158bfd1e9b06a74fc3ab7639810522a586a4aa6ac6 stages/tv.json diff --git a/wai/pack-conformance/package-mutations/metered-fabrics-unknown-step/claims/01d891a6690096d3de7c8e7cec802cb95f82deb84b5d45994dd713e7576673f2.measurement.json b/wai/pack-conformance/package-mutations/metered-fabrics-unknown-step/claims/01d891a6690096d3de7c8e7cec802cb95f82deb84b5d45994dd713e7576673f2.measurement.json new file mode 100644 index 000000000..92216c412 --- /dev/null +++ b/wai/pack-conformance/package-mutations/metered-fabrics-unknown-step/claims/01d891a6690096d3de7c8e7cec802cb95f82deb84b5d45994dd713e7576673f2.measurement.json @@ -0,0 +1 @@ +{"body":{"bracket":null,"build":{"profile":"release","target":null,"toolchain":null},"energy_calibration_ref":null,"energy_counter_filtering":null,"energy_provenance":"ModelBased","energy_uncertainty":null,"fabrics":{"shares":[{"attribution":"task","fabric":"cpu","joules_micro":500000}],"steps":[{"backend":"cpu: the reference implementation","fabric":"cpu","step":"base-code"},{"backend":"cpu: the reference implementation","fabric":"cpu","step":"lunch"}]},"figure_index":null,"figure_profile":"wai:video-package-receipt-id","group":null,"joules_micro":500000,"power_reader":"model:500mW"},"class":"measurement","issued_at":"2026-10-21T00:00:00Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"3d1c9daf255ffd4eded9a09f2ed4d9376ff7a94a28e179e2961aa354255aa3bcca287ac7d201a610fa5f87a527fb31718729fddd5982cb3fe2398a4b1e884806","signer_id":"did:key:z6MkfMo6gxqdBhaHMNnmfhgZFBjpCDTkmJMJLoypsBZS9PwD","signer_pubkey":"0d7550754e0800a5d237eef5826035766b9b3e5a15868a940ab289958788e3b0","subject":"01d891a6690096d3de7c8e7cec802cb95f82deb84b5d45994dd713e7576673f2","subject_type":"receipt"} \ No newline at end of file diff --git a/wai/pack-conformance/package-mutations/metered-fabrics-unknown-step/package.receipt.json b/wai/pack-conformance/package-mutations/metered-fabrics-unknown-step/package.receipt.json new file mode 100644 index 000000000..a59cb6c3b --- /dev/null +++ b/wai/pack-conformance/package-mutations/metered-fabrics-unknown-step/package.receipt.json @@ -0,0 +1 @@ +{"asset_id":"video-base-01","components":[{"id":"v","index":"index-v.wsix","index_digest":"5ffc2be3f21af79078a1158e3d669e4268a35b0ca61a6e73ff252fdf38f346d0","media_class":"video","stage_root":"bd0c7799633b108cf9ccb0c7a4cedbdf6173a83470d3d96b94d075c03e0db7b6"}],"energy_provenance":"ModelBased","grant":{"capability":"wai.video.package","funds_ceiling":null,"grant_hash":"0000000000000000000000000000000000000000000000000000000000000000","joule_ceiling_micro":null},"joules_micro":500000,"kind":"package","packager":"builtin:wai-pack@0.1.0","parent_kind":"none","parent_receipt_hash":null,"policy_digest":"e1c131d56798eb840e22736076d74908bca2b7585aeae7e4bb1698218c01a35e","receipt_hash":"01d891a6690096d3de7c8e7cec802cb95f82deb84b5d45994dd713e7576673f2","sig":"eb052a53c735830304a6c74230dcc348ca6d0c8110497e916407062da83e627db36ba165167ed217e451a26dfa1f5b5e21cf766725063c4532df82ff9d949604","signer_id":"did:key:z6MkfMo6gxqdBhaHMNnmfhgZFBjpCDTkmJMJLoypsBZS9PwD","signer_pubkey":"0d7550754e0800a5d237eef5826035766b9b3e5a15868a940ab289958788e3b0","stored_bytes":119779,"total_bytes":130963,"total_entries":10,"unique_objects":8,"version":2,"work_units":10} \ No newline at end of file diff --git a/wai/pack-conformance/package-mutations/metered-fabrics/LISTING b/wai/pack-conformance/package-mutations/metered-fabrics/LISTING new file mode 100644 index 000000000..472110750 --- /dev/null +++ b/wai/pack-conformance/package-mutations/metered-fabrics/LISTING @@ -0,0 +1,36 @@ +4c26bc6aa2694b67430cf49ce8632588c97927c6c776d62295704036a3049d38 claims/01d891a6690096d3de7c8e7cec802cb95f82deb84b5d45994dd713e7576673f2.measurement.json +91b8aba2292191f2046eb841fe6fcb7b214826cecd9e06a3c193a68e7089e144 dash/classic.mpd +a3aa7658db9b85a9d9a1fed977825189f75c5adad0f5fdfcd70ebd604824c138 dash/mobile.mpd +3f520749a6d2f231cddfd17804ee3629439f3678515e424c44e36cff54d89de9 dash/tv.mpd +04c948608aaa7b5d78ad5355042a2ff65b0f75d9d4b1ad78f3ebffb886a74be6 floor/v/04c948608aaa7b5d78ad5355042a2ff65b0f75d9d4b1ad78f3ebffb886a74be6.m4s +06d5cd79ba3860cccdc252fe915de0902f941a3c6a000295714f7cab193aa6cf floor/v/06d5cd79ba3860cccdc252fe915de0902f941a3c6a000295714f7cab193aa6cf.m4s +2f93f0ad449f9cc881b7816c6ab33fd445037d6538b445bcc76431f9c9f91c5b floor/v/2f93f0ad449f9cc881b7816c6ab33fd445037d6538b445bcc76431f9c9f91c5b.m4s +420ae4c941bba6e12a6c7fdb7d01dc69170ecb4240dffd2e86808ddf87baa12a floor/v/420ae4c941bba6e12a6c7fdb7d01dc69170ecb4240dffd2e86808ddf87baa12a.m4s +99a24de04f7be9fc21097e69d2d61b222e458b1f93b8082026a9874aeb9f4c15 floor/v/99a24de04f7be9fc21097e69d2d61b222e458b1f93b8082026a9874aeb9f4c15.m4s +58c598174eed554cd0d1bc8abedca8c9d51d992c8e3b963bcb7574da4983ed9f floor/v/init.mp4 +289bfcb09eb25124ac19597bc624371c34bea00aa1011ced9372d96b5e14b2bd hls/classic/floor-v.m3u8 +3026d7f11005e34ff66298175777ee6806d649cf9d5c1467aaa4fb00ac5987e6 hls/classic/master.m3u8 +3026d7f11005e34ff66298175777ee6806d649cf9d5c1467aaa4fb00ac5987e6 hls/legacy/master.m3u8 +c5902f61ffaf77f940c4d3dc6e850340aaaeb3f433e4541f935c32443c50b341 hls/mobile/master.m3u8 +c4044eca76959c9f87230484a2b207fb12c0bcaedb8d921eab806014fca53cb8 hls/mobile/wai-v.m3u8 +60a6378f46afc9308dc4ac4d138602b16568577616ea0bf4526b142d86899151 hls/mobile/wai.m3u8 +c5902f61ffaf77f940c4d3dc6e850340aaaeb3f433e4541f935c32443c50b341 hls/tv/master.m3u8 +ff4edf2929e380d205c1c74156621185feec53d593ffa968b8dfaf0d05448bcd hls/tv/wai-v.m3u8 +5d42147b308330913c3d9c8a868ca83ce4d3f3e460da8d84c84014eb24aaf9d7 hls/tv/wai.m3u8 +0b65400d0bbde57ed6a2c19d90cb72ec753de0e76165e4f286acfd5a7bda14d2 index-v.wsix +115e8e1e7ee0765de9bd2f71576cae01ce6576a5ce2a24f3374879eb74d9a6af moq/catalog.json +07e98fb9ef9ff3fa6bf04f9078da2a3d2423e586feea1061256131a1d1fbb943 objects/24761a434c91c627f11abd6265371966b50573602a200dd7bb04e0d90889326d.wai +4d583d03292b77f9d46b02f2c80a774b1cda1b07df38243d18fe586b82eaca60 objects/35c7a45323b7afe979c123fa885f35cc55fabd2a1954759aebe4b95dbc76361f.wai +cfb71256ef44758ea279846a8938823994d321abce83ef73afd2ed269a8e0259 objects/618e0a89be8c7e556b380326b219c2f6fbfc5a0cb97754e871c1c23a99bdb481.wai +f3eedf9eb2870c0160801397412e7a4c3aca4a6e16f1a69a276e1356d38c9220 objects/61c7c3b7ccab07ed01df5b0d7ca0240598b914dbf2cabe545aa001e8de12d365.wai +9fdf29f5c9536d1f7701c791328d656ef725ba9b8cb3760bdb00f5a503c69338 objects/761c4649427c51796a2b699568775765b209ee80d2f4fc1cf040838c6724aa2d.wai +a3135205d3139b39409d4e4e2aafed2c042797d293a9f9fc8664467165336e25 objects/7cc2b824ad6105abfccae27093190311b6a2348842e571684b021a213341b94a.wai +3c66c0b25b759cd1fe6245d282ba928441b873d62e4ac62c95260e45a80fff77 objects/8f748dc64787b5c5ece081270c8559277f926322dd6ddc345306b8231f2d1752.wai +b2a40a9a8cec3dcaf6c92a07d6765203c412c57ec879b7e33a52eabb2e74609d objects/c2ce1907255a041c388afd06ccd007436368e8d20467fdec2a1eebbb84b03bf2.wai +4a2370c37281e64f30cb66a4bd7125854d6280855e90f00229b177bd70af3d8c package.receipt.json +517630d9bee1f5559baff3bb3e642f6aa7b5276226f6e3bdba4f3f9724794859 plan-v.txt +a497fdf29528c4744ce8548e3b430d6311f15c10ca2a4cb548fb9958d3d98f07 policy.json +d667cfa0c1e4e52f254f3a89a80cb0d3b6be0d833c40c8b44f60be33765859b0 policy.toml +59b343802800c815390b840da3ee6981367b495a30d8a1952f6484d04271caf9 stages/legacy.json +b7514cb869338126e4785943a4624e9dbfa44464fa450c55341b7931bd9c19f9 stages/mobile.json +33898452413a8fe209fb6b158bfd1e9b06a74fc3ab7639810522a586a4aa6ac6 stages/tv.json diff --git a/wai/pack-conformance/package-mutations/metered-fabrics/claims/01d891a6690096d3de7c8e7cec802cb95f82deb84b5d45994dd713e7576673f2.measurement.json b/wai/pack-conformance/package-mutations/metered-fabrics/claims/01d891a6690096d3de7c8e7cec802cb95f82deb84b5d45994dd713e7576673f2.measurement.json new file mode 100644 index 000000000..2f0b235e0 --- /dev/null +++ b/wai/pack-conformance/package-mutations/metered-fabrics/claims/01d891a6690096d3de7c8e7cec802cb95f82deb84b5d45994dd713e7576673f2.measurement.json @@ -0,0 +1 @@ +{"body":{"bracket":null,"build":{"profile":"release","target":null,"toolchain":null},"energy_calibration_ref":null,"energy_counter_filtering":null,"energy_provenance":"ModelBased","energy_uncertainty":null,"fabrics":{"shares":[{"attribution":"task","fabric":"cpu","joules_micro":500000}],"steps":[{"backend":"cpu: the reference implementation","fabric":"cpu","step":"base-code"},{"backend":"cpu: the reference implementation","fabric":"cpu","step":"object-hash"}]},"figure_index":null,"figure_profile":"wai:video-package-receipt-id","group":null,"joules_micro":500000,"power_reader":"model:500mW"},"class":"measurement","issued_at":"2026-10-21T00:00:00Z","kind":"claim","parent_receipt_hash":null,"seq":0,"sig":"9888011d5eee7d70fe6f5671e2ad170c05d503b0977bfeacbd23bfb80bd27b95d54ba165c6b4066ce677217b949c5b4da10e251e6c857457c1743f122b9da90f","signer_id":"did:key:z6MkfMo6gxqdBhaHMNnmfhgZFBjpCDTkmJMJLoypsBZS9PwD","signer_pubkey":"0d7550754e0800a5d237eef5826035766b9b3e5a15868a940ab289958788e3b0","subject":"01d891a6690096d3de7c8e7cec802cb95f82deb84b5d45994dd713e7576673f2","subject_type":"receipt"} \ No newline at end of file diff --git a/wai/pack-conformance/package-mutations/metered-fabrics/package.receipt.json b/wai/pack-conformance/package-mutations/metered-fabrics/package.receipt.json new file mode 100644 index 000000000..a59cb6c3b --- /dev/null +++ b/wai/pack-conformance/package-mutations/metered-fabrics/package.receipt.json @@ -0,0 +1 @@ +{"asset_id":"video-base-01","components":[{"id":"v","index":"index-v.wsix","index_digest":"5ffc2be3f21af79078a1158e3d669e4268a35b0ca61a6e73ff252fdf38f346d0","media_class":"video","stage_root":"bd0c7799633b108cf9ccb0c7a4cedbdf6173a83470d3d96b94d075c03e0db7b6"}],"energy_provenance":"ModelBased","grant":{"capability":"wai.video.package","funds_ceiling":null,"grant_hash":"0000000000000000000000000000000000000000000000000000000000000000","joule_ceiling_micro":null},"joules_micro":500000,"kind":"package","packager":"builtin:wai-pack@0.1.0","parent_kind":"none","parent_receipt_hash":null,"policy_digest":"e1c131d56798eb840e22736076d74908bca2b7585aeae7e4bb1698218c01a35e","receipt_hash":"01d891a6690096d3de7c8e7cec802cb95f82deb84b5d45994dd713e7576673f2","sig":"eb052a53c735830304a6c74230dcc348ca6d0c8110497e916407062da83e627db36ba165167ed217e451a26dfa1f5b5e21cf766725063c4532df82ff9d949604","signer_id":"did:key:z6MkfMo6gxqdBhaHMNnmfhgZFBjpCDTkmJMJLoypsBZS9PwD","signer_pubkey":"0d7550754e0800a5d237eef5826035766b9b3e5a15868a940ab289958788e3b0","stored_bytes":119779,"total_bytes":130963,"total_entries":10,"unique_objects":8,"version":2,"work_units":10} \ No newline at end of file diff --git a/wai/wai-pack-gpu/Cargo.lock b/wai/wai-pack-gpu/Cargo.lock new file mode 100644 index 000000000..6f32f9a1d --- /dev/null +++ b/wai/wai-pack-gpu/Cargo.lock @@ -0,0 +1,1063 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "cc" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "find-msvc-tools" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror", + "windows", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash", + "spirv", + "thiserror", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash", + "thiserror", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio" +version = "1.54.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0c168a474b95bfecfe908a65d4bdffc523144b649a74c09c286afb35d53e0bc" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "wai-pack-gpu" +version = "0.1.0" +dependencies = [ + "blake3", + "wgpu", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror", + "wgpu-core-deps-apple", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags", + "block2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "gpu-allocator", + "hashbrown 0.17.1", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/wai/wai-pack-gpu/Cargo.toml b/wai/wai-pack-gpu/Cargo.toml new file mode 100644 index 000000000..05117ba88 --- /dev/null +++ b/wai/wai-pack-gpu/Cargo.toml @@ -0,0 +1,34 @@ +# The steps of WAI's packaging extension that a GPU computes exactly, as WGSL compute +# kernels through wgpu (Metal, Vulkan, DX12): the integer encoder's motion search, the +# sum of squared differences, and BLAKE3's chunk function. Every kernel is integer, in +# 32-bit words, so a device that runs it gives the CPU's result for every input +# (extensions/packaging.md §11.6). +# +# A SEPARATE crate with its own lockfile, as wai-ops-gpu is, so that wai-pack builds +# without wgpu by default; wai-pack's `gpu` feature is the only way it enters a pack. +[package] +name = "wai-pack-gpu" +version = "0.1.0" +edition = "2024" +rust-version = "1.97" +license = "Apache-2.0" +publish = false +description = "WAI packaging steps on GPUs: the integer motion search, SSE and BLAKE3's chunk function as WGSL kernels through wgpu, exact in 32-bit words" +repository = "https://git.transaction.science/Transaction-Science/open-standards" + +# Its own workspace and Cargo.lock. +[workspace] + +[lib] +name = "wai_pack_gpu" +path = "src/lib.rs" + +[dependencies] +wgpu = { version = "30.0.1", default-features = false, features = ["std", "wgsl", "metal", "vulkan", "dx12"] } +# The parent nodes of a message of several chunks are merged on the host, by the BLAKE3 +# reference crate's subtree functions (`blake3::hazmat`); the device computes the chunks. +blake3 = "1" + +[profile.release] +debug = false +overflow-checks = true diff --git a/wai/wai-pack-gpu/src/lib.rs b/wai/wai-pack-gpu/src/lib.rs new file mode 100644 index 000000000..c8bc845f8 --- /dev/null +++ b/wai/wai-pack-gpu/src/lib.rs @@ -0,0 +1,502 @@ +//! The steps of WAI's packaging extension that a GPU computes exactly +//! (`extensions/packaging.md` §11.6): WGSL compute kernels, run through `wgpu` on Metal, +//! Vulkan and DX12. +//! +//! - [`Gpu::motion_frame`]: the motion search of `wai.video.int_motion`'s encoder for one +//! frame (§5.5), every block's vector under the encoder's search order and tie-break; +//! - [`Gpu::sse`]: the sum of squared differences of two byte strings (§6.6); +//! - [`Gpu::blake3_many`]: BLAKE3 of many messages, each chunk's compressions on the +//! device and the parent nodes merged on the host by the reference crate. +//! +//! Every kernel is integer, in 32-bit words, with every intermediate bounded inside its +//! type, so a device that executes WGSL's integer arithmetic gives the CPU's result for +//! every input. This crate does not decide that a device does: its caller runs each kernel +//! against the CPU reference on qualifying inputs before it trusts a device with a step, +//! and `tests/` hold the kernels to their references and show that an edited kernel is +//! caught. A size a kernel does not take is [`GpuError::Unsupported`], before anything +//! runs, so the caller computes it on the CPU. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::{Arc, Mutex, MutexGuard}; + +/// How long one wait on a submission may take. +pub const POLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); + +/// Why the device gave no result. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum GpuError { + /// The input is outside what the kernel takes (a size, a bound); nothing ran. + Unsupported(String), + /// The device reported an error, was lost, or a wait failed or timed out. + Device(String), +} + +impl std::fmt::Display for GpuError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + GpuError::Unsupported(m) => write!(f, "not taken by the kernel: {m}"), + GpuError::Device(m) => write!(f, "device: {m}"), + } + } +} + +impl std::error::Error for GpuError {} + +/// What the platform reports about an adapter. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Adapter { + /// `metal`, `vulkan` or `dx12`. + pub backend: String, + pub name: String, + pub vendor: u32, + pub device: u32, + /// `discrete`, `integrated`, `virtual`, `cpu` or `other`. + pub device_type: String, + pub driver: String, + pub driver_info: String, +} + +impl Adapter { + /// One line naming the adapter, as a fabric record names its backend. + pub fn label(&self) -> String { + let mut s = format!("wai-pack-gpu {} on wgpu {}: {} ({:04x}:{:04x}, {})", env!("CARGO_PKG_VERSION"), self.backend, self.name, self.vendor, self.device, self.device_type); + if !self.driver.is_empty() { + s.push_str(format!(", driver {} {}", self.driver, self.driver_info).trim_end()); + } + s + } +} + +/// The kernels' sources: the shipped ones, or a test's edit of one. +#[derive(Clone, Debug)] +pub struct Sources { + pub motion: String, + pub sse: String, + pub blake3: String, +} + +impl Sources { + pub fn shipped() -> Sources { + Sources { motion: include_str!("wgsl/motion.wgsl").into(), sse: include_str!("wgsl/sse.wgsl").into(), blake3: include_str!("wgsl/blake3.wgsl").into() } + } +} + +#[derive(Default)] +struct Sink { + messages: Vec, + lost: Option, +} + +struct Pipelines { + layout: wgpu::BindGroupLayout, + motion: wgpu::ComputePipeline, + sse: wgpu::ComputePipeline, + blake3: wgpu::ComputePipeline, +} + +/// One opened adapter with the kernels compiled on it. +pub struct Gpu { + device: wgpu::Device, + queue: wgpu::Queue, + pipes: Pipelines, + adapter: Adapter, + max_binding: u64, + sink: Arc>, + pool: Mutex>>, + label: String, +} + +/// What discovery found: the adapters that opened, and why each other one did not. +#[derive(Default)] +pub struct Discovery { + pub found: Vec, + pub refused: Vec<(Adapter, String)>, +} + +fn lock(m: &Mutex) -> MutexGuard<'_, T> { + m.lock().unwrap_or_else(|p| p.into_inner()) +} + +fn backend_label(b: wgpu::Backend) -> Option<&'static str> { + match b { + wgpu::Backend::Metal => Some("metal"), + wgpu::Backend::Vulkan => Some("vulkan"), + wgpu::Backend::Dx12 => Some("dx12"), + _ => None, + } +} + +fn device_type_label(t: wgpu::DeviceType) -> &'static str { + match t { + wgpu::DeviceType::DiscreteGpu => "discrete", + wgpu::DeviceType::IntegratedGpu => "integrated", + wgpu::DeviceType::VirtualGpu => "virtual", + wgpu::DeviceType::Cpu => "cpu", + wgpu::DeviceType::Other => "other", + } +} + +/// Polls a future once with a waker that does nothing. Natively wgpu resolves every +/// future of its interface before returning it, and a map completes inside the poll that +/// waits for it, so one poll resolves each call; a future still pending is an error. +fn resolve(f: F) -> Result { + let mut f = std::pin::pin!(f); + match f.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) { + std::task::Poll::Ready(v) => Ok(v), + std::task::Poll::Pending => Err(GpuError::Device("a native future was pending after one poll".into())), + } +} + +/// Every adapter of the platform's primary interfaces, each opened with the shipped +/// kernels. Nothing is decided here about whether a device computes them correctly. +pub fn discover() -> Discovery { + discover_with(&Sources::shipped(), "shipped") +} + +/// [`discover`], with the kernels compiled from `src` and labelled `label`. +pub fn discover_with(src: &Sources, label: &str) -> Discovery { + let mut d = Discovery::default(); + if wgpu::Instance::enabled_backend_features().is_empty() { + return d; + } + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + desc.backends = wgpu::Backends::PRIMARY; + desc.flags = wgpu::InstanceFlags::empty(); + desc.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::Fxc; + let inst = wgpu::Instance::new(desc); + let Ok(adapters) = resolve(inst.enumerate_adapters(wgpu::Backends::PRIMARY)) else { + return d; + }; + for a in adapters { + match open(a, src, label) { + Ok(g) => d.found.push(g), + Err(e) => d.refused.push(*e), + } + } + d +} + +fn open(adapter: wgpu::Adapter, src: &Sources, label: &str) -> Result> { + let info = adapter.get_info(); + let who = Adapter { + backend: backend_label(info.backend).map_or_else(|| format!("{:?}", info.backend).to_lowercase(), str::to_string), + name: info.name.clone(), + vendor: info.vendor, + device: info.device, + device_type: device_type_label(info.device_type).into(), + driver: info.driver.clone(), + driver_info: info.driver_info.clone(), + }; + if backend_label(info.backend).is_none() { + return Err(Box::new((who, format!("backend {:?} is not one the kernels run on", info.backend)))); + } + if info.device_type == wgpu::DeviceType::Cpu { + return Err(Box::new((who, "a software rasteriser runs on the CPU: it is not another fabric".into()))); + } + if !adapter.get_downlevel_capabilities().flags.contains(wgpu::DownlevelFlags::COMPUTE_SHADERS) { + return Err(Box::new((who, "no compute shaders".into()))); + } + let offered = adapter.limits(); + let mut short: Option = None; + wgpu::Limits::default().check_limits_with_fail_fn(&offered, true, |name, needed, has| { + short.get_or_insert_with(|| format!("limit {name}: the kernels need {needed}, the adapter offers {has}")); + }); + if let Some(why) = short { + return Err(Box::new((who, why))); + } + let required_limits = wgpu::Limits { + max_storage_buffer_binding_size: offered.max_storage_buffer_binding_size, + max_buffer_size: offered.max_buffer_size, + ..wgpu::Limits::default() + }; + let descriptor = wgpu::DeviceDescriptor { label: Some("wai-pack-gpu"), required_features: wgpu::Features::empty(), required_limits, ..Default::default() }; + let (device, queue) = match resolve(adapter.request_device(&descriptor)) { + Ok(Ok(dq)) => dq, + Ok(Err(e)) => return Err(Box::new((who, format!("request_device: {e}")))), + Err(e) => return Err(Box::new((who, e.to_string()))), + }; + let sink = Arc::new(Mutex::new(Sink::default())); + let errors = Arc::clone(&sink); + device.on_uncaptured_error(Arc::new(move |e: wgpu::Error| lock(&errors).messages.push(e.to_string()))); + let lost = Arc::clone(&sink); + device.set_device_lost_callback(move |reason, message| { + let mut s = lock(&lost); + if s.lost.is_none() { + s.lost = Some(format!("device lost ({reason:?}): {message}")); + } + }); + let pipes = match pipelines(&device, src) { + Ok(p) => p, + Err(e) => return Err(Box::new((who, format!("the kernels did not compile: {e}")))), + }; + let granted = device.limits(); + let max_binding = granted.max_storage_buffer_binding_size.min(granted.max_buffer_size); + Ok(Gpu { device, queue, pipes, adapter: who, max_binding, sink, pool: Mutex::new(HashMap::new()), label: label.to_string() }) +} + +fn pipelines(device: &wgpu::Device, src: &Sources) -> Result { + let scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + let entry = |binding: u32, ty: wgpu::BufferBindingType| wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty, has_dynamic_offset: false, min_binding_size: None }, + count: None, + }; + let storage = |read_only| wgpu::BufferBindingType::Storage { read_only }; + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("wai-pack-gpu"), + entries: &[entry(0, wgpu::BufferBindingType::Uniform), entry(1, storage(true)), entry(2, storage(true)), entry(3, storage(false))], + }); + let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("wai-pack-gpu"), bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); + let pipe = |label: &str, text: &str, entry_point: &str| { + let m = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some(label), source: wgpu::ShaderSource::Wgsl(Cow::Owned(text.to_string())) }); + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { label: Some(label), layout: Some(&pl), module: &m, entry_point: Some(entry_point), compilation_options: Default::default(), cache: None }) + }; + let motion = pipe("wai-pack-gpu motion", &src.motion, "search"); + let sse = pipe("wai-pack-gpu sse", &src.sse, "sse"); + let blake3 = pipe("wai-pack-gpu blake3", &src.blake3, "chunk"); + if let Some(e) = resolve(scope.pop()).map_err(|e| e.to_string())? { + return Err(e.to_string()); + } + Ok(Pipelines { layout, motion, sse, blake3 }) +} + +/// `bytes` as little-endian words, zero-padded to a whole word. +fn words(bytes: &[u8]) -> Vec { + let mut v = bytes.to_vec(); + v.resize(bytes.len().div_ceil(4).max(1) * 4, 0); + v +} + +/// A grid of `n` workgroups of 64 threads as `(x, y)`, rows of at most 65,535. +fn grid(n: u64) -> (u32, u32) { + let x = n.clamp(1, 65_535); + (x as u32, n.div_ceil(x).max(1) as u32) +} + +impl Gpu { + /// The adapter, as the platform reports it. + pub fn adapter(&self) -> &Adapter { + &self.adapter + } + + /// The label of the kernels compiled on it: `shipped`, or a test's edit. + pub fn kernels(&self) -> &str { + &self.label + } + + fn settled(&self) -> Result<(), GpuError> { + let mut s = lock(&self.sink); + if let Some(why) = &s.lost { + return Err(GpuError::Device(why.clone())); + } + if s.messages.is_empty() { + return Ok(()); + } + Err(GpuError::Device(std::mem::take(&mut s.messages).join("; "))) + } + + fn take(&self, size: u64, usage: wgpu::BufferUsages) -> wgpu::Buffer { + let size = size.div_ceil(256).max(1) * 256; + if let Some(b) = lock(&self.pool).get_mut(&(size, usage.bits())).and_then(Vec::pop) { + return b; + } + self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("wai-pack-gpu"), size, usage, mapped_at_creation: false }) + } + + fn give(&self, bufs: Vec) { + let mut p = lock(&self.pool); + for b in bufs { + let v = p.entry((b.size(), b.usage().bits())).or_default(); + if v.len() < 32 { + v.push(b); + } + } + } + + fn input(&self, bytes: &[u8]) -> wgpu::Buffer { + let w = words(bytes); + let b = self.take(w.len() as u64, wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST); + self.queue.write_buffer(&b, 0, &w); + b + } + + /// Run `pipe` over `groups` workgroups with the uniform `params` and inputs `a`, `b`, + /// and read back the first `out_len` bytes of its output. + fn run(&self, pipe: &wgpu::ComputePipeline, params: &[u32], a: &[u8], b: &[u8], out_len: u64, groups: (u32, u32)) -> Result, GpuError> { + for (what, n) in [("an input", words(a).len() as u64), ("an input", words(b).len() as u64), ("the output", out_len)] { + if n > self.max_binding { + return Err(GpuError::Unsupported(format!("{what} of {n} bytes is past the device's binding of {}", self.max_binding))); + } + } + self.settled()?; + let mut pbytes: Vec = params.iter().flat_map(|w| w.to_le_bytes()).collect(); + pbytes.resize(pbytes.len().div_ceil(16).max(1) * 16, 0); + let ub = self.take(pbytes.len() as u64, wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST); + self.queue.write_buffer(&ub, 0, &pbytes); + let ab = self.input(a); + let bb = self.input(b); + let ob = self.take(out_len.max(4), wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC); + let staging = self.take(out_len.max(4), wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST); + let bg = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("wai-pack-gpu"), + layout: &self.pipes.layout, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: ub.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: ab.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: bb.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: ob.as_entire_binding() }, + ], + }); + let mut enc = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("wai-pack-gpu") }); + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("wai-pack-gpu"), timestamp_writes: None }); + pass.set_pipeline(pipe); + pass.set_bind_group(0, &bg, &[]); + pass.dispatch_workgroups(groups.0, groups.1, 1); + } + let len = out_len.div_ceil(4).max(1) * 4; + enc.copy_buffer_to_buffer(&ob, 0, &staging, 0, len); + let index = self.queue.submit([enc.finish()]); + let slot: Arc>>> = Arc::new(Mutex::new(None)); + let filled = Arc::clone(&slot); + staging.map_async(wgpu::MapMode::Read, 0..len, move |r| *lock(&filled) = Some(r)); + // Wait for the submission, then for the map's callback itself: with several threads + // polling one device, the callback can run inside another thread's poll, after this + // thread's wait has returned. + let deadline = std::time::Instant::now() + POLL_TIMEOUT; + let poll = |p: wgpu::PollType| std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| self.device.poll(p))); + match poll(wgpu::PollType::Wait { submission_index: Some(index), timeout: Some(POLL_TIMEOUT) }) { + Ok(Ok(_)) => {} + Ok(Err(e)) => return Err(GpuError::Device(format!("wait: {e}"))), + Err(_) => return Err(GpuError::Device("the device poll panicked".into())), + } + let mapped = loop { + if let Some(r) = lock(&slot).take() { + break r; + } + if std::time::Instant::now() >= deadline { + return Err(GpuError::Device("the map did not complete within its wait".into())); + } + match poll(wgpu::PollType::Poll) { + Ok(Ok(_)) => {} + Ok(Err(e)) => return Err(GpuError::Device(format!("poll: {e}"))), + Err(_) => return Err(GpuError::Device("the device poll panicked".into())), + } + std::thread::yield_now(); + }; + if let Err(e) = mapped { + return Err(GpuError::Device(format!("map: {e}"))); + } + let out = { + let view = staging.get_mapped_range(0..len).map_err(|e| GpuError::Device(format!("mapped range: {e}")))?; + view[..out_len as usize].to_vec() + }; + staging.unmap(); + self.settled()?; + self.give(vec![ub, ab, bb, ob, staging]); + Ok(out) + } + + /// The motion search of one frame (packaging §5.5): the vector of each of the + /// `⌈h / block⌉ · ⌈w / block⌉` blocks of `src` (`h × w` RGB8), in raster order, + /// predicted from `rf` with components in `−range..=range` at precision `prec` + /// (1 or 2), each the least-cost candidate under the encoder's order. + #[allow(clippy::too_many_arguments)] + pub fn motion_frame(&self, src: &[u8], rf: &[u8], h: usize, w: usize, block: usize, range: i64, prec: i64) -> Result, GpuError> { + let un = |m: &str| Err(GpuError::Unsupported(m.to_string())); + if h == 0 || w == 0 || block == 0 || !(1..=2).contains(&prec) || !(0..=127).contains(&range) { + return un("a zero size, or a precision or range the encoder does not take"); + } + let samples = (h as u64).checked_mul(w as u64).and_then(|p| p.checked_mul(3)); + if samples.is_none_or(|s| s >= 1 << 31) || src.len() as u64 != samples.unwrap_or(0) || rf.len() != src.len() { + return un("a frame past 2^31 samples, or not h × w × 3 bytes"); + } + // A block's cost, at most block^2 * 3 * 255, is held in 32 bits. + if (block as u64).saturating_mul(block as u64).saturating_mul(765) > u32::MAX as u64 { + return un("a block whose cost can pass 2^32 - 1"); + } + let (nby, nbx) = (h.div_ceil(block), w.div_ceil(block)); + if nby > 65_535 || nbx > 65_535 { + return un("more than 65,535 blocks in a row or a column"); + } + let side = 2 * range as u32 + 1; + let params = [h as u32, w as u32, block as u32, nbx as u32, range as i32 as u32, prec as u32, side, side * side]; + let nb = nby * nbx; + let out = self.run(&self.pipes.motion, ¶ms, src, rf, 8 * nb as u64, (nbx as u32, nby as u32))?; + Ok(out.as_chunks::<8>().0.iter().map(|c| (i32::from_le_bytes([c[0], c[1], c[2], c[3]]) as i16, i32::from_le_bytes([c[4], c[5], c[6], c[7]]) as i16)).collect()) + } + + /// `Σ (a_i − b_i)²` over two byte strings of one length (packaging §6.6), in partial + /// sums of at most 4,096 bytes added here in 64 bits. + pub fn sse(&self, a: &[u8], b: &[u8]) -> Result { + if a.len() != b.len() { + return Err(GpuError::Unsupported("two strings of different lengths".into())); + } + if a.is_empty() { + return Ok(0); + } + if a.len() as u64 >= 1 << 32 { + return Err(GpuError::Unsupported("a string of 2^32 bytes or more".into())); + } + const PER: u64 = 4096; + let threads = (a.len() as u64).div_ceil(PER); + let (gx, gy) = grid(threads.div_ceil(64)); + let params = [a.len() as u32, PER as u32, gx * 64, 0]; + let out = self.run(&self.pipes.sse, ¶ms, a, b, 4 * threads, (gx, gy))?; + Ok(out.as_chunks::<4>().0.iter().map(|c| u32::from_le_bytes(*c) as u64).sum()) + } + + /// BLAKE3 (the default hash mode) of each message: every chunk compressed on the + /// device, the parent nodes of a message of several chunks merged here by the + /// reference crate's subtree functions, in the tree the specification fixes. + pub fn blake3_many(&self, msgs: &[&[u8]]) -> Result, GpuError> { + // Each message on a word, its chunks one job each. + let mut data: Vec = Vec::new(); + let mut jobs: Vec = Vec::new(); + let mut spans: Vec<(usize, usize)> = Vec::with_capacity(msgs.len()); + for m in msgs { + if m.len() as u64 >= 1 << 32 { + return Err(GpuError::Unsupported("a message of 2^32 bytes or more".into())); + } + let word = (data.len() / 4) as u32; + let n_chunks = m.len().div_ceil(1024).max(1); + spans.push((jobs.len() / 8, n_chunks)); + for c in 0..n_chunks { + let len = (m.len() - c * 1024).min(1024); + let counter = c as u64; + jobs.extend_from_slice(&[word + (c * 256) as u32, len as u32, counter as u32, (counter >> 32) as u32, (n_chunks == 1) as u32, 0, 0, 0]); + } + data.extend_from_slice(m); + data.resize(data.len().div_ceil(4) * 4, 0); + } + let n = (jobs.len() / 8) as u64; + if n == 0 { + return Ok(Vec::new()); + } + let (gx, gy) = grid(n.div_ceil(64)); + let params = [n as u32, gx * 64, 0, 0]; + let job_bytes: Vec = jobs.iter().flat_map(|w| w.to_le_bytes()).collect(); + let out = self.run(&self.pipes.blake3, ¶ms, &data, &job_bytes, 32 * n, (gx, gy))?; + let cv = |i: usize| -> [u8; 32] { out[32 * i..32 * i + 32].try_into().expect("32 bytes") }; + Ok(spans.iter().map(|&(first, count)| if count == 1 { cv(first) } else { merge((first..first + count).map(cv).collect()) }).collect()) + } +} + +/// The root of a message of two or more chunks from its chunks' chaining values: each +/// level pairs adjacent nodes and carries an odd last one up, which is the tree BLAKE3 +/// fixes (the left subtree holds the largest power of two of chunks below the count), and +/// the last merge is the root. +fn merge(mut level: Vec<[u8; 32]>) -> [u8; 32] { + use blake3::hazmat::{merge_subtrees_non_root, merge_subtrees_root, Mode}; + debug_assert!(level.len() >= 2); + while level.len() > 2 { + let mut next = Vec::with_capacity(level.len().div_ceil(2)); + for pair in level.chunks(2) { + next.push(if pair.len() == 2 { merge_subtrees_non_root(&pair[0], &pair[1], Mode::Hash) } else { pair[0] }); + } + level = next; + } + *merge_subtrees_root(&level[0], &level[1], Mode::Hash).as_bytes() +} diff --git a/wai/wai-pack-gpu/src/wgsl/blake3.wgsl b/wai/wai-pack-gpu/src/wgsl/blake3.wgsl new file mode 100644 index 000000000..105fae311 --- /dev/null +++ b/wai/wai-pack-gpu/src/wgsl/blake3.wgsl @@ -0,0 +1,145 @@ +// BLAKE3's chunk function (the BLAKE3 specification, §2.1–§2.4), one thread per chunk of +// one message: a chunk's chaining value, or, for a message of one chunk, its root output. +// The host merges the chaining values of a message of several chunks into its root +// (§2.5, parent nodes), so this kernel does the compressions that read the message, all +// but one in every sixteen. +// +// A job names a chunk: where its words start, its length in bytes (0 to 1024; 0 only for +// the empty message), its counter, and whether it is a message's only chunk (the ROOT +// flag on its last block). The host starts each message on a word and fills the rest of +// its last word with zeros, so a block's bytes past the chunk's length read as zero, as +// the specification pads a block, and words past it are not read. + +struct Job { + word: u32, len: u32, counter_lo: u32, counter_hi: u32, + root: u32, pad0: u32, pad1: u32, pad2: u32, +} + +struct Params { + n: u32, row: u32, pad0: u32, pad1: u32, +} + +@group(0) @binding(0) var P: Params; +@group(0) @binding(1) var DATA: array; +@group(0) @binding(2) var JOBS: array; +// Eight words per job. +@group(0) @binding(3) var OUT: array; + +const CHUNK_START: u32 = 1u; +const CHUNK_END: u32 = 2u; +const ROOT: u32 = 8u; + +const IV0: u32 = 0x6A09E667u; +const IV1: u32 = 0xBB67AE85u; +const IV2: u32 = 0x3C6EF372u; +const IV3: u32 = 0xA54FF53Au; +const IV4: u32 = 0x510E527Fu; +const IV5: u32 = 0x9B05688Cu; +const IV6: u32 = 0x1F83D9ABu; +const IV7: u32 = 0x5BE0CD19u; + +fn rotr(x: u32, n: u32) -> u32 { + return (x >> n) | (x << (32u - n)); +} + +var v: array; +var m: array; + +fn g(a: u32, b: u32, c: u32, d: u32, mx: u32, my: u32) { + v[a] = v[a] + v[b] + mx; + v[d] = rotr(v[d] ^ v[a], 16u); + v[c] = v[c] + v[d]; + v[b] = rotr(v[b] ^ v[c], 12u); + v[a] = v[a] + v[b] + my; + v[d] = rotr(v[d] ^ v[a], 8u); + v[c] = v[c] + v[d]; + v[b] = rotr(v[b] ^ v[c], 7u); +} + +fn round_fn() { + g(0u, 4u, 8u, 12u, m[0], m[1]); + g(1u, 5u, 9u, 13u, m[2], m[3]); + g(2u, 6u, 10u, 14u, m[4], m[5]); + g(3u, 7u, 11u, 15u, m[6], m[7]); + g(0u, 5u, 10u, 15u, m[8], m[9]); + g(1u, 6u, 11u, 12u, m[10], m[11]); + g(2u, 7u, 8u, 13u, m[12], m[13]); + g(3u, 4u, 9u, 14u, m[14], m[15]); +} + +// The message permutation of §2.2, applied between rounds. +fn permute() { + let o = m; + m[0] = o[2]; + m[1] = o[6]; + m[2] = o[3]; + m[3] = o[10]; + m[4] = o[7]; + m[5] = o[0]; + m[6] = o[4]; + m[7] = o[13]; + m[8] = o[1]; + m[9] = o[11]; + m[10] = o[12]; + m[11] = o[5]; + m[12] = o[9]; + m[13] = o[14]; + m[14] = o[15]; + m[15] = o[8]; +} + +@compute @workgroup_size(64) +fn chunk(@builtin(global_invocation_id) gid: vec3) { + let t = gid.x + gid.y * P.row; + if (t >= P.n) { + return; + } + let job = JOBS[t]; + var cv = array(IV0, IV1, IV2, IV3, IV4, IV5, IV6, IV7); + let blocks = max(1u, (job.len + 63u) / 64u); + for (var b = 0u; b < blocks; b = b + 1u) { + let base = b * 64u; + let block_len = min(64u, job.len - min(job.len, base)); + for (var i = 0u; i < 16u; i = i + 1u) { + let at = base + i * 4u; + var word = 0u; + if (at < job.len) { + word = DATA[job.word + b * 16u + i]; + } + m[i] = word; + } + var flags = 0u; + if (b == 0u) { + flags = flags | CHUNK_START; + } + if (b == blocks - 1u) { + flags = flags | CHUNK_END; + if (job.root != 0u) { + flags = flags | ROOT; + } + } + for (var i = 0u; i < 8u; i = i + 1u) { + v[i] = cv[i]; + } + v[8] = IV0; + v[9] = IV1; + v[10] = IV2; + v[11] = IV3; + v[12] = job.counter_lo; + v[13] = job.counter_hi; + v[14] = block_len; + v[15] = flags; + for (var r = 0u; r < 7u; r = r + 1u) { + round_fn(); + if (r < 6u) { + permute(); + } + } + for (var i = 0u; i < 8u; i = i + 1u) { + cv[i] = v[i] ^ v[i + 8u]; + } + } + for (var i = 0u; i < 8u; i = i + 1u) { + OUT[t * 8u + i] = cv[i]; + } +} diff --git a/wai/wai-pack-gpu/src/wgsl/motion.wgsl b/wai/wai-pack-gpu/src/wgsl/motion.wgsl new file mode 100644 index 000000000..2032431d5 --- /dev/null +++ b/wai/wai-pack-gpu/src/wgsl/motion.wgsl @@ -0,0 +1,131 @@ +// The motion search of `wai.video.int_motion`'s encoder (packaging §5.5, +// wai-rs `int_video_enc`), one workgroup per block of one frame. +// +// For each candidate vector (dy, dx), both components in -R..=R, the cost is the sum, +// over the block's pixels inside the frame and the three channels, of +// |source - prediction|, the prediction being the decoder's (integer-payloads §3) from +// the previous frame as decoded: whole-pel at precision 1; at precision 2 the half-pel +// bilinear average with integer rounding, every coordinate clamped to the frame. The +// least cost wins; a tie goes to the smaller |dy| + |dx|, then to the earlier vector +// (dy outer, dx inner, each ascending). That order is a total order on +// (cost, |dy| + |dx|, index), so its minimum is the same whichever thread meets which +// candidate first: the threads each keep the least of the candidates they visit, and the +// workgroup reduces those to the least. +// +// Frames are bytes, four to a word, least significant first. Every value is a u32 or an +// i32 well inside its range: a cost is at most block^2 * 3 * 255 (the host refuses a block +// whose bound passes 2^32 - 1), an index at most 255^2. + +struct Params { + h: u32, w: u32, block: u32, nbx: u32, + range: i32, prec: u32, side: u32, ncand: u32, +} + +@group(0) @binding(0) var P: Params; +@group(0) @binding(1) var SRC: array; +@group(0) @binding(2) var REF: array; +// Two words per block, raster order: dy, dx. +@group(0) @binding(3) var OUT: array; + +const WG: u32 = 64u; +var wc: array; +var wl: array; +var wi: array; + +fn src_at(i: u32) -> i32 { + return i32((SRC[i >> 2u] >> ((i & 3u) * 8u)) & 0xffu); +} + +fn ref_at(i: u32) -> i32 { + return i32((REF[i >> 2u] >> ((i & 3u) * 8u)) & 0xffu); +} + +// A sample of the reference, its coordinates clamped to the frame. +fn px(yy: i32, xx: i32, c: u32) -> i32 { + let cy = u32(clamp(yy, 0, i32(P.h) - 1)); + let cx = u32(clamp(xx, 0, i32(P.w) - 1)); + return ref_at((cy * P.w + cx) * 3u + c); +} + +// integer-payloads §3's prediction; `>>` of an i32 is the arithmetic shift, so +// dy >> 1 is floor(dy / 2) and dy & 1 its fraction, as in the decoder. +fn predict(y: i32, x: i32, dy: i32, dx: i32, c: u32) -> i32 { + if (P.prec == 1u) { + return px(y + dy, x + dx, c); + } + let iy = y + (dy >> 1u); + let fy = dy & 1; + let ix = x + (dx >> 1u); + let fx = dx & 1; + if (fy == 0 && fx == 0) { + return px(iy, ix, c); + } + if (fy == 0) { + return (px(iy, ix, c) + px(iy, ix + 1, c) + 1) >> 1u; + } + if (fx == 0) { + return (px(iy, ix, c) + px(iy + 1, ix, c) + 1) >> 1u; + } + return (px(iy, ix, c) + px(iy, ix + 1, c) + px(iy + 1, ix, c) + px(iy + 1, ix + 1, c) + 2) >> 2u; +} + +// Whether (c, l, i) comes before (bc, bl, bi) in the search's order. +fn before(c: u32, l: u32, i: u32, bc: u32, bl: u32, bi: u32) -> bool { + if (c != bc) { + return c < bc; + } + if (l != bl) { + return l < bl; + } + return i < bi; +} + +@compute @workgroup_size(64) +fn search(@builtin(workgroup_id) g: vec3, @builtin(local_invocation_index) t: u32) { + let x0 = g.x * P.block; + let y0 = g.y * P.block; + let x1 = min(x0 + P.block, P.w); + let y1 = min(y0 + P.block, P.h); + var bc = 0xffffffffu; + var bl = 0xffffffffu; + var bi = 0xffffffffu; + for (var k = t; k < P.ncand; k = k + WG) { + let dy = i32(k / P.side) - P.range; + let dx = i32(k % P.side) - P.range; + var cost = 0u; + for (var y = y0; y < y1; y = y + 1u) { + for (var x = x0; x < x1; x = x + 1u) { + let s = (y * P.w + x) * 3u; + for (var c = 0u; c < 3u; c = c + 1u) { + cost = cost + u32(abs(src_at(s + c) - predict(i32(y), i32(x), dy, dx, c))); + } + } + } + let l1 = u32(abs(dy) + abs(dx)); + if (before(cost, l1, k, bc, bl, bi)) { + bc = cost; + bl = l1; + bi = k; + } + } + wc[t] = bc; + wl[t] = bl; + wi[t] = bi; + workgroupBarrier(); + for (var s = WG / 2u; s > 0u; s = s >> 1u) { + if (t < s) { + if (before(wc[t + s], wl[t + s], wi[t + s], wc[t], wl[t], wi[t])) { + wc[t] = wc[t + s]; + wl[t] = wl[t + s]; + wi[t] = wi[t + s]; + } + } + workgroupBarrier(); + } + if (t == 0u) { + let k = wi[0]; + let b = g.y * P.nbx + g.x; + OUT[2u * b] = i32(k / P.side) - P.range; + OUT[2u * b + 1u] = i32(k % P.side) - P.range; + } +} diff --git a/wai/wai-pack-gpu/src/wgsl/sse.wgsl b/wai/wai-pack-gpu/src/wgsl/sse.wgsl new file mode 100644 index 000000000..64478c15d --- /dev/null +++ b/wai/wai-pack-gpu/src/wgsl/sse.wgsl @@ -0,0 +1,41 @@ +// The sum of squared differences of two byte strings (packaging §6.6, an entry's `sse`), +// in partial sums that the host adds in 64 bits. +// +// Each thread sums the squares over `per` consecutive bytes (`per` at most 65,536, so a +// partial is at most 65,536 * 255^2 < 2^32 and never wraps) and writes its partial; the +// host adds the partials. The strings are bytes, four to a word, least significant +// first, and the host pads both with zeros to a whole word, which adds nothing. + +struct Params { + n: u32, per: u32, row: u32, pad: u32, +} + +@group(0) @binding(0) var P: Params; +@group(0) @binding(1) var A: array; +@group(0) @binding(2) var B: array; +@group(0) @binding(3) var OUT: array; + +fn byte_of(word: u32, k: u32) -> i32 { + return i32((word >> (k * 8u)) & 0xffu); +} + +@compute @workgroup_size(64) +fn sse(@builtin(global_invocation_id) g: vec3) { + let t = g.x + g.y * P.row; + let start = t * P.per; + if (start >= P.n) { + return; + } + let end = min(start + P.per, P.n); + var acc = 0u; + // `per` is a multiple of 4, so a thread's bytes start on a word. + for (var i = start; i < end; i = i + 4u) { + let a = A[i >> 2u]; + let b = B[i >> 2u]; + for (var k = 0u; k < 4u; k = k + 1u) { + let d = byte_of(a, k) - byte_of(b, k); + acc = acc + u32(d * d); + } + } + OUT[t] = acc; +} diff --git a/wai/wai-pack-gpu/tests/concurrent.rs b/wai/wai-pack-gpu/tests/concurrent.rs new file mode 100644 index 000000000..dfaad9824 --- /dev/null +++ b/wai/wai-pack-gpu/tests/concurrent.rs @@ -0,0 +1,47 @@ +//! One device shared by many threads, as a pack that codes many pieces at once shares +//! it: every call of every thread completes on the device (none is handed back to the +//! CPU) and gives the reference's result. A call whose buffer map is completed by another +//! thread's poll is the case this holds: the wait is for the map, not only for the +//! submission. + +mod support; + +use support::*; + +#[test] +fn every_call_completes_under_many_threads() { + for g in gpus() { + let (h, w, block, range, prec) = (24usize, 40usize, 8usize, 3i64, 2i64); + let mut rng = Rng(0x5eed_0004); + let pairs: Vec<(Vec, Vec)> = (0..8).map(|_| (rng.bytes(h * w * 3), rng.bytes(h * w * 3))).collect(); + let want: Vec> = pairs.iter().map(|(s, r)| search(s, r, h, w, block, range, prec)).collect(); + let msgs: Vec> = (0..8).map(|i| rng.bytes(1000 + 997 * i)).collect(); + let failures = std::sync::atomic::AtomicU64::new(0); + std::thread::scope(|s| { + for t in 0..16 { + let (g, pairs, want, msgs, failures) = (&g, &pairs, &want, &msgs, &failures); + s.spawn(move || { + for i in 0..600 { + let k = (t + i) % pairs.len(); + let ok = if i % 3 == 0 { + let m: Vec<&[u8]> = vec![msgs[k].as_slice()]; + g.blake3_many(&m).map(|h| &h[0] == blake3::hash(&msgs[k]).as_bytes()) + } else { + g.motion_frame(&pairs[k].0, &pairs[k].1, h, w, block, range, prec).map(|v| v == want[k]) + }; + match ok { + Ok(true) => {} + Ok(false) => panic!("{}: thread {t} call {i}: a result other than the reference's", g.adapter().label()), + Err(_) => { + failures.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + } + } + } + }); + } + }); + let n = failures.load(std::sync::atomic::Ordering::Relaxed); + assert_eq!(n, 0, "{}: {n} of 9,600 calls did not complete on the device", g.adapter().label()); + eprintln!("{}: 16 threads, 9,600 calls, every one completed with the reference's result", g.adapter().label()); + } +} diff --git a/wai/wai-pack-gpu/tests/kernels.rs b/wai/wai-pack-gpu/tests/kernels.rs new file mode 100644 index 000000000..9f2497e13 --- /dev/null +++ b/wai/wai-pack-gpu/tests/kernels.rs @@ -0,0 +1,79 @@ +//! Each kernel against its reference on every adapter that opens: the motion search +//! against packaging §5.5's search as the text states it, the SSE against the plain sum, +//! BLAKE3 against the reference crate on the test vectors' lengths. Every case is seeded, +//! so a difference names its case. With no adapter, each test prints SKIPPED and passes, +//! unless WAI_PACK_GPU_REQUIRE is set. + +mod support; + +use support::*; + +#[test] +fn the_motion_search_is_the_texts() { + for g in gpus() { + let mut rng = Rng(0x5eed_0001); + let mut n = 0; + for &(h, w, block, range, prec) in MOTION_CASES { + for (kind, src, rf) in frame_pairs(&mut rng, h, w) { + let got = g.motion_frame(&src, &rf, h, w, block, range, prec).expect("the kernel runs"); + let want = search(&src, &rf, h, w, block, range, prec); + assert_eq!(got, want, "{}: {kind} {h}x{w} block {block} range {range} prec {prec}", g.adapter().label()); + n += 1; + } + } + eprintln!("{}: motion search, {n} frames, every vector the text's", g.adapter().label()); + } +} + +/// What the kernel does not take is refused before anything runs. +#[test] +fn the_motion_search_refuses_what_it_does_not_take() { + for g in gpus() { + let f = vec![0u8; 4 * 4 * 3]; + let un = |r: Result, wai_pack_gpu::GpuError>| matches!(r, Err(wai_pack_gpu::GpuError::Unsupported(_))); + assert!(un(g.motion_frame(&f, &f, 4, 4, 4, 1, 3)), "precision 3"); + assert!(un(g.motion_frame(&f, &f, 4, 4, 4, 128, 2)), "range 128"); + assert!(un(g.motion_frame(&f, &f, 4, 5, 4, 1, 2)), "a frame that is not h × w × 3"); + assert!(un(g.motion_frame(&f, &f, 4, 4, 0, 1, 2)), "block 0"); + assert!(un(g.motion_frame(&f, &f, 4, 4, 2370, 1, 2)), "a block whose cost can pass 2^32 - 1"); + } +} + +#[test] +fn the_sse_is_the_sum() { + for g in gpus() { + let mut rng = Rng(0x5eed_0002); + for n in [0usize, 1, 3, 4, 5, 4095, 4096, 4097, 65_537, 1_048_579] { + let (a, b) = (rng.bytes(n), rng.bytes(n)); + assert_eq!(g.sse(&a, &b).unwrap(), sse(&a, &b), "{}: {n} random bytes", g.adapter().label()); + } + // The largest partial: every byte 0 against 255, 4,096 to a thread. + let (a, b) = (vec![0u8; 3 * 4096 + 7], vec![255u8; 3 * 4096 + 7]); + assert_eq!(g.sse(&a, &b).unwrap(), sse(&a, &b), "{}: the largest squares", g.adapter().label()); + } +} + +#[test] +fn blake3_is_the_reference() { + for g in gpus() { + let inputs: Vec> = BLAKE3_LENGTHS.iter().map(|&n| blake3_input(n)).collect(); + let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect(); + // All at once, in one dispatch, and each alone. + let many = g.blake3_many(&refs).unwrap(); + for (m, h) in refs.iter().zip(&many) { + assert_eq!(h, blake3::hash(m).as_bytes(), "{}: {} bytes, in a batch", g.adapter().label(), m.len()); + assert_eq!(&g.blake3_many(&[m]).unwrap()[0], blake3::hash(m).as_bytes(), "{}: {} bytes alone", g.adapter().label(), m.len()); + } + // Random messages at random lengths, hashed in batches of mixed sizes. + let mut rng = Rng(0x5eed_0003); + let msgs: Vec> = (0..200).map(|_| { + let n = rng.below(20_000) as usize; + rng.bytes(n) + }).collect(); + let refs: Vec<&[u8]> = msgs.iter().map(|v| v.as_slice()).collect(); + for (m, h) in refs.iter().zip(g.blake3_many(&refs).unwrap()) { + assert_eq!(&h, blake3::hash(m).as_bytes(), "{}: a random message of {} bytes", g.adapter().label(), m.len()); + } + eprintln!("{}: BLAKE3, {} lengths and 200 random messages, every hash the reference crate's", g.adapter().label(), BLAKE3_LENGTHS.len()); + } +} diff --git a/wai/wai-pack-gpu/tests/mutants.rs b/wai/wai-pack-gpu/tests/mutants.rs new file mode 100644 index 000000000..11a354567 --- /dev/null +++ b/wai/wai-pack-gpu/tests/mutants.rs @@ -0,0 +1,86 @@ +//! An edited kernel is caught: each mutant below changes one expression of a shipped +//! kernel (a tie rule, a rounding addend, a clamp bound, a square, a rotation, a flag), +//! and the cases of `kernels.rs` must find a result that differs from the reference on +//! every adapter. A mutant they did not catch would be a part of a kernel the tests do +//! not hold. + +mod support; + +use support::*; +use wai_pack_gpu::Sources; + +struct Mutant { + name: &'static str, + kernel: &'static str, + from: &'static str, + to: &'static str, +} + +const MUTANTS: &[Mutant] = &[ + Mutant { name: "the earlier vector loses a tie", kernel: "motion", from: "return i < bi;", to: "return i > bi;" }, + Mutant { name: "the longer vector wins a tie", kernel: "motion", from: "return l < bl;", to: "return l > bl;" }, + Mutant { name: "a horizontal half-pel truncates", kernel: "motion", from: "return (px(iy, ix, c) + px(iy, ix + 1, c) + 1) >> 1u;", to: "return (px(iy, ix, c) + px(iy, ix + 1, c)) >> 1u;" }, + Mutant { name: "a diagonal half-pel rounds by 1", kernel: "motion", from: "px(iy + 1, ix + 1, c) + 2) >> 2u;", to: "px(iy + 1, ix + 1, c) + 1) >> 2u;" }, + Mutant { name: "a column past the frame is read", kernel: "motion", from: "clamp(xx, 0, i32(P.w) - 1)", to: "clamp(xx, 0, i32(P.w))" }, + Mutant { name: "the reduction skips a lane", kernel: "motion", from: "for (var s = WG / 2u; s > 0u; s = s >> 1u)", to: "for (var s = WG / 2u; s > 1u; s = s >> 1u)" }, + Mutant { name: "the absolute difference, not its square", kernel: "sse", from: "acc = acc + u32(d * d);", to: "acc = acc + u32(abs(d));" }, + Mutant { name: "a rotation by 11", kernel: "blake3", from: "v[b] = rotr(v[b] ^ v[c], 12u);", to: "v[b] = rotr(v[b] ^ v[c], 11u);" }, + Mutant { name: "a permutation entry swapped", kernel: "blake3", from: "m[0] = o[2];\n m[1] = o[6];", to: "m[0] = o[6];\n m[1] = o[2];" }, + Mutant { name: "the root flag of a parent's value", kernel: "blake3", from: "const ROOT: u32 = 8u;", to: "const ROOT: u32 = 4u;" }, + Mutant { name: "every chunk counted as the first", kernel: "blake3", from: "v[12] = job.counter_lo;", to: "v[12] = 0u;" }, + Mutant { name: "a short last block counted whole", kernel: "blake3", from: "v[14] = block_len;", to: "v[14] = 64u;" }, +]; + +fn edited(m: &Mutant) -> Sources { + let mut s = Sources::shipped(); + let k = match m.kernel { + "motion" => &mut s.motion, + "sse" => &mut s.sse, + _ => &mut s.blake3, + }; + assert_eq!(k.matches(m.from).count(), 1, "{}: the expression is in the kernel once", m.name); + *k = k.replacen(m.from, m.to, 1); + s +} + +/// Whether the kernel cases find a difference from the reference on `g`. +fn caught(g: &wai_pack_gpu::Gpu, kernel: &str) -> bool { + match kernel { + "motion" => { + let mut rng = Rng(0x5eed_0001); + MOTION_CASES.iter().any(|&(h, w, block, range, prec)| { + frame_pairs(&mut rng, h, w).into_iter().any(|(_, src, rf)| g.motion_frame(&src, &rf, h, w, block, range, prec).ok() != Some(search(&src, &rf, h, w, block, range, prec))) + }) + } + "sse" => { + let mut rng = Rng(0x5eed_0002); + [1usize, 4097, 65_537].iter().any(|&n| { + let (a, b) = (rng.bytes(n), rng.bytes(n)); + g.sse(&a, &b).ok() != Some(sse(&a, &b)) + }) + } + _ => { + let inputs: Vec> = BLAKE3_LENGTHS.iter().map(|&n| blake3_input(n)).collect(); + let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect(); + match g.blake3_many(&refs) { + Ok(hs) => refs.iter().zip(hs).any(|(m, h)| &h != blake3::hash(m).as_bytes()), + Err(_) => true, + } + } + } +} + +#[test] +fn every_mutant_is_caught() { + if gpus().is_empty() { + return; + } + for m in MUTANTS { + let found = gpus_with(&edited(m), m.name); + assert!(!found.is_empty(), "{}: the mutant did not compile, so it proves nothing", m.name); + for g in &found { + assert!(caught(g, m.kernel), "{}: {} not caught", g.adapter().label(), m.name); + } + eprintln!("caught: {} ({})", m.name, m.kernel); + } +} diff --git a/wai/wai-pack-gpu/tests/support/mod.rs b/wai/wai-pack-gpu/tests/support/mod.rs new file mode 100644 index 000000000..9d53f9a91 --- /dev/null +++ b/wai/wai-pack-gpu/tests/support/mod.rs @@ -0,0 +1,170 @@ +//! What the kernel tests share: the adapters, the references the kernels are held to, and +//! the cases. +//! +//! The references are transcribed here from the extension text (packaging §5.5, §6.6), +//! apart from the crate's kernels and from wai-rs, whose encoder wai-pack's differential +//! test holds the device to; BLAKE3's is the reference crate. + +#![allow(dead_code)] + +use wai_pack_gpu::{Discovery, Gpu, Sources}; + +/// The adapters that opened. With none, a test prints SKIPPED and passes, unless +/// `WAI_PACK_GPU_REQUIRE` is set: then an absent adapter fails, never skips. +pub fn gpus_with(src: &Sources, label: &str) -> Vec { + let Discovery { found, refused } = wai_pack_gpu::discover_with(src, label); + for (a, why) in &refused { + eprintln!("adapter refused: {}: {why}", a.label()); + } + if found.is_empty() { + assert!(std::env::var_os("WAI_PACK_GPU_REQUIRE").is_none(), "WAI_PACK_GPU_REQUIRE is set and no adapter opened"); + eprintln!("SKIPPED: no GPU adapter opened"); + } + found +} + +pub fn gpus() -> Vec { + gpus_with(&Sources::shipped(), "shipped") +} + +/// A seeded generator (xorshift64*), so every run sees the same cases. +pub struct Rng(pub u64); + +impl Rng { + pub fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + pub fn below(&mut self, n: u64) -> u64 { + self.next() % n + } + pub fn bytes(&mut self, n: usize) -> Vec { + (0..n).map(|_| self.next() as u8).collect() + } +} + +/// integer-payloads §3's prediction: whole-pel, or half-pel bilinear with integer +/// rounding, every coordinate clamped to the frame. +#[allow(clippy::too_many_arguments)] +pub fn predict(rf: &[u8], h: usize, w: usize, y: i64, x: i64, dy: i64, dx: i64, c: usize, prec: i64) -> i64 { + let p = |yy: i64, xx: i64| rf[((yy.clamp(0, h as i64 - 1) as usize) * w + xx.clamp(0, w as i64 - 1) as usize) * 3 + c] as i64; + if prec == 1 { + return p(y + dy, x + dx); + } + let (iy, fy, ix, fx) = (y + dy.div_euclid(2), dy.rem_euclid(2), x + dx.div_euclid(2), dx.rem_euclid(2)); + match (fy, fx) { + (0, 0) => p(iy, ix), + (0, _) => (p(iy, ix) + p(iy, ix + 1) + 1) / 2, + (_, 0) => (p(iy, ix) + p(iy + 1, ix) + 1) / 2, + _ => (p(iy, ix) + p(iy, ix + 1) + p(iy + 1, ix) + p(iy + 1, ix + 1) + 2) / 4, + } +} + +/// packaging §5.5's search, as the text states it: for each block in raster order, every +/// candidate's full cost, and the least by cost, then by |dy| + |dx|, then the earliest. +pub fn search(src: &[u8], rf: &[u8], h: usize, w: usize, block: usize, range: i64, prec: i64) -> Vec<(i16, i16)> { + let mut out = Vec::new(); + for by in 0..h.div_ceil(block) { + for bx in 0..w.div_ceil(block) { + let mut best: Option<(u64, i64, i64, i64)> = None; + for dy in -range..=range { + for dx in -range..=range { + let mut cost = 0u64; + for y in by * block..((by + 1) * block).min(h) { + for x in bx * block..((bx + 1) * block).min(w) { + for c in 0..3 { + cost += (src[(y * w + x) * 3 + c] as i64 - predict(rf, h, w, y as i64, x as i64, dy, dx, c, prec)).unsigned_abs(); + } + } + } + let key = (cost, dy.abs() + dx.abs()); + if best.is_none_or(|b| key < (b.0, b.1)) { + best = Some((cost, key.1, dy, dx)); + } + } + } + let b = best.expect("one candidate at least"); + out.push((b.2 as i16, b.3 as i16)); + } + } + out +} + +/// `Σ (a − b)²`. +pub fn sse(a: &[u8], b: &[u8]) -> u64 { + a.iter().zip(b).map(|(&x, &y)| (x as i64 - y as i64).pow(2) as u64).sum() +} + +/// A frame pair of each kind the search meets: noise (costs all apart), a smooth field +/// moved by a fraction of a pel (near ties), a flat field (every candidate ties, so the +/// tie-break alone decides), and hard edges near the frame's borders (clamping). +pub fn frame_pairs(rng: &mut Rng, h: usize, w: usize) -> Vec<(&'static str, Vec, Vec)> { + let n = h * w * 3; + let noise_a = rng.bytes(n); + let noise_b = rng.bytes(n); + let field = |t: usize| -> Vec { + (0..h * w) + .flat_map(|i| { + let (y, x) = (i / w, i % w); + let v = ((x * 7 + y * 5 + t * 3) % 256) as u8; + [v, v / 2 + (y % 7) as u8, 255 - v] + }) + .collect() + }; + let flat = vec![97u8; n]; + let flat2 = vec![101u8; n]; + let edges = |t: usize| -> Vec { + (0..h * w) + .flat_map(|i| { + let (y, x) = (i / w, i % w); + let on = (x + t) % 5 < 2 || y + t < 3 || x + 2 >= w; + if on { [250, 10, 128] } else { [5, 240, 64] } + }) + .collect() + }; + // The noisy frame predicted from a blurred copy of itself, so costs differ by little. + let mut near = noise_a.clone(); + for v in near.iter_mut() { + *v = (*v / 8) * 8 + 3; + } + // Columns alternating in the reference and moved by one in the source: inside the + // frame a vector one column left and one right match alike, at the same |dy| + |dx|, + // so the earlier vector decides. + let stripes = |t: usize| -> Vec { + (0..h * w).flat_map(|i| if (i % w + t).is_multiple_of(2) { [40, 200, 90] } else { [200, 40, 160] }).collect() + }; + vec![ + ("noise", noise_a.clone(), noise_b), + ("near", noise_a, near), + ("field", field(1), field(0)), + ("flat", flat, flat2), + ("edges", edges(1), edges(0)), + ("stripes", stripes(1), stripes(0)), + ] +} + +/// The frame sizes and search parameters the motion cases cover: frames that are not a +/// whole number of blocks, a frame smaller than a block, both precisions, and ranges +/// from 0 to past the frame. +pub const MOTION_CASES: &[(usize, usize, usize, i64, i64)] = &[ + // (h, w, block, range, prec) + (24, 40, 8, 3, 1), + (24, 40, 8, 3, 2), + (19, 23, 4, 2, 2), + (33, 17, 16, 8, 2), + (9, 13, 16, 5, 1), + (12, 12, 4, 0, 2), + (16, 16, 32, 1, 2), + (36, 64, 16, 14, 2), + (5, 7, 2, 9, 2), +]; + +/// The message lengths of the BLAKE3 test vectors, and a few past them. +pub const BLAKE3_LENGTHS: &[usize] = &[0, 1, 63, 64, 65, 127, 128, 129, 1023, 1024, 1025, 2048, 2049, 3072, 3073, 4096, 4097, 5120, 5121, 6144, 6145, 7168, 7169, 8192, 8193, 16384, 31744, 102_400, 1_000_003]; + +/// The BLAKE3 test vectors' input: byte i is i mod 251. +pub fn blake3_input(n: usize) -> Vec { + (0..n).map(|i| (i % 251) as u8).collect() +} diff --git a/wai/wai-pack/Cargo.lock b/wai/wai-pack/Cargo.lock index ca0b3d0cb..7d98a2831 100644 --- a/wai/wai-pack/Cargo.lock +++ b/wai/wai-pack/Cargo.lock @@ -8,12 +8,36 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "arrayvec" version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -26,6 +50,21 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + [[package]] name = "bitflags" version = "2.13.2" @@ -54,6 +93,41 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "cbindgen" version = "0.29.4" @@ -88,6 +162,21 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -118,6 +207,12 @@ dependencies = [ "libc", ] +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -175,6 +270,25 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -233,6 +347,36 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -265,11 +409,51 @@ dependencies = [ "r-efi", ] +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror", + "windows", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + [[package]] name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] [[package]] name = "heck" @@ -284,7 +468,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.1", ] [[package]] @@ -293,18 +477,60 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libc" version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.34" @@ -326,6 +552,44 @@ dependencies = [ "adler2", ] +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash", + "spirv", + "thiserror", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash", + "thiserror", +] + [[package]] name = "num-bigint" version = "0.4.8" @@ -352,6 +616,93 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", ] [[package]] @@ -360,6 +711,44 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkcs8" version = "0.10.2" @@ -376,6 +765,27 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + [[package]] name = "proc-macro2" version = "1.0.107" @@ -385,6 +795,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "quote" version = "1.0.47" @@ -409,6 +825,51 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc_version" version = "0.4.1" @@ -431,6 +892,18 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -535,6 +1008,27 @@ dependencies = [ "rand_core", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + [[package]] name = "spki" version = "0.7.3" @@ -545,6 +1039,12 @@ dependencies = [ "der", ] +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "subtle" version = "2.6.1" @@ -586,6 +1086,35 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio" +version = "1.54.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" +dependencies = [ + "pin-project-lite", +] + [[package]] name = "toml" version = "0.8.23" @@ -671,6 +1200,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "version_check" version = "0.9.5" @@ -709,6 +1244,15 @@ dependencies = [ "sha2", "toml 0.8.23", "wai", + "wai-pack-gpu", +] + +[[package]] +name = "wai-pack-gpu" +version = "0.1.0" +dependencies = [ + "blake3", + "wgpu", ] [[package]] @@ -726,12 +1270,325 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror", + "wgpu-core-deps-apple", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags", + "block2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "gpu-allocator", + "hashbrown 0.17.1", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -741,6 +1598,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "winnow" version = "0.7.15" @@ -756,6 +1622,26 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "zeroize" version = "1.9.0" diff --git a/wai/wai-pack/Cargo.toml b/wai/wai-pack/Cargo.toml index 5e0eba82e..24e92b58d 100644 --- a/wai/wai-pack/Cargo.toml +++ b/wai/wai-pack/Cargo.toml @@ -26,6 +26,10 @@ path = "src/bin/wai_pack.rs" # view (receipt_claims); the model meter (energy_meter); and a still's writer and reader # (png_still, src/png.rs re-exports them). No codec library is linked. wai = { path = "../wai-rs", default-features = false, features = ["video_int", "video_package", "http", "stage_measure", "receipt_claims", "energy_meter", "png_still"] } +# Stages on a GPU (feature `gpu`, packaging §11.6): the motion search, SSE and BLAKE3's +# chunk function as WGSL kernels, in a crate of its own with its own lockfile, as +# wai-ops-gpu is. Off by default: a build without it places every stage on the CPU. +wai-pack-gpu = { path = "../wai-pack-gpu", optional = true } ed25519-dalek = "2" # TOML 1.0 (the 0.8 line; later lines read TOML 1.1, which a policy must not use). toml = { version = "0.8", default-features = false, features = ["parse"] } @@ -36,6 +40,14 @@ sha2 = "0.10" # re-exports): an implementation apart from the packer's own deflate, so a still is # decoded by code that did not write it. +[features] +default = [] +# A GPU backend for the stages §11.6 lets a GPU run (wai-pack-gpu, wgpu: Metal, Vulkan, DX12). +gpu = ["dep:wai-pack-gpu"] +# The fabric meter on macOS (src/fabric_meter.rs): the kernel's account of the process's +# CPU energy, and the GPU rail's energy attributed by GPU time, without privileges. +fabric-meter = [] + # Release builds check integer overflow as debug builds do, so the release tests in CI # catch a wrapped product (a field read as u16 and doubled) rather than run past it. [dev-dependencies] diff --git a/wai/wai-pack/src/av1.rs b/wai/wai-pack/src/av1.rs index 387954a13..e28ded464 100644 --- a/wai/wai-pack/src/av1.rs +++ b/wai/wai-pack/src/av1.rs @@ -344,6 +344,19 @@ impl SequenceHeader { /// Read a sequence header OBU's payload through `color_config()`. pub fn parse(payload: &[u8]) -> Result { + Self::parse_with_grain(payload).map(|(s, _)| s) + } + + /// `film_grain_params_present` (AV1 §5.5.1), the bit after `color_config()`'s + /// `separate_uv_delta_q`: `None` where the payload does not reach it. [`parse`] does not + /// read it, so what a packager accepts does not change with it. + /// + /// [`parse`]: SequenceHeader::parse + pub fn film_grain_params_present(payload: &[u8]) -> Option { + Self::parse_with_grain(payload).ok()?.1 + } + + fn parse_with_grain(payload: &[u8]) -> Result<(SequenceHeader, Option), Av1Error> { let mut r = Bits { b: payload, at: 0 }; let seq_profile = r.f(3)? as u8; if seq_profile > 2 { @@ -453,7 +466,15 @@ impl SequenceHeader { csp = r.f(2)? as u8; } } - Ok(SequenceHeader { + // separate_uv_delta_q, but for a monochrome stream; then film_grain_params_present. + let grain = (|| -> Result { + if !mono_chrome { + r.f(1)?; + } + r.flag() + })() + .ok(); + let header = SequenceHeader { seq_profile, still_picture, reduced_still_picture_header: reduced, @@ -467,7 +488,8 @@ impl SequenceHeader { chroma_subsampling_x: ssx, chroma_subsampling_y: ssy, chroma_sample_position: csp, - }) + }; + Ok((header, grain)) } /// The codecs string ([AV1-ISOBMFF] Codecs Parameter String), its short form: diff --git a/wai/wai-pack/src/bin/wai_pack.rs b/wai/wai-pack/src/bin/wai_pack.rs index b52b3c278..de73634e1 100644 --- a/wai/wai-pack/src/bin/wai_pack.rs +++ b/wai/wai-pack/src/bin/wai_pack.rs @@ -12,6 +12,7 @@ //! wai-pack pack --policy --out --key [--source =]... //! [--floor ] [--signer-id ] [--royalty-free-only] [--meter none|model:] //! [--base-from ] [--link-supplied] [--jobs ] +//! [--fabric cpu|gpu|auto:] [--meter fabric] //! pack the policy's components into : objects, stage indexes, the floor's CMAF //! files, catalogs, the receipt (and, metered, its measurement claim) and LISTING; //! is new or empty (`pack-out-not-empty` otherwise, before any work). Each file @@ -27,7 +28,11 @@ //! taken from that package, verified and matched to this one piece by piece, instead //! of coded, and the receipt names it as the package it re-packs. With //! --link-supplied, a supplied CMAF floor's files (packaging §7.7) are written as hard -//! links to the supplied ones where the file system allows +//! links to the supplied ones where the file system allows. --fabric places the +//! steps a GPU may run (packaging §11.6): the motion search, SSE and the object hashes; +//! the package is byte for byte the CPU's. --meter fabric meters the pack per fabric +//! (the CPU's energy as the kernel attributes it to the process, the GPU's rail by the +//! process's share of GPU time) and writes the measurement claim's fabric record //! wai-pack live --policy --out --key [--signer-id ] [--window ] //! pack a Y4M stream read from standard input as it arrives (packaging §9.2): each //! piece coded as the segmenter closes it, and its objects written to /objects/ @@ -44,9 +49,23 @@ //! the objects it lists, removes the objects only unsealed pieces published, writes //! no plan file or LISTING, and exits 1 saying what it kept. No floor: a supplied //! floor is the on-demand package's (§7.3). is new or empty -//! wai-pack verify [--parent ] +//! wai-pack verify [--parent ] [--fabric cpu|gpu|auto:] //! verify a package (packaging §9): every reason, or its counts and pack digest; with -//! --parent, a re-pack against the parent its receipt names (packaging §11.5) +//! --parent, a re-pack against the parent its receipt names (packaging §11.5). --fabric +//! places the steps a GPU may run (packaging §11.6); the verdict is the same on every +//! fabric +//! wai-pack fabric list [--fabric gpu] +//! the backends this build and host offer for each step, and why any did not qualify +//! (packaging §11.6) +//! wai-pack fabric calibrate --out [--seconds ] +//! measure each qualified backend's energy per call of each step it runs, at two +//! sizes, with the fabric meter, and write the cost table `--fabric auto:` places by +//! (a build with the `fabric-meter` feature, on macOS) +//! wai-pack floor-decode [--decoder dav1d|videotoolbox] [--ffmpeg ] +//! decode every floor rung's CMAF files with a conformant AV1 decoder through ffmpeg +//! (libdav1d on the CPU, or VideoToolbox on the media engine), film grain not applied, +//! and print each piece's SHA-256 of its 4:2:0 pictures at the stream's bit depth +//! (packaging §11.6): informative, for comparing decoders; no floor is refused for it //! wai-pack listing [--primary] //! the listing (SPEC §3.1) of every file of but LISTING; with --primary, of the //! plan files and the objects the directory's stage indexes (index-*.wsix, and @@ -100,9 +119,11 @@ fn usage() -> ExitCode { eprintln!( "usage:\n wai-pack policy [--royalty-free-only]\n \ wai-pack plan --policy --component [--source ] [--out ]\n \ - wai-pack pack --policy --out --key [--source =]... [--floor ] [--signer-id ] [--royalty-free-only] [--meter none|model:] [--base-from ] [--link-supplied] [--jobs ]\n \ + wai-pack pack --policy --out --key [--source =]... [--floor ] [--signer-id ] [--royalty-free-only] [--meter none|model:] [--base-from ] [--link-supplied] [--jobs ] [--fabric cpu|gpu|auto:] [--meter fabric]\n \ wai-pack live --policy --out --key [--signer-id ] [--window ]\n \ - wai-pack verify [--parent ]\n \ + wai-pack verify [--parent ] [--fabric cpu|gpu|auto:]\n \ + wai-pack fabric list [--fabric gpu] | fabric calibrate --out [--seconds ]\n \ + wai-pack floor-decode [--decoder dav1d|videotoolbox] [--ffmpeg ]\n \ wai-pack listing [--primary]\n \ wai-pack sim --policy --class --rate --startup [--source ] [--rtt ] [--chunk ] [--enc ] [--cap ] [--fifo]\n \ wai-pack stage-json\n \ @@ -214,8 +235,11 @@ fn cmd_pack(args: &[String]) -> Result<(), String> { o.overrides = Overrides { royalty_free_only: args.iter().any(|a| a == "--royalty-free-only") }; o.base_from = opt(args, "--base-from").map(|d| wai_pack::files::FileSet::Dir(d.into())); o.link_supplied = args.iter().any(|a| a == "--link-supplied"); + // The backends are opened and qualified before the meter starts. + o.fabrics = fabrics_of(args)?; match opt(args, "--meter").unwrap_or("none") { "none" => {} + "fabric" => fabric_meter_into(&mut o)?, m => { let mw: u64 = m.strip_prefix("model:").and_then(|x| x.parse().ok()).ok_or_else(|| format!("pack: --meter {m}: none or model:"))?; o.meter = Some(Box::new(wai::energy_meter::ModelMeter::wall_clock(mw))); @@ -226,20 +250,205 @@ fn cmd_pack(args: &[String]) -> Result<(), String> { } // Every file is written as it is made; a refusal removes what was written. let mut sink = wai_pack::files::DirSink::new(Path::new(out)).map_err(|e| format!("{out}: {e}"))?; - let receipt = wai_pack::pack::pack_into(&bytes, &sources, &mut o, &mut sink).map_err(|e| e.to_string())?; + let receipt = wai_pack::pack::pack_into(&bytes, &sources, &mut o, &mut sink).map_err(|e| match fabric_why() { + Some(why) if e.code == "pack-meter" => format!("{e} ({why})"), + _ => e.to_string(), + })?; sink.keep(); let listing = std::fs::read(Path::new(out).join("LISTING")).map_err(|e| format!("{out}: {e}"))?; println!("receipt {}", hex(&receipt.receipt_hash())); println!("pack digest {}", wai_pack::sha256_hex(&listing)); println!("entries {} ({} distinct, {} bytes referenced, {} stored)", receipt.total_entries, receipt.unique_objects, receipt.total_bytes, receipt.stored_bytes); println!("energy {}", if receipt.energy_provenance.is_some() { format!("{} uJ ({})", receipt.joules_micro, receipt.energy_provenance.as_ref().map(|p| p.label()).unwrap_or("")) } else { "not metered".into() }); + if let Some(split) = &o.fabric_split + && let Some(shares) = split() + { + for (f, uj, how) in shares { + println!("energy {} {uj} uJ ({how})", f.term()); + } + } + print_fabrics(&o.fabrics); Ok(()) } +/// `--fabric cpu|gpu|auto:` (packaging §11.6): the CPU reference alone (the +/// default), every step a qualified GPU backend takes on the GPU, or each call on the +/// fabric a measured cost table prices least. +fn fabrics_of(args: &[String]) -> Result, String> { + use wai_pack::fabric::{Costs, Fabrics, Mode}; + let f = match opt(args, "--fabric").unwrap_or("cpu") { + "cpu" => Fabrics::reference(), + "gpu" => Fabrics::open(Mode::Gpu, Costs::default()), + m => match m.strip_prefix("auto:") { + Some(path) => { + let t = std::fs::read_to_string(path).map_err(|e| format!("{path}: {e}"))?; + Fabrics::open(Mode::Auto, Costs::parse(&t)?) + } + None => return Err(format!("--fabric {m}: cpu, gpu or auto:")), + }, + }; + for (who, why) in f.refused() { + eprintln!("fabric not used: {who}: {why}"); + } + Ok(std::sync::Arc::new(f)) +} + +/// What each fabric ran, and the calls any handed back to the CPU. +fn print_fabrics(f: &wai_pack::fabric::Fabrics) { + for r in f.record() { + println!("fabric {} on {}: {} calls, {} {} ({})", r.step.term(), r.fabric.term(), r.calls, r.units, r.step.unit(), r.backend); + } + for (s, (n, why)) in f.fallbacks() { + println!("fabric {}: {n} calls ran on the CPU instead ({why})", s.term()); + } +} + +/// Why the fabric meter gave no figure, for a pack refused `pack-meter`. +#[cfg(all(target_os = "macos", feature = "fabric-meter"))] +static FABRIC_WHY: std::sync::OnceLock>>> = std::sync::OnceLock::new(); + +fn fabric_why() -> Option { + #[cfg(all(target_os = "macos", feature = "fabric-meter"))] + if let Some(w) = FABRIC_WHY.get() { + return w.lock().ok()?.clone(); + } + None +} + +/// `--meter fabric`: the fabric meter, its reader, and its shares for the claim. +#[cfg(all(target_os = "macos", feature = "fabric-meter"))] +fn fabric_meter_into(o: &mut wai_pack::pack::PackOpts) -> Result<(), String> { + let m = wai_pack::fabric_meter::FabricMeter::open().ok_or("pack: --meter fabric: this process's CPU energy cannot be read")?; + let handle = m.shares_handle(); + let _ = FABRIC_WHY.set(m.why_handle()); + o.power_reader = m.reader(); + o.meter = Some(Box::new(m)); + o.fabric_split = Some(std::sync::Arc::new(move || handle.lock().ok()?.as_ref().map(|s| s.split()))); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + o.issued_at = wai::claim_receipt::rfc3339_utc(now); + Ok(()) +} + +#[cfg(not(all(target_os = "macos", feature = "fabric-meter")))] +fn fabric_meter_into(_: &mut wai_pack::pack::PackOpts) -> Result<(), String> { + Err("pack: --meter fabric: this build has no fabric meter (wai-pack's feature `fabric-meter`, on macOS)".into()) +} + +fn cmd_floor_decode(args: &[String]) -> Result<(), String> { + use wai_pack::floor_decode::{decode_floors, Decoder}; + let dir = args.first().ok_or("floor-decode: a package directory")?; + let d = opt(args, "--decoder").unwrap_or("dav1d"); + let decoder = Decoder::from_term(d).ok_or_else(|| format!("floor-decode: --decoder {d}: dav1d or videotoolbox"))?; + let ffmpeg = opt(args, "--ffmpeg").unwrap_or("ffmpeg"); + let rungs = decode_floors(Path::new(dir), decoder, ffmpeg, &std::env::temp_dir())?; + for r in &rungs { + for (i, (n, digest)) in r.pieces.iter().enumerate() { + println!("{}\t{i}\t{n}\t{digest}", r.name); + } + let frames: u64 = r.pieces.iter().map(|p| p.0).sum(); + println!("rung {} {}x{} at {} bits: {frames} pictures in {} pieces, {} ({} on {})", r.name, r.w, r.h, r.bit_depth, r.pieces.len(), r.digest(), r.decoder, decoder.fabric().term()); + } + Ok(()) +} + +fn cmd_fabric(args: &[String]) -> Result<(), String> { + use wai_pack::fabric::{Costs, Fabrics, Mode}; + match args.first().map(String::as_str) { + Some("list") => { + let f = Fabrics::open(Mode::Gpu, Costs::default()); + for (step, fabric, backend) in f.backends() { + println!("{}\t{}\t{backend}", step.term(), fabric.term()); + } + for (who, why) in f.refused() { + println!("refused\t{who}\t{why}"); + } + Ok(()) + } + Some("calibrate") => calibrate(&args[1..]), + _ => Err("fabric: list, or calibrate --out [--seconds ]".into()), + } +} + +#[cfg(all(target_os = "macos", feature = "fabric-meter"))] +fn calibrate(args: &[String]) -> Result<(), String> { + use wai::int_video_enc::MotionSearch as _; + use wai_pack::fabric::{Cost, Costs, Fabric, Fabrics, Mode, Step}; + let out = opt(args, "--out").ok_or("fabric calibrate: --out")?; + let secs: u64 = opt(args, "--seconds").map(|s| s.parse().map_err(|_| format!("--seconds {s}"))).transpose()?.unwrap_or(4).max(1); + let mut meter = wai_pack::fabric_meter::FabricMeter::open().ok_or("fabric calibrate: this process's CPU energy cannot be read")?; + let gpu = Fabrics::open(Mode::Gpu, Costs::default()); + let cpu = Fabrics::reference(); + let host = std::process::Command::new("sysctl").args(["-n", "machdep.cpu.brand_string"]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()).unwrap_or_default(); + let mut costs = Costs { host, costs: Vec::new() }; + // A pseudo-random frame pair, two sizes: a 64x48 frame and big-buck-bunny's 256x144 proxy. + let frames = |h: usize, w: usize| -> (Vec, Vec) { + let mut x = 0x9e37_79b9_7f4a_7c15u64; + let mut next = || { x ^= x << 13; x ^= x >> 7; x ^= x << 17; x as u8 }; + let a: Vec = (0..h * w * 3).map(|_| next()).collect(); + let b: Vec = a.iter().map(|v| v.wrapping_add(next() % 9)).collect(); + (a, b) + }; + for step in [Step::BaseSearch, Step::Sse, Step::ObjectHash] { + for fabric in [Fabric::Cpu, Fabric::Gpu] { + let f = if fabric == Fabric::Cpu { &cpu } else { &gpu }; + if !f.offers(step, fabric) { + continue; + } + let mut points: Vec<(u64, u64)> = Vec::new(); + for size in [0usize, 1] { + let (units, mut call): (u64, Box) = match step { + Step::BaseSearch => { + let (h, w) = if size == 0 { (48, 64) } else { (144, 256) }; + let (a, b) = frames(h, w); + let units = (h * w * 3) as u64 * 17 * 17; + (units, Box::new(move || { let mut v = Vec::new(); f.search().frame(&a, &b, h, w, 16, 8, 2, &mut v); })) + } + Step::Sse => { + let n = if size == 0 { 65_536 } else { 8 << 20 }; + let (a, b) = frames(1, n / 3 + 1); + (2 * a.len() as u64, Box::new(move || { let _ = f.sse(std::slice::from_ref(&a), std::slice::from_ref(&b)); })) + } + _ => { + let n = if size == 0 { 16_384 } else { 4 << 20 }; + let (a, _) = frames(1, n / 3 + 1); + (a.len() as u64 + 15, Box::new(move || { let _ = f.object_hashes(&[a.as_slice()]); })) + } + }; + call(); + let mut calls = 0u64; + meter.begin(); + let t0 = std::time::Instant::now(); + while t0.elapsed() < std::time::Duration::from_millis(500 * secs) { + call(); + calls += 1; + } + let s = meter.end().ok_or("fabric calibrate: the meter gave no figure")?; + let nj = s.total_uj().saturating_mul(1000) / calls.max(1); + println!("{}\t{}\t{units} {}\t{calls} calls\t{nj} nJ a call (cpu {} uJ, gpu {} uJ of a rail's {} uJ)", step.term(), fabric.term(), step.unit(), s.cpu_uj, s.gpu_uj, s.gpu_rail_uj); + points.push((units, nj)); + } + // nJ = a + b * units through the two points, b in thousandths of a nJ. + let ((u0, e0), (u1, e1)) = (points[0], points[1]); + let slope_milli = (e1.saturating_sub(e0) as u128 * 1_000_000 / (u1 - u0).max(1) as u128) as u64; + let fixed_nj = e0.saturating_sub((slope_milli as u128 * u0 as u128 / 1_000_000) as u64); + let backend = f.backends().into_iter().find(|(s, fb, _)| *s == step && *fb == fabric).map(|x| x.2).unwrap_or_default(); + costs.costs.push(Cost { step, fabric, backend, uj_per_call: fixed_nj / 1000, nj_per_unit_milli: slope_milli / 1000 }); + } + } + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + std::fs::write(out, costs.to_json(&wai::claim_receipt::rfc3339_utc(now), &meter.reader())).map_err(|e| format!("{out}: {e}")) +} + +#[cfg(not(all(target_os = "macos", feature = "fabric-meter")))] +fn calibrate(_: &[String]) -> Result<(), String> { + Err("fabric calibrate: this build has no fabric meter (wai-pack's feature `fabric-meter`, on macOS)".into()) +} + fn cmd_verify(args: &[String]) -> Result<(), String> { let dir = args.first().ok_or("verify: a package directory")?; + let fabrics = fabrics_of(args)?; let verdict = match opt(args, "--parent") { - None => wai_pack::verify::verify_dir(Path::new(dir)), + None => wai_pack::verify::verify_dir_with(Path::new(dir), &fabrics), Some(parent) => { let read = |d: &str| wai_pack::verify::read_strict(Path::new(d)).map_err(|e| vec![wai_pack::verify::Reason { code: "unexpected-file".into(), detail: e }]); read(dir).and_then(|f| read(parent).and_then(|p| wai_pack::verify::verify_repack(&f, &p))) @@ -252,6 +461,7 @@ fn cmd_verify(args: &[String]) -> Result<(), String> { println!("pack digest {}", r.pack_digest); println!("entries {} ({} distinct, {} bytes referenced, {} stored)", r.total_entries, r.unique_objects, r.total_bytes, r.stored_bytes); println!("energy {}", if r.metered { "metered, with its measurement claim" } else { "not metered" }); + print_fabrics(&fabrics); Ok(()) } Err(v) => Err(v.iter().map(|r| r.to_string()).collect::>().join("\n")), @@ -608,6 +818,8 @@ fn main() -> ExitCode { "sim" => cmd_sim(rest), "stage-json" => cmd_stage_json(), "verify" => cmd_verify(rest), + "fabric" => cmd_fabric(rest), + "floor-decode" => cmd_floor_decode(rest), "listing" => cmd_listing(rest), "policy-verdict" => cmd_policy_verdict(rest), "source-verdict" => cmd_source_verdict(rest), diff --git a/wai/wai-pack/src/encode.rs b/wai/wai-pack/src/encode.rs index a7dd4e099..7b3b0598a 100644 --- a/wai/wai-pack/src/encode.rs +++ b/wai/wai-pack/src/encode.rs @@ -12,7 +12,7 @@ use crate::policy::Rung; use wai::int_payload::{IntPayloadError, Refusal}; -use wai::int_video_enc::{encode, EncParams, Encoded}; +use wai::int_video_enc::{encode_with, CpuSearch, EncParams, Encoded, MotionSearch}; /// The parameters of a `wai.video.int_motion` rung that fix its encode. #[derive(Debug, Clone, PartialEq, Eq)] @@ -56,12 +56,18 @@ pub fn sse_px_milli(sse: u64, h: u32, w: u32, n: usize) -> u64 { /// Choose the step for a piece of `frames` (each `h × w` RGB8) under `p`, and encode it. pub fn choose_q(frames: &[Vec], h: u32, w: u32, p: &MotionParams) -> Result { + choose_q_with(frames, h, w, p, &mut CpuSearch) +} + +/// [`choose_q`], each encode's motion search run by `search` (packaging §11.6): a search +/// that keeps the encoder's rule gives [`choose_q`]'s step and payload byte for byte. +pub fn choose_q_with(frames: &[Vec], h: u32, w: u32, p: &MotionParams, search: &mut dyn MotionSearch) -> Result { let mut qs: Vec = p.q_candidates.clone(); qs.sort_unstable_by(|a, b| b.cmp(a)); qs.dedup(); let smallest = *qs.last().ok_or_else(|| IntPayloadError::new(Refusal::FieldRange, "no step to choose from"))?; for &q in &qs { - let e = encode(frames, h, w, &EncParams { block: p.block, search: p.search, mv_precision: p.mv_precision, q })?; + let e = encode_with(frames, h, w, &EncParams { block: p.block, search: p.search, mv_precision: p.mv_precision, q }, search)?; if sse_px_milli(e.sse, h, w, frames.len()) <= p.max_sse_px_milli || q == smallest { return Ok(QChoice { q, encoded: e }); } @@ -100,6 +106,7 @@ pub fn encode_refinement(below: &[Vec], src: &[Vec], h: u32, w: u32, q: #[cfg(test)] mod tests { use super::*; + use wai::int_video_enc::encode; /// A layer brings every frame closer to its source, and a finer step closer still. #[test] diff --git a/wai/wai-pack/src/fabric.rs b/wai/wai-pack/src/fabric.rs new file mode 100644 index 000000000..9d3752f59 --- /dev/null +++ b/wai/wai-pack/src/fabric.rs @@ -0,0 +1,612 @@ +//! Fabrics: where each step of a pack or a verify runs ([packaging] §11.6). +//! +//! A step is a pure function of its inputs (§11.2), so where it runs changes no byte of +//! a package and no verdict: only the energy it takes. This module places each call of +//! a step on a fabric (the CPU, a GPU, a media engine, a neural engine), the one whose +//! measured cost for a call of that size is least, the way an energy-aware scheduler +//! places work on the node with the least energy per operation: a backend is a candidate +//! only once it has given the reference's output on the qualifying inputs +//! ([`Fabrics::open`]), and among candidates the least `µJ per call + nJ per unit · units` +//! of a measured cost table ([`Costs`]) wins, the CPU on a tie or where nothing was +//! measured. The CPU is the reference and the fallback: a call another fabric cannot take +//! (a size, a lost device) runs on the CPU, with the same result. +//! +//! What runs where in this revision: +//! +//! - `base-search`, the integer encoder's motion search (§5.5): the CPU, or a GPU through +//! `wai-pack-gpu` (feature `gpu`); +//! - `sse` (§6.6) and `object-hash` (§5.3): the CPU, or a GPU (feature `gpu`); +//! - every other step: the CPU. Their work is sequential (rANS coding, SHA-256), or +//! reading and writing, or too small to pay for a dispatch. +//! +//! [`Fabrics::record`] says which fabric ran each step and on which backend, for a +//! measurement claim's `fabrics` member (jwp-receipts §2.4.8) and for the command line. +//! +//! [packaging]: ../../../extensions/packaging.md + +use std::collections::BTreeMap; +#[cfg(feature = "gpu")] +use std::collections::BTreeSet; +use std::sync::Mutex; +use wai::int_video_enc::{CpuSearch, MotionSearch}; + +/// A kind of processor a step can run on (packaging §11.6). +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Fabric { + Cpu, + Gpu, + Media, + Neural, +} + +impl Fabric { + pub const ALL: [Fabric; 4] = [Fabric::Cpu, Fabric::Gpu, Fabric::Media, Fabric::Neural]; + + /// Its term: `cpu`, `gpu`, `media` or `neural`. + pub fn term(self) -> &'static str { + match self { + Fabric::Cpu => "cpu", + Fabric::Gpu => "gpu", + Fabric::Media => "media", + Fabric::Neural => "neural", + } + } + + pub fn from_term(s: &str) -> Option { + Fabric::ALL.into_iter().find(|f| f.term() == s) + } +} + +/// A step of a pack or a verify (packaging §11.6), named by its term. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum Step { + /// Decoding a source to the frames a packager reads: before a pack, by a producer's + /// tool. + SourceDecode, + /// Reading a source's frames or samples (§4.7). + SourceRead, + /// Scene-cut segmentation and classification (§4.3, §4.6). + Segment, + /// The integer base's motion search (§5.5). + BaseSearch, + /// The integer base's residuals, reconstruction, table and stream, its step (§5.5, + /// §5.6), and stills (§5.7). + BaseCode, + /// Refinement layers (§5.4), each applied by the reference sink. + RefineCode, + /// The sum of squared differences (§6.6). + Sse, + /// An object's content hash (§5.3). + ObjectHash, + /// SHA-256 digests: decoded-output digests (§6.5), listings, file names. + Digest, + /// A supplied floor's structure: its temporal units, CMAF boxes and listing (§7.3, + /// §7.7). + FloorRead, + /// Decoding a floor's units to pictures (§11.6). + FloorDecode, + /// The stage indexes, catalogs, receipt and claim (§6, §8, §9). + Index, + /// Reading and writing files. + Io, +} + +impl Step { + pub const ALL: [Step; 13] = [ + Step::SourceDecode, + Step::SourceRead, + Step::Segment, + Step::BaseSearch, + Step::BaseCode, + Step::RefineCode, + Step::Sse, + Step::ObjectHash, + Step::Digest, + Step::FloorRead, + Step::FloorDecode, + Step::Index, + Step::Io, + ]; + + pub fn term(self) -> &'static str { + match self { + Step::SourceDecode => "source-decode", + Step::SourceRead => "source-read", + Step::Segment => "segment", + Step::BaseSearch => "base-search", + Step::BaseCode => "base-code", + Step::RefineCode => "refine-code", + Step::Sse => "sse", + Step::ObjectHash => "object-hash", + Step::Digest => "digest", + Step::FloorRead => "floor-read", + Step::FloorDecode => "floor-decode", + Step::Index => "index", + Step::Io => "io", + } + } + + pub fn from_term(s: &str) -> Option { + Step::ALL.into_iter().find(|t| t.term() == s) + } + + /// The unit a cost table prices a call of the step in. + pub fn unit(self) -> &'static str { + match self { + Step::BaseSearch => "candidate-samples", + _ => "bytes", + } + } +} + +/// How calls are placed. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + /// Every step on the CPU reference. + Reference, + /// Every step a qualified GPU backend takes on the GPU, the rest on the CPU. + Gpu, + /// Each call on the fabric whose measured cost for its size is least ([`Costs`]). + Auto, +} + +/// One measured cost: what a call of `step` on `fabric` took on the host that measured +/// it, as `µJ per call + nJ per unit · units`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cost { + pub step: Step, + pub fabric: Fabric, + pub backend: String, + pub uj_per_call: u64, + pub nj_per_unit_milli: u64, +} + +impl Cost { + /// The cost of a call of `units`, in nanojoules (saturating). + pub fn nj(&self, units: u64) -> u128 { + self.uj_per_call as u128 * 1000 + self.nj_per_unit_milli as u128 * units as u128 / 1000 + } +} + +/// A cost table (`wai.pack.fabric-costs/1`), as `wai-pack fabric calibrate` writes it: +/// `{"schema", "host", "measured_at", "meter", "costs": [{"step", "fabric", "backend", +/// "uj_per_call", "nj_per_unit_milli", "unit"}]}`. The costs are measurements of one host +/// at one time, so a table is evidence for placing calls there and nowhere else. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Costs { + pub host: String, + pub costs: Vec, +} + +pub const COSTS_SCHEMA: &str = "wai.pack.fabric-costs/1"; + +impl Costs { + pub fn parse(text: &str) -> Result { + let v: serde_json::Value = serde_json::from_str(text).map_err(|e| format!("a cost table: {e}"))?; + if v.get("schema").and_then(|s| s.as_str()) != Some(COSTS_SCHEMA) { + return Err(format!("a cost table is of schema {COSTS_SCHEMA}")); + } + let mut costs = Vec::new(); + for c in v.get("costs").and_then(|c| c.as_array()).ok_or("a cost table's costs")? { + let s = |k: &str| c.get(k).and_then(|x| x.as_str()).ok_or_else(|| format!("a cost's {k}")); + let n = |k: &str| c.get(k).and_then(|x| x.as_u64()).ok_or_else(|| format!("a cost's {k}")); + let step = Step::from_term(s("step")?).ok_or("a cost's step is not a step of packaging §11.6")?; + let fabric = Fabric::from_term(s("fabric")?).ok_or("a cost's fabric is not cpu, gpu, media or neural")?; + costs.push(Cost { step, fabric, backend: s("backend")?.to_string(), uj_per_call: n("uj_per_call")?, nj_per_unit_milli: n("nj_per_unit_milli")? }); + } + Ok(Costs { host: v.get("host").and_then(|h| h.as_str()).unwrap_or("").to_string(), costs }) + } + + pub fn to_json(&self, measured_at: &str, meter: &str) -> String { + let costs: Vec = self + .costs + .iter() + .map(|c| serde_json::json!({"step": c.step.term(), "fabric": c.fabric.term(), "backend": c.backend, "uj_per_call": c.uj_per_call, "nj_per_unit_milli": c.nj_per_unit_milli, "unit": c.step.unit()})) + .collect(); + serde_json::to_string_pretty(&serde_json::json!({"schema": COSTS_SCHEMA, "host": self.host, "measured_at": measured_at, "meter": meter, "costs": costs})).expect("JSON") + } + + fn of(&self, step: Step, fabric: Fabric) -> Option<&Cost> { + self.costs.iter().find(|c| c.step == step && c.fabric == fabric) + } +} + +/// What a fabric did for a step over a run: calls, the step's units, and calls it handed +/// back to the CPU. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Tally { + pub calls: u64, + pub units: u64, +} + +/// One line of a fabric record: a step, a fabric that ran it, and its backend. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Ran { + pub step: Step, + pub fabric: Fabric, + pub backend: String, + pub calls: u64, + pub units: u64, +} + +/// The CPU reference that a coder given no fabrics uses; nothing reads its record. +pub fn reference() -> &'static Fabrics { + static R: std::sync::LazyLock = std::sync::LazyLock::new(Fabrics::reference); + &R +} + +/// The CPU reference's backend label. +pub fn cpu_label() -> String { + format!("cpu: wai-pack {} reference ({})", env!("CARGO_PKG_VERSION"), std::env::consts::ARCH) +} + +#[cfg(feature = "gpu")] +struct GpuSide { + gpu: wai_pack_gpu::Gpu, + /// The steps it gave the reference's output for on every qualifying input. + qualified: BTreeSet, +} + +/// The fabrics of one process: the backends that opened, what each was qualified for, +/// how calls are placed, and what each fabric ran. +pub struct Fabrics { + mode: Mode, + costs: Costs, + #[cfg(feature = "gpu")] + gpu: Option, + /// Backends that did not open or did not qualify, and why. + refused: Vec<(String, String)>, + tally: Mutex>, + /// Calls a fabric gave back to the CPU, and why (the first reason per step). + fallbacks: Mutex>, +} + +impl std::fmt::Debug for Fabrics { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Fabrics").field("mode", &self.mode).field("backends", &self.backends()).field("refused", &self.refused).finish() + } +} + +impl Default for Fabrics { + fn default() -> Fabrics { + Fabrics::reference() + } +} + +fn lock(m: &Mutex) -> std::sync::MutexGuard<'_, T> { + m.lock().unwrap_or_else(|p| p.into_inner()) +} + +impl Fabrics { + /// The CPU reference alone. + pub fn reference() -> Fabrics { + Fabrics { + mode: Mode::Reference, + costs: Costs::default(), + #[cfg(feature = "gpu")] + gpu: None, + refused: Vec::new(), + tally: Mutex::new(BTreeMap::new()), + fallbacks: Mutex::new(BTreeMap::new()), + } + } + + /// Open the backends this build has (a GPU with feature `gpu`), qualify each on every + /// step it offers against the CPU reference, and place calls by `mode`. Under + /// [`Mode::Auto`] a step goes to a fabric only where `costs` prices it. + pub fn open(mode: Mode, costs: Costs) -> Fabrics { + let mut f = Fabrics { mode, costs, ..Fabrics::reference() }; + if mode == Mode::Reference { + return f; + } + #[cfg(feature = "gpu")] + { + let d = wai_pack_gpu::discover(); + for (a, why) in d.refused { + f.refused.push((a.label(), why)); + } + // The first adapter that qualifies for a step at all; on a host with several, + // the platform's first. + for gpu in d.found { + let mut qualified = BTreeSet::new(); + for step in [Step::BaseSearch, Step::Sse, Step::ObjectHash] { + match qualify(&gpu, step) { + Ok(()) => { + qualified.insert(step); + } + Err(why) => f.refused.push((format!("{} for {}", gpu.adapter().label(), step.term()), why)), + } + } + if !qualified.is_empty() && f.gpu.is_none() { + f.gpu = Some(GpuSide { gpu, qualified }); + } + } + } + #[cfg(not(feature = "gpu"))] + f.refused.push(("gpu".into(), "this build has no GPU backend (wai-pack's feature `gpu`)".into())); + f + } + + /// Fabrics over `gpu` for `steps`, without qualifying it: for a test that shows what + /// an unqualified device would do, and that the verifier's verdict does not follow it. + #[cfg(feature = "gpu")] + #[doc(hidden)] + pub fn with_unqualified_gpu(gpu: wai_pack_gpu::Gpu, steps: &[Step], mode: Mode) -> Fabrics { + Fabrics { mode, gpu: Some(GpuSide { gpu, qualified: steps.iter().copied().collect() }), ..Fabrics::reference() } + } + + pub fn mode(&self) -> Mode { + self.mode + } + + /// Each backend that may run a step, as `(step, fabric, backend)`: the CPU for every + /// step, and each qualified backend for its steps. + pub fn backends(&self) -> Vec<(Step, Fabric, String)> { + let mut v: Vec<(Step, Fabric, String)> = Step::ALL.iter().map(|&s| (s, Fabric::Cpu, cpu_label())).collect(); + #[cfg(feature = "gpu")] + if let Some(g) = &self.gpu { + for &s in &g.qualified { + v.push((s, Fabric::Gpu, g.gpu.adapter().label())); + } + } + v.sort(); + v + } + + /// The backends that did not open or did not qualify, and why. + pub fn refused(&self) -> &[(String, String)] { + &self.refused + } + + /// Whether a backend of `fabric` is qualified for `step` (the CPU always is). + pub fn offers(&self, step: Step, fabric: Fabric) -> bool { + self.has(step, fabric) + } + + fn has(&self, step: Step, fabric: Fabric) -> bool { + match fabric { + Fabric::Cpu => true, + #[cfg(feature = "gpu")] + Fabric::Gpu => self.gpu.as_ref().is_some_and(|g| g.qualified.contains(&step)), + _ => { + let _ = step; + false + } + } + } + + /// The fabric a call of `step` over `units` goes to. + pub fn place(&self, step: Step, units: u64) -> Fabric { + match self.mode { + Mode::Reference => Fabric::Cpu, + Mode::Gpu => { + if self.has(step, Fabric::Gpu) { + Fabric::Gpu + } else { + Fabric::Cpu + } + } + Mode::Auto => { + let mut best = (Fabric::Cpu, self.costs.of(step, Fabric::Cpu).map(|c| c.nj(units))); + for f in [Fabric::Gpu, Fabric::Media, Fabric::Neural] { + if !self.has(step, f) { + continue; + } + // A fabric is chosen over the CPU only on a measurement of both. + if let (Some(c), Some(cpu)) = (self.costs.of(step, f), best.1) + && c.nj(units) < cpu + { + best = (f, Some(c.nj(units))); + } + } + best.0 + } + } + } + + fn count(&self, step: Step, fabric: Fabric, units: u64) { + let mut t = lock(&self.tally); + let e = t.entry((step, fabric)).or_default(); + e.calls += 1; + e.units = e.units.saturating_add(units); + } + + /// Count a call of `step` that ran on the CPU outside this module. + pub fn ran_on_cpu(&self, step: Step, units: u64) { + self.count(step, Fabric::Cpu, units); + } + + #[cfg(feature = "gpu")] + fn fell_back(&self, step: Step, why: String) { + let mut f = lock(&self.fallbacks); + let e = f.entry(step).or_insert((0, why)); + e.0 += 1; + } + + /// The steps that ran, each with every fabric that ran it and its backend, in the + /// order of their terms then the fabrics': a measurement claim's `fabrics.steps`. + pub fn record(&self) -> Vec { + let t = lock(&self.tally); + let mut v: Vec = t + .iter() + .map(|(&(step, fabric), n)| Ran { step, fabric, backend: self.label(fabric), calls: n.calls, units: n.units }) + .collect(); + v.sort_by(|a, b| (a.step.term(), a.fabric.term()).cmp(&(b.step.term(), b.fabric.term()))); + v + } + + /// Calls a fabric gave back to the CPU, by step, with the first reason. + pub fn fallbacks(&self) -> BTreeMap { + lock(&self.fallbacks).clone() + } + + fn label(&self, fabric: Fabric) -> String { + match fabric { + #[cfg(feature = "gpu")] + Fabric::Gpu => self.gpu.as_ref().map_or_else(|| "gpu".into(), |g| g.gpu.adapter().label()), + Fabric::Cpu => cpu_label(), + other => other.term().into(), + } + } + + /// A motion search placed frame by frame ([`MotionSearch`]). + pub fn search(&self) -> FabricSearch<'_> { + FabricSearch { f: self } + } + + /// `Σ (a − b)²` over two clips of RGB8 frames (§6.6), on the fabric the call goes to. + pub fn sse(&self, a: &[Vec], b: &[Vec]) -> u64 { + let units: u64 = a.iter().zip(b).map(|(x, y)| x.len().min(y.len()) as u64).sum(); + #[cfg(feature = "gpu")] + if self.place(Step::Sse, units) == Fabric::Gpu + && let Some(g) = &self.gpu + { + let (x, y): (Vec, Vec) = a.iter().zip(b).fold((Vec::with_capacity(units as usize), Vec::with_capacity(units as usize)), |(mut x, mut y), (p, q)| { + let n = p.len().min(q.len()); + x.extend_from_slice(&p[..n]); + y.extend_from_slice(&q[..n]); + (x, y) + }); + match g.gpu.sse(&x, &y) { + Ok(s) => { + self.count(Step::Sse, Fabric::Gpu, units); + return s; + } + Err(e) => self.fell_back(Step::Sse, e.to_string()), + } + } + self.count(Step::Sse, Fabric::Cpu, units); + crate::encode::sse(a, b) + } + + /// The content hash of each envelope (§5.3), each batch on the fabric it goes to. + pub fn object_hashes(&self, envelopes: &[&[u8]]) -> Vec<[u8; 32]> { + let units: u64 = envelopes.iter().map(|e| e.len() as u64 + 15).sum(); + #[cfg(feature = "gpu")] + if self.place(Step::ObjectHash, units) == Fabric::Gpu + && let Some(g) = &self.gpu + { + let msgs: Vec> = envelopes.iter().map(|e| [b"moq-jwp:object\x01".as_slice(), e].concat()).collect(); + let refs: Vec<&[u8]> = msgs.iter().map(|m| m.as_slice()).collect(); + match g.gpu.blake3_many(&refs) { + Ok(h) => { + self.count(Step::ObjectHash, Fabric::Gpu, units); + return h; + } + Err(e) => self.fell_back(Step::ObjectHash, e.to_string()), + } + } + self.count(Step::ObjectHash, Fabric::Cpu, units); + envelopes.iter().map(|e| crate::objects::object_hash(e)).collect() + } + + /// One envelope's content hash ([`Fabrics::object_hashes`]). + pub fn object_hash(&self, envelope: &[u8]) -> [u8; 32] { + self.object_hashes(&[envelope])[0] + } +} + +/// [`Fabrics`]'s motion search: each frame on the fabric the call goes to, the CPU +/// reference wherever another fabric does not take it. +pub struct FabricSearch<'a> { + f: &'a Fabrics, +} + +impl MotionSearch for FabricSearch<'_> { + fn frame(&mut self, src: &[u8], rf: &[u8], h: usize, w: usize, block: usize, range: i64, prec: i64, out: &mut Vec<(i16, i16)>) { + let side = (2 * range.max(0) + 1) as u64; + let units = (src.len() as u64).saturating_mul(side * side); + #[cfg(feature = "gpu")] + if self.f.place(Step::BaseSearch, units) == Fabric::Gpu + && let Some(g) = &self.f.gpu + { + match g.gpu.motion_frame(src, rf, h, w, block, range, prec) { + Ok(v) => { + out.extend(v); + self.f.count(Step::BaseSearch, Fabric::Gpu, units); + return; + } + Err(e) => self.f.fell_back(Step::BaseSearch, e.to_string()), + } + } + CpuSearch.frame(src, rf, h, w, block, range, prec, out); + self.f.count(Step::BaseSearch, Fabric::Cpu, units); + } +} + +/// Seeded qualifying inputs (xorshift64*). +struct Rng(u64); + +impl Rng { + fn next(&mut self) -> u64 { + self.0 ^= self.0 >> 12; + self.0 ^= self.0 << 25; + self.0 ^= self.0 >> 27; + self.0.wrapping_mul(0x2545_F491_4F6C_DD1D) + } + fn bytes(&mut self, n: usize) -> Vec { + (0..n).map(|_| self.next() as u8).collect() + } +} + +/// A qualifying motion-search input: `(h, w, block, range, precision, source, reference)`. +pub type SearchCase = (usize, usize, usize, i64, i64, Vec, Vec); + +/// Frame pairs that exercise the search's rule: noise, a smooth field, a flat field +/// (every candidate ties), alternating columns (two vectors tie at one length), at sizes +/// that are not whole blocks, at both precisions. +pub fn qualifying_frames() -> Vec { + let mut rng = Rng(0x00fa_b41c_0000_0001); + let mut v = Vec::new(); + for &(h, w, block, range, prec) in &[(24usize, 40usize, 8usize, 3i64, 1i64), (24, 40, 8, 3, 2), (19, 23, 4, 2, 2), (33, 17, 16, 8, 2), (36, 64, 16, 4, 2)] { + let n = h * w * 3; + let field = |t: usize| -> Vec { (0..h * w).flat_map(|i| { let (y, x) = (i / w, i % w); let p = ((x * 7 + y * 5 + t * 3) % 256) as u8; [p, p / 2 + (y % 7) as u8, 255 - p] }).collect() }; + let stripes = |t: usize| -> Vec { (0..h * w).flat_map(|i| if (i % w + t).is_multiple_of(2) { [40, 200, 90] } else { [200, 40, 160] }).collect() }; + v.push((h, w, block, range, prec, rng.bytes(n), rng.bytes(n))); + v.push((h, w, block, range, prec, field(1), field(0))); + v.push((h, w, block, range, prec, vec![97; n], vec![101; n])); + v.push((h, w, block, range, prec, stripes(1), stripes(0))); + } + v +} + +/// Whether `gpu` gives the CPU reference's output for `step` on every qualifying input. +#[cfg(feature = "gpu")] +pub fn qualify(gpu: &wai_pack_gpu::Gpu, step: Step) -> Result<(), String> { + match step { + Step::BaseSearch => { + for (i, (h, w, block, range, prec, src, rf)) in qualifying_frames().into_iter().enumerate() { + let mut want = Vec::new(); + CpuSearch.frame(&src, &rf, h, w, block, range, prec, &mut want); + let got = gpu.motion_frame(&src, &rf, h, w, block, range, prec).map_err(|e| e.to_string())?; + if got != want { + return Err(format!("qualifying frame {i} ({h}x{w}, block {block}, range {range}, precision {prec}): vectors other than the reference's")); + } + } + Ok(()) + } + Step::Sse => { + let mut rng = Rng(0x00fa_b41c_0000_0002); + for n in [1usize, 4097, 70_001] { + let (a, b) = (rng.bytes(n), rng.bytes(n)); + let got = gpu.sse(&a, &b).map_err(|e| e.to_string())?; + if got != crate::encode::sse(&[a], &[b]) { + return Err(format!("an SSE of {n} bytes other than the reference's")); + } + } + Ok(()) + } + Step::ObjectHash => { + let inputs: Vec> = [0usize, 1, 1023, 1024, 1025, 4097, 100_000].iter().map(|&n| (0..n).map(|i| (i % 251) as u8).collect()).collect(); + let refs: Vec<&[u8]> = inputs.iter().map(|v| v.as_slice()).collect(); + let got = gpu.blake3_many(&refs).map_err(|e| e.to_string())?; + for (m, h) in refs.iter().zip(got) { + if &h != blake3::hash(m).as_bytes() { + return Err(format!("a BLAKE3 of {} bytes other than the reference's", m.len())); + } + } + Ok(()) + } + _ => Err(format!("no GPU kernel for {}", step.term())), + } +} diff --git a/wai/wai-pack/src/fabric_meter.rs b/wai/wai-pack/src/fabric_meter.rs new file mode 100644 index 000000000..e561b585f --- /dev/null +++ b/wai/wai-pack/src/fabric_meter.rs @@ -0,0 +1,456 @@ +//! A fabric meter for this process on macOS, without privileges ([packaging] §11.6, +//! [energy-measurement] §6.3): the energy each fabric spent on the process's work, read +//! from what the operating system lets an unprivileged process read. +//! +//! - **CPU**: the kernel's account of the process's own CPU energy +//! (`proc_pid_rusage(RUSAGE_INFO_V6).ri_energy_nj`), summed over its threads. It is the +//! kernel's attribution of the cores' energy to the task (attribution `task`), and it is +//! not disturbed by other work on the host. +//! - **GPU**: the IOReport "Energy Model" group's `GPU Energy` channel, the GPU's whole +//! draw, read at each end of the span on its next republish (it can hold for hundreds of +//! milliseconds and then catch up in one step), and the GPU time every process accrued +//! over the span (the IORegistry's per-client `accumulatedGPUTime`, readable by any +//! process). The process's share is an exact 0 where it spent no GPU time, and the rail's +//! advance `· Δt_self / Δt_all` where it was the GPU's sole user, at least 99% of every +//! client's GPU time (attribution `rail`, the rule `jouleclaw-energy`'s `process_gpu` +//! measured and states). Where the GPU was shared the span has no figure: a rail's energy +//! is not proportional to its users' busy time, and a split by it has been measured to +//! charge a workload several times its own draw. +//! - **Media engine, neural engine**: none. On macOS 27 the Energy Model's other channels +//! (the CPU's, `ANE`, `AVE`, `DRAM`) read zero unless the caller holds a private +//! entitlement, and there is no decoder channel, so a span that ran a step there has no +//! figure ([`Meter::stop`] gives none, and a pack refuses `pack-meter`). +//! +//! IOReport is a private interface (`/usr/lib/libIOReport.dylib`), opened with `dlopen`; +//! where it or its channel is absent, the meter does not open. +//! +//! [packaging]: ../../../extensions/packaging.md +//! [energy-measurement]: ../../../extensions/energy-measurement.md + +#![allow(non_camel_case_types, non_upper_case_globals)] + +use crate::fabric::Fabric; +use std::ffi::{c_char, c_void, CStr, CString}; +use std::sync::{Arc, Mutex}; + +type CFTypeRef = *const c_void; +type CFStringRef = *const c_void; +type CFDictionaryRef = *const c_void; +type CFMutableDictionaryRef = *mut c_void; +type CFArrayRef = *const c_void; +type CFIndex = isize; +type io_object_t = u32; + +#[link(name = "CoreFoundation", kind = "framework")] +unsafe extern "C" { + fn CFStringCreateWithCString(alloc: CFTypeRef, s: *const c_char, encoding: u32) -> CFStringRef; + fn CFStringGetCString(s: CFStringRef, buf: *mut c_char, size: CFIndex, encoding: u32) -> u8; + fn CFDictionaryGetValue(d: CFDictionaryRef, key: CFTypeRef) -> CFTypeRef; + fn CFDictionaryGetCount(d: CFDictionaryRef) -> CFIndex; + fn CFDictionaryCreateMutableCopy(alloc: CFTypeRef, cap: CFIndex, d: CFDictionaryRef) -> CFMutableDictionaryRef; + fn CFArrayGetCount(a: CFArrayRef) -> CFIndex; + fn CFArrayGetValueAtIndex(a: CFArrayRef, i: CFIndex) -> CFTypeRef; + fn CFNumberGetValue(n: CFTypeRef, ty: CFIndex, out: *mut c_void) -> u8; + fn CFGetTypeID(x: CFTypeRef) -> usize; + fn CFStringGetTypeID() -> usize; + fn CFArrayGetTypeID() -> usize; + fn CFDictionaryGetTypeID() -> usize; + fn CFNumberGetTypeID() -> usize; + fn CFRelease(x: CFTypeRef); +} + +#[link(name = "IOKit", kind = "framework")] +unsafe extern "C" { + fn IOServiceMatching(name: *const c_char) -> CFMutableDictionaryRef; + fn IOServiceGetMatchingServices(port: u32, matching: CFMutableDictionaryRef, it: *mut io_object_t) -> i32; + fn IOIteratorNext(it: io_object_t) -> io_object_t; + fn IOObjectRelease(o: io_object_t) -> i32; + fn IORegistryEntryGetChildIterator(e: io_object_t, plane: *const c_char, it: *mut io_object_t) -> i32; + fn IORegistryEntryCreateCFProperty(e: io_object_t, key: CFStringRef, alloc: CFTypeRef, opts: u32) -> CFTypeRef; +} + +unsafe extern "C" { + fn dlopen(path: *const c_char, mode: i32) -> *mut c_void; + fn dlsym(handle: *mut c_void, name: *const c_char) -> *mut c_void; + fn proc_pid_rusage(pid: i32, flavor: i32, buffer: *mut c_void) -> i32; + fn getpid() -> i32; +} + +const UTF8: u32 = 0x0800_0100; +const NUMBER_SINT64: CFIndex = 4; +const RUSAGE_INFO_V6: i32 = 6; +/// `ri_energy_nj`'s place among `rusage_info_v6`'s 64-bit fields after its 16-byte UUID. +const RI_ENERGY_NJ: usize = 40; + +type CopyChannels = unsafe extern "C" fn(CFStringRef, CFStringRef, u64, u64, u64) -> CFDictionaryRef; +type CreateSubscription = unsafe extern "C" fn(*const c_void, CFMutableDictionaryRef, *mut CFMutableDictionaryRef, u64, CFTypeRef) -> *mut c_void; +type CreateSamples = unsafe extern "C" fn(*mut c_void, CFMutableDictionaryRef, CFTypeRef) -> CFDictionaryRef; +type CreateDelta = unsafe extern "C" fn(CFDictionaryRef, CFDictionaryRef, CFTypeRef) -> CFDictionaryRef; +type ChannelString = unsafe extern "C" fn(CFDictionaryRef) -> CFStringRef; +type SimpleInteger = unsafe extern "C" fn(CFDictionaryRef, i32) -> i64; + +fn cfstr(s: &str) -> CFStringRef { + let c = CString::new(s).expect("no NUL"); + // SAFETY: a C string and the UTF-8 encoding; the caller releases the result. + unsafe { CFStringCreateWithCString(std::ptr::null(), c.as_ptr(), UTF8) } +} + +fn string_of(s: CFStringRef) -> Option { + if s.is_null() { + return None; + } + let mut buf = [0 as c_char; 256]; + // SAFETY: a CFString and a buffer of its stated size. + let ok = unsafe { CFGetTypeID(s) == CFStringGetTypeID() && CFStringGetCString(s, buf.as_mut_ptr(), buf.len() as CFIndex, UTF8) != 0 }; + // SAFETY: CFStringGetCString wrote a NUL-terminated string into `buf`. + ok.then(|| unsafe { CStr::from_ptr(buf.as_ptr()) }.to_string_lossy().into_owned()) +} + +/// The kernel's account of this process's CPU energy so far, in nanojoules. +pub fn cpu_energy_nj() -> Option { + let mut buf = [0u64; 2 + 64]; + // SAFETY: a buffer larger than rusage_info_v6 for this process's own pid. + let rc = unsafe { proc_pid_rusage(getpid(), RUSAGE_INFO_V6, buf.as_mut_ptr().cast()) }; + (rc == 0).then(|| buf[2 + RI_ENERGY_NJ]) +} + +/// GPU time accrued so far, in nanoseconds: by every client of every accelerator, and by +/// this process's clients. +pub fn gpu_time_ns() -> Option<(u64, u64)> { + let me = format!("pid {}, ", unsafe { getpid() }); + let (mut all, mut mine) = (0u64, 0u64); + let plane = CString::new("IOService").expect("no NUL"); + let class = CString::new("IOAccelerator").expect("no NUL"); + let (k_creator, k_usage, k_time) = (cfstr("IOUserClientCreator"), cfstr("AppUsage"), cfstr("accumulatedGPUTime")); + let mut it: io_object_t = 0; + // SAFETY: IOKit's documented calls; every object obtained is released below. + unsafe { + if IOServiceGetMatchingServices(0, IOServiceMatching(class.as_ptr()), &mut it) != 0 { + return None; + } + loop { + let acc = IOIteratorNext(it); + if acc == 0 { + break; + } + let mut cit: io_object_t = 0; + if IORegistryEntryGetChildIterator(acc, plane.as_ptr(), &mut cit) == 0 { + loop { + let c = IOIteratorNext(cit); + if c == 0 { + break; + } + let creator = IORegistryEntryCreateCFProperty(c, k_creator, std::ptr::null(), 0); + let usage = IORegistryEntryCreateCFProperty(c, k_usage, std::ptr::null(), 0); + let ours = string_of(creator).is_some_and(|s| s.starts_with(&me)); + if !usage.is_null() && CFGetTypeID(usage) == CFArrayGetTypeID() { + for i in 0..CFArrayGetCount(usage) { + let d = CFArrayGetValueAtIndex(usage, i); + if d.is_null() || CFGetTypeID(d) != CFDictionaryGetTypeID() { + continue; + } + let n = CFDictionaryGetValue(d, k_time); + let mut v: i64 = 0; + if !n.is_null() && CFGetTypeID(n) == CFNumberGetTypeID() && CFNumberGetValue(n, NUMBER_SINT64, (&mut v as *mut i64).cast()) != 0 && v > 0 { + all = all.saturating_add(v as u64); + if ours { + mine = mine.saturating_add(v as u64); + } + } + } + } + for x in [creator, usage] { + if !x.is_null() { + CFRelease(x); + } + } + IOObjectRelease(c); + } + IOObjectRelease(cit); + } + IOObjectRelease(acc); + } + IOObjectRelease(it); + for k in [k_creator, k_usage, k_time] { + CFRelease(k); + } + } + Some((all, mine)) +} + +/// A subscription to the Energy Model group, read for its `GPU Energy` channel. +pub struct GpuRail { + sub: *mut c_void, + subd: CFMutableDictionaryRef, + samples: CreateSamples, + delta: CreateDelta, + name: ChannelString, + unit: ChannelString, + value: SimpleInteger, + last: CFDictionaryRef, +} + +// SAFETY: the subscription is used by one thread at a time, behind the meter's lock. +unsafe impl Send for GpuRail {} + +impl GpuRail { + /// Open the subscription; `None` where IOReport or the channel is absent. + pub fn open() -> Option { + let lib = CString::new("/usr/lib/libIOReport.dylib").expect("no NUL"); + // SAFETY: dlopen and dlsym of the system library's documented-by-use symbols; a + // symbol that is missing makes the rail absent. + unsafe { + let h = dlopen(lib.as_ptr(), 2); + if h.is_null() { + return None; + } + let sym = |n: &str| { + let c = CString::new(n).expect("no NUL"); + let p = dlsym(h, c.as_ptr()); + (!p.is_null()).then_some(p) + }; + let copy: CopyChannels = std::mem::transmute(sym("IOReportCopyChannelsInGroup")?); + let create: CreateSubscription = std::mem::transmute(sym("IOReportCreateSubscription")?); + let samples: CreateSamples = std::mem::transmute(sym("IOReportCreateSamples")?); + let delta: CreateDelta = std::mem::transmute(sym("IOReportCreateSamplesDelta")?); + let name: ChannelString = std::mem::transmute(sym("IOReportChannelGetChannelName")?); + let unit: ChannelString = std::mem::transmute(sym("IOReportChannelGetUnitLabel")?); + let value: SimpleInteger = std::mem::transmute(sym("IOReportSimpleGetIntegerValue")?); + let group = cfstr("Energy Model"); + let ch = copy(group, std::ptr::null(), 0, 0, 0); + CFRelease(group); + if ch.is_null() { + return None; + } + let mch = CFDictionaryCreateMutableCopy(std::ptr::null(), CFDictionaryGetCount(ch), ch); + CFRelease(ch); + let mut subd: CFMutableDictionaryRef = std::ptr::null_mut(); + let sub = create(std::ptr::null(), mch, &mut subd, 0, std::ptr::null()); + if sub.is_null() || subd.is_null() { + return None; + } + let last = samples(sub, subd, std::ptr::null()); + if last.is_null() { + return None; + } + Some(GpuRail { sub, subd, samples, delta, name, unit, value, last }) + } + } + + /// The `GPU Energy` channel's energy since the previous read, in nanojoules; `None` + /// where the sample or the channel is absent. + pub fn read_nj(&mut self) -> Option { + // SAFETY: the subscription opened above; every sample and delta is released. + unsafe { + let now = (self.samples)(self.sub, self.subd, std::ptr::null()); + if now.is_null() { + return None; + } + let d = (self.delta)(self.last, now, std::ptr::null()); + CFRelease(self.last); + self.last = now; + if d.is_null() { + return None; + } + let key = cfstr("IOReportChannels"); + let arr = CFDictionaryGetValue(d, key); + CFRelease(key); + let mut total: Option = None; + if !arr.is_null() && CFGetTypeID(arr) == CFArrayGetTypeID() { + for i in 0..CFArrayGetCount(arr) { + let it = CFArrayGetValueAtIndex(arr, i); + if string_of((self.name)(it)).as_deref() != Some("GPU Energy") { + continue; + } + let scale: u64 = match string_of((self.unit)(it)).as_deref() { + Some("nJ") => 1, + Some("uJ") | Some("µJ") => 1_000, + Some("mJ") => 1_000_000, + _ => continue, + }; + let v = (self.value)(it, 0).max(0) as u64; + total = Some(total.unwrap_or(0).saturating_add(v.saturating_mul(scale))); + } + } + CFRelease(d); + total + } + } +} + +/// The least share of every client's GPU time, in percent, for which the rail is this +/// process's ([`FabricMeter`]). +pub const SOLE_USER_PERCENT: u64 = 99; + +/// How long a read at a span's end waits for the rail's next republish. +pub const RAIL_EDGE_WAIT: std::time::Duration = std::time::Duration::from_millis(1000); + +/// What a span gave each fabric: microjoules, and how the share was attributed. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Shares { + pub cpu_uj: u64, + pub gpu_uj: u64, + /// The GPU rail's whole energy over the span, and the GPU times behind the share. + pub gpu_rail_uj: u64, + pub gpu_time_self_ns: u64, + pub gpu_time_all_ns: u64, + pub window_us: u64, +} + +impl Shares { + /// The shares as `(fabric, microjoules, attribution)`, the fabrics in order. + pub fn split(&self) -> Vec<(Fabric, u64, &'static str)> { + vec![(Fabric::Cpu, self.cpu_uj, "task"), (Fabric::Gpu, self.gpu_uj, "rail")] + } + + pub fn total_uj(&self) -> u64 { + self.cpu_uj.saturating_add(self.gpu_uj) + } +} + +/// A span's start. +struct Start { + cpu_nj: u64, + gpu: (u64, u64), + at: std::time::Instant, +} + +/// The meter: open it once, then [`FabricMeter::start`] and [`FabricMeter::stop`] around +/// a span. It is also a [`wai::energy_meter::Meter`] whose reading is the shares' sum, +/// classed `ModelBased`; the last span's shares stay readable through +/// [`FabricMeter::shares_handle`]. +pub struct FabricMeter { + rail: Option, + start: Option, + last: Arc>>, + /// Why the last span had no figure, where it had none. + why: Arc>>, +} + +impl FabricMeter { + /// The meter, or `None` where the CPU's account cannot be read. Without the GPU rail + /// it meters the CPU alone, and a span in which the process accrued GPU time then has + /// no figure. + pub fn open() -> Option { + cpu_energy_nj()?; + Some(FabricMeter { rail: GpuRail::open(), start: None, last: Arc::new(Mutex::new(None)), why: Arc::new(Mutex::new(None)) }) + } + + /// Whether the GPU rail opened. + pub fn has_gpu_rail(&self) -> bool { + self.rail.is_some() + } + + /// The shares of the last span that [`FabricMeter::stop`] measured. + pub fn shares_handle(&self) -> Arc>> { + Arc::clone(&self.last) + } + + /// Why the last span had no figure, readable after the meter has been moved. + pub fn why_handle(&self) -> Arc>> { + Arc::clone(&self.why) + } + + /// Why the last span had no figure, where it had none. + pub fn why_none(&self) -> Option { + self.why.lock().unwrap_or_else(|p| p.into_inner()).clone() + } + + fn refuse(&self, why: String) -> Option { + *self.why.lock().unwrap_or_else(|p| p.into_inner()) = Some(why); + None + } + + /// The rail's energy up to its next republish: what it has published since the last + /// read, then reads every 5 ms until one advances, for at most [`RAIL_EDGE_WAIT`]; an + /// idle GPU, whose clients accrue no GPU time over 100 ms, may publish nothing, and its + /// reading stands. + fn rail_to_edge(&mut self) -> Option { + let r = self.rail.as_mut()?; + let mut total = r.read_nj()?; + let t0 = std::time::Instant::now(); + let idle_from = gpu_time_ns().map(|g| g.0); + while t0.elapsed() < RAIL_EDGE_WAIT { + std::thread::sleep(std::time::Duration::from_millis(5)); + let d = r.read_nj()?; + if d > 0 { + return Some(total.saturating_add(d)); + } + if t0.elapsed() >= std::time::Duration::from_millis(100) && gpu_time_ns().map(|g| g.0) == idle_from { + return Some(total); + } + total = total.saturating_add(d); + } + None + } + + pub fn begin(&mut self) -> bool { + // The rail first, to its republish, so that the span starts on one; then the GPU + // times and the CPU, innermost. + if self.rail.is_some() && self.rail_to_edge().is_none() { + return false; + } + let gpu = gpu_time_ns().unwrap_or((0, 0)); + let Some(cpu_nj) = cpu_energy_nj() else { return false }; + *self.why.lock().unwrap_or_else(|p| p.into_inner()) = None; + self.start = Some(Start { cpu_nj, gpu, at: std::time::Instant::now() }); + true + } + + /// The span's shares; `None` with no span begun, a counter that went backwards, or GPU + /// time accrued by this process with no rail to attribute. + pub fn end(&mut self) -> Option { + let s = self.start.take()?; + // The CPU first, innermost; then the GPU times; then the rail, to its republish. + let cpu = cpu_energy_nj()?.checked_sub(s.cpu_nj)?; + let (all, mine) = gpu_time_ns().unwrap_or(s.gpu); + let window_us = s.at.elapsed().as_micros() as u64; + let rail_nj = if self.rail.is_some() { self.rail_to_edge() } else { None }; + let (d_all, d_mine) = (all.saturating_sub(s.gpu.0), mine.saturating_sub(s.gpu.1)); + let gpu_nj = match (d_mine, rail_nj) { + (0, _) => 0, + (_, None) => return self.refuse("the process used the GPU and the GPU rail gave no reading".into()), + (m, Some(e)) => { + let all = d_all.max(m); + if m as u128 * 100 < all as u128 * SOLE_USER_PERCENT as u128 { + return self.refuse(format!("the GPU was shared: this process had {m} ns of its {all} ns, under {SOLE_USER_PERCENT}%")); + } + (e as u128 * m as u128 / all as u128) as u64 + } + }; + let shares = Shares { + cpu_uj: cpu / 1000, + gpu_uj: gpu_nj / 1000, + gpu_rail_uj: rail_nj.unwrap_or(0) / 1000, + gpu_time_self_ns: d_mine, + gpu_time_all_ns: d_all, + window_us, + }; + *self.last.lock().unwrap_or_else(|p| p.into_inner()) = Some(shares.clone()); + Some(shares) + } + + /// What a report names as its power reader. + pub fn reader(&self) -> String { + format!( + "wai-pack fabric meter: cpu = proc_pid_rusage(RUSAGE_INFO_V6).ri_energy_nj of this process; gpu = IOReport Energy Model \"GPU Energy\"{}, this process at least 99% of IORegistry accumulatedGPUTime, else no figure", + if self.rail.is_some() { "" } else { " (absent on this host)" } + ) + } +} + +impl wai::energy_meter::Meter for FabricMeter { + fn start(&mut self) { + self.begin(); + } + + fn stop(&mut self) -> Option { + let s = self.end()?; + let uj = s.total_uj(); + (uj > 0).then_some(wai::energy_meter::EnergyReading { joules_micro: uj, provenance: wai::wai_video_manifest::EnergyProvenance::ModelBased }) + } + + fn kind(&self) -> wai::energy_class::AcquisitionKind { + wai::energy_class::AcquisitionKind::ModelBased + } +} diff --git a/wai/wai-pack/src/floor_decode.rs b/wai/wai-pack/src/floor_decode.rs new file mode 100644 index 000000000..ed17829db --- /dev/null +++ b/wai/wai-pack/src/floor_decode.rs @@ -0,0 +1,268 @@ +//! A package's floors decoded to pictures by a conformant AV1 decoder ([packaging] §11.6): +//! informative, for comparing decoders and fabrics. Nothing here refuses a floor. +//! +//! The AV1 decoding process fixes every output sample of a stream that carries no film +//! grain parameters, and of every stream before film grain is applied (AV1 §7.2), so two +//! conformant decoders give the same pictures, on any fabric. This decodes each floor +//! rung's CMAF files (§7.6, §7.7: `floor//init.mp4` and the fragments in its floor +//! playlist's order) through `ffmpeg`, with one of two decoders: +//! +//! - [`Decoder::Dav1d`], the CPU: `libdav1d`, film grain exported rather than applied, its +//! pictures at the stream's bit depth as ffmpeg's planar 4:2:0 formats hold them; +//! - [`Decoder::VideoToolbox`], the media engine of an Apple chip that decodes AV1 (M3 and +//! later): its frames required to be the hardware's (`videotoolbox_vld`), downloaded as +//! they are, P010. A rung whose sequence header says `film_grain_params_present` is not +//! decoded on it: it applies grain, which no rule fixes. +//! +//! Each piece's pictures are hashed with SHA-256 in one canonical form: the Y, U and V +//! planes in turn, row by row, a sample a byte for an 8-bit stream and a little-endian +//! 16-bit word for a 10-bit one. P010 holds a sample `s` as `s << 6`; it is read back here +//! exactly, and a word whose low six bits are not zero, or, for an 8-bit stream, a 10-bit +//! value that is not an 8-bit value widened by a shift (`v << 2`) or by bit replication +//! (`v << 2 | v >> 6`), fails the decode rather than being rounded. ffmpeg's own 16-bit to +//! 8-bit conversion rounds and dithers, so it is never used. Equal digests from both +//! decoders are the evidence that the media engine computed the decoding process. +//! +//! [packaging]: ../../../extensions/packaging.md + +use crate::policy::Policy; +use sha2::Digest as _; +use std::io::Read as _; +use std::path::Path; +use wai::stage_index::{Role, StageIndex}; + +/// The decoder a floor is decoded with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Decoder { + Dav1d, + VideoToolbox, +} + +impl Decoder { + pub fn from_term(s: &str) -> Option { + match s { + "dav1d" => Some(Decoder::Dav1d), + "videotoolbox" => Some(Decoder::VideoToolbox), + _ => None, + } + } + + pub fn fabric(self) -> crate::fabric::Fabric { + match self { + Decoder::Dav1d => crate::fabric::Fabric::Cpu, + Decoder::VideoToolbox => crate::fabric::Fabric::Media, + } + } +} + +/// One floor rung decoded: its name, picture size and bit depth, each piece's frame count +/// and digest, and the decoder's description. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RungDecode { + pub name: String, + pub w: u32, + pub h: u32, + pub bit_depth: u8, + pub pieces: Vec<(u64, String)>, + pub decoder: String, +} + +impl RungDecode { + /// SHA-256 over the pieces' digests in order: the rung's digest. + pub fn digest(&self) -> String { + let mut h = sha2::Sha256::new(); + for (_, d) in &self.pieces { + h.update(d.as_bytes()); + } + crate::hex(&h.finalize()) + } +} + +/// The units of a floor object: the §7.2 framing of its payload. +fn object_units(env: &[u8]) -> Result>, String> { + let w = wai::Wai::from_bytes(env).map_err(|e| format!("a floor object that is not an envelope: {e}"))?; + crate::floor::av1_units(&w.payload).map(|(u, _)| u).ok_or_else(|| "a floor object whose payload is not the §7.2 framing".into()) +} + +/// The sequence header of a stream's first unit, and whether it carries film grain. +fn first_sequence_header(unit: &[u8]) -> Result<(crate::av1::SequenceHeader, bool), String> { + let obus = crate::av1::obus(unit).map_err(|e| e.to_string())?; + let sh = obus.iter().find(|o| o.obu_type == crate::av1::OBU_SEQUENCE_HEADER).ok_or("the first unit carries no sequence header")?; + let seq = crate::av1::SequenceHeader::parse(sh.payload).map_err(|e| e.to_string())?; + let grain = crate::av1::SequenceHeader::film_grain_params_present(sh.payload).ok_or("a sequence header that ends before film_grain_params_present")?; + Ok((seq, grain)) +} + +/// ffmpeg's version line. +fn ffmpeg_version(ffmpeg: &str) -> String { + std::process::Command::new(ffmpeg).args(["-hide_banner", "-version"]).output().ok().and_then(|o| String::from_utf8_lossy(&o.stdout).lines().next().map(str::to_string)).unwrap_or_else(|| ffmpeg.to_string()) +} + +/// Decode every floor rung of the package in `dir` with `decoder`, through the `ffmpeg` +/// program, each rung's CMAF files joined into one file in `tmp` (removed after). +pub fn decode_floors(dir: &Path, decoder: Decoder, ffmpeg: &str, tmp: &Path) -> Result, String> { + let read = |p: &str| std::fs::read(dir.join(p)).map_err(|e| format!("{p}: {e}")); + let policy = Policy::parse_bytes(&read("policy.toml")?).map_err(|e| e.to_string())?; + let version = ffmpeg_version(ffmpeg); + let mut out = Vec::new(); + for comp in &policy.components { + let ix = StageIndex::parse(&read(&wai::package::index_file(&comp.id))?).map_err(|e| e.detail.clone())?; + let frames: Vec = ix.pieces.iter().map(|p| p.duration as u64).collect(); + for (k, (_, name)) in crate::pack::floor_rungs(&policy, &comp.id).into_iter().enumerate() { + // The stream's sequence header, from its first floor object (§7.2). + let first = ix.piece_entries(0).iter().filter(|e| e.role == Role::Floor).nth(k).map(|e| e.content_hash).ok_or_else(|| format!("{name}: no floor object"))?; + let units = object_units(&read(&crate::objects::object_path(&first))?)?; + let (seq, grain) = first_sequence_header(units.first().ok_or_else(|| format!("{name}: no unit"))?).map_err(|e| format!("{name}: {e}"))?; + if !matches!(seq.bit_depth(), 8 | 10) || !(seq.chroma_subsampling_x && seq.chroma_subsampling_y) || seq.mono_chrome { + return Err(format!("{name}: a floor of {} bits, not 4:2:0 at 8 or 10 bits, is not decoded here", seq.bit_depth())); + } + if grain && decoder == Decoder::VideoToolbox { + return Err(format!("{name}: the stream carries film grain, which VideoToolbox applies and no rule fixes; decode it with dav1d")); + } + // The floor playlist's fragments, after the rung's initialization segment. + let playlist = String::from_utf8(read(&format!("hls/classic/floor-{name}.m3u8"))?).map_err(|_| format!("{name}: the floor playlist is not UTF-8"))?; + let stream = tmp.join(format!(".floor-decode-{}-{name}.mp4", std::process::id())); + { + let mut f = std::io::BufWriter::new(std::fs::File::create(&stream).map_err(|e| format!("{}: {e}", stream.display()))?); + std::io::Write::write_all(&mut f, &read(&format!("floor/{name}/init.mp4"))?).map_err(|e| e.to_string())?; + for line in playlist.lines().filter(|l| !l.starts_with('#') && !l.trim().is_empty()) { + let rel = line.trim().trim_start_matches("../../"); + std::io::Write::write_all(&mut f, &read(rel)?).map_err(|e| e.to_string())?; + } + std::io::Write::flush(&mut f).map_err(|e| e.to_string())?; + } + let r = run(ffmpeg, decoder, &stream, seq.max_frame_width, seq.max_frame_height, seq.bit_depth(), &frames); + let _ = std::fs::remove_file(&stream); + let how = match decoder { + Decoder::Dav1d => "libdav1d, film grain not applied", + Decoder::VideoToolbox => "VideoToolbox, hardware frames read back from P010 exactly", + }; + out.push(RungDecode { name: name.clone(), w: seq.max_frame_width, h: seq.max_frame_height, bit_depth: seq.bit_depth(), pieces: r.map_err(|e| format!("{name}: {e}"))?, decoder: format!("{version}, {how}") }); + } + } + Ok(out) +} + +/// One P010 picture (`w × h` words of Y, then `⌈w/2⌉ × ⌈h/2⌉` interleaved U, V pairs) +/// in the canonical form, every word read back exactly or refused. +pub fn from_p010(p: &[u8], w: usize, h: usize, depth: u8) -> Result, String> { + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + if p.len() != 2 * (w * h + 2 * cw * ch) { + return Err("a P010 picture of another size".into()); + } + let word = |i: usize| u16::from_le_bytes([p[2 * i], p[2 * i + 1]]); + let sample = |s: u16| -> Result { + if s & 0x3f != 0 { + return Err(format!("a P010 word {s:#06x} with low bits set")); + } + let ten = s >> 6; + if depth == 10 { + return Ok(ten); + } + let v = ten >> 2; + if ten == v << 2 || ten == (v << 2) | (v >> 6) { + Ok(v) + } else { + Err(format!("a 10-bit value {ten} that no 8-bit value widens to")) + } + }; + let mut out = Vec::with_capacity(if depth == 8 { 1 } else { 2 } * (w * h + 2 * cw * ch)); + let mut put = |v: u16| { + if depth == 8 { + out.push(v as u8); + } else { + out.extend_from_slice(&v.to_le_bytes()); + } + }; + for i in 0..w * h { + put(sample(word(i))?); + } + for c in 0..2 { + for i in 0..cw * ch { + put(sample(word(w * h + 2 * i + c))?); + } + } + Ok(out) +} + +/// One rung's stream through ffmpeg, its pictures hashed piece by piece. +fn run(ffmpeg: &str, decoder: Decoder, stream: &Path, w: u32, h: u32, depth: u8, frames: &[u64]) -> Result, String> { + let planar = if depth == 8 { "yuv420p" } else { "yuv420p10le" }; + let mut cmd = std::process::Command::new(ffmpeg); + cmd.args(["-hide_banner", "-nostdin", "-v", "error", "-xerror"]); + match decoder { + Decoder::Dav1d => cmd.args(["-c:v", "libdav1d", "-export_side_data", "film_grain", "-i"]).arg(stream).args(["-map", "0:v:0", "-f", "rawvideo", "-pix_fmt", planar, "-"]), + Decoder::VideoToolbox => cmd + .args(["-hwaccel", "videotoolbox", "-hwaccel_output_format", "videotoolbox_vld", "-i"]) + .arg(stream) + .args(["-map", "0:v:0", "-vf", "hwdownload,format=p010le", "-f", "rawvideo", "-pix_fmt", "p010le", "-"]), + }; + cmd.stdout(std::process::Stdio::piped()).stderr(std::process::Stdio::piped()); + let mut child = cmd.spawn().map_err(|e| format!("{ffmpeg}: {e}"))?; + let mut so = child.stdout.take().ok_or("no standard output")?; + let (w, h) = (w as usize, h as usize); + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + let samples = w * h + 2 * cw * ch; + let frame = match decoder { + Decoder::Dav1d => samples * if depth == 8 { 1 } else { 2 }, + Decoder::VideoToolbox => 2 * samples, + }; + let mut buf = vec![0u8; frame]; + let mut out = Vec::with_capacity(frames.len()); + let mut fault: Option = None; + 'pieces: for &n in frames { + let mut d = sha2::Sha256::new(); + for i in 0..n { + if let Err(e) = so.read_exact(&mut buf) { + fault = Some(format!("the decoder gave fewer pictures than units (piece {}, picture {i}): {e}", out.len())); + break 'pieces; + } + match decoder { + Decoder::Dav1d => d.update(&buf), + Decoder::VideoToolbox => match from_p010(&buf, w, h, depth) { + Ok(c) => d.update(&c), + Err(e) => { + fault = Some(format!("piece {}, picture {i}: {e}", out.len())); + break 'pieces; + } + }, + } + } + out.push((n, crate::hex(&d.finalize()))); + } + let mut extra = Vec::new(); + let _ = so.read_to_end(&mut extra); + let o = child.wait_with_output().map_err(|e| e.to_string())?; + if !o.status.success() { + return Err(format!("the decoder refused the stream: {}", String::from_utf8_lossy(&o.stderr).trim())); + } + if let Some(s) = fault { + return Err(s); + } + if !extra.is_empty() { + return Err(format!("the decoder gave {} bytes past the last unit's picture", extra.len())); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// P010 words are read back exactly at 10 bits and, for an 8-bit stream, from a shift + /// or a bit replication; a word with low bits set, or an 8-bit read of a value no 8-bit + /// value widens to, is refused. + #[test] + fn p010_is_read_back_exactly_or_refused() { + let pic = |ys: [u16; 4], u: u16, v: u16| -> Vec { ys.iter().chain([u, v].iter()).flat_map(|s| s.to_le_bytes()).collect() }; + let ten = pic([64 << 6, 940 << 6, 513 << 6, 1023 << 6], 512 << 6, 7 << 6); + assert_eq!(from_p010(&ten, 2, 2, 10).unwrap(), [64u16, 940, 513, 1023, 512, 7].iter().flat_map(|s| s.to_le_bytes()).collect::>()); + let shifted = pic([16 << 8, 235 << 8, 128 << 8, 0], 255 << 8, 1 << 8); + assert_eq!(from_p010(&shifted, 2, 2, 8).unwrap(), vec![16, 235, 128, 0, 255, 1]); + let rep = |v: u16| ((v << 2) | (v >> 6)) << 6; + assert_eq!(from_p010(&pic([rep(200), rep(255), rep(3), rep(64)], rep(128), rep(250)), 2, 2, 8).unwrap(), vec![200, 255, 3, 64, 128, 250]); + assert!(from_p010(&pic([(64 << 6) | 1, 0, 0, 0], 0, 0), 2, 2, 10).is_err(), "low bits"); + assert!(from_p010(&pic([802 << 6, 0, 0, 0], 0, 0), 2, 2, 8).is_err(), "802 is no 8-bit value widened"); + assert!(from_p010(&ten[..ten.len() - 2], 2, 2, 10).is_err(), "a short picture"); + } +} diff --git a/wai/wai-pack/src/golden.rs b/wai/wai-pack/src/golden.rs index 64c9e0530..a9072d108 100644 --- a/wai/wai-pack/src/golden.rs +++ b/wai/wai-pack/src/golden.rs @@ -641,6 +641,12 @@ enum Claim { AnotherFigure, /// No claim. None, + /// The written claim with a fabric record (§11.6): two steps on the CPU, the figure + /// its one share. + Fabrics, + /// A fabric record that is a well-formed claim's but names a step §11.6 does not + /// define. + FabricUnknownStep, } /// The golden re-sealed as a metered pack (§9.4): a model meter of 500 mW over one second, @@ -679,10 +685,19 @@ fn metered(g: &Files, key: &SigningKey, claim: Claim) -> Files { bracket: None, build, power_reader: Some("model:500mW".into()), + fabrics: None, }); Some(ClaimReceipt::seal_unchecked(key, &did, SubjectType::Receipt, view.identity, 0, issued, body, None).expect("the claim seals")) } Claim::None => None, + Claim::Fabrics | Claim::FabricUnknownStep => { + use wai::claim_receipt::{FabricRecord, FabricShare, FabricStep}; + let fig = view.figure(None).expect("a figure").joules_micro; + let step = |s: &str| FabricStep { step: s.into(), fabric: "cpu".into(), backend: "cpu: the reference implementation".into() }; + let other = if claim == Claim::Fabrics { "object-hash" } else { "lunch" }; + let record = FabricRecord { steps: vec![step("base-code"), step(other)], shares: vec![FabricShare { fabric: "cpu".into(), joules_micro: fig, attribution: "task".into() }] }; + Some(wai::energy_figure::meter_report::emit_with_fabrics(&view, None, None, build, "model:500mW", Some(record), key, &did, None, issued).expect("the claim checks against the receipt")) + } }; if let Some(c) = written { f.insert(crate::pack::claim_path(&r), c.to_json().into_bytes()); @@ -1036,6 +1051,9 @@ pub fn package_mutations(g: &Files, key: &SigningKey) -> Vec { add("metered", "ok", metered(g, key, Claim::Written)); add("metered-claim-another-figure", "measurement-claim-invalid", metered(g, key, Claim::AnotherFigure)); add("metered-claim-missing", "measurement-claim-missing", metered(g, key, Claim::None)); + // A claim with a fabric record (§11.6), and one whose record names no step of §11.6. + add("metered-fabrics", "ok", metered(g, key, Claim::Fabrics)); + add("metered-fabrics-unknown-step", "measurement-claim-invalid", metered(g, key, Claim::FabricUnknownStep)); // Directories (§11.1, "no other"): one that holds nothing, one that holds nothing // under a dot-name (a version-control directory's), and one that cannot be read. let dir = |name: &'static str, empty: &[&str], unreadable: &[&str]| PackageMutation { diff --git a/wai/wai-pack/src/lib.rs b/wai/wai-pack/src/lib.rs index fbd97740d..0572de51f 100644 --- a/wai/wai-pack/src/lib.rs +++ b/wai/wai-pack/src/lib.rs @@ -5,7 +5,7 @@ //! may emit ([`capcheck`]), the source readers ([`source`]), `wai.pack.scenecut/1` //! ([`scenecut`]), piece classification ([`classify`]), a piece's plan, ladder and class //! plan ([`plan`]), the base encode's choice of step and the refinement layers -//! ([`encode`]), stills ([`png`]), the floor ([`floor`], [`av1`], [`cmaf`]), the objects +//! ([`encode`]), where each stage runs ([`fabric`]), stills ([`png`]), the floor ([`floor`], [`av1`], [`cmaf`]), the objects //! ([`objects`]), the catalogs ([`catalog`]), where a packer's inputs come from and its //! files go ([`files`]), the packer ([`pack`]) and its verifier //! ([`verify`]), the live packer ([`live`]), delivery over a simulated link @@ -26,9 +26,13 @@ pub mod cmaf_read; pub mod conformance; pub mod delivery; pub mod encode; +pub mod fabric; +#[cfg(all(target_os = "macos", feature = "fabric-meter"))] +pub mod fabric_meter; pub mod files; pub mod fixture; pub mod floor; +pub mod floor_decode; pub mod golden; pub mod live; pub mod objects; diff --git a/wai/wai-pack/src/live.rs b/wai/wai-pack/src/live.rs index b6751aa72..d2eba9dfb 100644 --- a/wai/wai-pack/src/live.rs +++ b/wai/wai-pack/src/live.rs @@ -154,7 +154,7 @@ impl LivePacker { let at_frame = self.pushed.saturating_sub(1); for piece in pieces { let comp = &self.policy.components[0]; - let coder = Coder { policy: &self.policy, comp, w: self.w, h: self.h }; + let coder = Coder { policy: &self.policy, comp, w: self.w, h: self.h, fabrics: crate::fabric::reference() }; let from = (piece.start - self.held_from) as usize; let mut cp = coder.code(&piece, &self.held[from..from + piece.len as usize])?; let number = self.first_piece + self.open.len() as u64; diff --git a/wai/wai-pack/src/pack.rs b/wai/wai-pack/src/pack.rs index e79024087..ae782721f 100644 --- a/wai/wai-pack/src/pack.rs +++ b/wai/wai-pack/src/pack.rs @@ -48,7 +48,7 @@ use crate::catalog::{self, CompView, FloorView}; use crate::classify::{self, PieceKind}; -use crate::encode::{choose_q, MotionParams}; +use crate::encode::MotionParams; use crate::files::{FileSet, Input, MemSink, Sink}; use crate::floor::{self, Floor}; use crate::objects; @@ -113,8 +113,18 @@ pub struct PackOpts { /// links to the supplied ones where the file system allows, so a server holds the /// ladder's bytes once; every byte is the same either way. pub link_supplied: bool, + /// Where each stage runs (§11.6): the CPU reference by default. No byte of the + /// package depends on it. + pub fabrics: std::sync::Arc, + /// The meter's figure divided by fabric, read once the meter has stopped, for the + /// measurement claim's `fabrics` member (jwp-receipts §2.4.8); `None` writes a claim + /// without one. + pub fabric_split: Option, } +/// What a fabric meter's last span gave each fabric: `(fabric, µJ, attribution)`. +pub type FabricSplit = std::sync::Arc Option> + Send + Sync>; + impl PackOpts { /// Unmetered options with the grant `wai.video.package` and no ceilings, coding one /// piece at a time, the supplied floor's files given in memory. @@ -136,6 +146,8 @@ impl PackOpts { jobs: 1, base_from: None, link_supplied: false, + fabrics: std::sync::Arc::new(crate::fabric::Fabrics::reference()), + fabric_split: None, } } } @@ -348,6 +360,8 @@ pub struct Coder<'a> { pub comp: &'a crate::policy::Component, pub w: u32, pub h: u32, + /// Where its stages run (§11.6); the CPU reference unless set. + pub fabrics: &'a crate::fabric::Fabrics, } impl<'a> Coder<'a> { @@ -370,7 +384,13 @@ impl<'a> Coder<'a> { return Err(PackError::new(format!("pack-capability-unsupported:{}", r.capability), format!("ladder.{}: its parameters", r.id))); } } - Ok(Coder { policy, comp, w, h }) + Ok(Coder { policy, comp, w, h, fabrics: crate::fabric::reference() }) + } + + /// A clip's decoded-output digest (§6.5), on the CPU. + fn digest(&self, frames: &[Vec]) -> [u8; 32] { + self.fabrics.ran_on_cpu(crate::fabric::Step::Digest, frames.iter().map(|f| f.len() as u64).sum()); + clip_digest(frames) } /// Code `piece`, whose source frames are `src`: its base, then each refinement of its @@ -389,14 +409,18 @@ impl<'a> Coder<'a> { let (payload, recon): (Vec, Vec>) = match ladder.base.capability.as_str() { CAP_INT_MOTION => { let mp = MotionParams::of(ladder.base).expect("checked by Coder::new"); - let q = choose_q(src, h, w, &mp).map_err(|e| enc(e.to_string()))?; + let q = crate::encode::choose_q_with(src, h, w, &mp, &mut self.fabrics.search()).map_err(|e| enc(e.to_string()))?; + self.fabrics.ran_on_cpu(crate::fabric::Step::BaseCode, n * h as u64 * w as u64 * 3); (q.encoded.wiv1, q.encoded.recon) } // A still: the piece's first frame, held for the piece (§5.7). - _ => (crate::png::encode(&src[0], w, h), vec![src[0].clone(); src.len()]), + _ => { + self.fabrics.ran_on_cpu(crate::fabric::Step::BaseCode, h as u64 * w as u64 * 3); + (crate::png::encode(&src[0], w, h), vec![src[0].clone(); src.len()]) + } }; let base_env = objects::base_envelope(self.comp.media.label(), &ladder.base.capability, payload, n, h, w); - let base_hash = objects::object_hash(&base_env); + let base_hash = self.fabrics.object_hash(&base_env); let tier = wsix::capability_prefix_tier(&ladder.base.capability); let mut stages = vec![Stage { hash: base_hash, @@ -404,8 +428,8 @@ impl<'a> Coder<'a> { role: Role::Base, layer: 0, prefix_tier: tier, - prefix_digest: if tier == wsix::TIER_DECODE { clip_digest(&recon) } else { [0u8; 32] }, - sse: crate::encode::sse(&recon, src), + prefix_digest: if tier == wsix::TIER_DECODE { self.digest(&recon) } else { [0u8; 32] }, + sse: self.fabrics.sse(&recon, src), deadline_ms: 0, flags: 0, lens: Lens::of(&base_env)?, @@ -418,6 +442,7 @@ impl<'a> Coder<'a> { let layer = u8::try_from(k + 1).map_err(|_| enc("a layer past 255".into()))?; let q = rung.param_u64("q").and_then(|q| u32::try_from(q).ok()).ok_or_else(|| enc(format!("ladder.{}: its step", rung.id)))?; let (payload, next) = crate::encode::encode_refinement(&below, src, h, w, q).map_err(enc)?; + self.fabrics.ran_on_cpu(crate::fabric::Step::RefineCode, n * h as u64 * w as u64 * 3); let prev = stages.last().expect("the base").hash; let env = objects::refinement_envelope(self.comp.media.label(), &rung.capability, payload, &base_hash, &prev, layer); // The sink's own application of the layer to the decoded base. @@ -433,13 +458,13 @@ impl<'a> Coder<'a> { let below_tier = stages.last().expect("the base").prefix_tier; let tier = if below_tier == wsix::TIER_DECODE && wsix::capability_prefix_tier(&rung.capability) == wsix::TIER_DECODE { wsix::TIER_DECODE } else { wsix::TIER_NONE }; stages.push(Stage { - hash: objects::object_hash(&env), + hash: self.fabrics.object_hash(&env), capability: rung.capability.clone(), role: Role::Refinement, layer, prefix_tier: tier, - prefix_digest: if tier == wsix::TIER_DECODE { clip_digest(&next) } else { [0u8; 32] }, - sse: crate::encode::sse(&next, src), + prefix_digest: if tier == wsix::TIER_DECODE { self.digest(&next) } else { [0u8; 32] }, + sse: self.fabrics.sse(&next, src), deadline_ms: deadline, flags: 0, lens: Lens::of(&env)?, @@ -938,7 +963,9 @@ pub fn pack_into(policy_bytes: &[u8], sources: &BTreeMap>, opt if let Some(m) = opts.meter.as_mut() { m.start(); } - let coder = Coder::new(&policy, comp, w, h)?; + let fabrics = std::sync::Arc::clone(&opts.fabrics); + let mut coder = Coder::new(&policy, comp, w, h)?; + coder.fabrics = &fabrics; // The second read: each piece coded, and each floor rung's units for it. let mut rd = match src { Some(src) => Some(crate::source::y4m::Y4mReader::new(src.open().map_err(|e| io_err("the source", e))?).map_err(source_err)?), @@ -986,6 +1013,7 @@ pub fn pack_into(policy_bytes: &[u8], sources: &BTreeMap>, opt continue; }; let fr = read_frames(rd, pieces[p].len)?; + fabrics.ran_on_cpu(crate::fabric::Step::SourceRead, fr.iter().map(|f| f.len() as u64).sum()); if frames_digest(&fr) != digests[p] { return Err(PackError::new("pack-source-changed", format!("piece {p}'s frames differ on the source's second read"))); } @@ -1059,13 +1087,14 @@ pub fn pack_into(policy_bytes: &[u8], sources: &BTreeMap>, opt } _ => unreachable!("a floor's read is of its form"), }; + fabrics.ran_on_cpu(crate::fabric::Step::FloorRead, units.iter().map(|u| u.len() as u64).sum()); let payload = floor::av1_payload(&units, fh, fw, tb.num, tb.den); let env = match &df { Some(df) => objects::floor_envelope(comp.media.label(), &f.rung.capability, payload, piece.len, fh, fw, df), None => objects::base_envelope(comp.media.label(), &f.rung.capability, payload, piece.len, fh, fw), }; cp.stages.push(Stage { - hash: objects::object_hash(&env), + hash: fabrics.object_hash(&env), capability: f.rung.capability.clone(), role: Role::Floor, layer: 0, @@ -1114,6 +1143,7 @@ pub fn pack_into(policy_bytes: &[u8], sources: &BTreeMap>, opt .map(|((f, init), fragments)| FloorView { name: f.name.clone(), codec: f.check.seq.codec_string(), w: f.check.seq.max_frame_width, h: f.check.seq.max_frame_height, init, fragments }) .collect(); let index_bytes = index.to_bytes(); + fabrics.ran_on_cpu(crate::fabric::Step::Index, index_bytes.len() as u64); let catalogs = { let objs = catalog::SinkObjects(&*sink); let view = CompView { index: &index, tb, w, h, objects: &objs, floors: views, base_cap: catalog::default_base_cap(&policy, &comp.id) }; @@ -1166,6 +1196,30 @@ fn measurement_claim(r: &PackageReceiptV2, index: &[u8], opts: &PackOpts) -> Res use wai::energy_figure::{meter_report, EnergyFigures}; let indexes: Vec<&[u8]> = vec![index]; let view = (r, indexes.as_slice()).figure_view().ok_or("the receipt does not verify")?; - let claim = meter_report::emit(&view, None, None, wai::energy_report::BuildRecord::of_this_build(), &opts.power_reader, &opts.signer, &opts.signer_id, None, &opts.issued_at)?; + let fabrics = match &opts.fabric_split { + None => None, + Some(split) => Some(fabric_record(&opts.fabrics, &split().ok_or("the fabric meter kept no shares")?)?), + }; + let claim = meter_report::emit_with_fabrics(&view, None, None, wai::energy_report::BuildRecord::of_this_build(), &opts.power_reader, fabrics, &opts.signer, &opts.signer_id, None, &opts.issued_at)?; Ok(claim.to_json()) } + +/// A measurement claim's fabric record (jwp-receipts §2.4.8): the steps that ran, each +/// with every fabric that ran it and its backend, and the meter's share on each fabric a +/// step ran on. A share on a fabric no step ran on is refused, not dropped, so the +/// shares always divide the figure. +pub fn fabric_record(f: &crate::fabric::Fabrics, split: &[(crate::fabric::Fabric, u64, &'static str)]) -> Result { + use wai::claim_receipt::{FabricRecord, FabricShare, FabricStep}; + let ran = f.record(); + let on: std::collections::BTreeSet = ran.iter().map(|r| r.fabric).collect(); + let mut shares = Vec::new(); + for &(fabric, uj, attribution) in split { + if on.contains(&fabric) { + shares.push(FabricShare { fabric: fabric.term().into(), joules_micro: uj, attribution: attribution.into() }); + } else if uj > 0 { + return Err(format!("the meter gave the {} {uj} uJ, and no step ran there", fabric.term())); + } + } + let steps = ran.into_iter().map(|r| FabricStep { step: r.step.term().into(), fabric: r.fabric.term().into(), backend: r.backend }).collect(); + Ok(FabricRecord { steps, shares }) +} diff --git a/wai/wai-pack/src/verify.rs b/wai/wai-pack/src/verify.rs index 97418c624..7e9e1ace8 100644 --- a/wai/wai-pack/src/verify.rs +++ b/wai/wai-pack/src/verify.rs @@ -116,8 +116,13 @@ pub fn read_strict(root: &std::path::Path) -> Result>, /// Verify the package in `dir`, read strictly ([`read_strict`]). pub fn verify_dir(dir: &std::path::Path) -> Result> { + verify_dir_with(dir, crate::fabric::reference()) +} + +/// [`verify_dir`], its stages placed by `fabrics` (packaging §11.6). +pub fn verify_dir_with(dir: &std::path::Path, fabrics: &crate::fabric::Fabrics) -> Result> { let files = read_strict(dir).map_err(|e| vec![reason("unexpected-file", e)])?; - verify_files(&files) + verify_files_with(&files, fabrics) } /// The policy's own refusals a package is held to (§11.1, §3.6): its ids and floor names, @@ -142,6 +147,15 @@ pub fn policy_refusal(policy: &Policy) -> Option { /// Verify a package given as its files, by path. pub fn verify_files(files: &BTreeMap>) -> Result> { + verify_files_with(files, crate::fabric::reference()) +} + +/// [`verify_files`], its stages placed by `fabrics` (packaging §11.6). The verdict and its +/// reasons are [`verify_files`]'s for every package: a check whose expected value the +/// package states (an object's content hash) may be computed on another fabric, where a +/// match shows the fabric computed it, and a mismatch is computed again on the CPU +/// reference before it is reported; every other check runs on the CPU. +pub fn verify_files_with(files: &BTreeMap>, fabrics: &crate::fabric::Fabrics) -> Result> { let one = |r: Reason| vec![r]; // 1. Every path one a listing can carry. let bad: Vec<&String> = files.keys().filter(|p| !wai::pin::is_listing_path(p)).collect(); @@ -205,7 +219,7 @@ pub fn verify_files(files: &BTreeMap>) -> Result = Vec::new(); @@ -382,6 +396,7 @@ pub fn index_against_policy(policy: &Policy, comp: &Component, ix: &StageIndex) } /// One component's checks of §11.3 step 6; the catalogs it gives, which the caller compares. +#[allow(clippy::too_many_arguments)] fn verify_component( files: &BTreeMap>, policy: &Policy, @@ -390,6 +405,7 @@ fn verify_component( policy_digest: &[u8; 32], expected: &mut std::collections::BTreeSet, listed_objects: &mut std::collections::BTreeSet, + fabrics: &crate::fabric::Fabrics, ) -> Result>, Vec> { let mut errs: Vec = Vec::new(); if let Some(x) = index_against_policy(policy, comp, ix) { @@ -405,6 +421,16 @@ fn verify_component( } let mut objects: BTreeMap<[u8; 32], Vec> = BTreeMap::new(); let mut dims: Option<(u32, u32)> = None; + // Every present object of the right length hashed in one batch, on the fabric the + // batch goes to; a refusal of its hash is decided again by the CPU reference. + let present: Vec<(usize, &[u8])> = ix + .entries + .iter() + .enumerate() + .filter_map(|(k, e)| files.get(&crate::objects::object_path(&e.content_hash)).filter(|b| b.len() as u64 == e.byte_len).map(|b| (k, b.as_slice()))) + .collect(); + let batch: Vec<&[u8]> = present.iter().map(|&(_, b)| b).collect(); + let hashed: BTreeMap = present.iter().map(|&(k, _)| k).zip(fabrics.object_hashes(&batch)).collect(); for (k, e) in ix.entries.iter().enumerate() { let path = crate::objects::object_path(&e.content_hash); listed_objects.insert(path.clone()); @@ -412,7 +438,14 @@ fn verify_component( errs.push(reason("object-missing", format!("entry {k}: {path}"))); continue; }; - if let Err(x) = wsix::check_object(ix, k, b) { + let checked = match hashed.get(&k) { + Some(&h) => match wsix::check_object_hashed(ix, k, b, &mut |_| h) { + Err(x) if x.code == "object-hash" => wsix::check_object(ix, k, b), + other => other, + }, + None => wsix::check_object(ix, k, b), + }; + if let Err(x) = checked { errs.push(reason(x.code, format!("entry {k}: {}", x.detail))); continue; } @@ -762,5 +795,12 @@ fn check_claim(r: &PackageReceiptV2, indexes: &[&[u8]], b: &[u8]) -> Result<(), if claim.subject != r.receipt_hash() { return Err("the claim's subject is not the receipt".into()); } + // A package's fabric record names the steps of packaging §11.6. + if let wai::claim_receipt::ClaimBody::Measurement(m) = &claim.body + && let Some(f) = &m.fabrics + && let Some(s) = f.steps.iter().find(|s| crate::fabric::Step::from_term(&s.step).is_none()) + { + return Err(format!("the claim's fabric record names {}, which is not a step of packaging §11.6", s.step)); + } Ok(()) } diff --git a/wai/wai-pack/tests/fabric.rs b/wai/wai-pack/tests/fabric.rs new file mode 100644 index 000000000..592a54244 --- /dev/null +++ b/wai/wai-pack/tests/fabric.rs @@ -0,0 +1,314 @@ +//! Where a step runs changes no byte of a package and no verdict (packaging §11.6). +//! +//! On every build: the reference places every call on the CPU; a cost table places a call +//! on the fabric it prices least, and only on a measurement; a metered pack's claim carries +//! the fabric record and the verifiers accept it, and a record whose shares do not divide +//! the figure is never written. +//! +//! With the `gpu` feature, on every GPU that opens, the differential: the motion search on +//! the device against the CPU reference on seeded frames of every kind the search meets; +//! every golden package packed with the device, byte for byte the committed package; every +//! golden and every package and staged mutation given the CPU's verdict by a verifier that +//! hashes on the device; a device whose kernel is edited refused by the qualification, and, +//! used anyway, still never changing a verdict. With no adapter these print SKIPPED and +//! pass, unless WAI_PACK_GPU_REQUIRE is set. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; +use wai_pack::fabric::{Cost, Costs, Fabric, Fabrics, Mode, Step}; +use wai_pack::golden; + +fn corpus() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../pack-conformance") +} + +#[test] +fn the_reference_places_every_call_on_the_cpu() { + let f = Fabrics::reference(); + for s in Step::ALL { + for units in [0, 1, u64::MAX] { + assert_eq!(f.place(s, units), Fabric::Cpu, "{}", s.term()); + } + assert!(f.offers(s, Fabric::Cpu) && !f.offers(s, Fabric::Media) && !f.offers(s, Fabric::Neural)); + assert_eq!(Step::from_term(s.term()), Some(s)); + } + for fb in Fabric::ALL { + assert_eq!(Fabric::from_term(fb.term()), Some(fb)); + } +} + +/// A cost table reads back as it was written, and a fabric is chosen over the CPU only +/// where it is qualified and both are priced: an unpriced or unqualified fabric is never +/// chosen, whatever the table says of it. +#[test] +fn a_cost_table_places_only_on_a_measurement() { + let t = Costs { + host: "a host".into(), + costs: vec![ + Cost { step: Step::Sse, fabric: Fabric::Cpu, backend: "cpu".into(), uj_per_call: 0, nj_per_unit_milli: 400 }, + Cost { step: Step::Sse, fabric: Fabric::Gpu, backend: "gpu".into(), uj_per_call: 30, nj_per_unit_milli: 20 }, + Cost { step: Step::BaseSearch, fabric: Fabric::Gpu, backend: "gpu".into(), uj_per_call: 1, nj_per_unit_milli: 1 }, + ], + }; + let back = Costs::parse(&t.to_json("2026-10-22T00:00:00Z", "a meter")).unwrap(); + assert_eq!(back, t); + // 30 uJ a call against 0.4 nJ a unit on the CPU: the GPU's from 78,948 units. + assert!(t.costs[1].nj(78_948) < t.costs[0].nj(78_948)); + assert!(t.costs[1].nj(78_946) >= t.costs[0].nj(78_946)); + let f = Fabrics::open(Mode::Auto, t.clone()); + // No CPU cost for the search: never off the CPU, however cheap the GPU's. + assert_eq!(f.place(Step::BaseSearch, 1 << 40), Fabric::Cpu); + let big = f.place(Step::Sse, 1 << 30); + assert_eq!(big, if f.offers(Step::Sse, Fabric::Gpu) { Fabric::Gpu } else { Fabric::Cpu }); + assert_eq!(f.place(Step::Sse, 1000), Fabric::Cpu, "a small call costs less on the CPU"); + for bad in ["{}", r#"{"schema":"wai.pack.fabric-costs/1","costs":[{"step":"lunch","fabric":"cpu","backend":"x","uj_per_call":0,"nj_per_unit_milli":0}]}"#] { + assert!(Costs::parse(bad).is_err(), "{bad}"); + } +} + +/// A meter whose figure the test reads back, so a fabric split can divide it. +struct Seen { + inner: wai::energy_meter::ModelMeter, + figure: Arc>>, +} + +impl wai::energy_meter::Meter for Seen { + fn start(&mut self) { + self.inner.start(); + } + fn stop(&mut self) -> Option { + let r = self.inner.stop()?; + *self.figure.lock().unwrap() = Some(r.joules_micro); + Some(r) + } + fn kind(&self) -> wai::energy_class::AcquisitionKind { + self.inner.kind() + } +} + +fn metered_pack(fabrics: Arc, split: impl Fn(u64) -> Vec<(Fabric, u64, &'static str)> + Send + Sync + 'static) -> Result { + let (policy, sources, mut o) = golden::golden_inputs(&corpus(), "video-base-01").unwrap(); + let figure = Arc::new(Mutex::new(None)); + let mut t = 0u64; + o.meter = Some(Box::new(Seen { inner: wai::energy_meter::ModelMeter::new(move || { t += 1_000_000; t }, 5_000), figure: Arc::clone(&figure) })); + o.power_reader = "model:5000mW".into(); + o.issued_at = "2026-10-22T00:00:00Z".into(); + o.fabrics = fabrics; + o.fabric_split = Some(Arc::new(move || figure.lock().unwrap().map(&split))); + wai_pack::pack::pack(&policy, &sources, &mut o) +} + +fn claim_body(files: &BTreeMap>) -> serde_json::Value { + let (_, b) = files.iter().find(|(p, _)| p.starts_with("claims/")).expect("a metered package has its claim"); + let v: serde_json::Value = serde_json::from_slice(b).unwrap(); + v["body"].clone() +} + +/// A metered pack writes the fabric record in its measurement claim: every step the pack +/// ran, each on the CPU here, its share the whole figure; both verifiers of a package take +/// it. A split that does not divide the figure is refused, never written. +#[test] +fn the_fabric_record_rides_in_the_measurement_claim() { + let p = metered_pack(Arc::new(Fabrics::reference()), |fig| vec![(Fabric::Cpu, fig, "task"), (Fabric::Gpu, 0, "rail")]).unwrap(); + wai_pack::verify::verify_files(&p.files).unwrap(); + let body = claim_body(&p.files); + let steps: Vec<(String, String)> = body["fabrics"]["steps"].as_array().unwrap().iter().map(|s| (s["step"].as_str().unwrap().to_string(), s["fabric"].as_str().unwrap().to_string())).collect(); + for want in ["base-code", "base-search", "digest", "floor-read", "index", "object-hash", "source-read", "sse"] { + assert!(steps.contains(&(want.to_string(), "cpu".to_string())), "{want} on the CPU in {steps:?}"); + } + let mut sorted = steps.clone(); + sorted.sort(); + assert_eq!(steps, sorted, "steps in the order of their terms"); + let shares = &body["fabrics"]["shares"]; + assert_eq!(shares.as_array().unwrap().len(), 1, "no share for a fabric no step ran on: {shares}"); + assert_eq!(shares[0]["joules_micro"], body["joules_micro"]); + assert_eq!(shares[0]["attribution"], "task"); + // The objects are the unmetered pack's. + let plain = golden::pack_golden(&corpus(), "video-base-01").unwrap(); + for (k, v) in &plain.files { + if k.starts_with("objects/") || k.starts_with("index-") || k.starts_with("floor/") { + assert!(p.files.get(k) == Some(v), "{k}"); + } + } + let short = metered_pack(Arc::new(Fabrics::reference()), |fig| vec![(Fabric::Cpu, fig - 1, "task")]).unwrap_err(); + assert_eq!(short.code, "pack-measurement-claim", "{short}"); + let elsewhere = metered_pack(Arc::new(Fabrics::reference()), |fig| vec![(Fabric::Cpu, fig - 1, "task"), (Fabric::Gpu, 1, "rail")]).unwrap_err(); + assert_eq!(elsewhere.code, "pack-measurement-claim", "a share on a fabric no step ran on: {elsewhere}"); + assert!(elsewhere.detail.contains("no step ran there"), "refused for the share itself, not only by the claim's sum: {elsewhere}"); +} + +#[cfg(feature = "gpu")] +mod gpu { + use super::*; + use wai::int_video_enc::{CpuSearch, MotionSearch}; + + fn held(p: &Path) -> BTreeMap> { + wai::pin::read_dir_set(p).unwrap() + } + + const GOLDENS: &[&str] = &["video-base-01", "video-staged-01", "video-ladder-01", "video-cmaf-01", "video-floor-only-01", "video-floor-only-02", "video-repack-01"]; + + fn gpu_fabrics() -> Option { + let f = Fabrics::open(Mode::Gpu, Costs::default()); + for (who, why) in f.refused() { + eprintln!("not used: {who}: {why}"); + } + if !f.offers(Step::BaseSearch, Fabric::Gpu) { + assert!(std::env::var_os("WAI_PACK_GPU_REQUIRE").is_none(), "WAI_PACK_GPU_REQUIRE is set and no GPU qualified"); + eprintln!("SKIPPED: no GPU qualified"); + return None; + } + Some(f) + } + + /// RGB8 frames of a conformance fixture. + fn fixture_frames(name: &str) -> (Vec>, usize, usize) { + let y4m = wai_pack::fixture::generate(name).unwrap(); + let (hd, frames) = wai_pack::source::y4m::read_all(&y4m).unwrap(); + (frames.into_iter().map(|f| f.rgb).collect(), hd.h as usize, hd.w as usize) + } + + /// The device's search is the reference's on the qualifying frames, on seeded noise at + /// every block size and range the policy admits, and on every frame pair of every + /// fixture; and the encoder, its search on the device, writes the reference's payload. + #[test] + fn the_search_on_the_gpu_is_the_reference() { + let Some(f) = gpu_fabrics() else { return }; + let mut n = 0; + let mut check = |src: &[u8], rf: &[u8], h: usize, w: usize, block: usize, range: i64, prec: i64| { + let (mut want, mut got) = (Vec::new(), Vec::new()); + CpuSearch.frame(src, rf, h, w, block, range, prec, &mut want); + f.search().frame(src, rf, h, w, block, range, prec, &mut got); + assert_eq!(got, want, "{h}x{w} block {block} range {range} prec {prec}"); + n += 1; + }; + for (h, w, block, range, prec, src, rf) in wai_pack::fabric::qualifying_frames() { + check(&src, &rf, h, w, block, range, prec); + } + let mut x = 0x5eedu64; + let mut byte = || { + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + x as u8 + }; + for block in [4usize, 8, 16, 32] { + for (search, prec) in [(0i64, 1i64), (1, 2), (4, 2), (7, 2), (32, 1), (63, 2)] { + let (h, w) = (37, 53); + let src: Vec = (0..h * w * 3).map(|_| byte()).collect(); + let rf: Vec = src.iter().map(|v| v.wrapping_add(byte() % 5)).collect(); + check(&src, &rf, h, w, block, search * prec, prec); + } + } + for name in wai_pack::fixture::NAMES { + let (frames, h, w) = fixture_frames(name); + for pair in frames.windows(2).take(12) { + check(&pair[1], &pair[0], h, w, 16, 8, 2); + check(&pair[1], &pair[0], h, w, 8, 3, 1); + } + let p = wai::int_video_enc::EncParams { block: 16, search: 4, mv_precision: 2, q: 12 }; + let clip = &frames[..frames.len().min(24)]; + let want = wai::int_video_enc::encode(clip, h as u32, w as u32, &p).unwrap(); + let got = wai::int_video_enc::encode_with(clip, h as u32, w as u32, &p, &mut f.search()).unwrap(); + assert_eq!(got, want, "{name}: the payload with the search on the GPU"); + } + assert!(f.record().iter().any(|r| r.step == Step::BaseSearch && r.fabric == Fabric::Gpu)); + assert!(f.fallbacks().is_empty(), "{:?}", f.fallbacks()); + eprintln!("the search on the GPU: {n} frames, every vector the reference's"); + } + + /// Every golden package, packed with every step a GPU takes on the GPU, is the + /// committed package byte for byte. + #[test] + fn every_golden_packs_the_same_on_the_gpu() { + let Some(f) = gpu_fabrics() else { return }; + let f = Arc::new(f); + for name in GOLDENS { + let (policy, sources, mut o) = golden::golden_inputs(&corpus(), name).unwrap(); + o.fabrics = Arc::clone(&f); + let p = wai_pack::pack::pack(&policy, &sources, &mut o).unwrap(); + let want = held(&corpus().join("packs").join(name).join("package")); + assert_eq!(p.files.keys().collect::>(), want.keys().collect::>(), "{name}"); + for (k, v) in &want { + assert!(p.files[k] == *v, "{name}: {k} differs from the committed golden"); + } + } + let rec = f.record(); + for s in [Step::BaseSearch, Step::Sse, Step::ObjectHash] { + assert!(rec.iter().any(|r| r.step == s && r.fabric == Fabric::Gpu), "{} ran on the GPU: {rec:?}", s.term()); + } + } + + fn verdict(files: &BTreeMap>, f: &Fabrics) -> String { + match wai_pack::verify::verify_files_with(files, f) { + Ok(_) => "ok".into(), + Err(v) => v.into_iter().next().map_or_else(|| "refused".into(), |r| r.code), + } + } + + fn cases() -> Vec<(String, BTreeMap>, String)> { + let mut out: Vec<(String, BTreeMap>, String)> = Vec::new(); + for name in ["video-base-01", "video-staged-01", "video-ladder-01", "video-cmaf-01", "video-floor-only-02"] { + out.push((name.to_string(), held(&corpus().join("packs").join(name).join("package")), "ok".into())); + } + let key = |name: &str| { + let t = std::fs::read_to_string(corpus().join("packs").join(name).join("key.hex")).unwrap(); + let b = wai_pack::unhex(t.trim()).unwrap(); + ed25519_dalek::SigningKey::from_bytes(&b.try_into().unwrap()) + }; + let base = held(&corpus().join("packs/video-base-01/package")); + for m in golden::package_mutations(&base, &key("video-base-01")) { + if m.empty_dirs.is_empty() && m.unreadable_dirs.is_empty() { + out.push((format!("package-mutations/{}", m.name), m.files, m.reason)); + } + } + let staged = held(&corpus().join("packs/video-staged-01/package")); + for m in golden::staged_mutations(&staged, &key("video-staged-01")) { + out.push((format!("staged-mutations/{}", m.name), m.files, m.reason)); + } + out + } + + /// Every golden verifies, and every package and staged mutation gets its reason, from a + /// verifier that hashes on the GPU: the CPU's verdict, case by case. + #[test] + fn every_verdict_is_the_same_on_the_gpu() { + let Some(f) = gpu_fabrics() else { return }; + let cases = cases(); + for (name, files, reason) in &cases { + let cpu = verdict(files, &Fabrics::reference()); + assert_eq!(&cpu, reason, "{name}: the corpus's reason"); + assert_eq!(verdict(files, &f), cpu, "{name}: on the GPU"); + } + assert!(f.record().iter().any(|r| r.step == Step::ObjectHash && r.fabric == Fabric::Gpu)); + eprintln!("{} verdicts, each the CPU's with the hashes on the GPU", cases.len()); + } + + /// An edited kernel does not qualify; used anyway, a search on it writes another + /// package, and a hash on it never changes a verdict, because a refusal it causes is + /// decided again on the CPU and a match it gives is the reference's hash. + #[test] + fn an_edited_kernel_is_refused_and_moves_no_verdict() { + if gpu_fabrics().is_none() { + return; + } + let mut src = wai_pack_gpu::Sources::shipped(); + src.motion = src.motion.replacen("return i < bi;", "return i > bi;", 1); + src.blake3 = src.blake3.replacen("v[b] = rotr(v[b] ^ v[c], 12u);", "v[b] = rotr(v[b] ^ v[c], 11u);", 1); + let mut found = wai_pack_gpu::discover_with(&src, "an edited tie rule and rotation").found; + assert!(!found.is_empty(), "the edited kernels compile"); + let g = found.remove(0); + assert!(wai_pack::fabric::qualify(&g, Step::BaseSearch).is_err(), "the edited search is not qualified"); + assert!(wai_pack::fabric::qualify(&g, Step::ObjectHash).is_err(), "the edited hash is not qualified"); + let bad = Fabrics::with_unqualified_gpu(g, &[Step::BaseSearch, Step::ObjectHash], Mode::Gpu); + for (name, files, _) in cases() { + assert_eq!(verdict(&files, &bad), verdict(&files, &Fabrics::reference()), "{name}: a broken hash on the GPU"); + } + let (policy, sources, mut o) = golden::golden_inputs(&corpus(), "video-base-01").unwrap(); + o.fabrics = Arc::new(bad); + let p = wai_pack::pack::pack(&policy, &sources, &mut o); + let want = held(&corpus().join("packs/video-base-01/package")); + assert!(p.map_or(true, |p| p.files["LISTING"] != want["LISTING"]), "an unqualified search writes another package, or none"); + } +} diff --git a/wai/wai-pack/tests/packs.rs b/wai/wai-pack/tests/packs.rs index c55df348b..fe2d6afee 100644 --- a/wai/wai-pack/tests/packs.rs +++ b/wai/wai-pack/tests/packs.rs @@ -270,6 +270,7 @@ fn forged_body(view: &wai::energy_figure::FigureView) -> wai::claim_receipt::Cla bracket: None, build: wai::energy_report::BuildRecord::of_this_build(), power_reader: Some("model".into()), + fabrics: None, }) } diff --git a/wai/wai-rs/src/claim_receipt.rs b/wai/wai-rs/src/claim_receipt.rs index c911cacdf..70d3cca46 100644 --- a/wai/wai-rs/src/claim_receipt.rs +++ b/wai/wai-rs/src/claim_receipt.rs @@ -260,6 +260,17 @@ pub enum Reason { MeasurementWindowMismatch, /// Under a hardware class, a declared uncertainty below the least the bracket supports. MeasurementDeclarationBelowBracket, + /// A fabric record naming a fabric or an attribution outside energy-measurement §6.3. + MeasurementFabricUnknown, + /// A fabric record with no step or no share, or out of its order (a pair or a fabric + /// twice among them). + MeasurementFabricOrder, + /// A step on a fabric the record gives no share. + MeasurementFabricUnmetered, + /// A share on a fabric no step of the record ran on. + MeasurementFabricUnused, + /// Shares that do not sum to the figure. + MeasurementFabricSum, /// An operating point present with no member. OperatingPointEmptyPoint, /// `op_delivered` without `op_committed`. @@ -457,6 +468,11 @@ impl Reason { Reason::MeasurementFigureMismatch => "measurement_figure_mismatch", Reason::MeasurementWindowMismatch => "measurement_window_mismatch", Reason::MeasurementDeclarationBelowBracket => "measurement_declaration_below_bracket", + Reason::MeasurementFabricUnknown => "measurement_fabric_unknown", + Reason::MeasurementFabricOrder => "measurement_fabric_order", + Reason::MeasurementFabricUnmetered => "measurement_fabric_unmetered", + Reason::MeasurementFabricUnused => "measurement_fabric_unused", + Reason::MeasurementFabricSum => "measurement_fabric_sum", Reason::OperatingPointEmptyPoint => "operating_point_empty_point", Reason::OperatingPointDeliveredAlone => "operating_point_delivered_alone", Reason::OperatingPointIncomplete => "operating_point_incomplete", @@ -989,6 +1005,78 @@ pub struct MeasurementClaim { pub build: BuildRecord, /// The reader of the power interface. pub power_reader: Option, + /// Where the work the figure covers ran, and the figure divided by fabric + /// (energy-measurement §6.3). `None`: the member is absent, the one member of a claim + /// that is absent rather than `null`, so a report sealed before it existed keeps its + /// bytes. + pub fabrics: Option, +} + +/// The fabrics a figure's work ran on (jwp-receipts §2.4.8, energy-measurement §6.3): +/// each step with every fabric that ran it and the backend that did, and the figure's +/// share on each fabric. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FabricRecord { + /// In the order of `(step, fabric)`, each pair once. + pub steps: Vec, + /// In the order of `fabric`, each once. + pub shares: Vec, +} + +/// One step of the work and a fabric that ran it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FabricStep { + /// A term of the vocabulary the figure's profile names (for a package receipt, + /// packaging §11.6). + pub step: String, + /// `cpu`, `gpu`, `media` or `neural`. + pub fabric: String, + /// The implementation and device that ran it, as its producer names them. + pub backend: String, +} + +/// The figure's share on one fabric. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct FabricShare { + pub fabric: String, + pub joules_micro: u64, + /// How the share was attributed to the work (energy-measurement §6.3): `task`, the + /// operating system's account of the work's own energy on the fabric, or `rail`, the + /// fabric's whole rail over a span in which the work was its sole user. + pub attribution: String, +} + +/// The fabrics of energy-measurement §6.3, in their order. +pub const FABRICS: [&str; 4] = ["cpu", "gpu", "media", "neural"]; +/// The attributions of energy-measurement §6.3. A rail shared with other work is not +/// divided by its users' busy time, which does not apportion energy, so no term names that. +pub const ATTRIBUTIONS: [&str; 2] = ["rail", "task"]; + +impl FabricRecord { + /// Rule 6 of jwp-receipts §2.4.8: the vocabularies, the orders, every fabric a step + /// names metered and every share's fabric running a step, and the shares summing to + /// the figure. + fn check(&self, joules_micro: u64) -> Result<(), Reason> { + let rank = |f: &str| FABRICS.iter().position(|x| *x == f); + if self.steps.iter().any(|s| rank(&s.fabric).is_none()) || self.shares.iter().any(|s| rank(&s.fabric).is_none() || !ATTRIBUTIONS.contains(&s.attribution.as_str())) { + return Err(Reason::MeasurementFabricUnknown); + } + let ordered_steps = self.steps.windows(2).all(|w| (w[0].step.as_bytes(), rank(&w[0].fabric)) < (w[1].step.as_bytes(), rank(&w[1].fabric))); + let ordered_shares = self.shares.windows(2).all(|w| rank(&w[0].fabric) < rank(&w[1].fabric)); + if self.steps.is_empty() || self.shares.is_empty() || !ordered_steps || !ordered_shares { + return Err(Reason::MeasurementFabricOrder); + } + if self.steps.iter().any(|s| !self.shares.iter().any(|x| x.fabric == s.fabric)) { + return Err(Reason::MeasurementFabricUnmetered); + } + if self.shares.iter().any(|x| !self.steps.iter().any(|s| s.fabric == x.fabric)) { + return Err(Reason::MeasurementFabricUnused); + } + if self.shares.iter().map(|s| s.joules_micro as u128).sum::() != joules_micro as u128 { + return Err(Reason::MeasurementFabricSum); + } + Ok(()) + } } impl MeasurementClaim { @@ -1638,6 +1726,65 @@ const MEASUREMENT_MEMBERS: &[&str] = &[ "build", "power_reader", ]; +/// A measurement body's members with its fabric record (§2.4.8). +const MEASUREMENT_FABRIC_MEMBERS: &[&str] = &[ + "figure_profile", + "figure_index", + "group", + "joules_micro", + "energy_provenance", + "energy_uncertainty", + "energy_counter_filtering", + "energy_calibration_ref", + "bracket", + "build", + "power_reader", + "fabrics", +]; +/// A fabric record's members, a step's and a share's (§2.4.8). +const FABRIC_MEMBERS: &[&str] = &["steps", "shares"]; +const FABRIC_STEP_MEMBERS: &[&str] = &["step", "fabric", "backend"]; +const FABRIC_SHARE_MEMBERS: &[&str] = &["fabric", "joules_micro", "attribution"]; + +/// A fabric record (§2.4.8): an object of `steps` and `shares`, each an array of objects +/// of their members, the terms and the backend non-empty strings, each share's figure an +/// integer. Never `null`: a report with no record has no member. +fn get_fabrics(v: &Value) -> Result { + let fm = v.as_object().ok_or(Reason::FieldType)?; + members(fm, FABRIC_MEMBERS)?; + let rows = |k: &str, required: &[&str]| -> Result>, Reason> { + let a = fm[k].as_array().ok_or(Reason::FieldType)?; + a.iter() + .map(|x| { + let o = x.as_object().ok_or(Reason::FieldType)?; + members(o, required)?; + Ok(o.clone()) + }) + .collect() + }; + let steps = rows("steps", FABRIC_STEP_MEMBERS)? + .iter() + .map(|o| { + let (step, fabric, backend) = (get_str(o, "step")?, get_str(o, "fabric")?, get_str(o, "backend")?); + term_ok(&step)?; + term_ok(&fabric)?; + if backend.is_empty() { + return Err(Reason::FieldType); + } + Ok(FabricStep { step, fabric, backend }) + }) + .collect::, Reason>>()?; + let shares = rows("shares", FABRIC_SHARE_MEMBERS)? + .iter() + .map(|o| { + let (fabric, attribution) = (get_str(o, "fabric")?, get_str(o, "attribution")?); + term_ok(&fabric)?; + term_ok(&attribution)?; + Ok(FabricShare { fabric, joules_micro: get_int(o, "joules_micro")?, attribution }) + }) + .collect::, Reason>>()?; + Ok(FabricRecord { steps, shares }) +} /// An operating-point body's members (§2.4.9), in the order step 2 reads them. const OPERATING_POINT_MEMBERS: &[&str] = &[ "figure_profile", @@ -1786,6 +1933,14 @@ impl ClaimBody { b.insert("target".into(), c.build.target.clone().map(Value::String).unwrap_or(Value::Null)); m.insert("build".into(), Value::Object(b)); m.insert("power_reader".into(), c.power_reader.clone().map(Value::String).unwrap_or(Value::Null)); + if let Some(f) = &c.fabrics { + let steps = f.steps.iter().map(|s| serde_json::json!({ "step": s.step, "fabric": s.fabric, "backend": s.backend })).collect(); + let shares = f.shares.iter().map(|s| serde_json::json!({ "fabric": s.fabric, "joules_micro": s.joules_micro, "attribution": s.attribution })).collect(); + let mut fm = Map::new(); + fm.insert("steps".into(), Value::Array(steps)); + fm.insert("shares".into(), Value::Array(shares)); + m.insert("fabrics".into(), Value::Object(fm)); + } } ClaimBody::OperatingPoint(c) => { c.figure.insert_into(&mut m); @@ -1956,7 +2111,12 @@ impl ClaimBody { }) } ClaimClass::Measurement => { - members(m, MEASUREMENT_MEMBERS)?; + // `fabrics` is the one member that is absent rather than null (§2.4.8). + if m.contains_key("fabrics") { + members(m, MEASUREMENT_FABRIC_MEMBERS)?; + } else { + members(m, MEASUREMENT_MEMBERS)?; + } let figure = FigureHeader::read(m)?; let bracket = match &m["bracket"] { Value::Null => None, @@ -1975,6 +2135,7 @@ impl ClaimBody { bracket, build: BuildRecord { profile, toolchain, target }, power_reader: get_opt_str(m, "power_reader")?, + fabrics: m.get("fabrics").map(get_fabrics).transpose()?, }) } ClaimClass::OperatingPoint => { @@ -2156,6 +2317,20 @@ impl ClaimBody { if let Some(t) = &c.build.target { term_ok(t)?; } + if let Some(f) = &c.fabrics { + for s in &f.steps { + term_ok(&s.step)?; + term_ok(&s.fabric)?; + if s.backend.is_empty() { + return Err(Reason::FieldType); + } + } + for s in &f.shares { + term_ok(&s.fabric)?; + term_ok(&s.attribution)?; + int_ok(s.joules_micro)?; + } + } Ok(()) } ClaimBody::OperatingPoint(c) => { @@ -2230,6 +2405,10 @@ impl ClaimBody { let hardware = class.uncertainty().map(|u| (u.relative_ppm, u.window_us)); b.check(c.figure.joules_micro, hardware).map_err(report_reason)?; } + // Rule 6: a fabric record's vocabularies, order, coverage and sum. + if let Some(f) = &c.fabrics { + f.check(c.figure.joules_micro)?; + } Ok(()) } ClaimBody::OperatingPoint(c) => { @@ -3509,7 +3688,7 @@ pub(crate) fn rfc3339(s: &str) -> bool { /// fails instead of passing on nothing. (A function rather than a public constant so /// cbindgen does not export it into the C header.) pub fn corpus_vector_count() -> usize { - 567 + 581 } /// The conformance corpus (`claim-conformance/vectors.json`), regenerated. Ed25519 @@ -4747,6 +4926,60 @@ mod corpus { v.extend(stage_vectors()); // §2.4.12, appended: the vectors above are an unchanged prefix. v.extend(session::vectors()); + // §2.4.8's fabric record, appended the same way. + v.extend(fabric_vectors()); + v + } + + /// A measurement claim's fabric record (§2.4.8, energy-measurement §6.3), appended + /// after every earlier vector so that they stay an unchanged prefix. + fn fabric_vectors() -> Vec { + let meter_id = "did:example:meter"; + let model = EnergyProvenance::ModelBased; + let mut v = Vec::new(); + let fstep = |step: &str, fabric: &str| FabricStep { step: step.into(), fabric: fabric.into(), backend: format!("claim-conformance: a {fabric} backend") }; + let fshare = |fabric: &str, joules_micro: u64, attribution: &str| FabricShare { fabric: fabric.into(), joules_micro, attribution: attribution.into() }; + let record = |steps: Vec, shares: Vec| FabricRecord { steps, shares }; + let good = || record( + vec![fstep("base-code", "cpu"), fstep("base-search", "cpu"), fstep("base-search", "gpu"), fstep("object-hash", "gpu")], + vec![fshare("cpu", 6_500, "task"), fshare("gpu", 2_500, "rail")], + ); + let e_fab = named(9_000, &model); + let fab_body = |f: FabricRecord| ClaimBody::Measurement(MeasurementClaim { figure: claim_link(9_000, &model), bracket: None, build: release(), power_reader: Some(READER.into()), fabrics: Some(f) }); + let fab_accept = seal(&meter(), meter_id, SubjectType::Receipt, e_fab.link_hash(), 1, fab_body(good()), None); + let fab_bad = |f: FabricRecord| seal_bad(&meter(), meter_id, SubjectType::Receipt, e_fab.link_hash(), 1, fab_body(f)); + let fab_edit = |f: &dyn Fn(&mut Map)| resigned(&fab_accept, &meter(), |m| f(body_of(m))); + v.push(with(claim("measurement_fabrics_accept", "a ModelBased figure of 9,000 uJ divided by fabric: the motion search ran on the CPU and on a GPU, the base's coding on the CPU, the object hashes on the GPU; the CPU's share is the task's own and the GPU's its whole rail, the work its sole user, and the shares sum to the figure", + fab_accept.to_json(), Ok(())), "figure", claim_ctx(&e_fab))); + v.extend([ + reject("measurement_fabrics_null", "a fabric record written null: a report without one has no member, so it has one canonical form", + fab_edit(&|b| { b.insert("fabrics".into(), Value::Null); }), 2, Reason::FieldType), + reject("measurement_fabric_step_unknown_member", "a step carrying its work count: a step is its term, fabric and backend", + fab_edit(&|b| { b["fabrics"]["steps"][0]["units"] = 48.into(); }), 2, Reason::UnknownMember), + reject("measurement_fabric_backend_empty", "a step whose backend is the empty string", + fab_edit(&|b| { b["fabrics"]["steps"][1]["backend"] = "".into(); }), 2, Reason::FieldType), + reject("measurement_fabric_share_not_integer", "a share written as a string", + fab_edit(&|b| { b["fabrics"]["shares"][0]["joules_micro"] = "6500".into(); }), 2, Reason::FieldType), + reject("measurement_fabric_unknown", "a share on a fabric energy-measurement 6.3 does not name", + fab_bad(record(good().steps, vec![fshare("cpu", 6_500, "task"), fshare("fpga", 2_500, "rail")])), 4, Reason::MeasurementFabricUnknown), + reject("measurement_fabric_attribution_unknown", "a share attributed by a method energy-measurement 6.3 does not name", + fab_bad(record(good().steps, vec![fshare("cpu", 6_500, "task"), fshare("gpu", 2_500, "estimate")])), 4, Reason::MeasurementFabricUnknown), + reject("measurement_fabric_time_share", "a shared GPU rail divided by its users' GPU time: busy time does not apportion energy, so energy-measurement 6.3 names no such attribution", + fab_bad(record(good().steps, vec![fshare("cpu", 6_500, "task"), fshare("gpu", 2_500, "time-share")])), 4, Reason::MeasurementFabricUnknown), + reject("measurement_fabric_steps_out_of_order", "the motion search listed before the base's coding: steps are in byte order of their terms", + fab_bad(record(vec![fstep("base-search", "cpu"), fstep("base-code", "cpu"), fstep("base-search", "gpu"), fstep("object-hash", "gpu")], good().shares)), 4, Reason::MeasurementFabricOrder), + reject("measurement_fabric_share_twice", "two shares on the CPU: a fabric has one share", + fab_bad(record(vec![fstep("base-code", "cpu")], vec![fshare("cpu", 6_500, "task"), fshare("cpu", 2_500, "task")])), 4, Reason::MeasurementFabricOrder), + reject("measurement_fabric_no_step", "shares with no step: a record says where the work ran", + fab_bad(record(Vec::new(), good().shares)), 4, Reason::MeasurementFabricOrder), + reject("measurement_fabric_unmetered", "a step on the GPU with a share on the CPU alone: a fabric that ran work has a share", + fab_bad(record(good().steps, vec![fshare("cpu", 9_000, "task")])), 4, Reason::MeasurementFabricUnmetered), + reject("measurement_fabric_unused", "a share on the GPU while every step ran on the CPU", + fab_bad(record(vec![fstep("base-code", "cpu"), fstep("base-search", "cpu")], good().shares)), 4, Reason::MeasurementFabricUnused), + reject("measurement_fabric_sum", "shares of 6,500 and 2,499 uJ under a figure of 9,000: the shares divide the figure", + fab_bad(record(good().steps, vec![fshare("cpu", 6_500, "task"), fshare("gpu", 2_499, "rail")])), 4, Reason::MeasurementFabricSum), + ]); + v } @@ -4816,7 +5049,7 @@ mod corpus { } fn measurement_body(h: FigureHeader, b: Option, build: BuildRecord) -> ClaimBody { - ClaimBody::Measurement(MeasurementClaim { figure: h, bracket: b, build, power_reader: Some(READER.into()) }) + ClaimBody::Measurement(MeasurementClaim { figure: h, bracket: b, build, power_reader: Some(READER.into()), fabrics: None }) } fn point(pairs: &[(&str, u64)]) -> Option { @@ -4924,7 +5157,7 @@ mod corpus { let est = EnergyProvenance::Estimator; let e_est = named(9_000, &est); let debug = BuildRecord { profile: BuildProfile::Debug, toolchain: None, target: None }; - let no_bracket = ClaimBody::Measurement(MeasurementClaim { figure: claim_link(9_000, &est), bracket: None, build: debug, power_reader: None }); + let no_bracket = ClaimBody::Measurement(MeasurementClaim { figure: claim_link(9_000, &est), bracket: None, build: debug, power_reader: None, fabrics: None }); v.push(with(claim("measurement_no_bracket_accept", "an Estimator figure obtained under no bracket: the report states the build alone, a debug build, its toolchain, target and reader unknown", seal(&meter(), meter_id, SubjectType::Receipt, e_est.link_hash(), 1, no_bracket, None).to_json(), Ok(())), "figure", claim_ctx(&e_est))); let counter = chip_class(20_000, 1_000_000); @@ -5046,11 +5279,12 @@ mod corpus { bad_about(&e_unit, ClaimBody::Measurement(MeasurementClaim { figure: FigureHeader { energy_uncertainty: None, ..claim_link(f_unit, &c_unit) }, bracket: Some(unit), build: release(), power_reader: Some(READER.into()), + fabrics: None, })), 4, Reason::EnergyAcquisitionEvidence), reject("measurement_no_power_reader", "an OnChipCounter report that names no power reader: a hardware figure is a reading, and its report names what read it", - bad_about(&e_unit, ClaimBody::Measurement(MeasurementClaim { figure: claim_link(f_unit, &c_unit), bracket: Some(unit), build: release(), power_reader: None })), 4, Reason::MeasurementNoPowerReader), + bad_about(&e_unit, ClaimBody::Measurement(MeasurementClaim { figure: claim_link(f_unit, &c_unit), bracket: Some(unit), build: release(), power_reader: None, fabrics: None })), 4, Reason::MeasurementNoPowerReader), reject("measurement_no_power_reader_no_bracket", "an OnChipCounter report with neither a bracket nor a reader: omitting the bracket does not excuse the reader", - bad_about(&e_unit, ClaimBody::Measurement(MeasurementClaim { figure: claim_link(f_unit, &c_unit), bracket: None, build: release(), power_reader: None })), 4, Reason::MeasurementNoPowerReader), + bad_about(&e_unit, ClaimBody::Measurement(MeasurementClaim { figure: claim_link(f_unit, &c_unit), bracket: None, build: release(), power_reader: None, fabrics: None })), 4, Reason::MeasurementNoPowerReader), reject("measurement_one_idle_sample", "an idle bracket of one sample has no standard deviation", hw_report(at(live(1, 20_000_000, 0), unit.busy), &c_unit, f_unit), 4, Reason::MeasurementTooFewSamples), reject("measurement_rail_not_live", "the busy bracket's rail advanced in 4 samples of 14: below five, and below four in five", @@ -5947,6 +6181,7 @@ mod tests { bracket: Some(b), build: BuildRecord::of_this_build(), power_reader: reader.map(str::to_owned), + fabrics: None, }); ClaimReceipt::seal_unchecked(key, "r", SubjectType::Receipt, e.link_hash(), 1, T, body, None).unwrap() }; @@ -5968,7 +6203,7 @@ mod tests { let class = EnergyProvenance::ModelBased; let h = |profile: &str, index: Option, j: u64| FigureHeader::new(profile, index, None, j, &class); let m = |h: FigureHeader, b: Option| { - ClaimBody::Measurement(MeasurementClaim { figure: h, bracket: b, build: BuildRecord::of_this_build(), power_reader: None }) + ClaimBody::Measurement(MeasurementClaim { figure: h, bracket: b, build: BuildRecord::of_this_build(), power_reader: None, fabrics: None }) }; let seal = |body: ClaimBody| ClaimReceipt::seal(&k(1), "m", SubjectType::Receipt, [1; 32], 0, T, body, None); assert!(seal(m(h("moq-jwp:claim-link", None, 5), None)).is_ok()); @@ -6441,6 +6676,19 @@ mod tests { assert_eq!(hex(digest.as_bytes()), "41465e7b57ca795b8ee4b998f11553a3a2b720fe994e6f0d53866af73f281ba1"); } + /// The vectors the corpus held before a measurement claim's fabric record was added + /// (all 567), pinned as the tests above pin theirs. The digest was computed from the + /// committed corpus before the record was added and must never be edited to make the + /// test pass. + #[test] + fn the_vectors_before_the_fabric_record_are_unchanged() { + let doc: Value = crate::oer::load(conformance_vectors().as_bytes()).unwrap(); + let vs = doc["vectors"].as_array().unwrap(); + let first = Value::Array(vs[..567].to_vec()); + let digest = blake3::hash(serde_json::to_string(&first).unwrap().as_bytes()); + assert_eq!(hex(digest.as_bytes()), "74f802d701daa0620b1577cb100a80951fedb797f9d47057efca61f21d40b8a6"); + } + /// A session claim's grammars (§2.4.12): `media`, a decoder identifier, and the /// rounding of its worker count. #[test] diff --git a/wai/wai-rs/src/claim_receipt/corpus/session.rs b/wai/wai-rs/src/claim_receipt/corpus/session.rs index e68a02ab0..1026e6edd 100644 --- a/wai/wai-rs/src/claim_receipt/corpus/session.rs +++ b/wai/wai-rs/src/claim_receipt/corpus/session.rs @@ -1365,6 +1365,7 @@ pub(super) fn vectors() -> Vec { bracket: None, build: BuildRecord { profile: BuildProfile::Release, toolchain: None, target: None }, power_reader: Some("a counter reader".into()), + fabrics: None, }); seal(&viewer(), SINK_ID, SubjectType::Receipt, s.link_hash(), 1, body, None) }; diff --git a/wai/wai-rs/src/energy_figure.rs b/wai/wai-rs/src/energy_figure.rs index 80987c08d..c12e00907 100644 --- a/wai/wai-rs/src/energy_figure.rs +++ b/wai/wai-rs/src/energy_figure.rs @@ -464,6 +464,25 @@ pub mod meter_report { signer_id: &str, previous: Option<&ClaimReceipt>, issued_at: &str, + ) -> Result { + emit_with_fabrics(view, index, bracket, build, power_reader, None, signer, signer_id, previous, issued_at) + } + + /// [`emit`], the report carrying `fabrics` (energy-measurement §6.3): where the work + /// the figure covers ran, and the figure divided by fabric. `None` is [`emit`]'s + /// report, which has no `fabrics` member. + #[allow(clippy::too_many_arguments)] + pub fn emit_with_fabrics( + view: &FigureView, + index: Option, + bracket: Option, + build: BuildRecord, + power_reader: &str, + fabrics: Option, + signer: &SigningKey, + signer_id: &str, + previous: Option<&ClaimReceipt>, + issued_at: &str, ) -> Result { let profile = super::profile(&view.profile).ok_or("the receipt's profile is not in energy-measurement section 6.1")?; let f = view.figure(index).ok_or("the receipt carries no figure at that index")?; @@ -473,6 +492,7 @@ pub mod meter_report { bracket, build, power_reader: Some(power_reader.to_owned()), + fabrics, }); let (seq, parent) = match previous { Some(p) => (p.seq + 1, Some(p.link_hash())), diff --git a/wai/wai-rs/src/int_video_enc.rs b/wai/wai-rs/src/int_video_enc.rs index f059686a5..f44ee312e 100644 --- a/wai/wai-rs/src/int_video_enc.rs +++ b/wai/wai-rs/src/int_video_enc.rs @@ -170,13 +170,51 @@ fn search_block(src: &[u8], rf: &[u8], h: usize, w: usize, y0: usize, x0: usize, (best.2 as i16, best.3 as i16) } +/// Where the motion search of [`encode_with`] runs: something that gives, for one frame, +/// the vector of every block in raster order, each the least-cost candidate under the +/// search order and tie-break of the module documentation. The search is a pure +/// function of the frame, the reference and the parameters, so every implementation +/// that keeps that rule gives the same vectors, and so the same payload; [`CpuSearch`] +/// is the reference. An implementation that cannot search a frame (a device lost, a +/// size it does not take) searches it with [`CpuSearch`] rather than fail, so a payload +/// never depends on where its search ran. +pub trait MotionSearch { + /// Append to `out` the vectors of the `⌈h / block⌉ · ⌈w / block⌉` blocks of `src` + /// (`h × w` RGB8), predicted from `rf` (the previous frame as decoded), with vector + /// components in `−range..=range` in units of `1 / prec` pel. + #[allow(clippy::too_many_arguments)] + fn frame(&mut self, src: &[u8], rf: &[u8], h: usize, w: usize, block: usize, range: i64, prec: i64, out: &mut Vec<(i16, i16)>); +} + +/// The reference motion search, on the CPU: [`search_block`] for each block, in raster +/// order. +#[derive(Debug, Clone, Copy, Default)] +pub struct CpuSearch; + +impl MotionSearch for CpuSearch { + fn frame(&mut self, src: &[u8], rf: &[u8], h: usize, w: usize, block: usize, range: i64, prec: i64, out: &mut Vec<(i16, i16)>) { + for by in 0..h.div_ceil(block) { + for bx in 0..w.div_ceil(block) { + out.push(search_block(src, rf, h, w, by * block, bx * block, block, range, prec)); + } + } + } +} + /// Encode `frames`, each `h × w` RGB8 (rows top to bottom), as a `WIV1` keyframe-0 /// payload under `p`. Refuses `field_range` for a parameter outside its range (`q` /// outside `1..=65535`, `block` 0, `mv_precision` other than 1 or 2, `search · /// mv_precision` past [`MAX_SEARCH_RANGE`]), an empty clip, a zero dimension or a frame /// that is not `h·w·3` bytes; `too_large` for a clip past the format's caps, before -/// anything is allocated. +/// anything is allocated. The motion search runs on the CPU ([`CpuSearch`]). pub fn encode(frames: &[Vec], h: u32, w: u32, p: &EncParams) -> Result { + encode_with(frames, h, w, p, &mut CpuSearch) +} + +/// [`encode`], with the motion search of frames 1 on run by `search`. Every other step +/// (the residuals, the reconstruction, its SSE, the table and the stream) runs here, so a +/// `search` that keeps [`MotionSearch`]'s rule gives [`encode`]'s payload byte for byte. +pub fn encode_with(frames: &[Vec], h: u32, w: u32, p: &EncParams, search: &mut dyn MotionSearch) -> Result { let field = |why: &str| IntPayloadError::new(Refusal::FieldRange, why.to_string()); if !(1..=wire::MAX_Q).contains(&p.q) { return Err(field("q is outside 1..=65535")); @@ -234,10 +272,10 @@ pub fn encode(frames: &[Vec], h: u32, w: u32, p: &EncParams) -> Result 0 { let rf = &recon[f - 1]; - for by in 0..nby as usize { - for bx in 0..nbx { - mvs.push(search_block(src, rf, h, w, by * block, bx * block, block, range, prec as i64)); - } + let before = mvs.len(); + search.frame(src, rf, h, w, block, range, prec as i64, &mut mvs); + if mvs.len() - before != nb as usize { + return Err(field("the motion search gave another number of vectors than the frame has blocks")); } } let mut cur = Vec::with_capacity(src.len()); @@ -374,6 +412,52 @@ mod tests { } } + /// A search run elsewhere is handed each frame from 1 on with the previous frame as + /// decoded; one that keeps the rule gives `encode`'s payload byte for byte, one that + /// moves a vector gives another payload that still decodes to its own + /// reconstruction, and one that gives another number of vectors is refused. + #[test] + fn encode_with_runs_only_the_search_elsewhere() { + struct Seen<'a> { + calls: Vec<(usize, Vec)>, + nudge: Option, + short: bool, + recon: &'a [Vec], + } + impl MotionSearch for Seen<'_> { + fn frame(&mut self, src: &[u8], rf: &[u8], h: usize, w: usize, block: usize, range: i64, prec: i64, out: &mut Vec<(i16, i16)>) { + let f = self.calls.len() + 1; + self.calls.push((f, rf.to_vec())); + let start = out.len(); + CpuSearch.frame(src, rf, h, w, block, range, prec, out); + if self.nudge == Some(f) { + out[start].1 = if out[start].1 == range as i16 { out[start].1 - 1 } else { out[start].1 + 1 }; + } + if self.short { + out.pop(); + } + } + } + let (h, w) = (19, 23); + let src = clip(h, w, 5); + for &(block, search, prec, q) in &[(4, 2, 2, 3), (8, 3, 1, 4), (16, 1, 2, 9)] { + let p = EncParams { block, search, mv_precision: prec, q }; + let want = encode(&src, h as u32, w as u32, &p).unwrap(); + let mut s = Seen { calls: Vec::new(), nudge: None, short: false, recon: &want.recon }; + assert_eq!(encode_with(&src, h as u32, w as u32, &p, &mut s).unwrap(), want, "{p:?}"); + assert_eq!(s.calls.len(), 4, "{p:?}: frames 1 to 4"); + for (f, rf) in &s.calls { + assert_eq!(rf, &s.recon[f - 1], "{p:?}: frame {f} searched from the previous frame as decoded"); + } + let mut moved = Seen { calls: Vec::new(), nudge: Some(2), short: false, recon: &want.recon }; + let other = encode_with(&src, h as u32, w as u32, &p, &mut moved).unwrap(); + assert_ne!(other.wiv1, want.wiv1, "{p:?}: a moved vector is another payload"); + assert_eq!(decode(&other.wiv1), other.recon, "{p:?}"); + let mut short = Seen { calls: Vec::new(), nudge: None, short: true, recon: &want.recon }; + assert_eq!(encode_with(&src, h as u32, w as u32, &p, &mut short).map(|_| ()).unwrap_err().code(), "field_range"); + } + } + #[test] fn quantize_rounds_halves_away_from_zero() { let cases = [(5, 2, 3), (-5, 2, -3), (3, 2, 2), (1, 2, 1), (-1, 2, -1), (7, 4, 2), (6, 4, 2), (2, 4, 1), (-2, 4, -1), (1, 4, 0), (0, 9, 0), (255, 1, 255), (-255, 255, -1)]; diff --git a/wai/wai-rs/src/key_log/corpus.rs b/wai/wai-rs/src/key_log/corpus.rs index b5d20298e..ede135e40 100644 --- a/wai/wai-rs/src/key_log/corpus.rs +++ b/wai/wai-rs/src/key_log/corpus.rs @@ -256,6 +256,7 @@ fn measurement_body(class: &str) -> ClaimBody { bracket: None, build: BuildRecord { profile: BuildProfile::Release, toolchain: None, target: None }, power_reader: Some("urn:example:power-reader".into()), + fabrics: None, }) } diff --git a/wai/wai-rs/src/stage_index.rs b/wai/wai-rs/src/stage_index.rs index 7f197f7d0..923dbd5b3 100644 --- a/wai/wai-rs/src/stage_index.rs +++ b/wai/wai-rs/src/stage_index.rs @@ -806,12 +806,22 @@ pub fn digest_of(file: &[u8]) -> [u8; 32] { /// among them the one its entry refines (`object-refines`); and `derived_from` is present, /// well formed, exactly when the entry says so (`object-derived-from`). pub fn check_object(ix: &StageIndex, k: usize, bytes: &[u8]) -> Result<(), IndexReason> { + check_object_hashed(ix, k, bytes, &mut |b| crate::jwp_hash::content_hash(b)) +} + +/// [`check_object`], with the content hash of `bytes` taken from `hash`, which is asked +/// for it at most once and only once the length holds: a caller that hashed the objects +/// elsewhere (a batch on another fabric, packaging §11.6) hands its result in. The rules +/// and their order are [`check_object`]'s; a caller whose hash is not the reference's +/// gets `object-hash` where the reference would not, so a caller re-checks that refusal +/// with the reference before it reports it. +pub fn check_object_hashed(ix: &StageIndex, k: usize, bytes: &[u8], hash: &mut dyn FnMut(&[u8]) -> [u8; 32]) -> Result<(), IndexReason> { let r = |code: &'static str, detail: String| Err(IndexReason::new(code, detail)); let e = ix.entries.get(k).ok_or_else(|| IndexReason::new("object-hash", format!("no entry {k}")))?; if bytes.len() as u64 != e.byte_len { return r("object-length", format!("{} bytes, the entry says {}", bytes.len(), e.byte_len)); } - if crate::jwp_hash::content_hash(bytes) != e.content_hash { + if hash(bytes) != e.content_hash { return r("object-hash", "the bytes do not hash to the entry's content hash".into()); } let man_len = bytes.get(4..8).map(|b| u32::from_le_bytes(b.try_into().expect("four bytes")));