WAI across fabrics: each step on the fabric that does it with the least measured energy (packaging §11.6) #438
48 changed files with 6579 additions and 55 deletions
40
.github/workflows/wai-pack.yml
vendored
40
.github/workflows/wai-pack.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
430
docs/research/wai-cross-fabric.md
Normal file
430
docs/research/wai-cross-fabric.md
Normal 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).
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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")]
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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
1063
wai/wai-pack-gpu/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
34
wai/wai-pack-gpu/Cargo.toml
Normal file
34
wai/wai-pack-gpu/Cargo.toml
Normal 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
502
wai/wai-pack-gpu/src/lib.rs
Normal 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, ¶ms, src, rf, 8 * nb as u64, (nbx as u32, nby as u32))?;
|
||||
Ok(out.as_chunks::<8>().0.iter().map(|c| (i32::from_le_bytes([c[0], c[1], c[2], c[3]]) as i16, i32::from_le_bytes([c[4], c[5], c[6], c[7]]) as i16)).collect())
|
||||
}
|
||||
|
||||
/// `Σ (a_i − b_i)²` over two byte strings of one length (packaging §6.6), in partial
|
||||
/// sums of at most 4,096 bytes added here in 64 bits.
|
||||
pub fn sse(&self, a: &[u8], b: &[u8]) -> Result<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, ¶ms, a, b, 4 * threads, (gx, gy))?;
|
||||
Ok(out.as_chunks::<4>().0.iter().map(|c| u32::from_le_bytes(*c) as u64).sum())
|
||||
}
|
||||
|
||||
/// BLAKE3 (the default hash mode) of each message: every chunk compressed on the
|
||||
/// device, the parent nodes of a message of several chunks merged here by the
|
||||
/// reference crate's subtree functions, in the tree the specification fixes.
|
||||
pub fn blake3_many(&self, msgs: &[&[u8]]) -> Result<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, ¶ms, &data, &job_bytes, 32 * n, (gx, gy))?;
|
||||
let cv = |i: usize| -> [u8; 32] { out[32 * i..32 * i + 32].try_into().expect("32 bytes") };
|
||||
Ok(spans.iter().map(|&(first, count)| if count == 1 { cv(first) } else { merge((first..first + count).map(cv).collect()) }).collect())
|
||||
}
|
||||
}
|
||||
|
||||
/// The root of a message of two or more chunks from its chunks' chaining values: each
|
||||
/// level pairs adjacent nodes and carries an odd last one up, which is the tree BLAKE3
|
||||
/// fixes (the left subtree holds the largest power of two of chunks below the count), and
|
||||
/// the last merge is the root.
|
||||
fn merge(mut level: Vec<[u8; 32]>) -> [u8; 32] {
|
||||
use blake3::hazmat::{merge_subtrees_non_root, merge_subtrees_root, Mode};
|
||||
debug_assert!(level.len() >= 2);
|
||||
while level.len() > 2 {
|
||||
let mut next = Vec::with_capacity(level.len().div_ceil(2));
|
||||
for pair in level.chunks(2) {
|
||||
next.push(if pair.len() == 2 { merge_subtrees_non_root(&pair[0], &pair[1], Mode::Hash) } else { pair[0] });
|
||||
}
|
||||
level = next;
|
||||
}
|
||||
*merge_subtrees_root(&level[0], &level[1], Mode::Hash).as_bytes()
|
||||
}
|
||||
145
wai/wai-pack-gpu/src/wgsl/blake3.wgsl
Normal file
145
wai/wai-pack-gpu/src/wgsl/blake3.wgsl
Normal 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];
|
||||
}
|
||||
}
|
||||
131
wai/wai-pack-gpu/src/wgsl/motion.wgsl
Normal file
131
wai/wai-pack-gpu/src/wgsl/motion.wgsl
Normal 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;
|
||||
}
|
||||
}
|
||||
41
wai/wai-pack-gpu/src/wgsl/sse.wgsl
Normal file
41
wai/wai-pack-gpu/src/wgsl/sse.wgsl
Normal 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;
|
||||
}
|
||||
47
wai/wai-pack-gpu/tests/concurrent.rs
Normal file
47
wai/wai-pack-gpu/tests/concurrent.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
79
wai/wai-pack-gpu/tests/kernels.rs
Normal file
79
wai/wai-pack-gpu/tests/kernels.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
86
wai/wai-pack-gpu/tests/mutants.rs
Normal file
86
wai/wai-pack-gpu/tests/mutants.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
170
wai/wai-pack-gpu/tests/support/mod.rs
Normal file
170
wai/wai-pack-gpu/tests/support/mod.rs
Normal 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
888
wai/wai-pack/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
612
wai/wai-pack/src/fabric.rs
Normal 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())),
|
||||
}
|
||||
}
|
||||
456
wai/wai-pack/src/fabric_meter.rs
Normal file
456
wai/wai-pack/src/fabric_meter.rs
Normal 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
|
||||
}
|
||||
}
|
||||
268
wai/wai-pack/src/floor_decode.rs
Normal file
268
wai/wai-pack/src/floor_decode.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
314
wai/wai-pack/tests/fabric.rs
Normal file
314
wai/wai-pack/tests/fabric.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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")));
|
||||
|
|
|
|||
Loading…
Reference in a new issue