diff --git a/.github/workflows/wai-quantum-webgpu.yml b/.github/workflows/wai-quantum-webgpu.yml index 981d3c7f8..dc3bf3178 100644 --- a/.github/workflows/wai-quantum-webgpu.yml +++ b/.github/workflows/wai-quantum-webgpu.yml @@ -23,6 +23,12 @@ name: wai-quantum WebGPU member # the four pinned digests, reproduce every vector and refuse every reject file with # its class. It is not a required status until it has been green three runs in a row. # +# The meter (wai/tools/quantum-meter) is wai-quantum's RunMeter over a process's own +# CPU and GPU energy counters. On the Metal host, where those counters exist, it meters +# a member's runs and the CPU reference's over windows they resolve, and its test must +# print what it read; on the software Vulkan host, where they do not, every window must +# come out unmetered. +# # There is no windows job while the windows runner is offline: a job queued on an # offline runner never settles. Add it back from the comment at the end when the # runner has survived a reboot. @@ -38,6 +44,11 @@ on: - 'wai/wai-quantum/**' - 'wai/quantum-conformance/**' - 'wai/tools/quantum-wgsl-check/**' + # The meter, and the counters it reads. + - 'wai/tools/quantum-meter/**' + - 'jouleclaw/jouleclaw-rs/Cargo.toml' + - 'jouleclaw/jouleclaw-rs/crates/jouleclaw-core/**' + - 'jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/**' - 'wai/wai-rs/src/bin/wai_quantum_vectors.rs' # The GPU jobs build wai-rs with the forwarded feature, --locked. - 'wai/wai-rs/Cargo.toml' @@ -57,6 +68,11 @@ on: - 'wai/wai-quantum/**' - 'wai/quantum-conformance/**' - 'wai/tools/quantum-wgsl-check/**' + # The meter, and the counters it reads. + - 'wai/tools/quantum-meter/**' + - 'jouleclaw/jouleclaw-rs/Cargo.toml' + - 'jouleclaw/jouleclaw-rs/crates/jouleclaw-core/**' + - 'jouleclaw/jouleclaw-rs/crates/jouleclaw-energy/**' - 'wai/wai-rs/src/bin/wai_quantum_vectors.rs' # The GPU jobs build wai-rs with the forwarded feature, --locked. - 'wai/wai-rs/Cargo.toml' @@ -175,6 +191,18 @@ jobs: - name: The member's tests shell: bash run: scripts/wai-quantum-webgpu-member.sh + # No per-process energy counter exists here: the meter must find none, and a + # run of the member through it must be unmetered. + - name: The meter, where its counters do not exist + shell: bash + working-directory: wai/tools/quantum-meter + run: | + set -euo pipefail + cargo test --locked -- --nocapture 2>&1 | tee "$RUNNER_TEMP/meter.log" + grep -q '^test a_members_runs_are_metered_over_a_window_the_counters_resolve \.\.\. ok$' "$RUNNER_TEMP/meter.log" \ + || { echo "::error::the meter's device test did not run"; exit 1; } + grep -q '^quantum-meter: the counters are not live here' "$RUNNER_TEMP/meter.log" \ + || { echo "::error::the meter's device test found no adapter, so it checked nothing"; exit 1; } member-metal: name: member · Metal (macos) @@ -195,6 +223,23 @@ jobs: shell: bash working-directory: wai/wai-rs run: nice -n 10 cargo run --locked --no-default-features --features quantum_fabric_webgpu --bin wai_quantum_vectors -- verify ../quantum-conformance --webgpu + # The counters exist here. The device test prints a `quantum-meter hardware:` line + # for each window it read: a figure, or why another process's GPU use left it + # none. The tool then calibrates both members on one circuit and, where both + # windows earn a figure, places it. + - name: The meter on this process's counters + shell: bash + working-directory: wai/tools/quantum-meter + run: | + set -euo pipefail + nice -n 10 cargo test --locked --release -- --nocapture 2>&1 | tee "$RUNNER_TEMP/meter.log" + grep -q '^test a_members_runs_are_metered_over_a_window_the_counters_resolve \.\.\. ok$' "$RUNNER_TEMP/meter.log" \ + || { echo "::error::the meter's device test did not run"; exit 1; } + grep -q '^quantum-meter hardware: ' "$RUNNER_TEMP/meter.log" \ + || { echo "::error::the meter read no window: its counters are not live on this host"; exit 1; } + nice -n 10 cargo run --locked --release -- --counter-ppm 50000 2>&1 | tee "$RUNNER_TEMP/meter-tool.log" + grep -q '^quantum-meter: local/cpu-' "$RUNNER_TEMP/meter-tool.log" \ + || { echo "::error::the tool ran no window on the CPU reference"; exit 1; } member-hw: name: member · discrete GPU, Vulkan (jetson-hub) diff --git a/wai/tools/quantum-meter/Cargo.lock b/wai/tools/quantum-meter/Cargo.lock new file mode 100644 index 000000000..f4645f51d --- /dev/null +++ b/wai/tools/quantum-meter/Cargo.lock @@ -0,0 +1,1411 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake3" +version = "1.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e454fc11f76977dc803893aff6304ed33d6a26efae8696573bea74baa27ae" +dependencies = [ + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.1", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "zeroize", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags", + "objc2", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror", + "windows", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jouleclaw-core" +version = "0.1.0" + +[[package]] +name = "jouleclaw-energy" +version = "0.1.0" +dependencies = [ + "jouleclaw-core", + "libc", + "thiserror", +] + +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "rustc-hash", + "spirv", + "thiserror", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash", + "thiserror", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "objc2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags", + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "ordered-float" +version = "5.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" +dependencies = [ + "num-traits", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quantum-meter" +version = "0.0.0" +dependencies = [ + "jouleclaw-energy", + "wai-quantum", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2", + "objc2-core-foundation", + "objc2-foundation", + "objc2-quartz-core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +dependencies = [ + "foldhash", + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "tokio" +version = "1.53.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" +dependencies = [ + "pin-project-lite", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wai-quantum" +version = "0.4.3" +dependencies = [ + "blake3", + "ed25519-dalek", + "serde_json", + "wgpu", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +dependencies = [ + "js-sys", + "tokio", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror", + "wgpu-core-deps-apple", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags", + "block2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "gpu-allocator", + "hashbrown 0.17.1", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-metal", + "objc2-quartz-core", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror", + "wgpu-naga-bridge", + "wgpu-types", + "windows", + "windows-core", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/wai/tools/quantum-meter/Cargo.toml b/wai/tools/quantum-meter/Cargo.toml new file mode 100644 index 000000000..29b7e7217 --- /dev/null +++ b/wai/tools/quantum-meter/Cargo.toml @@ -0,0 +1,20 @@ +# A meter for wai-quantum's fabric: its `RunMeter` over this process's own CPU and GPU +# energy counters, a calibration over windows long enough for those counters to resolve, +# and a tool that calibrates the CPU reference and a WebGPU member with it. +# +# Deliberately a SEPARATE crate with its own lockfile: a meter belongs with the energy +# source it reads, so the counters are a dependency here only, and never compile in +# wai-quantum's own builds or tests. +[package] +name = "quantum-meter" +version = "0.0.0" +edition = "2024" +rust-version = "1.97" +license = "Apache-2.0" +publish = false + +[dependencies] +wai-quantum = { path = "../../wai-quantum", default-features = false, features = ["quantum_fabric_webgpu"] } +# `ioreport` is the per-process counters' feature. They exist on macOS on Apple Silicon; +# elsewhere the crate builds, the counters are not live, and every run is unmetered. +jouleclaw-energy = { path = "../../../jouleclaw/jouleclaw-rs/crates/jouleclaw-energy", default-features = false, features = ["ioreport"] } diff --git a/wai/tools/quantum-meter/src/lib.rs b/wai/tools/quantum-meter/src/lib.rs new file mode 100644 index 000000000..85c4863f1 --- /dev/null +++ b/wai/tools/quantum-meter/src/lib.rs @@ -0,0 +1,496 @@ +//! [`RunMeter`] over this process's own energy counters, and a calibration +//! over windows those counters can resolve. +//! +//! **The source.** `jouleclaw-energy`'s span meter: this process's CPU energy +//! (the kernel's per-process count) plus its GPU energy (the GPU rail, for a +//! span over which this process was all but the GPU's only user, or an exact +//! zero where it spent no GPU time). Both are readings of counters the +//! hardware keeps about itself, so a figure is labelled +//! [`EnergyClass::OnChipCounter`]. The domain is this process's CPU cores and +//! the GPU: what a run of a fabric member in this process spends on both, and +//! so the same domain for the CPU reference (GPU part zero) and for an +//! accelerator member (the device's work, and the host's submissions, waits +//! and copies). Figures from this meter on two members can be compared. +//! Memory, and any other rail, is outside it. +//! +//! **When there is no figure.** A window is unmetered, never estimated, when: +//! +//! - the counters are not live in this process ([`ProcessRunMeter::is_live`]): +//! off macOS on Apple Silicon, always; +//! - the GPU counter put no figure on the span: another process used the GPU +//! over it, a GPU client came or went, the rail was not current or did not +//! advance. The CPU figure alone would leave the GPU's energy out, which is +//! not zero; +//! - the window is shorter than a counter can resolve ([`least_window`]): one +//! second for a span with GPU time, a millisecond for one without; +//! - the figure is zero, or its label does not reach wai-quantum's resolution +//! floor (`check_label`, applied by the fabric to every reading). +//! +//! **The declared uncertainty** ([`label`]) is the sum of three terms, each +//! relative to the figure: +//! +//! - what the caller declares for the counters themselves +//! ([`Declared::counter_ppm`]). The platform publishes no accuracy for +//! either counter, so this is the operator's own evaluation and this crate +//! supplies no default; +//! - the ends of the window. A CPU thread's count is folded when the thread +//! leaves its core, at the latest when its quantum ends, so a read can miss +//! up to a quantum (10 ms) of one thread at each end ([`CPU_EDGE`]). A GPU +//! snapshot reads the rail on a republish, so it can be off by about one +//! republish interval (30 ms) at each end ([`GPU_EDGE`]). Either is taken +//! as that much of the window at the window's mean power: `edge / window`; +//! - the GPU's other users, for a span with GPU time: the rail is apportioned +//! by GPU time, and the counter gives a figure only where this process held +//! at least [`SOLE_USER_PERCENT`] of it, so the rest is at most +//! `100 − SOLE_USER_PERCENT` percent. +//! +//! So a window's length is part of its label: three seconds of GPU work +//! declares 3% for its ends and its share, a tenth of a second would declare +//! 61%, and is refused before that as shorter than the rail resolves. +//! +//! **Calibrating.** The fabric's own calibrations open one window per corpus +//! circuit, and a corpus circuit runs for microseconds: every such window is +//! refused here. [`span_of_runs`] is the calibration this meter supports: one +//! window around as many runs of a circuit as fill a stated time, giving the +//! runs' total operations and one reading, which +//! [`EnergyCalibration::add`](wai_quantum::quantum_fabric::EnergyCalibration::add) +//! takes as it takes a single run's. + +use jouleclaw_energy::process_gpu::SOLE_USER_PERCENT; +use jouleclaw_energy::span::{ProcessSpanMeter, SpanSource, SpanStart, SpanUses}; +use std::time::{Duration, Instant}; +use wai_quantum::quantum_energy::{CounterFiltering, EnergyClass, Uncertainty}; +use wai_quantum::quantum_fabric::RunMeter; + +/// What a CPU reading can miss over the two ends of a window: up to one +/// scheduler quantum (10 ms) of one thread at each. +pub const CPU_EDGE: Duration = Duration::from_millis(20); + +/// What a GPU reading can be off by over the two ends of a window: about one +/// republish interval of the rail (30 ms) at each. +pub const GPU_EDGE: Duration = Duration::from_millis(60); + +/// The least window for a span with no GPU time: the CPU counter's own. It +/// moves about every half millisecond under load, and a window under a +/// millisecond may straddle no update at all. +pub const MIN_CPU_WINDOW: Duration = Duration::from_millis(1); + +/// The least window for a span with GPU time: the GPU counter's own, where a +/// republish interval at each end is a few percent of the figure. +pub const MIN_GPU_WINDOW: Duration = Duration::from_secs(1); + +/// How long the GPU counter's liveness probe load runs each time the probe +/// calls it ([`probe_load`]). The probe needs a few tens of milliseconds of +/// this process's GPU work between its samples, and one run of a member can +/// be far less: an 18-qubit run took about 2 ms on an M4 Max, and a host with +/// a larger GPU takes less. A single run as the load passed the probe on one +/// Mac and failed it, `Frozen`, on another whose GPU counter is live. +pub const PROBE_LOAD: Duration = Duration::from_millis(50); + +/// A load for [`ProcessRunMeter::new`] from one run of a member: `run` +/// repeated until [`PROBE_LOAD`] has passed, once at least, each time the +/// probe calls it. `run` must wait for its GPU work, as a member's run does. +pub fn probe_load(mut run: impl FnMut()) -> impl FnMut() { + move || { + let began = Instant::now(); + loop { + run(); + if began.elapsed() >= PROBE_LOAD { + break; + } + } + } +} + +/// What the operator declares about the counters. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Declared { + /// The standard relative uncertainty of the counters' own readings, in + /// parts per million: the operator's evaluation, since the platform + /// publishes none. Not zero. + pub counter_ppm: u32, +} + +/// One window as the counters read it. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Span { + /// The window, from the start readings to the call that ended it. + pub wall: Duration, + /// This process's CPU energy over it, µJ. + pub cpu_uj: u64, + /// This process's GPU energy over it, µJ: zero where it spent no GPU time. + pub gpu_uj: u64, + /// This process's GPU time over it, ns. + pub gpu_ns: u64, +} + +impl Span { + /// The figure: CPU and GPU together, µJ. + pub fn joules_micro(&self) -> Option { + self.cpu_uj.checked_add(self.gpu_uj) + } +} + +/// Why a window has no figure. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Refused { + /// This process's counters are not live. + NotLive(String), + /// The GPU counter put no figure on the span, and why. + NoGpuFigure(String), + /// The CPU counter put no figure on the span, and why. + NoCpuFigure(String), + /// A counter failed. + Counter(String), + /// The window is shorter than a counter resolves. + TooShort { window: Duration, least: Duration }, + /// The figure is zero. + Zero, + /// The declared uncertainty is zero, or with the window's terms no longer + /// fits its field. + Uncertainty, +} + +impl std::fmt::Display for Refused { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Refused::NotLive(why) => write!(f, "this process's counters are not live: {why}"), + Refused::NoGpuFigure(why) => write!(f, "the GPU counter put no figure on the window: {why}"), + Refused::NoCpuFigure(why) => write!(f, "the CPU counter put no figure on the window: {why}"), + Refused::Counter(why) => write!(f, "a counter failed: {why}"), + Refused::TooShort { window, least } => write!(f, "a window of {window:?} is shorter than the {least:?} its counters resolve"), + Refused::Zero => write!(f, "the window's figure is zero"), + Refused::Uncertainty => write!(f, "no uncertainty can be declared for the window"), + } + } +} + +/// The least window a span is labelled over: [`MIN_GPU_WINDOW`] with GPU +/// time, [`MIN_CPU_WINDOW`] without. +pub fn least_window(span: &Span) -> Duration { + if span.gpu_ns > 0 { MIN_GPU_WINDOW } else { MIN_CPU_WINDOW } +} + +/// `part / whole` in parts per million, rounded up. +fn ppm_of(part: Duration, whole: Duration) -> Option { + let whole = whole.as_nanos(); + if whole == 0 { + return None; + } + u32::try_from((part.as_nanos() * 1_000_000).div_ceil(whole)).ok() +} + +/// A span's figure and its label, or why it has none: the rule of the +/// [module docs](self), apart from the counters, so it is tested on spans +/// written by hand. +pub fn label(span: &Span, declared: Declared) -> Result<(u64, EnergyClass), Refused> { + let least = least_window(span); + if span.wall < least { + return Err(Refused::TooShort { window: span.wall, least }); + } + let joules_micro = span.joules_micro().ok_or_else(|| Refused::Counter("the CPU and GPU figures together overflow".to_string()))?; + if joules_micro == 0 { + return Err(Refused::Zero); + } + if declared.counter_ppm == 0 { + return Err(Refused::Uncertainty); + } + let with_gpu = span.gpu_ns > 0; + let ends = ppm_of(if with_gpu { GPU_EDGE } else { CPU_EDGE }, span.wall).ok_or(Refused::Uncertainty)?; + let share = if with_gpu { (100 - SOLE_USER_PERCENT as u32) * 10_000 } else { 0 }; + let relative_ppm = declared.counter_ppm.checked_add(ends).and_then(|p| p.checked_add(share)).ok_or(Refused::Uncertainty)?; + let window_us = u64::try_from(span.wall.as_micros()).map_err(|_| Refused::Uncertainty)?; + // Whether the platform filters these counters is not something a process + // can read, so the only honest value is that it is unknown. + Ok((joules_micro, EnergyClass::OnChipCounter { uncertainty: Uncertainty { relative_ppm, window_us }, filtering: CounterFiltering::Unknown })) +} + +/// [`RunMeter`] over this process's CPU and GPU energy counters: see the +/// [module docs](self). +pub struct ProcessRunMeter { + meter: ProcessSpanMeter, + declared: Declared, + open: Option, + last: Option>, +} + +impl ProcessRunMeter { + /// The meter. The first one in a process probes the counters: about + /// 0.3 s of this thread's CPU, and `gpu_load`, which must put a few tens + /// of milliseconds of work on the GPU from this process and wait for it + /// each time it is called: [`probe_load`] around a run of the member to + /// be metered. The answer is kept for the life of the process, so a first + /// meter whose `gpu_load` does too little GPU work leaves every later one + /// without a GPU counter. + pub fn new(gpu_load: impl FnMut(), declared: Declared) -> Self { + ProcessRunMeter { meter: ProcessSpanMeter::new(gpu_load), declared, open: None, last: None } + } + + /// Whether both counters are live in this process. When they are not, + /// [`RunMeter::start`] fails and every run is unmetered. + pub fn is_live(&self) -> bool { + self.meter.is_metered() + } + + /// The GPU counter's own account of whether it is live. + pub fn gpu_liveness(&self) -> String { + format!("{:?}", self.meter.gpu().liveness()) + } + + /// The last window closed: its readings, or why it had none. A span that + /// was read can still have no figure ([`label`]); `stop`'s return says. + pub fn last(&self) -> Option<&Result> { + self.last.as_ref() + } +} + +impl RunMeter for ProcessRunMeter { + fn start(&mut self) -> Result<(), String> { + if !self.is_live() { + let why = Refused::NotLive(format!("GPU counter {}", self.gpu_liveness())); + let text = why.to_string(); + self.last = Some(Err(why)); + return Err(text); + } + self.open = Some(self.meter.start()); + Ok(()) + } + + fn stop(&mut self) -> Result, String> { + let start = self.open.take().ok_or_else(|| "the meter was stopped without a start".to_string())?; + // The window is the work's own: the end readings can wait up to a + // second for the GPU rail to republish, and that wait is not the run. + let wall = start.at().elapsed(); + let span = match self.meter.finish(start, SpanUses::CpuGpu) { + Ok(energy) => match energy.source { + SpanSource::Process { cpu_uj, gpu } => Ok(Span { wall, cpu_uj, gpu_uj: gpu.uj, gpu_ns: gpu.self_ns }), + SpanSource::CpuOnly { why, .. } => Err(Refused::NoGpuFigure(why)), + SpanSource::Fallback { why, .. } => Err(Refused::NoCpuFigure(why)), + }, + Err(e) => Err(Refused::Counter(e.to_string())), + }; + let reading = match &span { + Ok(s) => label(s, self.declared).ok(), + Err(_) => None, + }; + self.last = Some(span); + Ok(reading) + } +} + +/// One window of a meter around repeated runs. +#[derive(Clone, Debug, PartialEq)] +pub struct Runs { + /// How many runs the window held. + pub runs: u64, + /// The operations they performed, together. + pub ops: u64, + /// The window's reading: zero and `None` when the meter gave none. + pub joules_micro: u64, + pub class: Option, + /// From just after the meter started to just before it was stopped. + pub wall: Duration, +} + +/// One window of `meter` around runs of `run`, repeated until `at_least` has +/// passed (once at least). `run` performs one run and returns the operations +/// it performed (`pair_updates` of its circuit). The meter is started before +/// the first run and stopped after the last; a run that fails stops it and +/// discards the window. +/// +/// The reading is as the meter gave it. It is the caller's to hold to +/// `check_label` before use, which +/// [`EnergyCalibration::add`](wai_quantum::quantum_fabric::EnergyCalibration::add) +/// does. +pub fn span_of_runs(meter: &mut dyn RunMeter, at_least: Duration, mut run: impl FnMut() -> Result) -> Result { + meter.start()?; + let began = Instant::now(); + let (mut runs, mut ops) = (0u64, 0u64); + loop { + match run() { + Ok(n) => { + runs += 1; + ops = ops.saturating_add(n); + } + Err(e) => { + let _ = meter.stop(); + return Err(e); + } + } + if began.elapsed() >= at_least { + break; + } + } + let wall = began.elapsed(); + let (joules_micro, class) = match meter.stop()? { + Some((joules_micro, class)) => (joules_micro, Some(class)), + None => (0, None), + }; + Ok(Runs { runs, ops, joules_micro, class, wall }) +} + +#[cfg(test)] +mod tests { + use super::*; + use wai_quantum::quantum_energy::check_label; + use wai_quantum::quantum_fabric::EnergyCalibration; + + const DECLARED: Declared = Declared { counter_ppm: 50_000 }; + + fn uncertainty(class: &EnergyClass) -> Uncertainty { + class.uncertainty().expect("an on-chip counter declares an uncertainty") + } + + #[test] + fn a_cpu_span_is_labelled_with_the_counter_and_its_ends() { + let span = Span { wall: Duration::from_secs(3), cpu_uj: 4_200_000, gpu_uj: 0, gpu_ns: 0 }; + let (joules_micro, class) = label(&span, DECLARED).expect("a figure"); + assert_eq!(joules_micro, 4_200_000); + assert!(matches!(class, EnergyClass::OnChipCounter { filtering: CounterFiltering::Unknown, .. }), "{class:?}"); + // 50 000 declared, and 20 ms of 3 s: 6 666.6…, rounded up. + assert_eq!(uncertainty(&class), Uncertainty { relative_ppm: 50_000 + 6_667, window_us: 3_000_000 }); + assert_eq!(check_label(joules_micro, &class), Ok(())); + } + + #[test] + fn a_gpu_span_adds_the_rails_ends_and_the_other_users_share() { + let span = Span { wall: Duration::from_secs(3), cpu_uj: 1_000_000, gpu_uj: 9_000_000, gpu_ns: 2_500_000_000 }; + let (joules_micro, class) = label(&span, DECLARED).expect("a figure"); + assert_eq!(joules_micro, 10_000_000, "CPU and GPU together"); + // 50 000 declared, 60 ms of 3 s, and the 1% other clients may hold. + assert_eq!(uncertainty(&class), Uncertainty { relative_ppm: 50_000 + 20_000 + 10_000, window_us: 3_000_000 }); + } + + #[test] + fn a_window_shorter_than_its_counters_resolve_has_no_figure() { + let gpu = Span { wall: Duration::from_millis(999), cpu_uj: 100_000, gpu_uj: 900_000, gpu_ns: 800_000_000 }; + assert_eq!(label(&gpu, DECLARED), Err(Refused::TooShort { window: Duration::from_millis(999), least: MIN_GPU_WINDOW })); + assert!(label(&Span { wall: MIN_GPU_WINDOW, ..gpu }, DECLARED).is_ok(), "the least window itself is labelled"); + // The same window with no GPU time is long enough for the CPU counter. + let cpu = Span { gpu_uj: 0, gpu_ns: 0, ..gpu }; + assert!(label(&cpu, DECLARED).is_ok()); + let brief = Span { wall: Duration::from_micros(999), ..cpu }; + assert_eq!(label(&brief, DECLARED), Err(Refused::TooShort { window: Duration::from_micros(999), least: MIN_CPU_WINDOW })); + } + + #[test] + fn the_shorter_the_window_the_wider_the_label() { + let at = |ms| { + let span = Span { wall: Duration::from_millis(ms), cpu_uj: 1_000_000, gpu_uj: 1_000_000, gpu_ns: 1 }; + uncertainty(&label(&span, DECLARED).expect("a figure").1).relative_ppm + }; + assert_eq!(at(1_000), 50_000 + 60_000 + 10_000); + assert_eq!(at(10_000), 50_000 + 6_000 + 10_000); + assert!(at(1_000) > at(2_000) && at(2_000) > at(10_000)); + } + + #[test] + fn a_zero_figure_or_a_zero_declaration_has_no_label() { + let span = Span { wall: Duration::from_secs(2), cpu_uj: 0, gpu_uj: 0, gpu_ns: 0 }; + assert_eq!(label(&span, DECLARED), Err(Refused::Zero)); + let spent = Span { cpu_uj: 4, ..span }; + assert_eq!(label(&spent, Declared { counter_ppm: 0 }), Err(Refused::Uncertainty)); + // A figure is labelled here and can still be under the fabric's + // resolution floor, which the fabric applies to every reading: 4 µJ at + // 6% (5% declared, 1% for the ends of two seconds) is 240 000 µJ·ppm, + // under 288 676; 5 µJ reaches it. + let (joules_micro, class) = label(&spent, DECLARED).expect("labelled"); + assert_eq!(uncertainty(&class).relative_ppm, 60_000); + assert!(check_label(joules_micro, &class).is_err(), "4 µJ at 6% is below the floor"); + let (joules_micro, class) = label(&Span { cpu_uj: 5, ..span }, DECLARED).expect("labelled"); + assert_eq!(check_label(joules_micro, &class), Ok(()), "5 µJ at 6% reaches it"); + } + + #[test] + fn an_uncertainty_that_does_not_fit_its_field_is_refused() { + let span = Span { wall: Duration::from_secs(2), cpu_uj: 1_000, gpu_uj: 0, gpu_ns: 0 }; + assert_eq!(label(&span, Declared { counter_ppm: u32::MAX }), Err(Refused::Uncertainty)); + assert_eq!(Span { cpu_uj: u64::MAX, gpu_uj: 1, ..span }.joules_micro(), None); + assert!(matches!(label(&Span { cpu_uj: u64::MAX, gpu_uj: 1, ..span }, DECLARED), Err(Refused::Counter(_)))); + } + + /// A meter that reads what it is told to, and counts its calls. + struct Scripted { + reading: Option<(u64, EnergyClass)>, + fail_start: bool, + starts: u32, + stops: u32, + } + + impl Scripted { + fn giving(reading: Option<(u64, EnergyClass)>) -> Scripted { + Scripted { reading, fail_start: false, starts: 0, stops: 0 } + } + } + + impl RunMeter for Scripted { + fn start(&mut self) -> Result<(), String> { + self.starts += 1; + if self.fail_start { Err("no counter".to_string()) } else { Ok(()) } + } + + fn stop(&mut self) -> Result, String> { + self.stops += 1; + Ok(self.reading.clone()) + } + } + + fn a_class() -> EnergyClass { + label(&Span { wall: Duration::from_secs(3), cpu_uj: 1, gpu_uj: 0, gpu_ns: 0 }, DECLARED).expect("a figure").1 + } + + #[test] + fn one_window_holds_every_run_and_their_operations() { + let mut meter = Scripted::giving(Some((3_000_000, a_class()))); + let mut calls = 0u64; + let runs = span_of_runs(&mut meter, Duration::from_millis(30), || { + calls += 1; + std::thread::sleep(Duration::from_millis(4)); + Ok(1_000) + }) + .expect("a window"); + assert_eq!((meter.starts, meter.stops), (1, 1), "one window, whatever the runs"); + assert!(runs.runs >= 2 && runs.runs == calls, "{runs:?}"); + assert_eq!(runs.ops, 1_000 * runs.runs); + assert!(runs.wall >= Duration::from_millis(30), "{runs:?}"); + assert_eq!((runs.joules_micro, runs.class.clone()), (3_000_000, Some(a_class()))); + // The window is one run of a calibration, over all its operations. + let mut calibration = EnergyCalibration::new(); + calibration.add(runs.ops, runs.joules_micro, runs.class.as_ref()); + let (nanojoules, _) = calibration.finish().expect("a figure"); + assert_eq!(nanojoules, (3_000_000u64 * 1_000).div_ceil(runs.ops)); + } + + #[test] + fn a_window_runs_once_at_least_and_an_unmetered_one_reads_nothing() { + let mut meter = Scripted::giving(None); + let runs = span_of_runs(&mut meter, Duration::ZERO, || Ok(7)).expect("a window"); + assert_eq!((runs.runs, runs.ops, runs.joules_micro, runs.class.clone()), (1, 7, 0, None)); + // It leaves a calibration without a figure, as any unmetered run does. + let mut calibration = EnergyCalibration::new(); + calibration.add(runs.ops, runs.joules_micro, runs.class.as_ref()); + assert_eq!(calibration.finish(), None); + } + + #[test] + fn a_run_that_fails_closes_the_window_and_a_meter_that_fails_to_start_runs_nothing() { + let mut meter = Scripted::giving(Some((1, a_class()))); + let mut calls = 0; + let failed = span_of_runs(&mut meter, Duration::from_secs(60), || { + calls += 1; + if calls == 2 { Err("the device was lost".to_string()) } else { Ok(1) } + }); + assert_eq!(failed, Err("the device was lost".to_string())); + assert_eq!((meter.starts, meter.stops), (1, 1), "the window is closed, its reading discarded"); + + let mut dead = Scripted { fail_start: true, ..Scripted::giving(None) }; + let mut ran = false; + let none = span_of_runs(&mut dead, Duration::ZERO, || { + ran = true; + Ok(1) + }); + assert_eq!(none, Err("no counter".to_string())); + assert!(!ran, "no run without a window"); + assert_eq!((dead.starts, dead.stops), (1, 0)); + } +} diff --git a/wai/tools/quantum-meter/src/main.rs b/wai/tools/quantum-meter/src/main.rs new file mode 100644 index 000000000..244e6bd35 --- /dev/null +++ b/wai/tools/quantum-meter/src/main.rs @@ -0,0 +1,215 @@ +//! Calibrates the fabric's CPU reference and a WebGPU member with this +//! process's own energy counters, and places a circuit by the figures. +//! +//! quantum-meter --counter-ppm [--qubits ] [--seconds ] [--gateset 1|2] [--expect ] +//! +//! `--counter-ppm` is the operator's evaluation of the counters' own relative +//! uncertainty, in parts per million. The platform publishes none, so there +//! is no default. +//! +//! One circuit on `--qubits` qubits (20) is run by each member, again and +//! again inside one window of `--seconds` seconds (3) at least: a window long +//! enough for the counters to resolve, where a single run is not. Each +//! member's line gives the window's readings and the figure they earn, in +//! nanojoules per operation (one pair of amplitudes under one gate), or says +//! why the window has none. With a figure on both, the last line is the member +//! the fabric places that circuit on. +//! +//! Exit 0 when it ran, with or without figures; 1 when a member could not be +//! found or run; 2 for a bad argument. + +use quantum_meter::{Declared, ProcessRunMeter, Refused, Runs, Span, label, probe_load, span_of_runs}; +use std::process::ExitCode; +use std::time::Duration; +use wai_quantum::quantum::{BaseGate, Circuit, Gate, Gateset}; +use wai_quantum::quantum_energy::EnergyClass; +use wai_quantum::quantum_fabric::{EnergyCalibration, FABRIC_DEFAULT, Member, Parity, REFERENCE, apply_kernel, detect_local, pair_updates, place_for}; +use wai_quantum::quantum_fabric_webgpu::blocking; + +struct Args { + declared: Declared, + qubits: u8, + at_least: Duration, + gateset: Gateset, + expect: Option, +} + +fn usage() -> ExitCode { + eprintln!("usage: quantum-meter --counter-ppm [--qubits ] [--seconds ] [--gateset 1|2] [--expect ]"); + ExitCode::from(2) +} + +fn args() -> Option { + let (mut ppm, mut qubits, mut seconds, mut gateset, mut expect) = (None, 20u8, 3u64, Gateset::V2, None); + let mut it = std::env::args().skip(1); + while let Some(flag) = it.next() { + let value = it.next()?; + match flag.as_str() { + "--counter-ppm" => ppm = Some(value.parse::().ok().filter(|&p| p > 0)?), + "--qubits" => qubits = value.parse::().ok().filter(|n| (2..=24).contains(n))?, + "--seconds" => seconds = value.parse::().ok().filter(|s| (1..=600).contains(s))?, + "--gateset" => { + gateset = match value.as_str() { + "1" => Gateset::V1, + "2" => Gateset::V2, + _ => return None, + } + } + "--expect" => expect = Some(value), + _ => return None, + } + } + Some(Args { declared: Declared { counter_ppm: ppm? }, qubits, at_least: Duration::from_secs(seconds), gateset, expect }) +} + +/// The circuit every window runs: on each qubit a Hadamard, then a chain of +/// controlled X, then T and a dyadic phase on each, then a Hadamard on each. +/// Valid in either gateset; `5n − 1` gates. +fn circuit(n: u8, gs: Gateset) -> Circuit { + let mut c = Circuit::with_gateset(n, gs); + let one = |base, target, param| Gate { base, controls: Vec::new(), target, param }; + (0..n).for_each(|q| c.ops.push(one(BaseGate::H, q, 0))); + (0..n - 1).for_each(|q| c.ops.push(Gate { base: BaseGate::X, controls: vec![q], target: q + 1, param: 0 })); + for q in 0..n { + c.ops.push(one(BaseGate::T, q, 0)); + c.ops.push(one(BaseGate::P, q, 1 + u16::from(q % 6))); + } + (0..n).for_each(|q| c.ops.push(one(BaseGate::H, q, 0))); + c +} + +/// One member's window as a line, and the figure it earns. +fn report(member: &str, kernel: &str, runs: &Runs, last: Option<&Result>, declared: Declared) -> Option<(u64, EnergyClass)> { + let mut calibration = EnergyCalibration::new(); + calibration.add(runs.ops, runs.joules_micro, runs.class.as_ref()); + let figure = calibration.finish(); + let read = match last { + Some(Ok(s)) => format!("cpu={} uJ gpu={} uJ gpu_time={} ms", s.cpu_uj, s.gpu_uj, s.gpu_ns / 1_000_000), + _ => "no readings".to_string(), + }; + let earned = match (&figure, last) { + (Some((nanojoules, class)), _) => { + let ppm = class.uncertainty().map_or(0, |u| u.relative_ppm); + format!("{nanojoules} nJ/op, {}, {ppm} ppm", class.label()) + } + (None, Some(Err(why))) => format!("unmetered: {why}"), + // Read, and still no figure: the labelling rule says why, or the + // fabric's resolution floor refused the label. + (None, Some(Ok(s))) => match label(s, declared) { + Err(why) => format!("unmetered: {why}"), + Ok(_) => "unmetered: the reading is below the resolution floor".to_string(), + }, + (None, None) => "unmetered".to_string(), + }; + println!("quantum-meter: {member} kernel={kernel} runs={} ops={} wall={} ms {read} -> {earned}", runs.runs, runs.ops, runs.wall.as_millis()); + figure +} + +fn main() -> ExitCode { + let Some(args) = args() else { return usage() }; + let kernel = apply_kernel(args.gateset); + let wide = circuit(args.qubits, args.gateset); + let ops = pair_updates(&wide); + println!("quantum-meter: circuit qubits={} gates={} ops={ops} kernel={kernel}", wide.n_qubits, wide.ops.len()); + + let discovery = match blocking::discover() { + Ok(d) => d, + Err(e) => { + eprintln!("quantum-meter: discovery failed: {e:?}"); + return ExitCode::FAILURE; + } + }; + for r in &discovery.refused { + println!("quantum-meter: adapter {} ({}) is not a member: {:?}", r.key, r.adapter, r.error); + } + let found = match &args.expect { + Some(key) => discovery.found.iter().find(|m| m.key() == key), + None => discovery.found.first(), + }; + let Some(gpu) = found else { + eprintln!("quantum-meter: no WebGPU member{}", args.expect.as_ref().map(|k| format!(" with key {k}")).unwrap_or_default()); + return ExitCode::FAILURE; + }; + println!("quantum-meter: adapter {} member={} holds {} qubits", gpu.key(), gpu.id(), gpu.max_qubits()); + + // The first meter in a process probes the GPU counter with this load: a + // run of the member it will meter. + let mut meter = ProcessRunMeter::new( + probe_load(|| { + let _ = blocking::simulate_from(gpu, &wide, 0); + }), + args.declared, + ); + println!("quantum-meter: counters live={} gpu={}", meter.is_live(), meter.gpu_liveness()); + + let mut gpu_member = match blocking::member(gpu, FABRIC_DEFAULT) { + Ok(m) => m, + Err(e) => { + eprintln!("quantum-meter: {} could not be certified: {e:?}", gpu.id()); + return ExitCode::FAILURE; + } + }; + let mut cpu_member = detect_local(); + + // An unmetered window still runs, so each member is exercised either way. + let window = |meter: &mut ProcessRunMeter, run: &mut dyn FnMut() -> Result| match span_of_runs(meter, args.at_least, &mut *run) { + Ok(runs) => Ok(runs), + // The meter would not start: run once without it. + Err(_) if !meter.is_live() => run().map(|ops| Runs { runs: 1, ops, joules_micro: 0, class: None, wall: Duration::ZERO }), + Err(e) => Err(e), + }; + + let gpu_runs = window(&mut meter, &mut || { + let state = blocking::simulate_from(gpu, &wide, 0).map_err(|e| format!("{e:?}"))?; + std::hint::black_box(state); + Ok(ops) + }); + let gpu_runs = match gpu_runs { + Ok(r) => r, + Err(e) => { + eprintln!("quantum-meter: {} failed its run: {e}", gpu.id()); + return ExitCode::FAILURE; + } + }; + let gpu_figure = report(&gpu_member.id, kernel, &gpu_runs, meter.last(), args.declared); + + let cpu_runs = window(&mut meter, &mut || { + let state = wide.simulate_from(0).map_err(|e| format!("{e:?}"))?; + std::hint::black_box(state); + Ok(ops) + }); + let cpu_runs = match cpu_runs { + Ok(r) => r, + Err(e) => { + eprintln!("quantum-meter: the CPU reference failed its run: {e}"); + return ExitCode::FAILURE; + } + }; + let cpu_figure = report(&cpu_member.id, kernel, &cpu_runs, meter.last(), args.declared); + + // Figures go on both members or on neither: a figure on one alone would + // place it whatever it costs. + let (Some(gpu_figure), Some(cpu_figure)) = (gpu_figure, cpu_figure) else { + println!("quantum-meter: no placement by energy: a figure is needed on both members"); + return ExitCode::SUCCESS; + }; + let set = |member: &mut Member, implementation: &str, figure: &(u64, EnergyClass)| { + member.kernels.iter_mut().filter(|k| k.kernel == kernel && k.implementation == implementation).for_each(|k| k.energy = Some(figure.clone())); + }; + set(&mut gpu_member, gpu.implementation(), &gpu_figure); + set(&mut cpu_member, REFERENCE, &cpu_figure); + let members = [cpu_member, gpu_member]; + let predict = |_: &Member, k: &wai_quantum::quantum_fabric::KernelEntry| k.energy.as_ref().map(|(nanojoules, _)| nanojoules.saturating_mul(ops).saturating_mul(1_000)); + match place_for(&members, kernel, &Parity::BitExact, predict) { + Some((member, entry)) => println!( + "quantum-meter: placed {} ({}) for {} qubits: {} nJ/op against {} nJ/op on the other member", + member.id, + entry.implementation, + wide.n_qubits, + entry.energy.as_ref().map_or(0, |e| e.0), + if member.id == members[0].id { gpu_figure.0 } else { cpu_figure.0 } + ), + None => println!("quantum-meter: no member is certified for {kernel}"), + } + ExitCode::SUCCESS +} diff --git a/wai/tools/quantum-meter/tests/hardware.rs b/wai/tools/quantum-meter/tests/hardware.rs new file mode 100644 index 000000000..fd0470579 --- /dev/null +++ b/wai/tools/quantum-meter/tests/hardware.rs @@ -0,0 +1,149 @@ +//! The meter on this host's own counters and GPU, where it has them: a WebGPU +//! member's runs and the CPU reference's, each in a window long enough to +//! resolve, and one run that is not. +//! +//! This is its own test binary because the first meter in a process probes +//! the GPU counter with the load it is given, once for the process's life. +//! +//! On a host with no WebGPU adapter, or where the counters are not live, the +//! test says so and checks what holds there: every window is unmetered. On a +//! host where they are live it prints `quantum-meter hardware:` lines, which a +//! CI job that expects live counters looks for. +//! +//! A GPU window can lose its figure to another process using the GPU over it, +//! which a shared host cannot prevent; the test then requires the refusal to +//! say so, and no figure. + +use quantum_meter::{Declared, MIN_GPU_WINDOW, ProcessRunMeter, Refused, label, probe_load, span_of_runs}; +use std::time::Duration; +use wai_quantum::quantum::{BaseGate, Circuit, Gate, Gateset}; +use wai_quantum::quantum_energy::{EnergyClass, check_label}; +use wai_quantum::quantum_fabric::{EnergyCalibration, RunMeter, pair_updates}; +use wai_quantum::quantum_fabric_webgpu::blocking; + +const DECLARED: Declared = Declared { counter_ppm: 50_000 }; + +/// Hadamards, a chain of controlled X, phases, Hadamards: `5n − 1` gates. +fn circuit(n: u8) -> Circuit { + let mut c = Circuit::with_gateset(n, Gateset::V2); + let one = |base, target, param| Gate { base, controls: Vec::new(), target, param }; + (0..n).for_each(|q| c.ops.push(one(BaseGate::H, q, 0))); + (0..n - 1).for_each(|q| c.ops.push(Gate { base: BaseGate::X, controls: vec![q], target: q + 1, param: 0 })); + for q in 0..n { + c.ops.push(one(BaseGate::T, q, 0)); + c.ops.push(one(BaseGate::P, q, 1 + u16::from(q % 6))); + } + (0..n).for_each(|q| c.ops.push(one(BaseGate::H, q, 0))); + c +} + +#[test] +fn a_members_runs_are_metered_over_a_window_the_counters_resolve() { + let discovery = blocking::discover().expect("discovery resolves natively"); + let Some(gpu) = discovery.found.first() else { + eprintln!("quantum-meter: no WebGPU adapter on this host ({} refused)", discovery.refused.len()); + return; + }; + let wide = circuit(gpu.max_qubits().min(18)); + let ops = pair_updates(&wide); + let mut meter = ProcessRunMeter::new( + probe_load(|| { + let _ = blocking::simulate_from(gpu, &wide, 0); + }), + DECLARED, + ); + let on_gpu = || blocking::simulate_from(gpu, &wide, 0).map(|_| ops).map_err(|e| format!("{e:?}")); + let on_cpu = || wide.simulate_from(0).map(|_| ops).map_err(|e| format!("{e:?}")); + + if !meter.is_live() { + eprintln!("quantum-meter: the counters are not live here (GPU counter {})", meter.gpu_liveness()); + // No window opens, and a run through the member is unmetered. + assert!(span_of_runs(&mut meter, Duration::ZERO, on_gpu).is_err()); + assert!(matches!(meter.last(), Some(Err(Refused::NotLive(_)))), "{:?}", meter.last()); + let (_, joules_micro, class) = blocking::simulate_metered(gpu, &wide, 0, &mut meter).expect("the run itself"); + assert_eq!((joules_micro, class), (0, None)); + return; + } + + // The member's runs, two seconds of them. + let runs = span_of_runs(&mut meter, MIN_GPU_WINDOW * 2, on_gpu).expect("the member runs"); + assert!(runs.runs >= 1 && runs.ops == ops * runs.runs, "{runs:?}"); + match meter.last().expect("a window was closed") { + Ok(span) => { + assert!(span.gpu_ns > 0, "the member's runs spent GPU time: {span:?}"); + assert!(span.cpu_uj > 0, "and the host's side of them CPU energy: {span:?}"); + let (joules_micro, class) = label(span, DECLARED).expect("two seconds of GPU work is labelled"); + assert_eq!((runs.joules_micro, runs.class.clone()), (joules_micro, Some(class.clone())), "stop returned the span's label"); + assert_eq!(joules_micro, span.cpu_uj + span.gpu_uj); + assert!(matches!(class, EnergyClass::OnChipCounter { .. }), "{class:?}"); + assert_eq!(check_label(joules_micro, &class), Ok(())); + let mut calibration = EnergyCalibration::new(); + calibration.add(runs.ops, runs.joules_micro, runs.class.as_ref()); + let (nanojoules, _) = calibration.finish().expect("a figure"); + eprintln!( + "quantum-meter hardware: {} on {} qubits: {} runs in {:?}, cpu {} uJ + gpu {} uJ over {} ms of GPU time -> {nanojoules} nJ/op, {} ppm", + gpu.id(), + wide.n_qubits, + runs.runs, + runs.wall, + span.cpu_uj, + span.gpu_uj, + span.gpu_ns / 1_000_000, + class.uncertainty().expect("declared").relative_ppm + ); + } + // The GPU was not this process's alone over the window, or its rail + // gave no reading: no figure, and the reason kept. + Err(why @ (Refused::NoGpuFigure(_) | Refused::Counter(_))) => { + assert_eq!((runs.joules_micro, runs.class.clone()), (0, None), "{why}"); + eprintln!("quantum-meter hardware: {} window unmetered: {why}", gpu.id()); + } + Err(other) => panic!("a two-second window of the member's runs was refused: {other}"), + } + + // The CPU reference's runs, in the same domain: no GPU time, so the + // figure is the CPU count alone. + let runs = span_of_runs(&mut meter, Duration::from_secs(1), on_cpu).expect("the reference runs"); + match meter.last().expect("a window was closed") { + Ok(span) => { + assert_eq!((span.gpu_ns, span.gpu_uj), (0, 0), "the reference spends no GPU time: {span:?}"); + assert!(span.cpu_uj > 0, "{span:?}"); + assert_eq!(runs.joules_micro, span.cpu_uj); + let class = runs.class.clone().expect("a second of CPU work is labelled"); + assert_eq!(check_label(runs.joules_micro, &class), Ok(())); + let mut calibration = EnergyCalibration::new(); + calibration.add(runs.ops, runs.joules_micro, runs.class.as_ref()); + let (nanojoules, _) = calibration.finish().expect("a figure"); + eprintln!( + "quantum-meter hardware: the CPU reference on {} qubits: {} runs in {:?}, cpu {} uJ -> {nanojoules} nJ/op, {} ppm", + wide.n_qubits, + runs.runs, + runs.wall, + span.cpu_uj, + class.uncertainty().expect("declared").relative_ppm + ); + } + // Another process's GPU client came or went over the window: the GPU + // counter then gives no figure, a zero included. + Err(why @ (Refused::NoGpuFigure(_) | Refused::Counter(_))) => { + assert_eq!((runs.joules_micro, runs.class.clone()), (0, None), "{why}"); + eprintln!("quantum-meter hardware: the CPU reference's window unmetered: {why}"); + } + Err(other) => panic!("a one-second window of the reference's runs was refused: {other}"), + } + + // One run of the member, in the window the fabric opens around it: far + // under a second, so it is unmetered, never read at a resolution the rail + // does not have. + let small = circuit(6); + let (_, joules_micro, class) = blocking::simulate_metered(gpu, &small, 0, &mut meter).expect("the run itself"); + assert_eq!((joules_micro, class), (0, None), "a run of {:?}", meter.last()); + match meter.last().expect("the run's window was closed") { + Ok(span) => assert!(matches!(label(span, DECLARED), Err(Refused::TooShort { .. })), "{span:?}"), + Err(Refused::NoGpuFigure(_) | Refused::Counter(_)) => {} + Err(other) => panic!("{other}"), + } + // Every start was matched by a stop: the meter starts again. + assert!(meter.start().is_ok()); + assert!(meter.stop().is_ok()); +}