WAI across fabrics: each step on the fabric that does it with the least measured energy (packaging §11.6) #438

Closed
dcharlot wants to merge 13 commits from wai/cross-fabric into wai/pack-efficiency
48 changed files with 6579 additions and 55 deletions

View file

@ -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

View file

@ -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<b) ? b-a : a-b)`; integer `add` exact; floating point may be fused.
- **Unified memory**: on Apple silicon, `MTLStorageMode.shared` "is the default storage
mode" (https://developer.apple.com/documentation/metal/mtlstoragemode/shared).
- **BLAKE3 on GPUs**: the specification splits input into 1 KiB chunks hashed
independently (https://github.com/BLAKE3-team/BLAKE3-specs/blob/master/blake3.pdf);
published GPU ports were slower than one CPU core (a T4,
https://github.com/chappjc/blake3-gpu-fork) or than 8 CPU threads (an integrated GPU,
https://github.com/BLAKE3-team/BLAKE3/pull/80), and no energy comparison was found.
Measured here in §9.
## 5. Neural engines
The Apple Neural Engine computes in FP16 ("The GPU and NE use float 16 precision",
https://apple.github.io/coremltools/docs-guides/source/typed-execution.html); Core ML can
permit it but not require it (`MLComputeUnits.cpuAndNeuralEngine`,
https://developer.apple.com/documentation/coreml/mlcomputeunits/cpuandneuralengine);
and "The ANE capabilities differ in each generation of hardware and software"
(https://developer.apple.com/forums/thread/689135). FP16 represents integers exactly only
up to 2048, and one 8-bit squared difference is 65025. **No WAI step can run on it.**
## 6. Determinism and conformance
### 6.1 AV1 decoding is exact, film grain aside
The AV1 specification, §7.2: "When film_grain_params_present is equal to 0, decoders
shall produce output frames that are identical in all respects and have the same output
order as those produced by the decoding process specified herein." With film grain a
conformant decoder may instead match only the pre-grain frames of §7.18.2 and add grain
with no "perceptually significant differences"
(https://aomediacodec.github.io/av1-spec/av1-spec.pdf, Version 1.0.0 with Errata 1;
source text https://raw.githubusercontent.com/AOMediaCodec/av1-spec/master/08.decoding.process.md).
dav1d passes the Argon conformance streams with grain on
(https://raw.githubusercontent.com/videolan/dav1d/master/README.md). Intel's Gen12 film
grain "does not guarantee the bit match" (https://github.com/intel/media-driver/issues/1579);
Intel and AMD have fixed AV1 decode bugs (https://github.com/intel/media-driver/issues/1108,
https://www.phoronix.com/news/Mesa-25.0-rc3-Released). Apple publishes no conformance
statement (unconfirmed).
**Tested [measured]**: the media engine decoded each big-buck-bunny rung, and its pictures
were compared with dav1d's (§9). The ladder carries no film grain: dav1d gives the same
frames with grain applied and with it exported.
### 6.2 Hardware encoders are not reproducible
No vendor states that its hardware encoder is deterministic; NVENC's guide documents
split-frame encoding that turns on with the number of engines and the frame height
(https://docs.nvidia.com/video-technologies/video-codec-sdk/13.0/nvenc-video-encoder-api-prog-guide/index.html),
and Intel's low-power AV1 rate control runs in HuC firmware
(https://raw.githubusercontent.com/intel/media-driver/master/README.md). Software
encoders are reproducible only under pinned conditions: SVT-AV1 gives "the same" output
for `--lp 1` and `--lp n` in the default CRF configuration
(https://gitlab.com/AOMediaCodec/SVT-AV1/-/raw/master/Docs/CommonQuestions.md).
### 6.3 What it means for WAI
- **Receipts.** A package's bytes do not depend on where its steps ran, because every
step is integer and the GPU kernels compute the same integers: the receipt, the index
and every object are the CPU's (§9, `G360`). Where the steps ran is recorded only in a
metered package's measurement claim, which §11.2 already exempts, so an unmetered
package records nothing machine-specific.
- **§11.2 on non-deterministic hardware.** No WAI step may be computed by a hardware
encoder. A floor a packager is given is an input; a floor encoded on a hardware encoder
is one §7.5 covers, its renderer naming the encoder, version, device and driver, and
§11.2 does not hold its bytes.
- **Verifiers' independence.** A check whose expected value the package states (an
object's content hash) may run anywhere: a match shows the fabric computed it, and a
mismatch is computed again on the CPU before it is reported. A floor states no
decoded-output digest, so a hardware decode can never decide a floor verdict; and a
hardware decoder may conceal an error that dav1d reports. The Python verifier stays on
the CPU and shares no code with wai-rs; the GPU path is wai-pack's.
## 7. Measuring energy per fabric without root
### 7.1 macOS 27
- **IOReport** is private (`/usr/lib/libIOReport.dylib`; the SDK ships a `.tbd` stub and
no header **[local]**). On macOS 27 its "Energy Model" CPU and ANE counters "can remain
visible but stop updating" without a private entitlement, and `sudo` does not help
(https://github.com/vladkens/macmon, "Power sources and macOS 27").
**[measured]** on the build host: of 645 Energy Model channels, only `GPU Energy` (nJ)
moved over a 6 s span during a VideoToolbox decode, a dav1d decode and idle; `CPU
Energy`, every core, `ANE`, `AVE`, `MSR`, `DRAM`, `ISP` and `DISP` read zero. There is no
decoder (AVD) energy channel at all. So **the media engine and the neural engine
cannot be metered** on the fleet.
- **CPU, per process**: `proc_pid_rusage(RUSAGE_INFO_V6)` gives `ri_energy_nj`, the
kernel's account of the task's own CPU energy, readable for one's own process or child;
`task_power_info_v2.task_energy` equals it, and its `gpu_energy.task_gpu_utilisation`
reads 0 under a Metal load **[measured]**. Other load on the host does not enter it.
- **GPU, per process**: the IORegistry's `IOAccelerator` user clients carry each
creator's pid and an `accumulatedGPUTime` (ns), readable by any process **[measured]**.
The repository's own measurement (`jouleclaw-energy`, `process_gpu.rs`) found that
dividing the shared rail by GPU time charged a decode 3 to 15 times its own GPU energy,
so the rail is attributed to a process only when it held at least 99% of every
client's GPU time over the span. This change follows that rule, and reads the rail on
its next republish at each end (it holds for hundreds of milliseconds, then catches up
in one step).
- `powermetrics` refuses to run without root ("must be invoked as the superuser")
**[local]**; its own man page says its figures "are estimated and may be inaccurate"
(https://keith.github.io/xcode-man-pages/powermetrics.1.html).
### 7.2 Linux and Windows (not run here)
- RAPL's `energy_uj` became root-only in v5.10 (commit `949dd0104c49`,
https://github.com/torvalds/linux/commit/949dd0104c496fa7c14991a23c03c62e44637e71; Intel
advisory INTEL-SA-00389); perf's RAPL events are system-wide only and Ubuntu sets
`perf_event_paranoid` ≥ 2 (https://ubuntu.com/security/CVE-2023-2235). Reading CPU
energy there takes an owner's act (`cap_perfmon`, or a group-readable `energy_uj`).
- NVML's `nvmlDeviceGetTotalEnergyConsumption` (mJ, Volta and later) is not marked as
needing root (https://docs.nvidia.com/deploy/nvml-api/latest/api/group__nvmlDeviceQueries.html);
amdgpu's `power1_average` and Intel discrete GPUs' `energy1_input` are world-readable
(https://raw.githubusercontent.com/torvalds/linux/master/drivers/gpu/drm/amd/pm/amdgpu_pm.c,
https://raw.githubusercontent.com/torvalds/linux/master/drivers/gpu/drm/i915/i915_hwmon.c).
- Windows' Energy Meter Interface is read unelevated by Firefox
(https://raw.githubusercontent.com/mozilla-firefox/firefox/main/tools/profiler/core/PowerCounters-win.cpp).
## 8. The design choices, and what decided them
| Choice | Decided by |
|---|---|
| The GPU computes the motion search, SSE and BLAKE3 chunks, in WGSL integer kernels through wgpu (Metal, Vulkan, DX12), not in Metal only | WGSL's defined integer arithmetic (§4) gives one result on every conformant device and every fleet OS; the repository's GPU precedent (`wai-ops-gpu`) is wgpu |
| A backend runs a step only after it gives the reference's output on qualifying inputs; any call it cannot take runs on the CPU | §11.2 must hold on every fabric; a device or driver can be wrong (§6.1's driver bugs) |
| Placement by a measured cost table (µJ per call + nJ per unit), the CPU on a tie or where unmeasured | **[measured]** the GPU's fixed cost per call (1.7 mJ for a hash, 3.1 mJ for an SSE) exceeds what a pack's calls save, while the search saves 14× per unit (§9.3); placing by fabric alone made the floor-only pack dearer (§9.2) |
| The fabric record lives in the measurement claim, not the receipt | the receipt and `LISTING` would otherwise differ by machine, voiding §11.2 for every package; the claim is already §11.2's exemption |
| `fabrics` is absent rather than `null` | every existing claim, and every package that carries one, keeps its bytes |
| Attributions `task` and `rail` only; no division of a shared rail by busy time | the repository's measurement of a 3–15× overcharge (§7.1) |
| Hardware AV1 decode for sources and for informative floor decodes; never a floor verdict | AV1 §7.2 (exact without grain); floors state no digest; hardware may conceal errors |
| VideoToolbox frames read from P010 exactly, never through FFmpeg's 16-to-8-bit conversion | **[measured]**: that conversion differs from dav1d's (§3.1) |
| No hardware encoder computes a WAI step | §6.2 |
| No neural-engine backend | §5 |
## 9. Measurements on big-buck-bunny
**Setup.** Big-buck-bunny, 596 s at 24 fps, 14,315 frames: phase 0's 256×144 proxy and
the ladder's four 10-bit AV1 rungs (1080, 720, 540, 360). On the build host (M3 Ultra,
macOS 27.0.1), shared with other work, the load averages given in each run's report.
wai-pack at this branch, release, features `gpu,fabric-meter`; FFmpeg 9.0.2 with libdav1d
1.5.4. Each run was metered by `xfab-meter`, which runs a command and reads:
- the kernel's account of the child's CPU energy (`ri_energy_nj`) before reaping it, and
of every VideoToolbox XPC service the run started (VideoToolbox decodes out of process:
a 2,000-frame decode billed 1.20 J to FFmpeg and 1.09 J to its service);
- the IOReport `GPU Energy` rail and every client's GPU time, every 200 ms, with idle
brackets before and after.
The GPU figure is the rail where the run held at least 99% of all GPU time (`rail`);
where it held less, there is no GPU figure, only the energy-measurement §3 marginal and
its §4 verdict, never a division by GPU time. The media engine has no counter, so a
media-engine run's figure is its CPU side alone, a lower bound.
The scripts and per-sample logs are kept on the build host in
`wai-build/wai-packages/big-buck-bunny-xfab/` (`tools/`, `report-run2/`, `report/`). The
CPU baselines are phase 0's and big-buck-bunny-v2's runs on the same host, measured the
same way for the CPU.
### 9.1 Exact results
- **Packages are the same bytes on the GPU.** Phase 0's `av1-360` policy packed with
`--fabric gpu`: pack digest `7454acc6…1118`, phase 0's, every file the same (`diff -r`),
in three runs, and with `--fabric auto:<costs>` 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).

View file

@ -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:<costs.json>`, `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

View file

@ -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`

View file

@ -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")]

View file

@ -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"
}
}
]
}

View file

@ -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

View file

@ -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` |

View file

@ -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 |

View file

@ -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

View file

@ -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",

View file

@ -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

View file

@ -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"}

View file

@ -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:[email protected]","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}

View file

@ -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

View file

@ -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"}

View file

@ -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:[email protected]","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}

1063
wai/wai-pack-gpu/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -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

502
wai/wai-pack-gpu/src/lib.rs Normal file
View file

@ -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<String>,
lost: Option<String>,
}
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<Mutex<Sink>>,
pool: Mutex<HashMap<(u64, u32), Vec<wgpu::Buffer>>>,
label: String,
}
/// What discovery found: the adapters that opened, and why each other one did not.
#[derive(Default)]
pub struct Discovery {
pub found: Vec<Gpu>,
pub refused: Vec<(Adapter, String)>,
}
fn lock<T>(m: &Mutex<T>) -> 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: std::future::Future>(f: F) -> Result<F::Output, GpuError> {
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<Gpu, Box<(Adapter, String)>> {
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<String> = 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<Pipelines, String> {
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<u8> {
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<wgpu::Buffer>) {
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<Vec<u8>, 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<u8> = 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<Mutex<Option<Result<(), wgpu::BufferAsyncError>>>> = 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<Vec<(i16, i16)>, 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, &params, 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<u64, GpuError> {
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, &params, 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<Vec<[u8; 32]>, GpuError> {
// Each message on a word, its chunks one job each.
let mut data: Vec<u8> = Vec::new();
let mut jobs: Vec<u32> = 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<u8> = jobs.iter().flat_map(|w| w.to_le_bytes()).collect();
let out = self.run(&self.pipes.blake3, &params, &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()
}

View file

@ -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<uniform> P: Params;
@group(0) @binding(1) var<storage, read> DATA: array<u32>;
@group(0) @binding(2) var<storage, read> JOBS: array<Job>;
// Eight words per job.
@group(0) @binding(3) var<storage, read_write> OUT: array<u32>;
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<private> v: array<u32, 16>;
var<private> m: array<u32, 16>;
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<u32>) {
let t = gid.x + gid.y * P.row;
if (t >= P.n) {
return;
}
let job = JOBS[t];
var cv = array<u32, 8>(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];
}
}

View file

@ -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<uniform> P: Params;
@group(0) @binding(1) var<storage, read> SRC: array<u32>;
@group(0) @binding(2) var<storage, read> REF: array<u32>;
// Two words per block, raster order: dy, dx.
@group(0) @binding(3) var<storage, read_write> OUT: array<i32>;
const WG: u32 = 64u;
var<workgroup> wc: array<u32, 64>;
var<workgroup> wl: array<u32, 64>;
var<workgroup> wi: array<u32, 64>;
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<u32>, @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;
}
}

View file

@ -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<uniform> P: Params;
@group(0) @binding(1) var<storage, read> A: array<u32>;
@group(0) @binding(2) var<storage, read> B: array<u32>;
@group(0) @binding(3) var<storage, read_write> OUT: array<u32>;
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<u32>) {
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;
}

View file

@ -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<u8>, Vec<u8>)> = (0..8).map(|_| (rng.bytes(h * w * 3), rng.bytes(h * w * 3))).collect();
let want: Vec<Vec<(i16, i16)>> = pairs.iter().map(|(s, r)| search(s, r, h, w, block, range, prec)).collect();
let msgs: Vec<Vec<u8>> = (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());
}
}

View file

@ -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<Vec<(i16, i16)>, 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<Vec<u8>> = 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<Vec<u8>> = (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());
}
}

View file

@ -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<Vec<u8>> = 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);
}
}

View file

@ -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<Gpu> {
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<Gpu> {
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<u8> {
(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<u8>, Vec<u8>)> {
let n = h * w * 3;
let noise_a = rng.bytes(n);
let noise_b = rng.bytes(n);
let field = |t: usize| -> Vec<u8> {
(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<u8> {
(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<u8> {
(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<u8> {
(0..n).map(|i| (i % 251) as u8).collect()
}

888
wai/wai-pack/Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -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]

View file

@ -344,6 +344,19 @@ impl SequenceHeader {
/// Read a sequence header OBU's payload through `color_config()`.
pub fn parse(payload: &[u8]) -> Result<SequenceHeader, Av1Error> {
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<bool> {
Self::parse_with_grain(payload).ok()?.1
}
fn parse_with_grain(payload: &[u8]) -> Result<(SequenceHeader, Option<bool>), 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<bool, Av1Error> {
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:

View file

@ -12,6 +12,7 @@
//! wai-pack pack --policy <policy.toml> --out <dir> --key <seed.hex> [--source <id>=<file>]...
//! [--floor <dir>] [--signer-id <id>] [--royalty-free-only] [--meter none|model:<mW>]
//! [--base-from <package dir>] [--link-supplied] [--jobs <n>]
//! [--fabric cpu|gpu|auto:<costs.json>] [--meter fabric]
//! pack the policy's components into <dir>: objects, stage indexes, the floor's CMAF
//! files, catalogs, the receipt (and, metered, its measurement claim) and LISTING;
//! <dir> 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 <policy.toml> --out <dir> --key <seed.hex> [--signer-id <id>] [--window <pieces>]
//! 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 <dir>/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). <dir> is new or empty
//! wai-pack verify <dir> [--parent <dir>]
//! wai-pack verify <dir> [--parent <dir>] [--fabric cpu|gpu|auto:<costs.json>]
//! 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 <costs.json> [--seconds <n>]
//! 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 <dir> [--decoder dav1d|videotoolbox] [--ffmpeg <path>]
//! 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 <dir> [--primary]
//! the listing (SPEC §3.1) of every file of <dir> 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 <policy.toml> [--royalty-free-only]\n \
wai-pack plan --policy <policy.toml> --component <id> [--source <file.y4m>] [--out <plan.txt>]\n \
wai-pack pack --policy <policy.toml> --out <dir> --key <seed.hex> [--source <id>=<file>]... [--floor <dir>] [--signer-id <id>] [--royalty-free-only] [--meter none|model:<mW>] [--base-from <dir>] [--link-supplied] [--jobs <n>]\n \
wai-pack pack --policy <policy.toml> --out <dir> --key <seed.hex> [--source <id>=<file>]... [--floor <dir>] [--signer-id <id>] [--royalty-free-only] [--meter none|model:<mW>] [--base-from <dir>] [--link-supplied] [--jobs <n>] [--fabric cpu|gpu|auto:<costs.json>] [--meter fabric]\n \
wai-pack live --policy <policy.toml> --out <dir> --key <seed.hex> [--signer-id <id>] [--window <pieces>]\n \
wai-pack verify <dir> [--parent <dir>]\n \
wai-pack verify <dir> [--parent <dir>] [--fabric cpu|gpu|auto:<costs.json>]\n \
wai-pack fabric list [--fabric gpu] | fabric calibrate --out <costs.json> [--seconds <n>]\n \
wai-pack floor-decode <dir> [--decoder dav1d|videotoolbox] [--ffmpeg <path>]\n \
wai-pack listing <dir> [--primary]\n \
wai-pack sim --policy <policy.toml> --class <id> --rate <bytes/ms> --startup <ms> [--source <file.y4m>] [--rtt <us>] [--chunk <bytes>] [--enc <ms>] [--cap <layer>] [--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:<milliwatts>"))?;
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:<costs.json>` (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<std::sync::Arc<wai_pack::fabric::Fabrics>, 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:<costs.json>")),
},
};
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::Arc<std::sync::Mutex<Option<String>>>> = std::sync::OnceLock::new();
fn fabric_why() -> Option<String> {
#[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 <costs.json> [--seconds <n>]".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<u8>, Vec<u8>) {
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<u8> = (0..h * w * 3).map(|_| next()).collect();
let b: Vec<u8> = 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<dyn FnMut() + '_>) = 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::<Vec<_>>().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),

View file

@ -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<u8>], h: u32, w: u32, p: &MotionParams) -> Result<QChoice, IntPayloadError> {
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<u8>], h: u32, w: u32, p: &MotionParams, search: &mut dyn MotionSearch) -> Result<QChoice, IntPayloadError> {
let mut qs: Vec<u32> = 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<u8>], src: &[Vec<u8>], 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]

612
wai/wai-pack/src/fabric.rs Normal file
View file

@ -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> {
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> {
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<Cost>,
}
pub const COSTS_SCHEMA: &str = "wai.pack.fabric-costs/1";
impl Costs {
pub fn parse(text: &str) -> Result<Costs, String> {
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<serde_json::Value> = 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<Fabrics> = 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<Step>,
}
/// 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<GpuSide>,
/// Backends that did not open or did not qualify, and why.
refused: Vec<(String, String)>,
tally: Mutex<BTreeMap<(Step, Fabric), Tally>>,
/// Calls a fabric gave back to the CPU, and why (the first reason per step).
fallbacks: Mutex<BTreeMap<Step, (u64, String)>>,
}
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<T>(m: &Mutex<T>) -> 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<Ran> {
let t = lock(&self.tally);
let mut v: Vec<Ran> = 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<Step, (u64, String)> {
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<u8>], b: &[Vec<u8>]) -> 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<u8>, Vec<u8>) = 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<Vec<u8>> = 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<u8> {
(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<u8>, Vec<u8>);
/// 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<SearchCase> {
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<u8> { (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<u8> { (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<Vec<u8>> = [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())),
}
}

View file

@ -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<String> {
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<u64> {
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<GpuRail> {
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<u64> {
// 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<u64> = 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<GpuRail>,
start: Option<Start>,
last: Arc<Mutex<Option<Shares>>>,
/// Why the last span had no figure, where it had none.
why: Arc<Mutex<Option<String>>>,
}
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<FabricMeter> {
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<Mutex<Option<Shares>>> {
Arc::clone(&self.last)
}
/// Why the last span had no figure, readable after the meter has been moved.
pub fn why_handle(&self) -> Arc<Mutex<Option<String>>> {
Arc::clone(&self.why)
}
/// Why the last span had no figure, where it had none.
pub fn why_none(&self) -> Option<String> {
self.why.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
fn refuse(&self, why: String) -> Option<Shares> {
*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<u64> {
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<Shares> {
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<wai::energy_meter::EnergyReading> {
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
}
}

View file

@ -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/<f>/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<Decoder> {
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<Vec<Vec<u8>>, 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<Vec<RungDecode>, 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<u64> = 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<Vec<u8>, 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<u16, String> {
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<Vec<(u64, String)>, 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<String> = 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<u8> { 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::<Vec<u8>>());
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");
}
}

View file

@ -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<PackageMutation> {
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 {

View file

@ -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;

View file

@ -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;

View file

@ -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<crate::fabric::Fabrics>,
/// 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<FabricSplit>,
}
/// What a fabric meter's last span gave each fabric: `(fabric, µJ, attribution)`.
pub type FabricSplit = std::sync::Arc<dyn Fn() -> Option<Vec<(crate::fabric::Fabric, u64, &'static str)>> + 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>]) -> [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<u8>, Vec<Vec<u8>>) = 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<String, Input<'_>>, 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<String, Input<'_>>, 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<String, Input<'_>>, 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<String, Input<'_>>, 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<wai::claim_receipt::FabricRecord, String> {
use wai::claim_receipt::{FabricRecord, FabricShare, FabricStep};
let ran = f.record();
let on: std::collections::BTreeSet<crate::fabric::Fabric> = 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 })
}

View file

@ -116,8 +116,13 @@ pub fn read_strict(root: &std::path::Path) -> Result<BTreeMap<String, Vec<u8>>,
/// Verify the package in `dir`, read strictly ([`read_strict`]).
pub fn verify_dir(dir: &std::path::Path) -> Result<VerifyReport, Vec<Reason>> {
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<VerifyReport, Vec<Reason>> {
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<Reason> {
/// Verify a package given as its files, by path.
pub fn verify_files(files: &BTreeMap<String, Vec<u8>>) -> Result<VerifyReport, Vec<Reason>> {
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<String, Vec<u8>>, fabrics: &crate::fabric::Fabrics) -> Result<VerifyReport, Vec<Reason>> {
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<String, Vec<u8>>) -> Result<VerifyReport, V
for ((c, bytes), comp) in r.components.iter().zip(&index_bytes).zip(&policy.components) {
expected.insert(c.index_file());
let ix = StageIndex::parse(bytes).map_err(|e| one(reason(e.code, e.detail)))?;
let produced = verify_component(files, &policy, comp, &ix, &r.policy_digest, &mut expected, &mut listed_objects)?;
let produced = verify_component(files, &policy, comp, &ix, &r.policy_digest, &mut expected, &mut listed_objects, fabrics)?;
catalog_files.extend(produced);
}
let mut errs: Vec<Reason> = 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<String, Vec<u8>>,
policy: &Policy,
@ -390,6 +405,7 @@ fn verify_component(
policy_digest: &[u8; 32],
expected: &mut std::collections::BTreeSet<String>,
listed_objects: &mut std::collections::BTreeSet<String>,
fabrics: &crate::fabric::Fabrics,
) -> Result<BTreeMap<String, Vec<u8>>, Vec<Reason>> {
let mut errs: Vec<Reason> = 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<u8>> = 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<usize, [u8; 32]> = 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(())
}

View file

@ -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<Mutex<Option<u64>>>,
}
impl wai::energy_meter::Meter for Seen {
fn start(&mut self) {
self.inner.start();
}
fn stop(&mut self) -> Option<wai::energy_meter::EnergyReading> {
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<Fabrics>, split: impl Fn(u64) -> Vec<(Fabric, u64, &'static str)> + Send + Sync + 'static) -> Result<wai_pack::pack::PackOutput, wai_pack::pack::PackError> {
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<String, Vec<u8>>) -> 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<String, Vec<u8>> {
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<Fabrics> {
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<Vec<u8>>, 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<u8> = (0..h * w * 3).map(|_| byte()).collect();
let rf: Vec<u8> = 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::<Vec<_>>(), want.keys().collect::<Vec<_>>(), "{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<String, Vec<u8>>, 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, Vec<u8>>, String)> {
let mut out: Vec<(String, BTreeMap<String, Vec<u8>>, 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");
}
}

View file

@ -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,
})
}

View file

@ -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<String>,
/// 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<FabricRecord>,
}
/// 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<FabricStep>,
/// In the order of `fabric`, each once.
pub shares: Vec<FabricShare>,
}
/// 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::<u128>() != 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<FabricRecord, Reason> {
let fm = v.as_object().ok_or(Reason::FieldType)?;
members(fm, FABRIC_MEMBERS)?;
let rows = |k: &str, required: &[&str]| -> Result<Vec<Map<String, Value>>, 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::<Result<Vec<_>, 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::<Result<Vec<_>, 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<V> {
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<FabricStep>, shares: Vec<FabricShare>| 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<String, Value>)| 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<BracketValues>, 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<OperatingPoint> {
@ -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<u64>, j: u64| FigureHeader::new(profile, index, None, j, &class);
let m = |h: FigureHeader, b: Option<BracketValues>| {
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]

View file

@ -1365,6 +1365,7 @@ pub(super) fn vectors() -> Vec<V> {
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)
};

View file

@ -464,6 +464,25 @@ pub mod meter_report {
signer_id: &str,
previous: Option<&ClaimReceipt>,
issued_at: &str,
) -> Result<ClaimReceipt, String> {
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<u64>,
bracket: Option<BracketValues>,
build: BuildRecord,
power_reader: &str,
fabrics: Option<crate::claim_receipt::FabricRecord>,
signer: &SigningKey,
signer_id: &str,
previous: Option<&ClaimReceipt>,
issued_at: &str,
) -> Result<ClaimReceipt, String> {
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())),

View file

@ -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<u8>], h: u32, w: u32, p: &EncParams) -> Result<Encoded, IntPayloadError> {
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<u8>], h: u32, w: u32, p: &EncParams, search: &mut dyn MotionSearch) -> Result<Encoded, IntPayloadError> {
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<u8>], h: u32, w: u32, p: &EncParams) -> Result<Encod
for (f, src) in frames.iter().enumerate() {
if f > 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<u8>)>,
nudge: Option<usize>,
short: bool,
recon: &'a [Vec<u8>],
}
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)];

View file

@ -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,
})
}

View file

@ -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")));